Compare commits

...

55 Commits

Author SHA1 Message Date
syeopite
fd8dc93569 Show message when connection to the database is not possible (#5346) 2025-08-23 04:04:06 -07:00
syeopite
67f93e55d8 Fix "ex" variable collision in invidious.cr
The exception handling for database connections results in an
`ex` variable which Ameba sees as overshadowing the `ex` used by the
`ex` block arg used to define the HTTP status code 500 handler below.

Although this is a non-issue since the db connection exception handling
will cause Invidious to exit, Ameba's nature as a static checker means
that it isn't aware of this.

The simplest fix without a dirty ameba ignore comment is to rename `ex`
within the Kemal handler block below, since `ex` within a begin rescue
block is a Crystal convention that will also cause Ameba to raise when
not adhered to.
2025-08-23 03:35:59 -07:00
syeopite
f35f529adc Videos: Fix missing .id to retrieve first playlist video ID (#5366) 2025-08-23 03:30:00 -07:00
syeopite
b32b077a80 Player: Persist caption settings (#5417) 2025-08-23 03:29:07 -07:00
syeopite
6badb80082 Channels: Fix fetching channel playlists (#5418) 2025-08-23 03:26:49 -07:00
syeopite
15099ac1dd Frontend: Fix notification count of TRUE (#5391) 2025-08-23 03:26:11 -07:00
syeopite
adc83f1c09 Documentation: Fix typo (effet -> effect) (#5369) 2025-08-23 03:23:42 -07:00
syeopite
41e0e77d33 HTML: Add Missing Noreferrers (#5368) 2025-08-23 03:23:05 -07:00
syeopite
9ebc76462f Channels: Fix fetching of individual community posts (#5361) 2025-08-23 03:20:04 -07:00
syeopite
0308acb624 Videos: Add fallback to TvSimply client (#5345) 2025-08-23 03:18:41 -07:00
syeopite
cac2397494 YTAPI: Add TvSimply client (#5344) 2025-08-23 03:17:28 -07:00
syeopite
cf640d808e YtAPI: Bump client versions (#5325) 2025-08-23 03:16:55 -07:00
syeopite
80ec027c8f CI: Fix docker ci job not checking if Invidious starts successfully or not (#5306) 2025-08-23 03:16:32 -07:00
syeopite
6f5f0dceca CI: Use public ARM64 Github actions runners for ARM64 builds (#5305) 2025-08-23 03:16:05 -07:00
syeopite
a8ab7b61f7 Player: Add keyboard shortcuts to configure captions (#5188) 2025-08-23 03:15:28 -07:00
Kristian Vos
dd8086e6d9 fix: fetching channel playlists returned 500 error 2025-08-13 15:43:54 +02:00
Eugene Pakhomov
875d8e7e41 Persist caption settings 2025-08-13 14:39:58 +03:00
dependabot[bot]
1ae0f45b0e Bump actions/checkout from 4 to 5 (#5415)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-12 15:06:16 +02:00
fieryhenry
3335bc8c38 Get a count of 0 if STORAGE_KEY_NOTIF_COUNT is not present in storage
Not sure if this is necessary as I think it should always be present in storage, but just in case it isn't
2025-07-18 19:07:41 +00:00
fieryhenry
a84bb1d22e Fix TRUE number of notifications
`update_ticker_count` used to use STORAGE_KEY_STREAM to get the number of notifications which is a boolean value, now it uses STORAGE_KEY_NOTIF_COUNT which is an integer
2025-07-18 19:02:50 +00:00
epicsam123
24252b836c add back semicolon 2025-06-30 22:38:30 -04:00
Nami Sunami
227c041b86 fix(config.example.yml): Fix typo (effet -> effect) 2025-06-28 11:38:31 +02:00
ChunkyProgrammer
803311713d make sort_by code more legible 2025-06-27 11:38:08 -04:00
epicsam123
64ac3b5203 add missing noreferrers 2025-06-26 18:40:06 -04:00
Samantaz Fox
b0c9f87fbe Fix missing .id to retrieve first playlist video ID
This was missed in the review of PR 5196
2025-06-26 19:09:52 +00:00
ChunkyProgrammer
f8febbe2b2 format changes 2025-06-25 23:53:07 -04:00
ChunkyProgrammer
436f955e0f update fetch_community_post_comments protobuf to match currently used protobuf, add sort_by option 2025-06-25 23:34:30 -04:00
ChunkyProgrammer
4155f15bf7 update resolve_url api to better support new post endpoint 2025-06-25 23:33:28 -04:00
ChunkyProgrammer
b9171d9dab Update protobuf for individual community post 2025-06-25 22:35:16 -04:00
ChunkyProgrammer
f3f6937ffc Fix community tab not loading 2025-06-25 22:22:30 -04:00
Fijxu
8723fdca06 Update src/invidious.cr
Co-authored-by: Samantaz Fox <coding@samantaz.fr>
2025-06-21 12:02:32 -04:00
Fijxu
d51e1cb051 remove fallback to TV client 2025-06-15 17:45:53 -04:00
Fijxu
cf0a68bd77 store adaptiveFormats data into a variable 2025-06-15 17:43:07 -04:00
Fijxu
8cd9d53fb1 show message when connection to the database is not possible 2025-06-12 18:44:01 -04:00
Fijxu
01cdb384e0 add suggestions from syeopite 2025-06-12 17:25:19 -04:00
Fijxu
b1e7e0c45e replace url by signatureCipher if url is not present 2025-06-12 16:18:01 -04:00
Fijxu
0c96e0977f check for signatureCipher too 2025-06-12 16:07:58 -04:00
Fijxu
37be513e14 Add fallback to TvSimply client 2025-06-12 01:25:59 -04:00
Fijxu
09d342b84d Update src/invidious/yt_backend/youtube_api.cr
Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>
2025-05-22 17:55:46 -04:00
Fijxu
3a8d4f333f update IOS_APP_VERSION 2025-05-22 17:17:01 -04:00
Fijxu
97354adf0f Update src/invidious/yt_backend/youtube_api.cr
Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com>
2025-05-22 17:15:45 -04:00
Fijxu
6497e1c418 YtAPI: Bump client versions 2025-05-22 16:06:13 -04:00
epicsam123
f9472e4e4b revert format 2025-05-19 22:34:59 -04:00
Fijxu
cc643f209a CI: Fix build-docker job not checking if Invidious starts successfully or not 2025-05-15 19:57:46 -04:00
Fijxu
381074fce1 CI: Replace Dockerfile path depending of the os used 2025-05-15 19:38:21 -04:00
Fijxu
033a44fab5 CI: Also use matrix.docker_compose_file for Run Docker step 2025-05-15 17:58:24 -04:00
Fijxu
a3375e512e CI: Add name attribute to build-docker job 2025-05-15 17:43:03 -04:00
Fijxu
1d664c759f CI: Use matrix for build-docker on ci.yml 2025-05-15 16:33:03 -04:00
Fijxu
94f0a7a9d2 CI: remove --build-arg
Dockerfile and Dockerfile.arm64 already build Invidious without release mode if
`release` argument is not present.
2025-05-15 15:31:17 -04:00
Fijxu
1d2f4b6813 CI: fix typo on comment about the os used on the ARM64 builder 2025-05-15 15:29:24 -04:00
Fijxu
cef0097a30 CI: fix typo on matrix platforms 2025-05-15 15:28:14 -04:00
Fijxu
bef2d7b6b5 CI: Use public ARM64 Github actions runners for ARM64 builds.
Currently, Invidious uses QEMU to build it's ARM64 Invidious image,
which is slow (since we are basically using a virtual machine).

This helps with the speed of building ARM64 binaries for Invidious
on each release/commit.

More information about the public ARM64 runners here:
https://github.com/orgs/community/discussions/148648

CI: Use ARM64 compose file for build-docker-arm64
2025-05-15 01:49:17 -04:00
epicsam123
e67a30b124 formatting 2025-03-20 10:29:26 -04:00
epicsam123
bc3b3f6d69 updated caption features to use videojs interface 2025-03-20 10:09:43 -04:00
epicsam123
73bf956af5 captions: provide "w", "o", "-", "+" keydowns for player from YT 2025-02-19 21:08:45 -05:00
21 changed files with 229 additions and 172 deletions

View File

@@ -17,16 +17,26 @@ on:
jobs: jobs:
release: 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -43,45 +53,22 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: quay.io/invidious/invidious images: quay.io/invidious/invidious
flavor: |
suffix=${{ matrix.tag_suffix }}
tags: | tags: |
type=sha,format=short,prefix={{date 'YYYY.MM.DD'}}-,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} 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') }} type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }}
labels: | labels: |
quay.expires-after=12w 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 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: ${{ matrix.dockerfile }}
platforms: linux/amd64 platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
build-args: | build-args: |
"release=1" "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"

View File

@@ -8,16 +8,26 @@ on:
jobs: jobs:
release: 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -36,46 +46,21 @@ jobs:
images: quay.io/invidious/invidious images: quay.io/invidious/invidious
flavor: | flavor: |
latest=false latest=false
suffix=${{ matrix.tag_suffix }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=raw,value=latest type=raw,value=latest
labels: | labels: |
quay.expires-after=12w 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 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: ${{ matrix.dockerfile }}
platforms: linux/amd64 platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
build-args: | build-args: |
"release=1" "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"

View File

@@ -48,7 +48,7 @@ jobs:
stable: false stable: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
submodules: true submodules: true
@@ -83,46 +83,43 @@ jobs:
run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr run: crystal build --warnings all --error-on-warnings --error-trace src/invidious.cr
build-docker: 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Use ARM64 Dockerfile if ARM64
if: ${{ matrix.name }} == "ARM64"
run: sed -i 's/Dockerfile/Dockerfile.arm64/' docker-compose.yml
- name: Build Docker - 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 - name: Run Docker
run: docker compose up -d run: docker compose up -d
- name: Test Docker - 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: - name: Print Invidious container logs
# Tells Github Actions to always run this step regardless of whether the previous step has failed
runs-on: ubuntu-latest # Without this expression this step would simply be skipped when the previous step fails.
if: success() || steps.test.conclusion == 'failure'
steps: run: docker compose logs
- 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
lint: lint:
@@ -131,7 +128,7 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
submodules: true submodules: true

View File

@@ -86,6 +86,7 @@ ul.vjs-menu-content::-webkit-scrollbar {
background-color: rgba(0, 0, 0, 0.75) !important; background-color: rgba(0, 0, 0, 0.75) !important;
border-radius: 9px !important; border-radius: 9px !important;
padding: 5px !important; padding: 5px !important;
line-height: 1.5 !important;
} }
.vjs-play-control, .vjs-play-control,

View File

@@ -77,7 +77,7 @@ function create_notification_stream(subscriptions) {
function update_ticker_count() { function update_ticker_count() {
var notification_ticker = document.getElementById('notification_ticker'); 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) { if (notification_count > 0) {
notification_ticker.innerHTML = notification_ticker.innerHTML =
'<span id="notification_count">' + notification_count + '</span> <i class="icon ion-ios-notifications"></i>'; '<span id="notification_count">' + notification_count + '</span> <i class="icon ion-ios-notifications"></i>';

View File

@@ -5,6 +5,10 @@ var video_data = JSON.parse(document.getElementById('video_data').textContent);
var options = { var options = {
liveui: true, liveui: true,
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], 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: { controlBar: {
children: [ children: [
'playToggle', 'playToggle',
@@ -180,7 +184,7 @@ var shareOptions = {
}; };
if (location.pathname.startsWith('/embed/')) { if (location.pathname.startsWith('/embed/')) {
var overlay_content = '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>'; var overlay_content = '<h1><a rel="noopener noreferrer" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>';
player.overlay({ player.overlay({
overlays: [ overlays: [
{ start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'}, { start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'},
@@ -450,7 +454,7 @@ if (!video_data.params.listen && video_data.params.annotations) {
if (target === 'current') { if (target === 'current') {
location.href = path; location.href = path;
} else if (target === 'new') { } else if (target === 'new') {
open(path, '_blank'); open(path, '_blank', 'noopener,noreferrer');
} }
}); });
@@ -585,6 +589,13 @@ const toggle_captions = (function () {
}; };
})(); })();
// For real-time updates to captions (if currently showing)
function update_captions() {
if (document.body.querySelector('.vjs-text-track-cue')) {
toggle_captions(); toggle_captions();
}
}
function toggle_fullscreen() { function toggle_fullscreen() {
player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen(); player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen();
} }
@@ -597,6 +608,34 @@ function increase_playback_rate(steps) {
player.playbackRate(options.playbackRates[newIndex]); player.playbackRate(options.playbackRates[newIndex]);
} }
function increase_caption_size(steps) {
const maxIndex = options.fontPercent.length - 1;
const fontPercent = player.textTrackSettings.getValues().fontPercent || 1.25;
const curIndex = options.fontPercent.indexOf(fontPercent);
let newIndex = curIndex + steps;
newIndex = helpers.clamp(newIndex, 0, maxIndex);
player.textTrackSettings.setValues({ fontPercent: options.fontPercent[newIndex] });
update_captions();
}
function toggle_caption_window() {
const numOptions = options.windowOpacity.length;
const windowOpacity = player.textTrackSettings.getValues().windowOpacity || '0';
const curIndex = options.windowOpacity.indexOf(windowOpacity);
const newIndex = (curIndex + 1) % numOptions;
player.textTrackSettings.setValues({ windowOpacity: options.windowOpacity[newIndex] });
update_captions();
}
function toggle_caption_opacity() {
const numOptions = options.textOpacity.length;
const textOpacity = player.textTrackSettings.getValues().textOpacity || '1';
const curIndex = options.textOpacity.indexOf(textOpacity);
const newIndex = (curIndex + 1) % numOptions;
player.textTrackSettings.setValues({ textOpacity: options.textOpacity[newIndex] });
update_captions();
}
addEventListener('keydown', function (e) { addEventListener('keydown', function (e) {
if (e.target.tagName.toLowerCase() === 'input') { if (e.target.tagName.toLowerCase() === 'input') {
// Ignore input when focus is on certain elements, e.g. form fields. // Ignore input when focus is on certain elements, e.g. form fields.
@@ -693,6 +732,12 @@ addEventListener('keydown', function (e) {
case '>': action = increase_playback_rate.bind(this, 1); break; case '>': action = increase_playback_rate.bind(this, 1); break;
case '<': action = increase_playback_rate.bind(this, -1); break; case '<': action = increase_playback_rate.bind(this, -1); break;
case '=': action = increase_caption_size.bind(this, 1); break;
case '-': action = increase_caption_size.bind(this, -1); break;
case 'w': action = toggle_caption_window; break;
case 'o': action = toggle_caption_opacity; break;
default: default:
console.info('Unhandled key down event: %s:', decoratedKey, e); console.info('Unhandled key down event: %s:', decoratedKey, e);
break; break;

View File

@@ -141,7 +141,7 @@ function get_reddit_comments() {
</b> \ </b> \
</p> \ </p> \
<b> \ <b> \
<a rel="noopener" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \ <a rel="noopener noreferrer" target="_blank" href="https://reddit.com{permalink}">{redditPermalinkText}</a> \
</b> \ </b> \
</div> \ </div> \
<div>{contentHtml}</div> \ <div>{contentHtml}</div> \

View File

@@ -865,7 +865,7 @@ default_user_preferences:
## ##
## Default dash video quality. ## Default dash video quality.
## ##
## Note: this setting only takes effet if the ## Note: this setting only takes effect if the
## 'quality' parameter is set to "dash". ## 'quality' parameter is set to "dash".
## ##
## Accepted values: ## Accepted values:

View File

@@ -14,6 +14,10 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:3000:3000" - "127.0.0.1:3000:3000"
depends_on:
invidious-db:
condition: service_healthy
restart: true
environment: environment:
# Please read the following file for a comprehensive list of all available # Please read the following file for a comprehensive list of all available
# configuration options and their associated syntax: # configuration options and their associated syntax:

View File

@@ -60,7 +60,13 @@ alias IV = Invidious
CONFIG = Config.load CONFIG = Config.load
HMAC_KEY = CONFIG.hmac_key HMAC_KEY = CONFIG.hmac_key
PG_DB = DB.open CONFIG.database_url PG_DB = begin
DB.open CONFIG.database_url
rescue ex
puts "Failed to connect to PostgreSQL database: #{ex.cause.try &.message}"
puts "Check your 'config.yml' database settings or PostgreSQL settings."
exit(1)
end
ARCHIVE_URL = URI.parse("https://archive.org") ARCHIVE_URL = URI.parse("https://archive.org")
PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com")
REDDIT_URL = URI.parse("https://www.reddit.com") REDDIT_URL = URI.parse("https://www.reddit.com")
@@ -221,8 +227,8 @@ error 404 do |env|
Invidious::Routes::ErrorRoutes.error_404(env) Invidious::Routes::ErrorRoutes.error_404(env)
end end
error 500 do |env, ex| error 500 do |env, exception|
error_template(500, ex) error_template(500, exception)
end end
static_headers do |env| static_headers do |env|

View File

@@ -3,8 +3,8 @@ private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000}
# TODO: Add "sort_by" # TODO: Add "sort_by"
def fetch_channel_community(ucid, cursor, locale, format, thin_mode) def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
if cursor.nil? if cursor.nil?
# Egljb21tdW5pdHk%3D is the protobuf object to load "community" # EgVwb3N0c_IGBAoCSgA%3D is the protobuf object to load "posts"
initial_data = YoutubeAPI.browse(ucid, params: "Egljb21tdW5pdHk%3D") initial_data = YoutubeAPI.browse(ucid, params: "EgVwb3N0c_IGBAoCSgA%3D")
items = [] of JSON::Any items = [] of JSON::Any
extract_items(initial_data) do |item| extract_items(initial_data) do |item|
@@ -24,15 +24,21 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode)
return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode)
end end
def decode_ucid_from_post_protobuf(params)
decoded_protobuf = params.try { |i| URI.decode_www_form(i) }
.try { |i| Base64.decode(i) }
.try { |i| IO::Memory.new(i) }
.try { |i| Protodec::Any.parse(i) }
return decoded_protobuf.try(&.["56:0:embedded"]["2:0:string"].as_s)
end
def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
object = { object = {
"2:string" => "community", "56:embedded" => {
"25:embedded" => { "2:string" => ucid,
"22:string" => post_id.to_s, "3:string" => post_id.to_s,
}, "11:string" => ucid,
"45:embedded" => {
"2:varint" => 1_i64,
"3:varint" => 1_i64,
}, },
} }
params = object.try { |i| Protodec::Any.cast_json(i) } params = object.try { |i| Protodec::Any.cast_json(i) }
@@ -40,7 +46,7 @@ def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode)
.try { |i| Base64.urlsafe_encode(i) } .try { |i| Base64.urlsafe_encode(i) }
.try { |i| URI.encode_www_form(i) } .try { |i| URI.encode_www_form(i) }
initial_data = YoutubeAPI.browse(ucid, params: params) initial_data = YoutubeAPI.browse("FEpost_detail", params: params)
items = [] of JSON::Any items = [] of JSON::Any
extract_items(initial_data) do |item| extract_items(initial_data) do |item|

View File

@@ -6,19 +6,19 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by)
case sort_by case sort_by
when "last", "last_added" when "last", "last_added"
# Equivalent to "&sort=lad" # Equivalent to "&sort=lad"
# {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1} # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
"EglwbGF5bGlzdHMYBCABMAE%3D" "EglwbGF5bGlzdHMYBCABMAHyBgQKAkIA"
when "oldest", "oldest_created" when "oldest", "oldest_created"
# formerly "&sort=da" # formerly "&sort=da"
# Not available anymore :c or maybe ?? # Not available anymore :c or maybe ??
# {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1} # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
"EglwbGF5bGlzdHMYAiABMAE%3D" "EglwbGF5bGlzdHMYAiABMAHyBgQKAkIA"
# {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1} # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1}
# "EglwbGF5bGlzdHMYASABMAE%3D" # "EglwbGF5bGlzdHMYASABMAE%3D"
when "newest", "newest_created" when "newest", "newest_created"
# Formerly "&sort=dd" # Formerly "&sort=dd"
# {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1} # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1, "110:embedded": {"1:embedded": {"8:string": ""}}}
"EglwbGF5bGlzdHMYAyABMAE%3D" "EglwbGF5bGlzdHMYAyABMAHyBgQKAkIA"
end end
initial_data = YoutubeAPI.browse(ucid, params: params || "") initial_data = YoutubeAPI.browse(ucid, params: params || "")

View File

@@ -16,23 +16,27 @@ module Invidious::Comments
return parse_youtube(id, response, format, locale, thin_mode, sort_by) return parse_youtube(id, response, format, locale, thin_mode, sort_by)
end end
def fetch_community_post_comments(ucid, post_id) def fetch_community_post_comments(ucid, post_id, sort_by = "top")
case sort_by
when "top"
sort_by_val = 0_i64
when "new", "newest"
sort_by_val = 1_i64
else # top
sort_by_val = 0_i64
end
object = { object = {
"2:string" => "community", "2:string" => "posts",
"25:embedded" => {
"22:string" => post_id,
},
"45:embedded" => {
"2:varint" => 1_i64,
"3:varint" => 1_i64,
},
"53:embedded" => { "53:embedded" => {
"4:embedded" => { "4:embedded" => {
"6:varint" => 0_i64, "6:varint" => sort_by_val,
"27:varint" => 1_i64, "15:varint" => 2_i64,
"25:varint" => 0_i64,
"29:string" => post_id, "29:string" => post_id,
"30:string" => ucid, "30:string" => ucid,
}, },
"7:varint" => 0_i64,
"8:string" => "comments-section", "8:string" => "comments-section",
}, },
} }
@@ -43,7 +47,7 @@ module Invidious::Comments
object2 = { object2 = {
"80226972:embedded" => { "80226972:embedded" => {
"2:string" => ucid, "2:string" => "FEcomment_post_detail_page_web_top_level",
"3:string" => object_parsed, "3:string" => object_parsed,
}, },
} }
@@ -320,6 +324,15 @@ module Invidious::Comments
end end
def produce_continuation(video_id, cursor = "", sort_by = "top") def produce_continuation(video_id, cursor = "", sort_by = "top")
case sort_by
when "top"
sort_by_val = 0_i64
when "new", "newest"
sort_by_val = 1_i64
else # top
sort_by_val = 0_i64
end
object = { object = {
"2:embedded" => { "2:embedded" => {
"2:string" => video_id, "2:string" => video_id,
@@ -340,21 +353,12 @@ module Invidious::Comments
"1:string" => cursor, "1:string" => cursor,
"4:embedded" => { "4:embedded" => {
"4:string" => video_id, "4:string" => video_id,
"6:varint" => 0_i64, "6:varint" => sort_by_val,
}, },
"5:varint" => 20_i64, "5:varint" => 20_i64,
}, },
} }
case sort_by
when "top"
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
when "new", "newest"
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64
else # top
object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64
end
continuation = object.try { |i| Protodec::Any.cast_json(i) } continuation = object.try { |i| Protodec::Any.cast_json(i) }
.try { |i| Protodec::Any.from_json(i) } .try { |i| Protodec::Any.from_json(i) }
.try { |i| Base64.urlsafe_encode(i) } .try { |i| Base64.urlsafe_encode(i) }

View File

@@ -34,7 +34,7 @@ module Invidious::Frontend::WatchPage
str << " class=\"pure-form pure-form-stacked\"" str << " class=\"pure-form pure-form-stacked\""
str << " action='#{url}'" str << " action='#{url}'"
str << " method='post'" str << " method='post'"
str << " rel='noopener'" str << " rel='noopener noreferrer'"
str << " target='_blank'>" str << " target='_blank'>"
str << '\n' str << '\n'

View File

@@ -436,7 +436,7 @@ module Invidious::Routes::API::V1::Channels
if ucid.nil? if ucid.nil?
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
return error_json(400, "Invalid post ID") if response["error"]? 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 else
ucid = ucid.to_s ucid = ucid.to_s
end end
@@ -460,13 +460,15 @@ module Invidious::Routes::API::V1::Channels
format = env.params.query["format"]? format = env.params.query["format"]?
format ||= "json" format ||= "json"
sort_by = env.params.query["sort_by"]?.try &.downcase
sort_by ||= "top"
continuation = env.params.query["continuation"]? continuation = env.params.query["continuation"]?
case continuation case continuation
when nil, "" when nil, ""
ucid = env.params.query["ucid"] 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 else
comments = YoutubeAPI.browse(continuation: continuation) comments = YoutubeAPI.browse(continuation: continuation)
end end

View File

@@ -190,15 +190,30 @@ module Invidious::Routes::API::V1::Misc
sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint
params = sub_endpoint.try &.dig?("params") 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 rescue ex
return error_json(500, ex) return error_json(500, ex)
end end
JSON.build do |json| JSON.build do |json|
json.object do 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 "videoId", sub_endpoint["videoId"].as_s if sub_endpoint["videoId"]?
json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? 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 "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 "params", params.try &.as_s
json.field "pageType", page_type json.field "pageType", page_type
end end

View File

@@ -284,7 +284,7 @@ module Invidious::Routes::Channels
response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}")
return error_template(400, "Invalid post ID") if response["error"]? 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) post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode)
end end

View File

@@ -20,7 +20,7 @@ module Invidious::Routes::Embed
return error_template(500, ex) return error_template(500, ex)
end end
url = "/embed/#{first_playlist_video}?#{env.params.query}" url = "/embed/#{first_playlist_video.id}?#{env.params.query}"
if env.params.query.size > 0 if env.params.query.size > 0
url += "?#{env.params.query}" url += "?#{env.params.query}"

View File

@@ -111,16 +111,17 @@ def extract_video_info(video_id : String)
if !CONFIG.invidious_companion.present? if !CONFIG.invidious_companion.present?
if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil? if player_response.dig?("streamingData", "adaptiveFormats", 0, "url").nil?
LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.") LOGGER.warn("Missing URLs for adaptive formats, falling back to other YT clients.")
players_fallback = {YoutubeAPI::ClientType::TvHtml5, YoutubeAPI::ClientType::WebMobile} players_fallback = {YoutubeAPI::ClientType::TvSimply, YoutubeAPI::ClientType::WebMobile}
players_fallback.each do |player_fallback| players_fallback.each do |player_fallback|
client_config.client_type = player_fallback client_config.client_type = player_fallback
next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config)) next if !(player_fallback_response = try_fetch_streaming_data(video_id, client_config))
if player_fallback_response.dig?("streamingData", "adaptiveFormats", 0, "url") adaptive_formats = player_fallback_response.dig?("streamingData", "adaptiveFormats")
if adaptive_formats && (adaptive_formats.dig?(0, "url") || adaptive_formats.dig?(0, "signatureCipher"))
streaming_data = player_response["streamingData"].as_h streaming_data = player_response["streamingData"].as_h
streaming_data["adaptiveFormats"] = player_fallback_response["streamingData"]["adaptiveFormats"] streaming_data["adaptiveFormats"] = adaptive_formats
player_response["streamingData"] = JSON::Any.new(streaming_data) player_response["streamingData"] = JSON::Any.new(streaming_data)
break break
end end
@@ -146,7 +147,11 @@ def extract_video_info(video_id : String)
if streaming_data = player_response["streamingData"]? if streaming_data = player_response["streamingData"]?
%w[formats adaptiveFormats].each do |key| %w[formats adaptiveFormats].each do |key|
streaming_data.as_h[key]?.try &.as_a.each do |format| 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
end end

View File

@@ -14,7 +14,7 @@
<div class="pure-control-group"> <div class="pure-control-group">
<label for="import_youtube"> <label for="import_youtube">
<a rel="noopener" target="_blank" href="https://github.com/iv-org/documentation/blob/master/docs/export-youtube-subscriptions.md"> <a rel="noopener noreferrer" target="_blank" href="https://github.com/iv-org/documentation/blob/master/docs/export-youtube-subscriptions.md">
<%= translate(locale, "Import YouTube subscriptions") %> <%= translate(locale, "Import YouTube subscriptions") %>
</a> </a>
</label> </label>

View File

@@ -6,10 +6,10 @@ module YoutubeAPI
extend self extend self
# For Android versions, see https://en.wikipedia.org/wiki/Android_version_history # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history
private ANDROID_APP_VERSION = "19.32.34" private ANDROID_APP_VERSION = "19.35.36"
private ANDROID_VERSION = "12" private ANDROID_VERSION = "13"
private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip" private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; en_US; SM-S908E Build/TP1A.220624.014) gzip"
private ANDROID_SDK_VERSION = 31_i64 private ANDROID_SDK_VERSION = 33_i64
private ANDROID_TS_APP_VERSION = "1.9" private ANDROID_TS_APP_VERSION = "1.9"
private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip" private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip"
@@ -17,9 +17,9 @@ module YoutubeAPI
# For Apple device names, see https://gist.github.com/adamawolf/3048717 # For Apple device names, see https://gist.github.com/adamawolf/3048717
# For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases, # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases,
# then go to the dedicated article of the major version you want. # then go to the dedicated article of the major version you want.
private IOS_APP_VERSION = "19.32.8" private IOS_APP_VERSION = "20.11.6"
private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)" private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 18_5 like Mac OS X;)"
private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build private IOS_VERSION = "18.5.0.22F76" # Major.Minor.Patch.Build
private WINDOWS_VERSION = "10.0" private WINDOWS_VERSION = "10.0"
@@ -50,7 +50,7 @@ module YoutubeAPI
ClientType::Web => { ClientType::Web => {
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240814.00.00", version: "2.20250222.10.00",
screen: "WATCH_FULL_SCREEN", screen: "WATCH_FULL_SCREEN",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@@ -59,7 +59,7 @@ module YoutubeAPI
ClientType::WebEmbeddedPlayer => { ClientType::WebEmbeddedPlayer => {
name: "WEB_EMBEDDED_PLAYER", name: "WEB_EMBEDDED_PLAYER",
name_proto: "56", name_proto: "56",
version: "1.20240812.01.00", version: "1.20250219.01.00",
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@@ -68,7 +68,7 @@ module YoutubeAPI
ClientType::WebMobile => { ClientType::WebMobile => {
name: "MWEB", name: "MWEB",
name_proto: "2", name_proto: "2",
version: "2.20240813.02.00", version: "2.20250224.01.00",
os_name: "Android", os_name: "Android",
os_version: ANDROID_VERSION, os_version: ANDROID_VERSION,
platform: "MOBILE", platform: "MOBILE",
@@ -76,7 +76,7 @@ module YoutubeAPI
ClientType::WebScreenEmbed => { ClientType::WebScreenEmbed => {
name: "WEB", name: "WEB",
name_proto: "1", name_proto: "1",
version: "2.20240814.00.00", version: "2.20250222.10.00",
screen: "EMBED", screen: "EMBED",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
@@ -85,7 +85,7 @@ module YoutubeAPI
ClientType::WebCreator => { ClientType::WebCreator => {
name: "WEB_CREATOR", name: "WEB_CREATOR",
name_proto: "62", name_proto: "62",
version: "1.20240918.03.00", version: "1.20241203.01.00",
os_name: "Windows", os_name: "Windows",
os_version: WINDOWS_VERSION, os_version: WINDOWS_VERSION,
platform: "DESKTOP", platform: "DESKTOP",
@@ -171,7 +171,7 @@ module YoutubeAPI
ClientType::TvHtml5 => { ClientType::TvHtml5 => {
name: "TVHTML5", name: "TVHTML5",
name_proto: "7", name_proto: "7",
version: "7.20240813.07.00", version: "7.20250219.14.00",
}, },
ClientType::TvHtml5ScreenEmbed => { ClientType::TvHtml5ScreenEmbed => {
name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",