invidiousアプデ
このコミットが含まれているのは:
コミット
9ae30acca6
|
@ -0,0 +1,18 @@
|
|||
# Default and lowest precedence. If none of the below matches, @iv-org/developers would be requested for review.
|
||||
* @iv-org/developers
|
||||
|
||||
docker-compose.yml @unixfox
|
||||
docker/ @unixfox
|
||||
kubernetes/ @unixfox
|
||||
|
||||
README.md @thefrenchghosty
|
||||
config/config.example.yml @thefrenchghosty @SamantazFox @unixfox
|
||||
|
||||
scripts/ @syeopite
|
||||
shards.lock @syeopite
|
||||
shards.yml @syeopite
|
||||
|
||||
locales/ @SamantazFox
|
||||
src/invidious/helpers/i18n.cr @SamantazFox
|
||||
|
||||
src/invidious/helpers/youtube_api.cr @SamantazFox
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve Invidious
|
||||
title: '[Bug] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Please use the search function to check if the bug you found has already been reported by someone else -->
|
||||
<!-- If you want to suggest a new feature please use "Feature request" instead -->
|
||||
<!-- If you want to suggest an enhancement to an existing feature please use "Enhancement" instead -->
|
||||
|
||||
**Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**Steps to Reproduce**
|
||||
<!-- Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
-->
|
||||
|
||||
**Logs**
|
||||
<!-- If applicable, copy the log that appear in the browser page where the error is reported. -->
|
||||
|
||||
**Screenshots**
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context about the problem here.
|
||||
- Browser (if applicable):
|
||||
- OS (if applicable):
|
||||
-->
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: Enhancement
|
||||
about: Suggest an enhancement for an existing feature
|
||||
title: '[Enhancement] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Please use the search function to check if the desired function has already been requested by someone else -->
|
||||
<!-- If you want to suggest a new feature please use "Feature request" instead -->
|
||||
<!-- If you want to report a bug, please use "Bug report" instead -->
|
||||
|
||||
**Is your enhancement request related to a problem? Please describe.**
|
||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
||||
|
||||
**Describe the solution you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context or screenshots about the enhancement here. -->
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[Feature request] '
|
||||
labels: feature-request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Please use the search function to check if the desired function has already been requested by someone else -->
|
||||
<!-- If you want to suggest an enhancement to an existing feature please use "Enhancement" instead -->
|
||||
<!-- If you want to report a bug, please use "Bug report" instead -->
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
|
||||
|
||||
**Describe the solution you'd like**
|
||||
<!-- A clear and concise description of what you want to happen. -->
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
|
||||
|
||||
**Additional context**
|
||||
<!-- Add any other context or screenshots about the feature request here. -->
|
|
@ -0,0 +1,124 @@
|
|||
name: Invidious CI
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # Every day at 00:00
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "api-only"
|
||||
pull_request:
|
||||
branches: "*"
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- LICENCE
|
||||
- TRANSLATION
|
||||
- invidious.service
|
||||
- .git*
|
||||
- .editorconfig
|
||||
|
||||
- screenshots/*
|
||||
- assets/**
|
||||
- locales/*
|
||||
- config/**
|
||||
- .github/ISSUE_TEMPLATE/*
|
||||
- kubernetes/**
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: "build - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}"
|
||||
|
||||
continue-on-error: ${{ !matrix.stable }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
stable: [true]
|
||||
crystal:
|
||||
- 1.0.0
|
||||
- 1.1.1
|
||||
- 1.2.0
|
||||
include:
|
||||
- crystal: nightly
|
||||
stable: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install Crystal
|
||||
uses: crystal-lang/install-crystal@v1.5.3
|
||||
with:
|
||||
crystal: ${{ matrix.crystal }}
|
||||
|
||||
- name: Cache Shards
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ./lib
|
||||
key: shards-${{ hashFiles('shard.lock') }}
|
||||
|
||||
- name: Install Shards
|
||||
run: |
|
||||
if ! shards check; then
|
||||
shards install
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
run: crystal spec
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
if ! crystal tool format --check; then
|
||||
crystal tool format
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
|
||||
|
||||
build-docker:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build Docker
|
||||
run: docker-compose build --build-arg release=0
|
||||
|
||||
- name: Run Docker
|
||||
run: docker-compose up -d
|
||||
|
||||
- name: Test Docker
|
||||
run: while curl -Isf http://localhost:3000; do sleep 1; done
|
||||
|
||||
build-docker-arm64:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build Docker ARM64 image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.arm64
|
||||
platforms: linux/arm64/v8
|
||||
build-args: release=0
|
||||
|
||||
- name: Test Docker
|
||||
run: while curl -Isf http://localhost:3000; do sleep 1; done
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
name: Build and release container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- LICENCE
|
||||
- TRANSLATION
|
||||
- invidious.service
|
||||
- .git*
|
||||
- .editorconfig
|
||||
|
||||
- screenshots/*
|
||||
- .github/ISSUE_TEMPLATE/*
|
||||
- kubernetes/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Crystal
|
||||
uses: oprypin/install-crystal@v1.2.4
|
||||
with:
|
||||
crystal: 1.1.1
|
||||
|
||||
- name: Run lint
|
||||
run: |
|
||||
if ! crystal tool format --check; then
|
||||
crystal tool format
|
||||
git diff
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
password: ${{ secrets.QUAY_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker AMD64 image for Push Event
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
labels: quay.expires-after=12w
|
||||
push: true
|
||||
tags: quay.io/invidious/invidious:${{ github.sha }},quay.io/invidious/invidious:latest
|
||||
build-args: release=1
|
||||
|
||||
- name: Build and push Docker ARM64 image for Push Event
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.arm64
|
||||
platforms: linux/arm64/v8
|
||||
labels: quay.expires-after=12w
|
||||
push: true
|
||||
tags: quay.io/invidious/invidious:${{ github.sha }}-arm64,quay.io/invidious/invidious:latest-arm64
|
||||
build-args: release=1
|
|
@ -0,0 +1,22 @@
|
|||
# Documentation: https://github.com/marketplace/actions/lock-threads
|
||||
|
||||
name: 'Lock Threads'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: '240'
|
||||
pr-lock-inactive-days: '240'
|
||||
issue-lock-reason: 'resolved'
|
||||
pr-lock-reason: 'resolved'
|
||||
|
||||
# issue-lock-comment: 'This issue has been automatically locked since there has not been any activity in it in the last 30 days. If this is still applicable to the current version of Invidious feel free to open a new issue.'
|
||||
# pr-lock-comment: 'This pull request has been automatically locked since there has not been any activity in it in the last 30 days. If you want to tell us about needed or wanted changes or if problems related to this code are discovered, feel free to open an issue or a new pull request.'
|
|
@ -0,0 +1,24 @@
|
|||
# Documentation: https://github.com/marketplace/actions/close-stale-issues
|
||||
|
||||
name: "Stale issue handler"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 365
|
||||
days-before-pr-stale: 45 # PRs should be active. Anything that hasn't had activity in more than 45 days should be considered abandoned.
|
||||
days-before-close: 30
|
||||
exempt-pr-labels: blocked
|
||||
stale-issue-message: 'This issue has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely outdated. If you think this issue is still relevant and applicable, you just have to post a comment and it will be unmarked.'
|
||||
stale-pr-message: 'This pull request has been automatically marked as stale and will be closed in 30 days because it has not had recent activity and is much likely abandoned or outdated. If you think this pull request is still relevant and applicable, you just have to post a comment and it will be unmarked.'
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
ascending: true
|
|
@ -3,7 +3,7 @@
|
|||
<h1>Invidious</h1>
|
||||
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0.en.html">
|
||||
<img alt="License: AGPLv3+" src="https://shields.io/badge/License-AGPL%20v3+-blue.svg">
|
||||
<img alt="License: AGPLv3" src="https://shields.io/badge/License-AGPL%20v3-blue.svg">
|
||||
</a>
|
||||
<a href="https://github.com/iv-org/invidious/actions">
|
||||
<img alt="Build Status" src="https://github.com/iv-org/invidious/workflows/Invidious%20CI/badge.svg">
|
||||
|
@ -58,7 +58,7 @@
|
|||
- No JavaScript required
|
||||
- Light/Dark themes
|
||||
- Customizable homepage
|
||||
- Subscriptions independant from Google
|
||||
- Subscriptions independent from Google
|
||||
- Notifications for all subscribed channels
|
||||
- Audio-only mode (with background play on mobile)
|
||||
- Support for Reddit comments
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
FROM alpine:edge AS builder
|
||||
RUN apk add --no-cache 'crystal=1.1.1-r0' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
|
||||
RUN apk add --no-cache 'crystal=1.1.1-r1' shards sqlite-static yaml-static yaml-dev libxml2-dev zlib-static openssl-libs-static openssl-dev musl-dev
|
||||
|
||||
ARG release
|
||||
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
"search": "بحث",
|
||||
"Log out": "تسجيل الخروج",
|
||||
"Released under the AGPLv3 on Github.": "تم إصداره بموجب AGPLv3 على Github.",
|
||||
"Released under the AGPLv3 on Github.": "صدر تحت AGPLv3 على Github.",
|
||||
"Source available here.": "الأكواد متوفرة هنا.",
|
||||
"View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.",
|
||||
"View privacy policy.": "عرض سياسة الخصوصية.",
|
||||
|
@ -382,7 +382,7 @@
|
|||
"News": "الأخبار",
|
||||
"Movies": "الأفلام",
|
||||
"Download": "نزّل",
|
||||
"Download as: ": "نزله كـ:. ",
|
||||
"Download as: ": "نزله ك:. ",
|
||||
"%A %B %-d, %Y": "%A %-d %B %Y",
|
||||
"(edited)": "(تم تعديلة)",
|
||||
"YouTube comment permalink": "رابط التعليق على اليوتيوب",
|
||||
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "تحديث",
|
||||
"next_steps_error_message_go_to_youtube": "انتقل إلى يوتيوب",
|
||||
"short": "قصير (< 4 دقائق)",
|
||||
"long": "طويل (> 20 دقيقة)"
|
||||
"long": "طويل (> 20 دقيقة)",
|
||||
"footer_source_code": "شفرة المصدر",
|
||||
"footer_original_source_code": "شفرة المصدر الأصلية",
|
||||
"footer_modfied_source_code": "شفرة المصدر المعدلة",
|
||||
"adminprefs_modified_source_code_url_label": "URL إلى مستودع التعليمات البرمجية المصدرية المعدلة",
|
||||
"footer_documentation": "التوثيق",
|
||||
"footer_donate": "تبرّع: ",
|
||||
"footer_donate_page": "تبرّع"
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
"Player volume: ": "Player volume: ",
|
||||
"Default comments: ": "Default comments: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "reddit",
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Default captions: ",
|
||||
"Fallback captions: ": "Fallback captions: ",
|
||||
"Show related videos: ": "Show related videos: ",
|
||||
|
@ -426,7 +426,7 @@
|
|||
"next_steps_error_message": "After which you should try to: ",
|
||||
"next_steps_error_message_refresh": "Refresh",
|
||||
"next_steps_error_message_go_to_youtube": "Go to YouTube",
|
||||
"footer_donate": "Donate: ",
|
||||
"footer_donate_page": "Donate",
|
||||
"footer_documentation": "Documentation",
|
||||
"footer_source_code": "Source code",
|
||||
"footer_original_source_code": "Original source code",
|
||||
|
|
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "Reŝargi",
|
||||
"next_steps_error_message_go_to_youtube": "Iri al JuTubo",
|
||||
"long": "Longa (> 20 minutos)",
|
||||
"short": "Mallonga (< 4 minutos)"
|
||||
"short": "Mallonga (< 4 minutos)",
|
||||
"footer_donate": "Doni: ",
|
||||
"footer_documentation": "Dokumentaro",
|
||||
"footer_source_code": "Fontkodo",
|
||||
"adminprefs_modified_source_code_url_label": "URL al modifita deponejo de fontkodo",
|
||||
"footer_modfied_source_code": "Modifita Fontkodo",
|
||||
"footer_original_source_code": "Originala fontkodo",
|
||||
"footer_donate_page": "Donaci"
|
||||
}
|
||||
|
|
|
@ -424,6 +424,13 @@
|
|||
"next_steps_error_message": "Después de lo cual deberías intentar: ",
|
||||
"next_steps_error_message_refresh": "Recargar",
|
||||
"next_steps_error_message_go_to_youtube": "Ir a YouTube",
|
||||
"short": "Corto (< minutos)",
|
||||
"long": "Largo (> minutos)"
|
||||
"short": "Corto (< 4 minutos)",
|
||||
"long": "Largo (> 20 minutos)",
|
||||
"footer_documentation": "Documentación",
|
||||
"footer_original_source_code": "Código fuente original",
|
||||
"adminprefs_modified_source_code_url_label": "URL al repositorio de código fuente modificado",
|
||||
"footer_source_code": "Código fuente",
|
||||
"footer_donate": "Donar: ",
|
||||
"footer_modfied_source_code": "Código fuente modificado",
|
||||
"footer_donate_page": "Donar"
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
"Show related videos: ": "関連動画を表示: ",
|
||||
"Show annotations by default: ": "デフォルトでアノテーションを表示: ",
|
||||
"Automatically extend video description: ": "動画の説明文を自動的に拡張: ",
|
||||
"Interactive 360 degree videos: ": "インタラクティブ360°動画: ",
|
||||
"Interactive 360 degree videos: ": "対話的な360°動画: ",
|
||||
"Visual preferences": "外観設定",
|
||||
"Player style: ": "プレイヤースタイル: ",
|
||||
"Dark mode: ": "ダークモード: ",
|
||||
|
@ -137,7 +137,7 @@
|
|||
},
|
||||
"Import/export": "インポート/エクスポート",
|
||||
"unsubscribe": "登録解除",
|
||||
"revoke": "revoke",
|
||||
"revoke": "取り消す",
|
||||
"Subscriptions": "登録チャンネル",
|
||||
"`x` unseen notifications": {
|
||||
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の未読通知",
|
||||
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
"search": "検索",
|
||||
"Log out": "ログアウト",
|
||||
"Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の下で公開されています",
|
||||
"Released under the AGPLv3 on Github.": "Github 上で AGPLv3 の元で公開されています",
|
||||
"Source available here.": "ソースはここで閲覧可能です。",
|
||||
"View JavaScript license information.": "JavaScript ライセンス情報",
|
||||
"View privacy policy.": "プライバシーポリシー",
|
||||
|
@ -423,5 +423,13 @@
|
|||
"Current version: ": "現在のバージョン: ",
|
||||
"next_steps_error_message": "下記のものを試して下さい: ",
|
||||
"next_steps_error_message_refresh": "再読込",
|
||||
"next_steps_error_message_go_to_youtube": "YouTubeへ"
|
||||
"next_steps_error_message_go_to_youtube": "YouTubeへ",
|
||||
"short": "4 分未満",
|
||||
"footer_donate": "寄金: ",
|
||||
"footer_documentation": "文書",
|
||||
"footer_source_code": "ソースコード",
|
||||
"footer_original_source_code": "ソースコード(元)",
|
||||
"footer_modfied_source_code": "ソースコード(編集)",
|
||||
"adminprefs_modified_source_code_url_label": "編集したソースコードのレポジトリーURL",
|
||||
"long": "20 分以上"
|
||||
}
|
||||
|
|
|
@ -423,5 +423,13 @@
|
|||
"today": "오늘",
|
||||
"hour": "지난 1시간",
|
||||
"sort": "정렬기준",
|
||||
"features": "기능별"
|
||||
"features": "기능별",
|
||||
"short": "4분 미만",
|
||||
"long": "20분 초과",
|
||||
"footer_donate": "후원: ",
|
||||
"footer_documentation": "문서",
|
||||
"footer_source_code": "소스 코드",
|
||||
"footer_original_source_code": "원본 소스 코드",
|
||||
"footer_modfied_source_code": "수정된 소스 코드",
|
||||
"adminprefs_modified_source_code_url_label": "수정된 소스 코드 저장소의 URL"
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
"Player volume: ": "Grotuvo garsas: ",
|
||||
"Default comments: ": "Numatytieji komentarai: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "reddit",
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Numatytieji subtitrai: ",
|
||||
"Fallback captions: ": "Atsarginiai subtitrai: ",
|
||||
"Show related videos: ": "Rodyti susijusius vaizdo įrašus: ",
|
||||
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "Atnaujinti",
|
||||
"next_steps_error_message_go_to_youtube": "Eiti į YouTube",
|
||||
"short": "Trumpas (< 4 minučių)",
|
||||
"long": "Ilgas (> 20 minučių)"
|
||||
"long": "Ilgas (> 20 minučių)",
|
||||
"footer_documentation": "Dokumentacija",
|
||||
"footer_source_code": "Pirminis kodas",
|
||||
"footer_donate": "Paaukoti: ",
|
||||
"footer_original_source_code": "Pradinis pirminis kodas",
|
||||
"adminprefs_modified_source_code_url_label": "URL į pakeisto pirminio kodo repozitoriją",
|
||||
"footer_modfied_source_code": "Pakeistas pirminis kodas",
|
||||
"footer_donate_page": "Paaukoti"
|
||||
}
|
||||
|
|
|
@ -145,7 +145,7 @@
|
|||
},
|
||||
"search": "søk",
|
||||
"Log out": "Logg ut",
|
||||
"Released under the AGPLv3 on Github.": "",
|
||||
"Released under the AGPLv3 on Github.": "Tilgjengelig med AGPLv3-lisens på Github.",
|
||||
"Source available here.": "Kildekode tilgjengelig her.",
|
||||
"View JavaScript license information.": "Vis JavaScript-lisensinfo.",
|
||||
"View privacy policy.": "Vis personvernspraksis.",
|
||||
|
@ -423,5 +423,13 @@
|
|||
"Current version: ": "Gjeldende versjon: ",
|
||||
"next_steps_error_message": "Etterpå bør du prøve dette: ",
|
||||
"next_steps_error_message_refresh": "Gjenoppfrisk",
|
||||
"next_steps_error_message_go_to_youtube": "Gå til YouTube"
|
||||
"next_steps_error_message_go_to_youtube": "Gå til YouTube",
|
||||
"long": "Lang (> 20 minutter)",
|
||||
"footer_donate_page": "Doner",
|
||||
"short": "Kort (< 4 minutter)",
|
||||
"footer_documentation": "Dokumentasjon",
|
||||
"footer_source_code": "Kildekode",
|
||||
"footer_original_source_code": "Opprinnelig kildekode",
|
||||
"footer_modfied_source_code": "Endret kildekode",
|
||||
"adminprefs_modified_source_code_url_label": "Nettadresse til kodelager inneholdende endret kildekode"
|
||||
}
|
||||
|
|
|
@ -425,5 +425,11 @@
|
|||
"([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscritores"
|
||||
},
|
||||
"short": "Curto (< 4 minutos)",
|
||||
"long": "Longo (> 20 minutos)"
|
||||
"long": "Longo (> 20 minutos)",
|
||||
"footer_source_code": "Código-fonte",
|
||||
"footer_original_source_code": "Código-fonte original",
|
||||
"adminprefs_modified_source_code_url_label": "URL do repositório do código-fonte alterado",
|
||||
"footer_donate": "Fazer um donativo: ",
|
||||
"footer_documentation": "Documentação",
|
||||
"footer_modfied_source_code": "Código-fonte alterado"
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
"Player volume: ": "Oynatıcı ses seviyesi: ",
|
||||
"Default comments: ": "Öntanımlı yorumlar: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "reddit",
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "Öntanımlı altyazılar: ",
|
||||
"Fallback captions: ": "Yedek altyazılar: ",
|
||||
"Show related videos: ": "İlgili videoları göster: ",
|
||||
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "Yenile",
|
||||
"next_steps_error_message_go_to_youtube": "YouTube'a git",
|
||||
"short": "Kısa (4 dakikadan az)",
|
||||
"long": "Uzun (20 dakikadan fazla)"
|
||||
"long": "Uzun (20 dakikadan fazla)",
|
||||
"footer_donate": "Bağış yap: ",
|
||||
"footer_documentation": "Belgelendirme",
|
||||
"footer_source_code": "Kaynak kodları",
|
||||
"footer_original_source_code": "Orijinal kaynak kodları",
|
||||
"footer_modfied_source_code": "Değiştirilmiş kaynak kodları",
|
||||
"adminprefs_modified_source_code_url_label": "Değiştirilmiş kaynak kodları deposunun URL'si",
|
||||
"footer_donate_page": "Bağış yap"
|
||||
}
|
||||
|
|
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "刷新",
|
||||
"next_steps_error_message_go_to_youtube": "转到 YouTube",
|
||||
"short": "短(少于4分钟)",
|
||||
"long": "长(多于 20 分钟)"
|
||||
"long": "长(多于 20 分钟)",
|
||||
"footer_donate": "捐赠: ",
|
||||
"footer_documentation": "文档",
|
||||
"footer_source_code": "源代码",
|
||||
"footer_modfied_source_code": "修改的源代码",
|
||||
"adminprefs_modified_source_code_url_label": "更改的源代码仓库网址",
|
||||
"footer_original_source_code": "原始源代码",
|
||||
"footer_donate_page": "捐赠"
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
"Player volume: ": "播放器音量: ",
|
||||
"Default comments: ": "預設留言: ",
|
||||
"youtube": "YouTube",
|
||||
"reddit": "reddit",
|
||||
"reddit": "Reddit",
|
||||
"Default captions: ": "預設字幕: ",
|
||||
"Fallback captions: ": "汰退字幕: ",
|
||||
"Show related videos: ": "顯示相關的影片: ",
|
||||
|
@ -425,5 +425,12 @@
|
|||
"next_steps_error_message_refresh": "重新整理",
|
||||
"next_steps_error_message_go_to_youtube": "到 YouTube",
|
||||
"short": "短(小於4分鐘)",
|
||||
"long": "長(多於20分鐘)"
|
||||
"long": "長(多於20分鐘)",
|
||||
"footer_donate": "抖內: ",
|
||||
"footer_documentation": "文件",
|
||||
"footer_source_code": "原始碼",
|
||||
"footer_original_source_code": "原本的原始碼",
|
||||
"footer_modfied_source_code": "修改後的原始碼",
|
||||
"adminprefs_modified_source_code_url_label": "修改後的原始碼倉庫 URL",
|
||||
"footer_donate_page": "捐款"
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ require "../src/invidious/comments"
|
|||
require "../src/invidious/playlists"
|
||||
require "../src/invidious/search"
|
||||
require "../src/invidious/trending"
|
||||
require "../src/invidious/users"
|
||||
|
||||
CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
|
||||
|
||||
|
|
197
src/invidious.cr
197
src/invidious.cr
|
@ -27,8 +27,10 @@ require "yaml"
|
|||
require "compress/zip"
|
||||
require "protodec/utils"
|
||||
require "./invidious/helpers/*"
|
||||
require "./invidious/yt_backend/*"
|
||||
require "./invidious/*"
|
||||
require "./invidious/channels/*"
|
||||
require "./invidious/user/*"
|
||||
require "./invidious/routes/**"
|
||||
require "./invidious/jobs/**"
|
||||
|
||||
|
@ -389,6 +391,13 @@ end
|
|||
Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post
|
||||
{% end %}
|
||||
|
||||
Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht
|
||||
Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard
|
||||
Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard
|
||||
Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image
|
||||
Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image
|
||||
Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails
|
||||
|
||||
# API routes (macro)
|
||||
define_v1_api_routes()
|
||||
|
||||
|
@ -1273,194 +1282,6 @@ post "/api/v1/auth/notifications" do |env|
|
|||
create_notification_stream(env, topics, connection_channel)
|
||||
end
|
||||
|
||||
get "/ggpht/*" do |env|
|
||||
url = env.request.path.lchop("/ggpht")
|
||||
|
||||
headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
options "/sb/:authority/:id/:storyboard/:index" do |env|
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
|
||||
end
|
||||
|
||||
get "/sb/:authority/:id/:storyboard/:index" do |env|
|
||||
authority = env.params.url["authority"]
|
||||
id = env.params.url["id"]
|
||||
storyboard = env.params.url["storyboard"]
|
||||
index = env.params.url["index"]
|
||||
|
||||
url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
|
||||
|
||||
headers = HTTP::Headers.new
|
||||
|
||||
headers[":authority"] = "#{authority}.ytimg.com"
|
||||
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Connection"] = "close"
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
get "/s_p/:id/:name" do |env|
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
|
||||
url = env.request.resource
|
||||
|
||||
headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
get "/yts/img/:name" do |env|
|
||||
headers = HTTP::Headers.new
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(env.request.resource, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
get "/vi/:id/:name" do |env|
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
|
||||
headers = HTTP::Headers{":authority" => "i.ytimg.com"}
|
||||
|
||||
if name == "maxres.jpg"
|
||||
build_thumbnails(id).each do |thumb|
|
||||
if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
|
||||
name = thumb[:url] + ".jpg"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
url = "/vi/#{id}/#{name}"
|
||||
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
get "/Captcha" do |env|
|
||||
headers = HTTP::Headers{":authority" => "accounts.google.com"}
|
||||
response = YT_POOL.client &.get(env.request.resource, headers)
|
||||
|
|
|
@ -329,7 +329,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
|
|||
html << <<-END_HTML
|
||||
<div class="pure-g" style="width:100%">
|
||||
<div class="channel-profile pure-u-4-24 pure-u-md-2-24">
|
||||
<img style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}">
|
||||
<img loading="lazy" style="margin-right:1em;margin-top:1em;width:90%" src="#{author_thumbnail}">
|
||||
</div>
|
||||
<div class="pure-u-20-24 pure-u-md-22-24">
|
||||
<p>
|
||||
|
@ -349,7 +349,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
|
|||
html << <<-END_HTML
|
||||
<div class="pure-g">
|
||||
<div class="pure-u-1 pure-u-md-1-2">
|
||||
<img style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}">
|
||||
<img loading="lazy" style="width:100%" src="/ggpht#{URI.parse(attachment["url"].as_s).request_target}">
|
||||
</div>
|
||||
</div>
|
||||
END_HTML
|
||||
|
@ -410,7 +410,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
|
|||
html << <<-END_HTML
|
||||
<span class="creator-heart-container" title="#{translate(locale, "`x` marked it with a ❤", child["creatorHeart"]["creatorName"].as_s)}">
|
||||
<div class="creator-heart">
|
||||
<img class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
|
||||
<img loading="lazy" class="creator-heart-background-hearted" src="#{creator_thumbnail}"></img>
|
||||
<div class="creator-heart-small-hearted">
|
||||
<div class="icon ion-ios-heart creator-heart-small-container"></div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
struct DBConfig
|
||||
include YAML::Serializable
|
||||
|
||||
property user : String
|
||||
property password : String
|
||||
property host : String
|
||||
property port : Int32
|
||||
property dbname : String
|
||||
end
|
||||
|
||||
struct ConfigPreferences
|
||||
include YAML::Serializable
|
||||
|
||||
property annotations : Bool = false
|
||||
property annotations_subscribed : Bool = false
|
||||
property autoplay : Bool = false
|
||||
property captions : Array(String) = ["", "", ""]
|
||||
property comments : Array(String) = ["youtube", ""]
|
||||
property continue : Bool = false
|
||||
property continue_autoplay : Bool = true
|
||||
property dark_mode : String = ""
|
||||
property latest_only : Bool = false
|
||||
property listen : Bool = false
|
||||
property local : Bool = false
|
||||
property locale : String = "en-US"
|
||||
property max_results : Int32 = 40
|
||||
property notifications_only : Bool = false
|
||||
property player_style : String = "invidious"
|
||||
property quality : String = "hd720"
|
||||
property quality_dash : String = "auto"
|
||||
property default_home : String? = "Popular"
|
||||
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
||||
property automatic_instance_redirect : Bool = false
|
||||
property related_videos : Bool = true
|
||||
property sort : String = "published"
|
||||
property speed : Float32 = 1.0_f32
|
||||
property thin_mode : Bool = false
|
||||
property unseen_only : Bool = false
|
||||
property video_loop : Bool = false
|
||||
property extend_desc : Bool = false
|
||||
property volume : Int32 = 100
|
||||
property vr_mode : Bool = true
|
||||
property show_nick : Bool = true
|
||||
|
||||
def to_tuple
|
||||
{% begin %}
|
||||
{
|
||||
{{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}}
|
||||
}
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
class Config
|
||||
include YAML::Serializable
|
||||
|
||||
property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||
property feed_threads : Int32 = 1 # Number of threads to use for updating feeds
|
||||
property output : String = "STDOUT" # Log file path or STDOUT
|
||||
property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
|
||||
property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc)
|
||||
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax
|
||||
property decrypt_polling : Bool = true # Use polling to keep decryption function up to date
|
||||
property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel
|
||||
property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||
property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||
property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
|
||||
property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||
property popular_enabled : Bool = true
|
||||
property captcha_enabled : Bool = true
|
||||
property login_enabled : Bool = true
|
||||
property registration_enabled : Bool = true
|
||||
property statistics_enabled : Bool = false
|
||||
property admins : Array(String) = [] of String
|
||||
property external_port : Int32? = nil
|
||||
property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
|
||||
property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs
|
||||
property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc.
|
||||
property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
||||
property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc.
|
||||
property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||
property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
|
||||
|
||||
# URL to the modified source code to be easily AGPL compliant
|
||||
# Will display in the footer, next to the main source code link
|
||||
property modified_source_code_url : String? = nil
|
||||
|
||||
@[YAML::Field(converter: Preferences::FamilyConverter)]
|
||||
property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
|
||||
property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument)
|
||||
property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument)
|
||||
property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||
property use_quic : Bool = true # Use quic transport for youtube api
|
||||
|
||||
@[YAML::Field(converter: Preferences::StringToCookies)]
|
||||
property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
|
||||
property captcha_key : String? = nil # Key for Anti-Captcha
|
||||
property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
|
||||
|
||||
def disabled?(option)
|
||||
case disabled = CONFIG.disable_proxy
|
||||
when Bool
|
||||
return disabled
|
||||
when Array
|
||||
if disabled.includes? option
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def self.load
|
||||
# Load config from file or YAML string env var
|
||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
||||
env_config_yaml = "INVIDIOUS_CONFIG"
|
||||
|
||||
config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
|
||||
config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
|
||||
|
||||
config = Config.from_yaml(config_yaml)
|
||||
|
||||
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
|
||||
{% for ivar in Config.instance_vars %}
|
||||
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
||||
|
||||
if ENV.has_key?({{env_id}})
|
||||
# puts %(Config.{{ivar.id}} : Loading from env var {{env_id}})
|
||||
env_value = ENV.fetch({{env_id}})
|
||||
success = false
|
||||
|
||||
# Use YAML converter if specified
|
||||
{% ann = ivar.annotation(::YAML::Field) %}
|
||||
{% if ann && ann[:converter] %}
|
||||
puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter)
|
||||
config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
|
||||
puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}})
|
||||
success = true
|
||||
|
||||
# Use regular YAML parser otherwise
|
||||
{% else %}
|
||||
{% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
|
||||
# Sort types to avoid parsing nulls and numbers as strings
|
||||
{% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
|
||||
{{ivar_types}}.each do |ivar_type|
|
||||
if !success
|
||||
begin
|
||||
# puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type})
|
||||
config.{{ivar.id}} = ivar_type.from_yaml(env_value)
|
||||
puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type}))
|
||||
success = true
|
||||
rescue
|
||||
# nop
|
||||
end
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Exit on fail
|
||||
if !success
|
||||
puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}})
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Build database_url from db.* if it's not set directly
|
||||
if config.database_url.to_s.empty?
|
||||
if db = config.db
|
||||
config.database_url = URI.new(
|
||||
scheme: "postgres",
|
||||
user: db.user,
|
||||
password: db.password,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
path: db.dbname,
|
||||
)
|
||||
else
|
||||
puts "Config : Either database_url or db.* is required"
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
return config
|
||||
end
|
||||
end
|
|
@ -22,197 +22,6 @@ struct Annotation
|
|||
property annotations : String
|
||||
end
|
||||
|
||||
struct ConfigPreferences
|
||||
include YAML::Serializable
|
||||
|
||||
property annotations : Bool = false
|
||||
property annotations_subscribed : Bool = false
|
||||
property autoplay : Bool = false
|
||||
property captions : Array(String) = ["", "", ""]
|
||||
property comments : Array(String) = ["youtube", ""]
|
||||
property continue : Bool = false
|
||||
property continue_autoplay : Bool = true
|
||||
property dark_mode : String = ""
|
||||
property latest_only : Bool = false
|
||||
property listen : Bool = false
|
||||
property local : Bool = false
|
||||
property locale : String = "ja"
|
||||
property max_results : Int32 = 40
|
||||
property notifications_only : Bool = false
|
||||
property player_style : String = "invidious"
|
||||
property quality : String = "hd720"
|
||||
property quality_dash : String = "auto"
|
||||
property default_home : String? = "Popular"
|
||||
property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"]
|
||||
property automatic_instance_redirect : Bool = false
|
||||
property related_videos : Bool = true
|
||||
property sort : String = "published"
|
||||
property speed : Float32 = 1.0_f32
|
||||
property thin_mode : Bool = false
|
||||
property unseen_only : Bool = false
|
||||
property video_loop : Bool = false
|
||||
property extend_desc : Bool = false
|
||||
property volume : Int32 = 100
|
||||
property vr_mode : Bool = true
|
||||
property show_nick : Bool = true
|
||||
|
||||
def to_tuple
|
||||
{% begin %}
|
||||
{
|
||||
{{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}}
|
||||
}
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
class Config
|
||||
include YAML::Serializable
|
||||
|
||||
property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions)
|
||||
property feed_threads : Int32 = 1 # Number of threads to use for updating feeds
|
||||
property output : String = "STDOUT" # Log file path or STDOUT
|
||||
property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr
|
||||
property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc)
|
||||
|
||||
@[YAML::Field(converter: Preferences::URIConverter)]
|
||||
property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax
|
||||
property decrypt_polling : Bool = true # Use polling to keep decryption function up to date
|
||||
property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel
|
||||
property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https://
|
||||
property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions
|
||||
property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required
|
||||
property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key)
|
||||
property popular_enabled : Bool = true
|
||||
property captcha_enabled : Bool = true
|
||||
property login_enabled : Bool = true
|
||||
property registration_enabled : Bool = true
|
||||
property statistics_enabled : Bool = false
|
||||
property admins : Array(String) = [] of String
|
||||
property external_port : Int32? = nil
|
||||
property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("")
|
||||
property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs
|
||||
property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc.
|
||||
property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards
|
||||
property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc.
|
||||
property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely
|
||||
property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local'
|
||||
|
||||
# URL to the modified source code to be easily AGPL compliant
|
||||
# Will display in the footer, next to the main source code link
|
||||
property modified_source_code_url : String? = nil
|
||||
|
||||
@[YAML::Field(converter: Preferences::FamilyConverter)]
|
||||
property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729)
|
||||
property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument)
|
||||
property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument)
|
||||
property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
|
||||
property use_quic : Bool = true # Use quic transport for youtube api
|
||||
|
||||
@[YAML::Field(converter: Preferences::StringToCookies)]
|
||||
property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format
|
||||
property captcha_key : String? = nil # Key for Anti-Captcha
|
||||
property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha
|
||||
|
||||
def disabled?(option)
|
||||
case disabled = CONFIG.disable_proxy
|
||||
when Bool
|
||||
return disabled
|
||||
when Array
|
||||
if disabled.includes? option
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
def self.load
|
||||
# Load config from file or YAML string env var
|
||||
env_config_file = "INVIDIOUS_CONFIG_FILE"
|
||||
env_config_yaml = "INVIDIOUS_CONFIG"
|
||||
|
||||
config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml"
|
||||
config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file)
|
||||
|
||||
config = Config.from_yaml(config_yaml)
|
||||
|
||||
# Update config from env vars (upcased and prefixed with "INVIDIOUS_")
|
||||
{% for ivar in Config.instance_vars %}
|
||||
{% env_id = "INVIDIOUS_#{ivar.id.upcase}" %}
|
||||
|
||||
if ENV.has_key?({{env_id}})
|
||||
# puts %(Config.{{ivar.id}} : Loading from env var {{env_id}})
|
||||
env_value = ENV.fetch({{env_id}})
|
||||
success = false
|
||||
|
||||
# Use YAML converter if specified
|
||||
{% ann = ivar.annotation(::YAML::Field) %}
|
||||
{% if ann && ann[:converter] %}
|
||||
puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter)
|
||||
config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0])
|
||||
puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}})
|
||||
success = true
|
||||
|
||||
# Use regular YAML parser otherwise
|
||||
{% else %}
|
||||
{% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %}
|
||||
# Sort types to avoid parsing nulls and numbers as strings
|
||||
{% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %}
|
||||
{{ivar_types}}.each do |ivar_type|
|
||||
if !success
|
||||
begin
|
||||
# puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type})
|
||||
config.{{ivar.id}} = ivar_type.from_yaml(env_value)
|
||||
puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type}))
|
||||
success = true
|
||||
rescue
|
||||
# nop
|
||||
end
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Exit on fail
|
||||
if !success
|
||||
puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}})
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
|
||||
# Build database_url from db.* if it's not set directly
|
||||
if config.database_url.to_s.empty?
|
||||
if db = config.db
|
||||
config.database_url = URI.new(
|
||||
scheme: "postgres",
|
||||
user: db.user,
|
||||
password: db.password,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
path: db.dbname,
|
||||
)
|
||||
else
|
||||
puts "Config : Either database_url or db.* is required"
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
return config
|
||||
end
|
||||
end
|
||||
|
||||
struct DBConfig
|
||||
include YAML::Serializable
|
||||
|
||||
property user : String
|
||||
property password : String
|
||||
property host : String
|
||||
property port : Int32
|
||||
property dbname : String
|
||||
end
|
||||
|
||||
def login_req(f_req)
|
||||
data = {
|
||||
# Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard
|
||||
|
@ -251,43 +60,6 @@ def html_to_content(description_html : String)
|
|||
return description
|
||||
end
|
||||
|
||||
def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
|
||||
extracted = extract_items(initial_data, author_fallback, author_id_fallback)
|
||||
|
||||
target = [] of SearchItem
|
||||
extracted.each do |i|
|
||||
if i.is_a?(Category)
|
||||
i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
|
||||
else
|
||||
target << i
|
||||
end
|
||||
end
|
||||
return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
|
||||
end
|
||||
|
||||
def extract_selected_tab(tabs)
|
||||
# Extract the selected tab from the array of tabs Youtube returns
|
||||
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
|
||||
end
|
||||
|
||||
def fetch_continuation_token(items : Array(JSON::Any))
|
||||
# Fetches the continuation token from an array of items
|
||||
return items.last["continuationItemRenderer"]?
|
||||
.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
|
||||
end
|
||||
|
||||
def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
|
||||
# Fetches the continuation token from initial data
|
||||
if initial_data["onResponseReceivedActions"]?
|
||||
continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
|
||||
else
|
||||
tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
|
||||
continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
|
||||
end
|
||||
|
||||
return fetch_continuation_token(continuation_items.as_a)
|
||||
end
|
||||
|
||||
def check_enum(db, enum_name, struct_type = nil)
|
||||
return # TODO
|
||||
|
||||
|
|
|
@ -1,70 +1,5 @@
|
|||
require "lsquic"
|
||||
require "db"
|
||||
|
||||
def add_yt_headers(request)
|
||||
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36"
|
||||
request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
|
||||
request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
request.headers["accept-language"] ||= "en-us,en;q=0.5"
|
||||
return if request.resource.starts_with? "/sorry/index"
|
||||
request.headers["x-youtube-client-name"] ||= "1"
|
||||
request.headers["x-youtube-client-version"] ||= "2.20200609"
|
||||
# Preserve original cookies and add new YT consent cookie for EU servers
|
||||
request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+"
|
||||
if !CONFIG.cookies.empty?
|
||||
request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
|
||||
end
|
||||
end
|
||||
|
||||
struct YoutubeConnectionPool
|
||||
property! url : URI
|
||||
property! capacity : Int32
|
||||
property! timeout : Float64
|
||||
property pool : DB::Pool(QUIC::Client | HTTP::Client)
|
||||
|
||||
def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true)
|
||||
@url = url
|
||||
@pool = build_pool(use_quic)
|
||||
end
|
||||
|
||||
def client(region = nil, &block)
|
||||
if region
|
||||
conn = make_client(url, region)
|
||||
response = yield conn
|
||||
else
|
||||
conn = pool.checkout
|
||||
begin
|
||||
response = yield conn
|
||||
rescue ex
|
||||
conn.close
|
||||
conn = QUIC::Client.new(url)
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
response = yield conn
|
||||
ensure
|
||||
pool.release(conn)
|
||||
end
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private def build_pool(use_quic)
|
||||
DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
|
||||
if use_quic
|
||||
conn = QUIC::Client.new(url)
|
||||
else
|
||||
conn = HTTP::Client.new(url)
|
||||
end
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
|
||||
def ci_lower_bound(pos, n)
|
||||
if n == 0
|
||||
|
@ -85,37 +20,6 @@ def elapsed_text(elapsed)
|
|||
"#{(millis * 1000).round(2)}µs"
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil)
|
||||
# TODO: Migrate any applicable endpoints to QUIC
|
||||
client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
|
||||
client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
|
||||
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
||||
if region
|
||||
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||
begin
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return client
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil, &block)
|
||||
client = make_client(url, region)
|
||||
begin
|
||||
yield client
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
end
|
||||
|
||||
def decode_length_seconds(string)
|
||||
length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i
|
||||
length_seconds = [0] * (3 - length_seconds.size) + length_seconds
|
||||
|
@ -397,19 +301,6 @@ def parse_range(range)
|
|||
return 0_i64, nil
|
||||
end
|
||||
|
||||
def convert_theme(theme)
|
||||
case theme
|
||||
when "true"
|
||||
"dark"
|
||||
when "false"
|
||||
"light"
|
||||
when "", nil
|
||||
nil
|
||||
else
|
||||
theme
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_random_instance
|
||||
begin
|
||||
instance_api_client = make_client(URI.parse("https://api.invidious.io"))
|
||||
|
|
|
@ -9,7 +9,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
|
|||
lim_fibers = max_fibers
|
||||
active_fibers = 0
|
||||
active_channel = Channel(Bool).new
|
||||
backoff = 1.seconds
|
||||
backoff = 2.minutes
|
||||
|
||||
loop do
|
||||
LOGGER.debug("RefreshChannelsJob: Refreshing all channels")
|
||||
|
@ -58,8 +58,9 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob
|
|||
end
|
||||
end
|
||||
|
||||
LOGGER.debug("RefreshChannelsJob: Done, sleeping for one minute")
|
||||
sleep 1.minute
|
||||
# TODO: make this configurable
|
||||
LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes")
|
||||
sleep 30.minutes
|
||||
Fiber.yield
|
||||
end
|
||||
end
|
||||
|
|
|
@ -97,7 +97,7 @@ def template_mix(mix)
|
|||
<li class="pure-menu-item">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{mix["mixId"]}">
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
|
||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
|
||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||
</div>
|
||||
<p style="width:100%">#{video["title"]}</p>
|
||||
|
|
|
@ -535,7 +535,7 @@ def template_playlist(playlist)
|
|||
<li class="pure-menu-item" id="#{video["videoId"]}">
|
||||
<a href="/watch?v=#{video["videoId"]}&list=#{playlist["playlistId"]}&index=#{video["index"]}">
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
|
||||
<img loading="lazy" class="thumbnail" src="/vi/#{video["videoId"]}/mqdefault.jpg">
|
||||
<p class="length">#{recode_length_seconds(video["lengthSeconds"].as_i)}</p>
|
||||
</div>
|
||||
<p style="width:100%">#{video["title"]}</p>
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
module Invidious::Routes::Images
|
||||
# Avatars, banners and other large image assets.
|
||||
def self.ggpht(env)
|
||||
url = env.request.path.lchop("/ggpht")
|
||||
|
||||
headers = HTTP::Headers{":authority" => "yt3.ggpht.com"}
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
def self.options_storyboard(env)
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
||||
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range"
|
||||
end
|
||||
|
||||
def self.get_storyboard(env)
|
||||
authority = env.params.url["authority"]
|
||||
id = env.params.url["id"]
|
||||
storyboard = env.params.url["storyboard"]
|
||||
index = env.params.url["index"]
|
||||
|
||||
url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}"
|
||||
|
||||
headers = HTTP::Headers.new
|
||||
|
||||
headers[":authority"] = "#{authority}.ytimg.com"
|
||||
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Connection"] = "close"
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
# ??? maybe also for storyboards?
|
||||
def self.s_p_image(env)
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
|
||||
url = env.request.resource
|
||||
|
||||
headers = HTTP::Headers{":authority" => "i9.ytimg.com"}
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
def self.yts_image(env)
|
||||
headers = HTTP::Headers.new
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(env.request.resource, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
|
||||
def self.thumbnails(env)
|
||||
id = env.params.url["id"]
|
||||
name = env.params.url["name"]
|
||||
|
||||
headers = HTTP::Headers{":authority" => "i.ytimg.com"}
|
||||
|
||||
if name == "maxres.jpg"
|
||||
build_thumbnails(id).each do |thumb|
|
||||
if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200
|
||||
name = thumb[:url] + ".jpg"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
url = "/vi/#{id}/#{name}"
|
||||
|
||||
REQUEST_HEADERS_WHITELIST.each do |header|
|
||||
if env.request.headers[header]?
|
||||
headers[header] = env.request.headers[header]
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
YT_POOL.client &.get(url, headers) do |response|
|
||||
env.response.status_code = response.status_code
|
||||
response.headers.each do |key, value|
|
||||
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
|
||||
env.response.headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
if response.status_code >= 300 && response.status_code != 404
|
||||
env.response.headers.delete("Transfer-Encoding")
|
||||
break
|
||||
end
|
||||
|
||||
proxy_file(response, env)
|
||||
end
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
def convert_theme(theme)
|
||||
case theme
|
||||
when "true"
|
||||
"dark"
|
||||
when "false"
|
||||
"light"
|
||||
when "", nil
|
||||
nil
|
||||
else
|
||||
theme
|
||||
end
|
||||
end
|
|
@ -0,0 +1,257 @@
|
|||
struct Preferences
|
||||
include JSON::Serializable
|
||||
include YAML::Serializable
|
||||
|
||||
property annotations : Bool = CONFIG.default_user_preferences.annotations
|
||||
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
|
||||
property autoplay : Bool = CONFIG.default_user_preferences.autoplay
|
||||
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
|
||||
|
||||
@[JSON::Field(converter: Preferences::StringToArray)]
|
||||
@[YAML::Field(converter: Preferences::StringToArray)]
|
||||
property captions : Array(String) = CONFIG.default_user_preferences.captions
|
||||
|
||||
@[JSON::Field(converter: Preferences::StringToArray)]
|
||||
@[YAML::Field(converter: Preferences::StringToArray)]
|
||||
property comments : Array(String) = CONFIG.default_user_preferences.comments
|
||||
property continue : Bool = CONFIG.default_user_preferences.continue
|
||||
property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay
|
||||
|
||||
@[JSON::Field(converter: Preferences::BoolToString)]
|
||||
@[YAML::Field(converter: Preferences::BoolToString)]
|
||||
property dark_mode : String = CONFIG.default_user_preferences.dark_mode
|
||||
property latest_only : Bool = CONFIG.default_user_preferences.latest_only
|
||||
property listen : Bool = CONFIG.default_user_preferences.listen
|
||||
property local : Bool = CONFIG.default_user_preferences.local
|
||||
property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
|
||||
property show_nick : Bool = CONFIG.default_user_preferences.show_nick
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property locale : String = CONFIG.default_user_preferences.locale
|
||||
|
||||
@[JSON::Field(converter: Preferences::ClampInt)]
|
||||
property max_results : Int32 = CONFIG.default_user_preferences.max_results
|
||||
property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property player_style : String = CONFIG.default_user_preferences.player_style
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property quality : String = CONFIG.default_user_preferences.quality
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property quality_dash : String = CONFIG.default_user_preferences.quality_dash
|
||||
property default_home : String? = CONFIG.default_user_preferences.default_home
|
||||
property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
|
||||
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property sort : String = CONFIG.default_user_preferences.sort
|
||||
property speed : Float32 = CONFIG.default_user_preferences.speed
|
||||
property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode
|
||||
property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only
|
||||
property video_loop : Bool = CONFIG.default_user_preferences.video_loop
|
||||
property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc
|
||||
property volume : Int32 = CONFIG.default_user_preferences.volume
|
||||
|
||||
module BoolToString
|
||||
def self.to_json(value : String, json : JSON::Builder)
|
||||
json.string value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : String
|
||||
begin
|
||||
result = value.read_string
|
||||
|
||||
if result.empty?
|
||||
CONFIG.default_user_preferences.dark_mode
|
||||
else
|
||||
result
|
||||
end
|
||||
rescue ex
|
||||
if value.read_bool
|
||||
"dark"
|
||||
else
|
||||
"light"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
||||
unless node.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
|
||||
case node.value
|
||||
when "true"
|
||||
"dark"
|
||||
when "false"
|
||||
"light"
|
||||
when ""
|
||||
CONFIG.default_user_preferences.dark_mode
|
||||
else
|
||||
node.value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ClampInt
|
||||
def self.to_json(value : Int32, json : JSON::Builder)
|
||||
json.number value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : Int32
|
||||
value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
|
||||
end
|
||||
|
||||
def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
|
||||
node.value.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||
end
|
||||
end
|
||||
|
||||
module FamilyConverter
|
||||
def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
|
||||
case value
|
||||
when Socket::Family::UNSPEC
|
||||
yaml.scalar nil
|
||||
when Socket::Family::INET
|
||||
yaml.scalar "ipv4"
|
||||
when Socket::Family::INET6
|
||||
yaml.scalar "ipv6"
|
||||
when Socket::Family::UNIX
|
||||
raise "Invalid socket family #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
case node.value.downcase
|
||||
when "ipv4"
|
||||
Socket::Family::INET
|
||||
when "ipv6"
|
||||
Socket::Family::INET6
|
||||
else
|
||||
Socket::Family::UNSPEC
|
||||
end
|
||||
else
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module URIConverter
|
||||
def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value.normalize!
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
URI.parse node.value
|
||||
else
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ProcessString
|
||||
def self.to_json(value : String, json : JSON::Builder)
|
||||
json.string value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : String
|
||||
HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
|
||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
||||
HTML.escape(node.value[0, 100])
|
||||
end
|
||||
end
|
||||
|
||||
module StringToArray
|
||||
def self.to_json(value : Array(String), json : JSON::Builder)
|
||||
json.array do
|
||||
value.each do |element|
|
||||
json.string element
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : Array(String)
|
||||
begin
|
||||
result = [] of String
|
||||
value.read_array do
|
||||
result << HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
result = [HTML.escape(value.read_string[0, 100]), ""]
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
|
||||
yaml.sequence do
|
||||
value.each do |element|
|
||||
yaml.scalar element
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
|
||||
begin
|
||||
unless node.is_a?(YAML::Nodes::Sequence)
|
||||
node.raise "Expected sequence, not #{node.class}"
|
||||
end
|
||||
|
||||
result = [] of String
|
||||
node.nodes.each do |item|
|
||||
unless item.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{item.class}"
|
||||
end
|
||||
|
||||
result << HTML.escape(item.value[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
result = [HTML.escape(node.value[0, 100]), ""]
|
||||
else
|
||||
result = ["", ""]
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
module StringToCookies
|
||||
def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
|
||||
(value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
|
||||
unless node.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
|
||||
cookies = HTTP::Cookies.new
|
||||
node.value.split(";").each do |cookie|
|
||||
next if cookie.strip.empty?
|
||||
name, value = cookie.split("=", 2)
|
||||
cookies << HTTP::Cookie.new(name.strip, value.strip)
|
||||
end
|
||||
|
||||
cookies
|
||||
end
|
||||
end
|
||||
end
|
|
@ -29,264 +29,6 @@ struct User
|
|||
end
|
||||
end
|
||||
|
||||
struct Preferences
|
||||
include JSON::Serializable
|
||||
include YAML::Serializable
|
||||
|
||||
property annotations : Bool = CONFIG.default_user_preferences.annotations
|
||||
property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed
|
||||
property autoplay : Bool = CONFIG.default_user_preferences.autoplay
|
||||
property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect
|
||||
|
||||
@[JSON::Field(converter: Preferences::StringToArray)]
|
||||
@[YAML::Field(converter: Preferences::StringToArray)]
|
||||
property captions : Array(String) = CONFIG.default_user_preferences.captions
|
||||
|
||||
@[JSON::Field(converter: Preferences::StringToArray)]
|
||||
@[YAML::Field(converter: Preferences::StringToArray)]
|
||||
property comments : Array(String) = CONFIG.default_user_preferences.comments
|
||||
property continue : Bool = CONFIG.default_user_preferences.continue
|
||||
property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay
|
||||
|
||||
@[JSON::Field(converter: Preferences::BoolToString)]
|
||||
@[YAML::Field(converter: Preferences::BoolToString)]
|
||||
property dark_mode : String = CONFIG.default_user_preferences.dark_mode
|
||||
property latest_only : Bool = CONFIG.default_user_preferences.latest_only
|
||||
property listen : Bool = CONFIG.default_user_preferences.listen
|
||||
property local : Bool = CONFIG.default_user_preferences.local
|
||||
property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode
|
||||
property show_nick : Bool = CONFIG.default_user_preferences.show_nick
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property locale : String = CONFIG.default_user_preferences.locale
|
||||
|
||||
@[JSON::Field(converter: Preferences::ClampInt)]
|
||||
property max_results : Int32 = CONFIG.default_user_preferences.max_results
|
||||
property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property player_style : String = CONFIG.default_user_preferences.player_style
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property quality : String = CONFIG.default_user_preferences.quality
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property quality_dash : String = CONFIG.default_user_preferences.quality_dash
|
||||
property default_home : String? = CONFIG.default_user_preferences.default_home
|
||||
property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu
|
||||
property related_videos : Bool = CONFIG.default_user_preferences.related_videos
|
||||
|
||||
@[JSON::Field(converter: Preferences::ProcessString)]
|
||||
property sort : String = CONFIG.default_user_preferences.sort
|
||||
property speed : Float32 = CONFIG.default_user_preferences.speed
|
||||
property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode
|
||||
property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only
|
||||
property video_loop : Bool = CONFIG.default_user_preferences.video_loop
|
||||
property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc
|
||||
property volume : Int32 = CONFIG.default_user_preferences.volume
|
||||
|
||||
module BoolToString
|
||||
def self.to_json(value : String, json : JSON::Builder)
|
||||
json.string value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : String
|
||||
begin
|
||||
result = value.read_string
|
||||
|
||||
if result.empty?
|
||||
CONFIG.default_user_preferences.dark_mode
|
||||
else
|
||||
result
|
||||
end
|
||||
rescue ex
|
||||
if value.read_bool
|
||||
"dark"
|
||||
else
|
||||
"light"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
||||
unless node.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
|
||||
case node.value
|
||||
when "true"
|
||||
"dark"
|
||||
when "false"
|
||||
"light"
|
||||
when ""
|
||||
CONFIG.default_user_preferences.dark_mode
|
||||
else
|
||||
node.value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ClampInt
|
||||
def self.to_json(value : Int32, json : JSON::Builder)
|
||||
json.number value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : Int32
|
||||
value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32
|
||||
end
|
||||
|
||||
def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32
|
||||
node.value.clamp(0, MAX_ITEMS_PER_PAGE)
|
||||
end
|
||||
end
|
||||
|
||||
module FamilyConverter
|
||||
def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder)
|
||||
case value
|
||||
when Socket::Family::UNSPEC
|
||||
yaml.scalar nil
|
||||
when Socket::Family::INET
|
||||
yaml.scalar "ipv4"
|
||||
when Socket::Family::INET6
|
||||
yaml.scalar "ipv6"
|
||||
when Socket::Family::UNIX
|
||||
raise "Invalid socket family #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
case node.value.downcase
|
||||
when "ipv4"
|
||||
Socket::Family::INET
|
||||
when "ipv6"
|
||||
Socket::Family::INET6
|
||||
else
|
||||
Socket::Family::UNSPEC
|
||||
end
|
||||
else
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module URIConverter
|
||||
def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value.normalize!
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
URI.parse node.value
|
||||
else
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ProcessString
|
||||
def self.to_json(value : String, json : JSON::Builder)
|
||||
json.string value
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : String
|
||||
HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
|
||||
def self.to_yaml(value : String, yaml : YAML::Nodes::Builder)
|
||||
yaml.scalar value
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String
|
||||
HTML.escape(node.value[0, 100])
|
||||
end
|
||||
end
|
||||
|
||||
module StringToArray
|
||||
def self.to_json(value : Array(String), json : JSON::Builder)
|
||||
json.array do
|
||||
value.each do |element|
|
||||
json.string element
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_json(value : JSON::PullParser) : Array(String)
|
||||
begin
|
||||
result = [] of String
|
||||
value.read_array do
|
||||
result << HTML.escape(value.read_string[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
result = [HTML.escape(value.read_string[0, 100]), ""]
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder)
|
||||
yaml.sequence do
|
||||
value.each do |element|
|
||||
yaml.scalar element
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String)
|
||||
begin
|
||||
unless node.is_a?(YAML::Nodes::Sequence)
|
||||
node.raise "Expected sequence, not #{node.class}"
|
||||
end
|
||||
|
||||
result = [] of String
|
||||
node.nodes.each do |item|
|
||||
unless item.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{item.class}"
|
||||
end
|
||||
|
||||
result << HTML.escape(item.value[0, 100])
|
||||
end
|
||||
rescue ex
|
||||
if node.is_a?(YAML::Nodes::Scalar)
|
||||
result = [HTML.escape(node.value[0, 100]), ""]
|
||||
else
|
||||
result = ["", ""]
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
module StringToCookies
|
||||
def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder)
|
||||
(value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml)
|
||||
end
|
||||
|
||||
def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies
|
||||
unless node.is_a?(YAML::Nodes::Scalar)
|
||||
node.raise "Expected scalar, not #{node.class}"
|
||||
end
|
||||
|
||||
cookies = HTTP::Cookies.new
|
||||
node.value.split(";").each do |cookie|
|
||||
next if cookie.strip.empty?
|
||||
name, value = cookie.split("=", 2)
|
||||
cookies << HTTP::Cookie.new(name.strip, value.strip)
|
||||
end
|
||||
|
||||
cookies
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_user(sid, headers, db, refresh = true)
|
||||
if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String)
|
||||
user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<a href="/channel/<%= item.ucid %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<center>
|
||||
<img style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
|
||||
<img loading="lazy" style="width:56.25%" src="/ggpht<%= URI.parse(item.author_thumbnail).request_target.gsub(/=s\d+/, "=s176") %>"/>
|
||||
</center>
|
||||
<% end %>
|
||||
<p dir="auto"><%= HTML.escape(item.author) %></p>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<a style="width:100%" href="<%= url %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
|
||||
<img loading="lazy" class="thumbnail" src="<%= URI.parse(item.thumbnail || "/").request_target %>"/>
|
||||
<p class="length"><%= number_with_separator(item.video_count) %> videos</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
@ -36,7 +36,7 @@
|
|||
<a href="/watch?v=<%= item.id %>&list=<%= item.rdid %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<% if item.length_seconds != 0 %>
|
||||
<p class="length"><%= recode_length_seconds(item.length_seconds) %></p>
|
||||
<% end %>
|
||||
|
@ -51,7 +51,7 @@
|
|||
<a style="width:100%" href="/watch?v=<%= item.id %>&list=<%= item.plid %>&index=<%= item.index %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<% if plid = env.get?("remove_playlist_items") %>
|
||||
<form data-onsubmit="return_false" action="/playlist_ajax?action_remove_video=1&set_video_id=<%= item.index %>&playlist_id=<%= plid %>&referer=<%= env.get("current_page") %>" method="post">
|
||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||
|
@ -114,7 +114,7 @@
|
|||
<a style="width:100%" href="/watch?v=<%= item.id %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<img loading="lazy" class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/>
|
||||
<% if env.get? "show_watched" %>
|
||||
<form data-onsubmit="return_false" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post">
|
||||
<input type="hidden" name="csrf_token" value="<%= URI.encode_www_form(env.get?("csrf_token").try &.as(String) || "") %>">
|
||||
|
|
|
@ -303,7 +303,7 @@ we're going to need to do it here in order to allow for translations.
|
|||
<a href="/watch?v=<%= rv["id"] %>">
|
||||
<% if !env.get("preferences").as(Preferences).thin_mode %>
|
||||
<div class="thumbnail">
|
||||
<img class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
|
||||
<img loading="lazy" class="thumbnail" src="/vi/<%= rv["id"] %>/mqdefault.jpg">
|
||||
<p class="length"><%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
require "lsquic"
|
||||
|
||||
def add_yt_headers(request)
|
||||
request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36"
|
||||
request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
|
||||
request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
request.headers["accept-language"] ||= "en-us,en;q=0.5"
|
||||
return if request.resource.starts_with? "/sorry/index"
|
||||
request.headers["x-youtube-client-name"] ||= "1"
|
||||
request.headers["x-youtube-client-version"] ||= "2.20200609"
|
||||
# Preserve original cookies and add new YT consent cookie for EU servers
|
||||
request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+"
|
||||
if !CONFIG.cookies.empty?
|
||||
request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
|
||||
end
|
||||
end
|
||||
|
||||
struct YoutubeConnectionPool
|
||||
property! url : URI
|
||||
property! capacity : Int32
|
||||
property! timeout : Float64
|
||||
property pool : DB::Pool(QUIC::Client | HTTP::Client)
|
||||
|
||||
def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true)
|
||||
@url = url
|
||||
@pool = build_pool(use_quic)
|
||||
end
|
||||
|
||||
def client(region = nil, &block)
|
||||
if region
|
||||
conn = make_client(url, region)
|
||||
response = yield conn
|
||||
else
|
||||
conn = pool.checkout
|
||||
begin
|
||||
response = yield conn
|
||||
rescue ex
|
||||
conn.close
|
||||
conn = QUIC::Client.new(url)
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
response = yield conn
|
||||
ensure
|
||||
pool.release(conn)
|
||||
end
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private def build_pool(use_quic)
|
||||
DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do
|
||||
if use_quic
|
||||
conn = QUIC::Client.new(url)
|
||||
else
|
||||
conn = HTTP::Client.new(url)
|
||||
end
|
||||
conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET
|
||||
conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC
|
||||
conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil)
|
||||
# TODO: Migrate any applicable endpoints to QUIC
|
||||
client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure)
|
||||
client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC
|
||||
client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com"
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
||||
if region
|
||||
PROXY_LIST[region]?.try &.sample(40).each do |proxy|
|
||||
begin
|
||||
proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port])
|
||||
client.set_proxy(proxy)
|
||||
break
|
||||
rescue ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return client
|
||||
end
|
||||
|
||||
def make_client(url : URI, region = nil, &block)
|
||||
client = make_client(url, region)
|
||||
begin
|
||||
yield client
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
end
|
|
@ -321,11 +321,13 @@ private module Parsers
|
|||
content_container = item_contents["contents"]
|
||||
end
|
||||
|
||||
raw_contents = content_container["items"].as_a
|
||||
raw_contents.each do |item|
|
||||
result = extract_item(item)
|
||||
if !result.nil?
|
||||
contents << result
|
||||
raw_contents = content_container["items"]?.try &.as_a
|
||||
if !raw_contents.nil?
|
||||
raw_contents.each do |item|
|
||||
result = extract_item(item)
|
||||
if !result.nil?
|
||||
contents << result
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -399,7 +401,7 @@ private module Extractors
|
|||
items_container = renderer_container_contents
|
||||
end
|
||||
|
||||
items_container["items"].as_a.each do |item|
|
||||
items_container["items"]?.try &.as_a.each do |item|
|
||||
raw_items << item
|
||||
end
|
||||
end
|
||||
|
@ -531,37 +533,6 @@ private module HelperExtractors
|
|||
end
|
||||
end
|
||||
|
||||
# Extracts text from InnerTube response
|
||||
#
|
||||
# InnerTube can package text in three different formats
|
||||
# "runs": [
|
||||
# {"text": "something"},
|
||||
# {"text": "cont"},
|
||||
# ...
|
||||
# ]
|
||||
#
|
||||
# "SimpleText": "something"
|
||||
#
|
||||
# Or sometimes just none at all as with the data returned from
|
||||
# category continuations.
|
||||
#
|
||||
# In order to facilitate calling this function with `#[]?`:
|
||||
# A nil will be accepted. Of course, since nil cannot be parsed,
|
||||
# another nil will be returned.
|
||||
def extract_text(item : JSON::Any?) : String?
|
||||
if item.nil?
|
||||
return nil
|
||||
end
|
||||
|
||||
if text_container = item["simpleText"]?
|
||||
return text_container.as_s
|
||||
elsif text_container = item["runs"]?
|
||||
return text_container.as_a.map(&.["text"].as_s).join("")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parses an item from Youtube's JSON response into a more usable structure.
|
||||
# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel.
|
||||
def extract_item(item : JSON::Any, author_fallback : String? = "",
|
|
@ -0,0 +1,67 @@
|
|||
# Extracts text from InnerTube response
|
||||
#
|
||||
# InnerTube can package text in three different formats
|
||||
# "runs": [
|
||||
# {"text": "something"},
|
||||
# {"text": "cont"},
|
||||
# ...
|
||||
# ]
|
||||
#
|
||||
# "SimpleText": "something"
|
||||
#
|
||||
# Or sometimes just none at all as with the data returned from
|
||||
# category continuations.
|
||||
#
|
||||
# In order to facilitate calling this function with `#[]?`:
|
||||
# A nil will be accepted. Of course, since nil cannot be parsed,
|
||||
# another nil will be returned.
|
||||
def extract_text(item : JSON::Any?) : String?
|
||||
if item.nil?
|
||||
return nil
|
||||
end
|
||||
|
||||
if text_container = item["simpleText"]?
|
||||
return text_container.as_s
|
||||
elsif text_container = item["runs"]?
|
||||
return text_container.as_a.map(&.["text"].as_s).join("")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil)
|
||||
extracted = extract_items(initial_data, author_fallback, author_id_fallback)
|
||||
|
||||
target = [] of SearchItem
|
||||
extracted.each do |i|
|
||||
if i.is_a?(Category)
|
||||
i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video }
|
||||
else
|
||||
target << i
|
||||
end
|
||||
end
|
||||
return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo))
|
||||
end
|
||||
|
||||
def extract_selected_tab(tabs)
|
||||
# Extract the selected tab from the array of tabs Youtube returns
|
||||
return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"]
|
||||
end
|
||||
|
||||
def fetch_continuation_token(items : Array(JSON::Any))
|
||||
# Fetches the continuation token from an array of items
|
||||
return items.last["continuationItemRenderer"]?
|
||||
.try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s
|
||||
end
|
||||
|
||||
def fetch_continuation_token(initial_data : Hash(String, JSON::Any))
|
||||
# Fetches the continuation token from initial data
|
||||
if initial_data["onResponseReceivedActions"]?
|
||||
continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"]
|
||||
else
|
||||
tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])
|
||||
continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"]
|
||||
end
|
||||
|
||||
return fetch_continuation_token(continuation_items.as_a)
|
||||
end
|
|
@ -92,7 +92,7 @@ class HTTPClient < HTTP::Client
|
|||
end
|
||||
end
|
||||
|
||||
def get_proxies(country_code = "JP")
|
||||
def get_proxies(country_code = "US")
|
||||
# return get_spys_proxies(country_code)
|
||||
return get_nova_proxies(country_code)
|
||||
end
|
||||
|
@ -119,7 +119,7 @@ def filter_proxies(proxies)
|
|||
return proxies
|
||||
end
|
||||
|
||||
def get_nova_proxies(country_code = "JP")
|
||||
def get_nova_proxies(country_code = "US")
|
||||
country_code = country_code.downcase
|
||||
client = HTTP::Client.new(URI.parse("https://www.proxynova.com"))
|
||||
client.read_timeout = 10.seconds
|
||||
|
@ -128,7 +128,7 @@ def get_nova_proxies(country_code = "JP")
|
|||
headers = HTTP::Headers.new
|
||||
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
||||
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
|
||||
headers["Accept-Language"] = "Accept-Language: ja-JP,ja;q=0.9"
|
||||
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
|
||||
headers["Host"] = "www.proxynova.com"
|
||||
headers["Origin"] = "https://www.proxynova.com"
|
||||
headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/"
|
||||
|
@ -158,7 +158,7 @@ def get_nova_proxies(country_code = "JP")
|
|||
return proxies
|
||||
end
|
||||
|
||||
def get_spys_proxies(country_code = "JP")
|
||||
def get_spys_proxies(country_code = "US")
|
||||
client = HTTP::Client.new(URI.parse("http://spys.one"))
|
||||
client.read_timeout = 10.seconds
|
||||
client.connect_timeout = 10.seconds
|
||||
|
@ -166,7 +166,7 @@ def get_spys_proxies(country_code = "JP")
|
|||
headers = HTTP::Headers.new
|
||||
headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36"
|
||||
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
|
||||
headers["Accept-Language"] = "Accept-Language: ja-JP,ja;q=0.9"
|
||||
headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9"
|
||||
headers["Host"] = "spys.one"
|
||||
headers["Origin"] = "http://spys.one"
|
||||
headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/"
|
|
@ -72,7 +72,7 @@ module YoutubeAPI
|
|||
#
|
||||
# ```
|
||||
# # Get Norwegian search results
|
||||
# conf_1 = ClientConfig.new(region: "JP")
|
||||
# conf_1 = ClientConfig.new(region: "NO")
|
||||
# YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1)
|
||||
#
|
||||
# # Use the Android client to request video streams URLs
|
||||
|
@ -80,7 +80,7 @@ module YoutubeAPI
|
|||
# YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2)
|
||||
#
|
||||
# # Proxy request through russian proxies
|
||||
# conf_3 = ClientConfig.new(proxy_region: "JP")
|
||||
# conf_3 = ClientConfig.new(proxy_region: "RU")
|
||||
# YoutubeAPI::next({video_id: "dQw4w9WgXcQ"}, client_config: conf_3)
|
||||
# ```
|
||||
#
|
||||
|
@ -101,7 +101,7 @@ module YoutubeAPI
|
|||
def initialize(
|
||||
*,
|
||||
@client_type = ClientType::Web,
|
||||
@region = "JP",
|
||||
@region = "US",
|
||||
@proxy_region = nil
|
||||
)
|
||||
end
|
||||
|
@ -153,7 +153,7 @@ module YoutubeAPI
|
|||
client_context = {
|
||||
"client" => {
|
||||
"hl" => "en",
|
||||
"gl" => "JP", #client_config.region || "JP", # Can't be empty!
|
||||
"gl" => client_config.region || "US", # Can't be empty!
|
||||
"clientName" => client_config.name,
|
||||
"clientVersion" => client_config.version,
|
||||
},
|
読み込み中…
新しいイシューから参照