diff --git a/.github/workflows/build-nightly-container.yml b/.github/workflows/build-nightly-container.yml index 4149bd0b..44be0bae 100644 --- a/.github/workflows/build-nightly-container.yml +++ b/.github/workflows/build-nightly-container.yml @@ -17,16 +17,26 @@ on: jobs: release: - runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux/amd64 + name: "AMD64" + dockerfile: "docker/Dockerfile" + tag_suffix: "" + # GitHub doesn't have a ubuntu-latest-arm runner + - os: ubuntu-24.04-arm + platform: linux/arm64/v8 + name: "ARM64" + dockerfile: "docker/Dockerfile.arm64" + tag_suffix: "-arm64" + + runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -43,45 +53,22 @@ jobs: uses: docker/metadata-action@v5 with: images: quay.io/invidious/invidious + flavor: | + suffix=${{ matrix.tag_suffix }} tags: | type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} labels: | quay.expires-after=12w - - name: Build and push Docker AMD64 image for Push Event + - name: Build and push Docker ${{ matrix.name }} image for Push Event uses: docker/build-push-action@v6 with: context: . - file: docker/Dockerfile - platforms: linux/amd64 + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} push: true tags: ${{ steps.meta.outputs.tags }} build-args: | "release=1" - - - name: Docker meta - id: meta-arm64 - uses: docker/metadata-action@v5 - with: - images: quay.io/invidious/invidious - flavor: | - suffix=-arm64 - tags: | - type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} - type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} - labels: | - quay.expires-after=12w - - - name: Build and push Docker ARM64 image for Push Event - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile.arm64 - platforms: linux/arm64/v8 - labels: ${{ steps.meta-arm64.outputs.labels }} - push: true - tags: ${{ steps.meta-arm64.outputs.tags }} - build-args: | - "release=1" diff --git a/.github/workflows/build-stable-container.yml b/.github/workflows/build-stable-container.yml index 1a23e68c..e119880d 100644 --- a/.github/workflows/build-stable-container.yml +++ b/.github/workflows/build-stable-container.yml @@ -8,16 +8,26 @@ on: jobs: release: - runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux/amd64 + name: "AMD64" + dockerfile: "docker/Dockerfile" + tag_suffix: "" + # GitHub doesn't have a ubuntu-latest-arm runner + - os: ubuntu-24.04-arm + platform: linux/arm64/v8 + name: "ARM64" + dockerfile: "docker/Dockerfile.arm64" + tag_suffix: "-arm64" + + runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -36,46 +46,21 @@ jobs: images: quay.io/invidious/invidious flavor: | latest=false + suffix=${{ matrix.tag_suffix }} tags: | type=semver,pattern={{version}} type=raw,value=latest labels: | quay.expires-after=12w - - name: Build and push Docker AMD64 image for Push Event + - name: Build and push Docker ${{ matrix.name }} image for Push Event uses: docker/build-push-action@v6 with: context: . - file: docker/Dockerfile - platforms: linux/amd64 + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} push: true tags: ${{ steps.meta.outputs.tags }} build-args: | "release=1" - - - name: Docker meta - id: meta-arm64 - uses: docker/metadata-action@v5 - with: - images: quay.io/invidious/invidious - flavor: | - latest=false - suffix=-arm64 - tags: | - type=semver,pattern={{version}} - type=raw,value=latest - labels: | - quay.expires-after=12w - - - name: Build and push Docker ARM64 image for Push Event - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile.arm64 - platforms: linux/arm64/v8 - labels: ${{ steps.meta-arm64.outputs.labels }} - push: true - tags: ${{ steps.meta-arm64.outputs.tags }} - build-args: | - "release=1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc2aa3f..a5fc9af9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: stable: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true @@ -58,7 +58,7 @@ jobs: shell: bash - name: Install Crystal - uses: crystal-lang/install-crystal@v1.8.2 + uses: crystal-lang/install-crystal@v1.9.1 with: crystal: ${{ matrix.crystal }} @@ -83,46 +83,43 @@ jobs: run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr build-docker: + strategy: + matrix: + include: + - os: ubuntu-latest + name: "AMD64" + # GitHub doesn't have a ubuntu-latest-arm runner + - os: ubuntu-24.04-arm + name: "ARM64" - runs-on: ubuntu-latest + name: Test ${{ matrix.name }} Docker build + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + + - name: Use ARM64 Dockerfile if ARM64 + if: ${{ matrix.name == 'ARM64' }} + run: sed -i 's/Dockerfile/Dockerfile.arm64/' docker-compose.yml - name: Build Docker - run: docker compose build --build-arg release=0 + run: docker compose build + + - name: Change hmac_key on docker-compose.yml + run: sed -i '/hmac_key/s/CHANGE_ME!!/docker-build-hmac-key/' docker-compose.yml - name: Run Docker run: docker compose up -d - name: Test Docker - run: while curl -Isf http://localhost:3000; do sleep 1; done + id: test + run: curl -If http://localhost:3000 --retry 5 --retry-delay 1 --retry-all-errors - build-docker-arm64: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker ARM64 image - uses: docker/build-push-action@v6 - 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 + - name: Print Invidious container logs + # Tells Github Actions to always run this step regardless of whether the previous step has failed + # Without this expression this step would simply be skipped when the previous step fails. + if: success() || steps.test.conclusion == 'failure' + run: docker compose logs lint: @@ -131,13 +128,13 @@ jobs: continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true - name: Install Crystal id: lint_step_install_crystal - uses: crystal-lang/install-crystal@v1.8.2 + uses: crystal-lang/install-crystal@v1.9.1 with: crystal: latest diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 65340d14..ab45ce12 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,7 +10,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 730 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3e7acd..81210997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,140 @@ ## vX.Y.0 (future) +## v2.20250913.0 + +### Wrap-up + +This release primarily marks Invidious companion's ascend out of beta and its stable integration thereof into Invidious! + +For those unaware Invidious companion is the successor to the `inv-sig-helper` tool, designed to securely pass YouTube's attestation checks and allow for the efficient retrieval and playback of video streams reliably. + +Companion delivers YouTube fixes faster since it’s built on the community-driven [YouTube.js](https://github.com/LuanRT/YouTube.js) project, used by many open source projects such as [FreeTube](https://github.com/FreeTubeApp/FreeTube). + +For more information see https://github.com/iv-org/invidious-companion and https://docs.invidious.io/installation/ + +But companion isn't the only new thing in this release! + +Invidious will no longer error out completely as soon as a single item failed to parse in search results, channel pages, etc. Instead it now handles it gracefully by substituting those problematic items with an error card and rendering the page normally. + +The player has gained some quality of life features such as being able to choose a default playlist for videos to be added to, or persisting caption appearance settings across the session. + +Base Invidious video retrieval without Invidious companion has also been made more stable. + +And finally a significant amount of bugs were fixed alongside many other minor improvements. + +### New features & important changes +#### For Users + - DASH is now enabled by default due to YouTube's removal of the 720p non-dash streams + - Javascript licencing info has been added to all of Invidious' scripts, restoring full compatibility with LibreJS + - There is no longer an option for a text captcha during registration due to the shutdown (presumably) of the upstream service + - Parse errors in feeds will no longer render the entire feed unusable and instead will substitute only the broken items with error cards + - Keyboard shortcuts have been added to configure caption styles: + - `-`,`=` can be used to change the font size + - `o` can be used to cycle the opacity of the caption text + - `w` can be used to cycle the opacity of the caption box + - Caption styles changed through the VideoJS menu will now persist + - You can now choose a default playlist to add videos to instead of needing to manually select one each time + +#### For instance owners + - Invidious companion support has been added to replace the deprecated inv-sig-helper + - **DASH is now the default resolution! Please ensure that your instances can withstand the significantly higher bandwidth usage or manually configure your instance to use non-dash streams by default** + - Invidious will now warn when it is unable to connect to the database instead of failing silently + - **The text captcha during registration has been removed due to the shutdown (presumably) of the upstream service** + +#### For developers + - Dependabot has been added to keep Github Actions and Docker dependencies up-to-date. + - CI version matrix has been bumped to the latest patch release for each minor version + - The versions of Crystal that we test in CI/CD are now: `1.12.2`, `1.13.3`, `1.14.1`, `1.15.1`, `1.16.3` + - `Kilt` is no longer a dependency of Invidious + - The ARM64 docker image builds (and the test CI) has been changed to use Github's ARM64 runner instead of QEMU + - **An "error" JSON object can now be returned in various API responses in-place of an item that has failed to parse**: + + ```json + { + "type": "parse-error", + "errorMessage": "...", + "errorBacktrace": "..." + } + ``` + +### Bugs fixed +#### User-side + - Livestream will now be properly proxied again allowing playback from the UI + - The proxy video preference for logged-in users will no longer get ignored when a default value is set by the instance + - Fixes the missing `label` key error on select search results and other feeds + - Invidious will no longer strip out spaces from search queries when navigating back from the preferences page + - Restores functionality to the `subscriptions:true` search keyword + - The channel RSS feeds will no longer have an empty title + - Individual community posts can be viewed again + - The playlists tab of channels can be viewed again + - Fix incorrect dates, region, etc of videos + - Various minor fixes were made to how video info is extracted in setups without Invidious companion to improve resiliency and chances of success + - Fix issue where the notification count becomes `TRUE` rather than an actual number +#### For instance owners + - Fixed a minor typo in config.example.yml (`effet` -> `effect`) +#### For developers + - The docker image test CI will now properly check whether Invidious has started + +### Full list of pull requests merged since the last release (newest first) + +* Add Invidious companion support (https://github.com/iv-org/invidious/pull/4985, by @unixfox) +* Bump shards.yml version to dev version (https://github.com/iv-org/invidious/pull/5206, by @syeopite) +* chore: enforce 16 characters for invidious_companion_key (https://github.com/iv-org/invidious/pull/5220, by @unixfox) +* chore: set dash by default (https://github.com/iv-org/invidious/pull/5216, by @unixfox) +* Fix minor casing issues in brand names (https://github.com/iv-org/invidious/pull/5258, thanks @efb4f5ff-1298-471a-8973-3d47447115dc) +* feat: route to invidious companion on downloads (https://github.com/iv-org/invidious/pull/5224, by @alexmaras) +* Fix proxying live DASH streams (https://github.com/iv-org/invidious/pull/4589, thanks @absidue) +* Reflect companion secret character limit in example config comment (https://github.com/iv-org/invidious/pull/5269, thanks @Vyquos) +* chore: Add dependabot for docker and github actions (https://github.com/iv-org/invidious/pull/5285, by @unixfox) +* Bump actions/stale from 8 to 9 (https://github.com/iv-org/invidious/pull/5291, thanks @dependabot[bot]) +* Bump actions/cache from 3 to 4 (https://github.com/iv-org/invidious/pull/5289, thanks @dependabot[bot]) +* Bump alpine from 3.20 to 3.21 in /docker (https://github.com/iv-org/invidious/pull/5288, thanks @dependabot[bot]) +* Bump docker/build-push-action from 5 to 6 (https://github.com/iv-org/invidious/pull/5287, thanks @dependabot[bot]) +* Bump crystal-lang/install-crystal from 1.8.0 to 1.8.2 (https://github.com/iv-org/invidious/pull/5286, thanks @dependabot[bot]) +* Bump crystallang/crystal from 1.12.2-alpine to 1.16.2-alpine in /docker (https://github.com/iv-org/invidious/pull/5290, thanks @dependabot[bot]) +* Bump crystallang/crystal from 1.16.2-alpine to 1.16.3-alpine in /docker (https://github.com/iv-org/invidious/pull/5301, thanks @dependabot[bot]) +* CI: Bump Crystal version matrix (https://github.com/iv-org/invidious/pull/5293, by @Fijxu) +* fix(typo): 'Salect' -> 'Select' (https://github.com/iv-org/invidious/pull/5242, by @Fijxu) +* fix: set CSP header after setting preferences of registered users (https://github.com/iv-org/invidious/pull/5275, by @Fijxu) +* fix: safely access "label" key (https://github.com/iv-org/invidious/pull/5282, by @Fijxu) +* Add missing javascript licenses (https://github.com/iv-org/invidious/pull/5292, by @Fijxu) +* Add Javascript licence information automatically (https://github.com/iv-org/invidious/pull/5297, by @syeopite) +* Remove text captcha due to textcaptcha.com being down (https://github.com/iv-org/invidious/pull/5308, by @Fijxu) +* Release versioning maintenance (https://github.com/iv-org/invidious/pull/5310, by @syeopite) +* Update Kemal to 1.6.0 and remove Kilt (https://github.com/iv-org/invidious/pull/5120, by @syeopite) +* Translations update from Hosted Weblate (https://github.com/iv-org/invidious/pull/5192, thanks @weblate) +* require base_job before the other jobs (https://github.com/iv-org/invidious/pull/5194, by @Fijxu) +* Handle parse errors gracefully on timeline items (https://github.com/iv-org/invidious/pull/5196, by @syeopite) +* fix: do not strip '+' character from referer (https://github.com/iv-org/invidious/pull/5276, by @Fijxu) +* fix: pass user to `query.process` if present. (https://github.com/iv-org/invidious/pull/5277, by @Fijxu) +* Add missing xml.text on "title" element for channels RSS (https://github.com/iv-org/invidious/pull/5320, by @Fijxu) +* Remove `@iv-org/developers` from codeowners (https://github.com/iv-org/invidious/pull/5314, by @syeopite) +* Make base-Invidious video info extraction more resilient (https://github.com/iv-org/invidious/pull/5312, by @syeopite) +* Bump actions/checkout from 4 to 5 (https://github.com/iv-org/invidious/pull/5415, thanks @dependabot[bot]) +* Player: Add keyboard shortcuts to configure captions (https://github.com/iv-org/invidious/pull/5188, thanks @epicsam123) +* CI: Use public ARM64 Github actions runners for ARM64 builds. (https://github.com/iv-org/invidious/pull/5305, by @Fijxu) +* CI: Fix docker ci job not checking if Invidious starts successfully or not (https://github.com/iv-org/invidious/pull/5306, by @Fijxu) +* YtAPI: Bump client versions (https://github.com/iv-org/invidious/pull/5325, by @Fijxu) +* YTAPI: Add `TvSimply` client (https://github.com/iv-org/invidious/pull/5344, by @Fijxu) +* Videos: Add fallback to TvSimply client (https://github.com/iv-org/invidious/pull/5345, by @Fijxu) +* Show message when connection to the database is not possible (https://github.com/iv-org/invidious/pull/5346, by @Fijxu) +* Channels: Fix fetching of individual community posts (https://github.com/iv-org/invidious/pull/5361, thanks @ChunkyProgrammer) +* Videos: Fix missing .id to retrieve first playlist video ID (https://github.com/iv-org/invidious/pull/5366, by @SamantazFox) +* HTML: Add Missing Noreferrers (https://github.com/iv-org/invidious/pull/5368, thanks @epicsam123) +* Documentation: Fix typo (effet -> effect) (https://github.com/iv-org/invidious/pull/5369, thanks @nsunami) +* Frontend: Fix notification count of `TRUE` (https://github.com/iv-org/invidious/pull/5391, thanks @fieryhenry) +* Player: Persist caption settings (https://github.com/iv-org/invidious/pull/5417, thanks @p-himik) +* Channels: Fix fetching channel playlists (https://github.com/iv-org/invidious/pull/5418, thanks @KrisVos130) +* CI: fix wrong if statement for build-docker job (https://github.com/iv-org/invidious/pull/5442, by @Fijxu) +* initial base_url companion support + proxy companion (https://github.com/iv-org/invidious/pull/5266, by @unixfox) +* Prevent player microformat from being overwritten by the next microformat (https://github.com/iv-org/invidious/pull/5453, by @Fijxu) +* Bump actions/stale from 9 to 10 (https://github.com/iv-org/invidious/pull/5457, thanks @dependabot[bot]) +* Better documentation for the specific case public_url with companion (https://github.com/iv-org/invidious/pull/5461, by @unixfox) +* Add default playlist preference (https://github.com/iv-org/invidious/pull/5449, by @Fijxu) +* Translations update from Hosted Weblate (https://github.com/iv-org/invidious/pull/5313, thanks to our many translators) +* Release `v2.20250913.0` (https://github.com/iv-org/invidious/pull/5463, by @syeopite) + ## v2.20250517.0 Inverse fallback for the YouTube client from TVHTML then MWEB. Fixes https://github.com/iv-org/invidious/issues/5273 diff --git a/assets/css/default.css b/assets/css/default.css index 752dd570..bdf76f76 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -167,6 +167,7 @@ body a.pure-button-primary, .pure-button-primary, .pure-button-secondary { + white-space: normal; border: 1px solid #a0a0a0; border-radius: 3px; margin: 0 .4em; @@ -403,8 +404,9 @@ input[type="search"]::-webkit-search-cancel-button { .video-card-row { margin: 15px 0; } -p.channel-name { margin: 0; } +p.channel-name { margin: 0; overflow-wrap: anywhere;} p.video-data { margin: 0; font-weight: bold; font-size: 80%; } +.channel-profile > .channel-name { overflow-wrap: anywhere;} /* diff --git a/assets/css/player.css b/assets/css/player.css index 9cb400ad..d95549ac 100644 --- a/assets/css/player.css +++ b/assets/css/player.css @@ -86,6 +86,7 @@ ul.vjs-menu-content::-webkit-scrollbar { background-color: rgba(0, 0, 0, 0.75) !important; border-radius: 9px !important; padding: 5px !important; + line-height: 1.5 !important; } .vjs-play-control, diff --git a/assets/js/notifications.js b/assets/js/notifications.js index 55b7a15c..16d9866d 100644 --- a/assets/js/notifications.js +++ b/assets/js/notifications.js @@ -77,7 +77,7 @@ function create_notification_stream(subscriptions) { function update_ticker_count() { var notification_ticker = document.getElementById('notification_ticker'); - const notification_count = helpers.storage.get(STORAGE_KEY_STREAM); + const notification_count = helpers.storage.get(STORAGE_KEY_NOTIF_COUNT) || 0; if (notification_count > 0) { notification_ticker.innerHTML = '' + notification_count + ' '; diff --git a/assets/js/player.js b/assets/js/player.js index f32c9b56..ecdc0448 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -5,6 +5,10 @@ var video_data = JSON.parse(document.getElementById('video_data').textContent); var options = { liveui: true, playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], + fontPercent: [0.5, 0.75, 1.25, 1.5, 1.75, 2, 3, 4], + windowOpacity: ['0', '0.5', '1'], + textOpacity: ['0.5', '1'], + persistTextTrackSettings: true, controlBar: { children: [ 'playToggle', @@ -133,16 +137,18 @@ player.on('timeupdate', function () { // YouTube links - let elem_yt_watch = document.getElementById('link-yt-watch'); - if (elem_yt_watch) { - let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url'); - elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch); - } - - let elem_yt_embed = document.getElementById('link-yt-embed'); - if (elem_yt_embed) { - let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); - elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed); + if (!video_data.live_now) { + let elem_yt_watch = document.getElementById('link-yt-watch'); + if (elem_yt_watch) { + let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url'); + elem_yt_watch.href = addCurrentTimeToURL(base_url_yt_watch); + } + + let elem_yt_embed = document.getElementById('link-yt-embed'); + if (elem_yt_embed) { + let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); + elem_yt_embed.href = addCurrentTimeToURL(base_url_yt_embed); + } } // Invidious links @@ -180,7 +186,7 @@ var shareOptions = { }; if (location.pathname.startsWith('/embed/')) { - var overlay_content = '
#{translate(locale, "Download is disabled")}
" end + if CONFIG.dmca_content.includes?(video.id) + return "#{translate(locale, "dmca_content")}
" + end + url = "/download" if (CONFIG.invidious_companion.present?) invidious_companion = CONFIG.invidious_companion.sample @@ -34,7 +38,7 @@ module Invidious::Frontend::WatchPage str << " class=\"pure-form pure-form-stacked\"" str << " action='#{url}'" str << " method='post'" - str << " rel='noopener'" + str << " rel='noopener noreferrer'" str << " target='_blank'>" str << '\n' diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 13ea9fe9..7c5ef118 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -61,28 +61,13 @@ class Kemal::ExceptionHandler end end -class FilteredCompressHandler < Kemal::Handler +class FilteredCompressHandler < HTTP::CompressHandler exclude ["/videoplayback", "/videoplayback/*", "/vi/*", "/sb/*", "/ggpht/*", "/api/v1/auth/notifications"] exclude ["/api/v1/auth/notifications", "/data_control"], "POST" - def call(env) - return call_next env if exclude_match? env - - {% if flag?(:without_zlib) %} - call_next env - {% else %} - request_headers = env.request.headers - - if request_headers.includes_word?("Accept-Encoding", "gzip") - env.response.headers["Content-Encoding"] = "gzip" - env.response.output = Compress::Gzip::Writer.new(env.response.output, sync_close: true) - elsif request_headers.includes_word?("Accept-Encoding", "deflate") - env.response.headers["Content-Encoding"] = "deflate" - env.response.output = Compress::Deflate::Writer.new(env.response.output, sync_close: true) - end - - call_next env - {% end %} + def call(context) + return call_next context if exclude_match? context + super end end diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr deleted file mode 100644 index 6d198a42..00000000 --- a/src/invidious/helpers/sig_helper.cr +++ /dev/null @@ -1,349 +0,0 @@ -require "uri" -require "socket" -require "socket/tcp_socket" -require "socket/unix_socket" - -{% if flag?(:advanced_debug) %} - require "io/hexdump" -{% end %} - -private alias NetworkEndian = IO::ByteFormat::NetworkEndian - -module Invidious::SigHelper - enum UpdateStatus - Updated - UpdateNotRequired - Error - end - - # ------------------- - # Payload types - # ------------------- - - abstract struct Payload - end - - struct StringPayload < Payload - getter string : String - - def initialize(str : String) - raise Exception.new("SigHelper: String can't be empty") if str.empty? - @string = str - end - - def self.from_bytes(slice : Bytes) - size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice) - if size == 0 # Error code - raise Exception.new("SigHelper: Server encountered an error") - end - - if (slice.bytesize - 2) != size - raise Exception.new("SigHelper: String size mismatch") - end - - if str = String.new(slice[2..]) - return self.new(str) - else - raise Exception.new("SigHelper: Can't read string from socket") - end - end - - def to_io(io) - # `.to_u16` raises if there is an overflow during the conversion - io.write_bytes(@string.bytesize.to_u16, NetworkEndian) - io.write(@string.to_slice) - end - end - - private enum Opcode - FORCE_UPDATE = 0 - DECRYPT_N_SIGNATURE = 1 - DECRYPT_SIGNATURE = 2 - GET_SIGNATURE_TIMESTAMP = 3 - GET_PLAYER_STATUS = 4 - PLAYER_UPDATE_TIMESTAMP = 5 - end - - private record Request, - opcode : Opcode, - payload : Payload? - - # ---------------------- - # High-level functions - # ---------------------- - - class Client - @mux : Multiplexor - - def initialize(uri_or_path) - @mux = Multiplexor.new(uri_or_path) - end - - # Forces the server to re-fetch the YouTube player, and extract the necessary - # components from it (nsig function code, sig function code, signature timestamp). - def force_update : UpdateStatus - request = Request.new(Opcode::FORCE_UPDATE, nil) - - value = send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt16, bytes) - end - - case value - when 0x0000 then return UpdateStatus::Error - when 0xFFFF then return UpdateStatus::UpdateNotRequired - when 0xF44F then return UpdateStatus::Updated - else - code = value.nil? ? "nil" : value.to_s(base: 16) - raise Exception.new("SigHelper: Invalid status code received #{code}") - end - end - - # Decrypt a provided n signature using the server's current nsig function - # code, and return the result (or an error). - def decrypt_n_param(n : String) : String? - request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) - - n_dec = self.send_request(request) do |bytes| - StringPayload.from_bytes(bytes).string - end - - return n_dec - end - - # Decrypt a provided s signature using the server's current sig function - # code, and return the result (or an error). - def decrypt_sig(sig : String) : String? - request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) - - sig_dec = self.send_request(request) do |bytes| - StringPayload.from_bytes(bytes).string - end - - return sig_dec - end - - # Return the signature timestamp from the server's current player - def get_signature_timestamp : UInt64? - request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) - - return self.send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) - end - end - - # Return the current player's version - def get_player : UInt32? - request = Request.new(Opcode::GET_PLAYER_STATUS, nil) - - return self.send_request(request) do |bytes| - has_player = (bytes[0] == 0xFF) - player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) - has_player ? player_version : nil - end - end - - # Return when the player was last updated - def get_player_timestamp : UInt64? - request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil) - - return self.send_request(request) do |bytes| - IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) - end - end - - private def send_request(request : Request, &) - channel = @mux.send(request) - slice = channel.receive - return yield slice - rescue ex - LOGGER.debug("SigHelper: Error when sending a request") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end - end - - # --------------------- - # Low level functions - # --------------------- - - class Multiplexor - alias TransactionID = UInt32 - record Transaction, channel = ::Channel(Bytes).new - - @prng = Random.new - @mutex = Mutex.new - @queue = {} of TransactionID => Transaction - - @conn : Connection - @uri_or_path : String - - def initialize(@uri_or_path) - @conn = Connection.new(uri_or_path) - listen - end - - def listen : Nil - raise "Socket is closed" if @conn.closed? - - LOGGER.debug("SigHelper: Multiplexor listening") - - spawn do - loop do - begin - receive_data - rescue ex - LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...") - # We close the socket because for some reason is not closed. - @conn.close - loop do - begin - @conn = Connection.new(@uri_or_path) - LOGGER.info("SigHelper: Reconnected to SigHelper!") - rescue ex - LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying") - sleep 500.milliseconds - next - end - break if !@conn.closed? - end - end - Fiber.yield - end - end - end - - def send(request : Request) - transaction = Transaction.new - transaction_id = @prng.rand(TransactionID) - - # Add transaction to queue - @mutex.synchronize do - # On a 32-bits random integer, this should never happen. Though, just in case, ... - if @queue[transaction_id]? - raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!") - end - - @queue[transaction_id] = transaction - end - - write_packet(transaction_id, request) - - return transaction.channel - end - - def receive_data - transaction_id, slice = read_packet - - @mutex.synchronize do - if transaction = @queue.delete(transaction_id) - # Remove transaction from queue and send data to the channel - transaction.channel.send(slice) - LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel") - else - raise Exception.new("SigHelper: Received transaction was not in queue") - end - end - end - - # Read a single packet from the socket - private def read_packet : {TransactionID, Bytes} - # Header - transaction_id = @conn.read_bytes(UInt32, NetworkEndian) - length = @conn.read_bytes(UInt32, NetworkEndian) - - LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}") - - if length > 67_000 - raise Exception.new("SigHelper: Packet longer than expected (#{length})") - end - - # Payload - slice = Bytes.new(length) - @conn.read(slice) if length > 0 - - LOGGER.trace("SigHelper: payload = #{slice}") - LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done") - - return transaction_id, slice - end - - # Write a single packet to the socket - private def write_packet(transaction_id : TransactionID, request : Request) - LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}") - - io = IO::Memory.new(1024) - io.write_bytes(request.opcode.to_u8, NetworkEndian) - io.write_bytes(transaction_id, NetworkEndian) - - if payload = request.payload - payload.to_io(io) - end - - @conn.send(io) - @conn.flush - - LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done") - end - end - - class Connection - @socket : UNIXSocket | TCPSocket - - {% if flag?(:advanced_debug) %} - @io : IO::Hexdump - {% end %} - - def initialize(host_or_path : String) - case host_or_path - when .starts_with?('/') - # Make sure that the file exists - if File.exists?(host_or_path) - @socket = UNIXSocket.new(host_or_path) - else - raise Exception.new("SigHelper: '#{host_or_path}' no such file") - end - when .starts_with?("tcp://") - uri = URI.parse(host_or_path) - @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) - else - uri = URI.parse("tcp://#{host_or_path}") - @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) - end - LOGGER.info("SigHelper: Using helper at '#{host_or_path}'") - - {% if flag?(:advanced_debug) %} - @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true) - {% end %} - - @socket.sync = false - @socket.blocking = false - end - - def closed? : Bool - return @socket.closed? - end - - def close : Nil - @socket.close if !@socket.closed? - end - - def flush(*args, **options) - @socket.flush(*args, **options) - end - - def send(*args, **options) - @socket.send(*args, **options) - end - - # Wrap IO functions, with added debug tooling if needed - {% for function in %w(read read_bytes write write_bytes) %} - def {{function.id}}(*args, **options) - {% if flag?(:advanced_debug) %} - @io.{{function.id}}(*args, **options) - {% else %} - @socket.{{function.id}}(*args, **options) - {% end %} - end - {% end %} - end -end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr deleted file mode 100644 index 82a28fc0..00000000 --- a/src/invidious/helpers/signatures.cr +++ /dev/null @@ -1,53 +0,0 @@ -require "http/params" -require "./sig_helper" - -class Invidious::DecryptFunction - @last_update : Time = Time.utc - 42.days - - def initialize(uri_or_path) - @client = SigHelper::Client.new(uri_or_path) - self.check_update - end - - def check_update - # If we have updated in the last 5 minutes, do nothing - return if (Time.utc - @last_update) < 5.minutes - - # Get the amount of time elapsed since when the player was updated, in the - # event where multiple invidious processes are run in parallel. - update_time_elapsed = (@client.get_player_timestamp || 301).seconds - - if update_time_elapsed > 5.minutes - LOGGER.debug("Signature: Player might be outdated, updating") - @client.force_update - @last_update = Time.utc - end - end - - def decrypt_nsig(n : String) : String? - self.check_update - return @client.decrypt_n_param(n) - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end - - def decrypt_signature(str : String) : String? - self.check_update - return @client.decrypt_sig(str) - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end - - def get_sts : UInt64? - self.check_update - return @client.get_signature_timestamp - rescue ex - LOGGER.debug(ex.message || "Signature: Unknown error") - LOGGER.trace(ex.inspect_with_backtrace) - return nil - end -end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 58805af2..ff9ea70a 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -266,7 +266,6 @@ module Invidious::JSONify::APIv1 json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i json.field "viewCountText", rv["short_view_count"]? - json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 json.field "published", rv["published"]? if rv["published"]?.try &.presence json.field "publishedText", translate(locale, "`x` ago", recode_date(Time.parse_rfc3339(rv["published"].to_s), locale)) diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index a940ee68..503b8c05 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -436,7 +436,7 @@ module Invidious::Routes::API::V1::Channels if ucid.nil? response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") return error_json(400, "Invalid post ID") if response["error"]? - ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s) else ucid = ucid.to_s end @@ -460,13 +460,15 @@ module Invidious::Routes::API::V1::Channels format = env.params.query["format"]? format ||= "json" + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "top" continuation = env.params.query["continuation"]? case continuation when nil, "" ucid = env.params.query["ucid"] - comments = Comments.fetch_community_post_comments(ucid, id) + comments = Comments.fetch_community_post_comments(ucid, id, sort_by: sort_by) else comments = YoutubeAPI.browse(continuation: continuation) end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4f5b58da..4ae877a8 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -190,15 +190,30 @@ module Invidious::Routes::API::V1::Misc sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint params = sub_endpoint.try &.dig?("params") + + if sub_endpoint["browseId"]?.try &.as_s == "FEpost_detail" + decoded_protobuf = params.try &.as_s.try { |i| URI.decode_www_form(i) } + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } + + ucid = decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s) + post_id = decoded_protobuf.try(&.["56:0:embedded"]["3:1:string"].as_s) + else + ucid = sub_endpoint["browseId"]? if sub_endpoint["browseId"]? && sub_endpoint["browseId"]?.try &.as_s.starts_with? "UC" + post_id = nil + end rescue ex return error_json(500, ex) end JSON.build do |json| json.object do - json.field "ucid", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? + json.field "browseId", sub_endpoint["browseId"].as_s if sub_endpoint["browseId"]? + json.field "ucid", ucid if ucid != nil json.field "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]? json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]? + json.field "postId", post_id if post_id != nil json.field "params", params.try &.as_s json.field "pageType", page_type end diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index b5269668..63b935ec 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -63,6 +63,7 @@ module Invidious::Routes::BeforeAll "/videoplayback", "/latest_version", "/download", + "/companion/", }.any? { |r| env.request.resource.starts_with? r } if env.request.cookies.has_key? "SID" diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 508aa3e4..f785de18 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -264,11 +264,11 @@ module Invidious::Routes::Channels id = env.params.url["id"] ucid = env.params.query["ucid"]? - prefs = env.get("preferences").as(Preferences) + preferences = env.get("preferences").as(Preferences) - locale = prefs.locale + locale = preferences.locale - thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode + thin_mode = env.params.query["thin_mode"]? || preferences.thin_mode thin_mode = thin_mode == "true" nojs = env.params.query["nojs"]? @@ -284,7 +284,7 @@ module Invidious::Routes::Channels response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") return error_template(400, "Invalid post ID") if response["error"]? - ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + ucid = decode_ucid_from_post_protobuf(response.dig("endpoint", "browseEndpoint", "params").as_s) post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) end diff --git a/src/invidious/routes/companion.cr b/src/invidious/routes/companion.cr new file mode 100644 index 00000000..11c2e3f5 --- /dev/null +++ b/src/invidious/routes/companion.cr @@ -0,0 +1,43 @@ +module Invidious::Routes::Companion + # /companion + def self.get_companion(env) + url = env.request.path + if env.request.query + url += "?#{env.request.query}" + end + + begin + COMPANION_POOL.client do |wrapper| + wrapper.client.get(url, env.request.headers) do |resp| + return self.proxy_companion(env, resp) + end + end + rescue ex + end + end + + def self.options_companion(env) + url = env.request.path + if env.request.query + url += "?#{env.request.query}" + end + + begin + COMPANION_POOL.client do |wrapper| + wrapper.client.options(url, env.request.headers) do |resp| + return self.proxy_companion(env, resp) + end + end + rescue ex + end + end + + private def self.proxy_companion(env, response) + env.response.status_code = response.status_code + response.headers.each do |key, value| + env.response.headers[key] = value + end + + return IO.copy response.body_io, env.response + end +end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 930e4915..d0a3b5c1 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -20,7 +20,7 @@ module Invidious::Routes::Embed return error_template(500, ex) end - url = "/embed/#{first_playlist_video}?#{env.params.query}" + url = "/embed/#{first_playlist_video.id}?#{env.params.query}" if env.params.query.size > 0 url += "?#{env.params.query}" @@ -33,7 +33,8 @@ module Invidious::Routes::Embed end def self.show(env) - locale = env.get("preferences").as(Preferences).locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") @@ -45,8 +46,6 @@ module Invidious::Routes::Embed env.params.query.delete("playlist") end - preferences = env.get("preferences").as(Preferences) - if id.includes?("%20") || id.includes?("+") || env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") id = env.params.url["id"].gsub("%20", "").delete("+") @@ -209,10 +208,17 @@ module Invidious::Routes::Embed if CONFIG.invidious_companion.present? invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") + invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion| + uri = + "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}" + end.join(" ") + + if !invidious_companion_urls.empty? + env.response.headers["Content-Security-Policy"] = + env.response.headers["Content-Security-Policy"] + .gsub("media-src", "media-src #{invidious_companion_urls}") + .gsub("connect-src", "connect-src #{invidious_companion_urls}") + end end rendered "embed" diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb..ce173760 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -43,13 +43,14 @@ module Invidious::Routes::Feeds end def self.trending(env) - locale = env.get("preferences").as(Preferences).locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale trending_type = env.params.query["type"]? trending_type ||= "Default" region = env.params.query["region"]? - region ||= env.get("preferences").as(Preferences).region + region ||= preferences.region begin trending, plid = fetch_trending(trending_type, region, locale) diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7de5018..674f0a46 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -98,6 +98,8 @@ module Invidious::Routes::Login begin validate_request(tokens[0], answer, env.request, HMAC_KEY, locale) + rescue ex : InfoException + return error_template(400, InfoException.new("Erroneous CAPTCHA")) rescue ex return error_template(400, ex) end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index f2213da4..56e529b2 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -225,10 +225,10 @@ module Invidious::Routes::Playlists end def self.add_playlist_items_page(env) - prefs = env.get("preferences").as(Preferences) - locale = prefs.locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale - region = env.params.query["region"]? || prefs.region + region = env.params.query["region"]? || preferences.region user = env.get? "user" sid = env.get? "sid" diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 39ca77c0..d9fad1b1 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -2,12 +2,11 @@ module Invidious::Routes::PreferencesRoute def self.show(env) - locale = env.get("preferences").as(Preferences).locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale referer = get_referer(env) - preferences = env.get("preferences").as(Preferences) - templated "user/preferences" end @@ -144,6 +143,8 @@ module Invidious::Routes::PreferencesRoute notifications_only ||= "off" notifications_only = notifications_only == "on" + default_playlist = env.params.body["default_playlist"]?.try &.as(String) + # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ annotations: annotations, @@ -180,6 +181,7 @@ module Invidious::Routes::PreferencesRoute vr_mode: vr_mode, show_nick: show_nick, save_player_pos: save_player_pos, + default_playlist: default_playlist, }.to_json) if user = env.get? "user" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b3..11e6f171 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -37,10 +37,10 @@ module Invidious::Routes::Search end def self.search(env) - prefs = env.get("preferences").as(Preferences) - locale = prefs.locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale - region = env.params.query["region"]? || prefs.region + region = env.params.query["region"]? || preferences.region query = Invidious::Search::Query.new(env.params.query, :regular, region) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index e777b3f1..4c181503 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -2,7 +2,8 @@ module Invidious::Routes::Watch def self.handle(env) - locale = env.get("preferences").as(Preferences).locale + preferences = env.get("preferences").as(Preferences) + locale = preferences.locale region = env.params.query["region"]? if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") @@ -38,8 +39,6 @@ module Invidious::Routes::Watch nojs ||= "0" nojs = nojs == "1" - preferences = env.get("preferences").as(Preferences) - user = env.get?("user").try &.as(User) if user subscriptions = user.subscriptions @@ -194,10 +193,17 @@ module Invidious::Routes::Watch if CONFIG.invidious_companion.present? invidious_companion = CONFIG.invidious_companion.sample - env.response.headers["Content-Security-Policy"] = - env.response.headers["Content-Security-Policy"] - .gsub("media-src", "media-src #{invidious_companion.public_url}") - .gsub("connect-src", "connect-src #{invidious_companion.public_url}") + invidious_companion_urls = CONFIG.invidious_companion.reject(&.builtin_proxy).map do |companion| + uri = + "#{companion.public_url.scheme}://#{companion.public_url.host}#{companion.public_url.port ? ":#{companion.public_url.port}" : ""}" + end.join(" ") + + if !invidious_companion_urls.empty? + env.response.headers["Content-Security-Policy"] = + env.response.headers["Content-Security-Policy"] + .gsub("media-src", "media-src #{invidious_companion_urls}") + .gsub("connect-src", "connect-src #{invidious_companion_urls}") + end end templated "watch" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 46b71f1f..a51bb4b6 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -46,6 +46,7 @@ module Invidious::Routing self.register_api_v1_routes self.register_api_manifest_routes self.register_video_playback_routes + self.register_companion_routes end # ------------------- @@ -188,7 +189,7 @@ module Invidious::Routing end # ------------------- - # Media proxy routes + # Proxy routes # ------------------- def register_api_manifest_routes @@ -223,6 +224,13 @@ module Invidious::Routing get "/vi/:id/:name", Routes::Images, :thumbnails end + def register_companion_routes + if CONFIG.invidious_companion.present? + get "/companion/*", Routes::Companion, :get_companion + options "/companion/*", Routes::Companion, :options_companion + end + end + # ------------------- # API routes # ------------------- diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index d14cde5d..622fe517 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -4,19 +4,25 @@ def fetch_trending(trending_type, region, locale) plid = nil + browse_id = "" + case trending_type.try &.downcase - when "music" - params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" when "gaming" - params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" - when "movies" - params = "4gIKGgh0cmFpbGVycw%3D%3D" - else # Default - params = "" + browse_id = "UCOpNcN46UbXVtpKMrmU4Abg" + params = "Egh0cmVuZGluZw%3D%3D" + when "livestreams" + browse_id = "UC4R8DWoMoI7CAwX8_LjQHig" + params = "EgdsaXZldGFikgEDCKEK" + else + # Livestreams is the default one as Youtube removed + # the aggregated trending page + # https://github.com/iv-org/invidious/issues/5397#issuecomment-3218928458 + browse_id = "UC4R8DWoMoI7CAwX8_LjQHig" + params = "EgdsaXZldGFikgEDCKEK" end client_config = YoutubeAPI::ClientConfig.new(region: region) - initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) + initial_data = YoutubeAPI.browse(browse_id, params: params, client_config: client_config) items, _ = extract_items(initial_data) diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index 0a8525f3..df195dd6 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -56,6 +56,7 @@ struct Preferences property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc property volume : Int32 = CONFIG.default_user_preferences.volume property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos + property default_playlist : String? = nil module BoolToString def self.to_json(value : String, json : JSON::Builder) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 348a0a66..0446922f 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -326,6 +326,14 @@ end def fetch_video(id, region) info = extract_video_info(video_id: id) + if info.nil? + raise InfoException.new("Invidious companion is not available. \ + Video playback cannot continue. \ + If you are the administrator of this instance, install Invidious companion \ + following the installation instructions \ + https://docs.invidious.io/installation/") + end + if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index feb58440..8114ad68 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -25,11 +25,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } - # "4,088,033 views", only available on compact renderer - # and when video is not a livestream - view_count = related.dig?("viewCountText", "simpleText") - .try &.as_s.gsub(/\D/, "") - short_view_count = related.try do |r| HelperExtractors.get_short_view_count(r).to_s end @@ -51,7 +46,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "author" => author || JSON::Any.new(""), "ucid" => JSON::Any.new(ucid || ""), "length_seconds" => JSON::Any.new(length || "0"), - "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), "author_verified" => JSON::Any.new(author_verified), "published" => JSON::Any.new(published || ""), @@ -59,11 +53,12 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? end def extract_video_info(video_id : String) - # Init client config for the API - client_config = YoutubeAPI::ClientConfig.new - # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) + player_response = YoutubeAPI.player(video_id: video_id) + + if player_response.nil? + return nil + end playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -102,42 +97,15 @@ def extract_video_info(video_id : String) # Don't fetch the next endpoint if the video is unavailable. if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + # Remove the microformat returned by the /next endpoint on some videos + # to prevent player_response microformat from being overwritten. + next_response.delete("microformat") player_response = player_response.merge(next_response) end params = parse_video_info(video_id, player_response) params["reason"] = JSON::Any.new(reason) if reason - if !CONFIG.invidious_companion.present? - if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") - players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile} - - players_fallback.each do |player_fallback| - client_config.client_type = player_fallback - - next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) - - if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") - streaming_data = player_response["streamingData"].as_h - streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] - player_response["streamingData"] = JSON::Any.new(streaming_data) - break - end - rescue InfoException - next LOGGER.warn("Failed to fetch streams with #{player_fallback}") - end - end - - # Seems like video page can still render even without playable streams. - # its better than nothing. - # - # # Were we able to find playable video streams? - # if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? - # # No :( - # end - end - {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| params[f] = player_response[f] if player_response[f]? end @@ -146,7 +114,11 @@ def extract_video_info(video_id : String) if streaming_data = player_response["streamingData"]? %w[formats adaptiveFormats].each do |key| streaming_data.as_h[key]?.try &.as_a.each do |format| - format.as_h["url"] = JSON::Any.new(convert_url(format)) + format = format.as_h + if format["url"]?.nil? + format["url"] = format["signatureCipher"] + end + format["url"] = JSON::Any.new(convert_url(format)) end end @@ -161,7 +133,7 @@ end def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") - response = YoutubeAPI.player(video_id: id, params: "2AMB", client_config: client_config) + response = YoutubeAPI.player(video_id: id) playability_status = response["playabilityStatus"]["status"] LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") @@ -473,26 +445,15 @@ end private def convert_url(fmt) if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } - sp = cfr["sp"] url = URI.parse(cfr["url"]) params = url.query_params LOGGER.debug("convert_url: Decoding '#{cfr}'") - - unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) - params[sp] = unsig if unsig else url = URI.parse(fmt["url"].as_s) params = url.query_params end - n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) - params["n"] = n if n - - if token = CONFIG.po_token - params["pot"] = token - end - url.query_params = params LOGGER.trace("convert_url: new url is '#{url}'") diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index f4164f31..2c177b59 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -12,7 +12,7 @@