Compare commits

..

23 Commits

Author SHA1 Message Date
3bd6603396 Add initial video player implementation with controls and error handling
All checks were successful
Build and release container directly from master / release (push) Successful in 5m42s
Stale issue handler / stale (push) Successful in 13s
2025-05-05 20:31:12 -04:00
5c2700f9fd Add video player with controls, hotkeys, and quality selection for DASH streams
All checks were successful
Build and release container directly from master / release (push) Successful in 5m33s
Stale issue handler / stale (push) Successful in 24s
2025-05-05 16:34:33 -04:00
16333615fa Add initial player.js with video playback and controls implementation
All checks were successful
Build and release container directly from master / release (push) Successful in 5m40s
2025-05-05 16:03:10 -04:00
3b56443825 Add initial player.js with Video.js setup and event handling
All checks were successful
Build and release container directly from master / release (push) Successful in 5m34s
2025-05-05 15:01:51 -04:00
8d3ff993dd Loggn
All checks were successful
Build and release container directly from master / release (push) Successful in 5m24s
2025-05-05 14:31:34 -04:00
7a750167eb Add video player with autoplay, DASH streaming and mobile UI support
All checks were successful
Build and release container directly from master / release (push) Successful in 5m26s
2025-05-05 14:20:21 -04:00
9a83674a42 Add video player implementation with controls, autoplay and quality settings
All checks were successful
Build and release container directly from master / release (push) Successful in 5m32s
2025-05-05 14:06:13 -04:00
91a4ac3744 Add video player functionality with controls, autoplay, and time tracking
All checks were successful
Build and release container directly from master / release (push) Successful in 11m48s
2025-05-05 13:26:42 -04:00
f22891d7b6 Add GitHub workflow for building nightly container images and player.js
All checks were successful
Build and release container directly from master / release (push) Successful in 6m2s
2025-05-05 13:14:29 -04:00
1fca61734b Add GitHub workflow to build and push nightly Docker images for AMD64 and ARM64
Some checks failed
Build and release container directly from master / release (push) Has been cancelled
2025-05-05 11:22:18 -04:00
e447d4275e Add GitHub workflow to build and push nightly Docker images for AMD64 and ARM64
Some checks failed
Build and release container directly from master / release (push) Failing after 48s
2025-05-05 11:21:11 -04:00
43cf4fabaa Add GitHub workflow to build and push nightly Docker images for AMD64 and ARM64
Some checks failed
Build and release container directly from master / release (push) Has been cancelled
2025-05-05 11:12:45 -04:00
668aff0cc2 Add GitHub workflow to build and push nightly Docker images for AMD64 and ARM64
Some checks failed
Build and release container directly from master / release (push) Failing after 29s
2025-05-05 11:11:41 -04:00
5c282637a6 ehh
Some checks failed
Build and release container directly from master / release (push) Failing after 28s
2025-05-05 11:10:56 -04:00
2ba1fe4db9 cant' start with gitea
Some checks failed
Build and release container directly from master / release (push) Failing after 34s
2025-05-05 11:09:20 -04:00
d891212545 Add GitHub workflow to build and push nightly Docker images for AMD64 and ARM64 2025-05-05 11:08:10 -04:00
f50559ac18 Add initial video player implementation with controls and features
Some checks failed
Build and release container directly from master / release (push) Failing after 34s
2025-05-05 10:56:06 -04:00
Emilien
d1bc15b8bf Release v2.20250504.0 2025-05-04 11:59:42 +02:00
Vyquos
1f028fee0f
Reflect companion secret character limit in example config comment (#5269)
Update the comments in the example config to show that the companion secret key must be exactly 16 characters long as per https://github.com/iv-org/invidious-companion/pull/81#issuecomment-2750675405.
2025-05-04 07:47:42 +00:00
absidue
2c1400c41e
Fix proxying live DASH streams (#4589) 2025-05-03 20:28:19 +00:00
Alex Maras
8fd0b82c38
feat: route to invidious companion on downloads (#5224) 2025-05-03 01:28:18 +02:00
Émilien (perso)
7579adc3a3
fix: fallback other yt clients no url found for adaptive formats (#5262) 2025-05-02 16:57:02 +02:00
efb4f5ff-1298-471a-8973-3d47447115dc
d567c6be6e
Fix minor casing issues in brand names (#5258) 2025-05-02 15:36:31 +02:00
29 changed files with 623 additions and 616 deletions

View File

@ -25,7 +25,7 @@ Lint/NotNil:
Lint/SpecFilename:
Excluded:
- spec/*_helper.cr
- spec/parsers_helper.cr
#

View File

@ -23,10 +23,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3
# with:
# platforms: arm64
- name: Install docker from docker webpage
run: |
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -34,20 +41,18 @@ jobs:
- name: Login to registry
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
registry: gitea.ghost.tel
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
images: gitea.ghost.tel/knight/invidious
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
uses: docker/build-push-action@v5
@ -55,7 +60,6 @@ jobs:
context: .
file: docker/Dockerfile
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
push: true
tags: ${{ steps.meta.outputs.tags }}
build-args: |
@ -65,23 +69,9 @@ jobs:
id: meta-arm64
uses: docker/metadata-action@v5
with:
images: quay.io/invidious/invidious
images: gitea.ghost.tel/knight/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@v5
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

@ -2,6 +2,12 @@
## vX.Y.0 (future)
## v2.20250504.0
Small release with quick workaround fix for issue #4251 (Nil assertion failed).
PR: https://github.com/iv-org/invidious/issues/5263
## v2.20250314.0
### Wrap-up

View File

@ -81,9 +81,9 @@
- [Available in many languages](locales/), thanks to [our translators](#contribute)
**Data import/export**
- Import subscriptions from YouTube, NewPipe and Freetube
- Import subscriptions from YouTube, NewPipe and FreeTube
- Import watch history from YouTube and NewPipe
- Export subscriptions to NewPipe and Freetube
- Export subscriptions to NewPipe and FreeTube
- Import/Export Invidious user data
**Technical features**
@ -95,11 +95,11 @@
## Quick start
**Using invidious:**
**Using Invidious:**
- [Select a public instance from the list](https://instances.invidious.io) and start watching videos right now!
**Hosting invidious:**
**Hosting Invidious:**
- [Follow the installation instructions](https://docs.invidious.io/installation/)
@ -114,8 +114,8 @@ https://github.com/iv-org/documentation
### Extensions
We highly recommend the use of [Privacy Redirect](https://github.com/SimonBrazell/privacy-redirect#get),
a browser extension that automatically redirects Youtube URLs to any Invidious instance and replaces
embedded youtube videos on other websites with invidious.
a browser extension that automatically redirects YouTube URLs to any Invidious instance and replaces
embedded YouTube videos on other websites with Invidious.
The documentation contains a list of browser extensions that we recommended to use along with Invidious.
@ -140,7 +140,7 @@ We use [Weblate](https://weblate.org) to manage Invidious translations.
You can suggest new translations and/or correction here: https://hosted.weblate.org/engage/invidious/.
Creating an account is not required, but recommended, especially if you want to contribute regularly.
Weblate also allows you to log-in with major SSO providers like Github, Gitlab, BitBucket, Google, ...
Weblate also allows you to log-in with major SSO providers like GitHub, GitLab, BitBucket, Google, ...
## Projects using Invidious

View File

@ -1,6 +1,8 @@
'use strict';
console.log('[Invidious Debug] player.js start');
var player_data = JSON.parse(document.getElementById('player_data').textContent);
var video_data = JSON.parse(document.getElementById('video_data').textContent);
console.log('[Invidious Debug] video_data.params:', JSON.stringify(video_data.params));
var options = {
liveui: true,
@ -50,9 +52,27 @@ videojs.Vhs.xhr.beforeRequest = function(options) {
return options;
};
var player = videojs('player', options);
console.log('[Invidious Debug] Initializing videojs...');
var player;
try {
player = videojs('player', options);
console.log('[Invidious Debug] videojs initialized successfully.');
} catch (e) {
console.error('[Invidious Debug] videojs initialization FAILED:', e);
// Stop further execution if player init fails
throw e;
}
// --- Test listener for loadedmetadata ---
player.on('loadedmetadata', function() {
console.log('[Invidious Debug] Event: loadedmetadata Fired!');
});
player.on('error', function () {
var error = player.error();
console.error('[Invidious Debug] Player Error Event:', error ? error.message : 'Unknown error', 'Code:', error ? error.code : 'N/A', 'Type:', error ? error.type : 'N/A', error);
if (video_data.params.quality === 'dash') return;
var localNotDisabled = (
@ -70,6 +90,7 @@ player.on('error', function () {
return source;
}));
} else if (reloadMakesSense) {
console.log('[Invidious Debug] Player error: Attempting reload in 5 seconds.');
setTimeout(function () {
console.warn('An error occurred in the player, reloading...');
@ -86,13 +107,54 @@ player.on('error', function () {
player.playbackRate(playbackRate);
if (!paused) player.play();
}, 5000);
} else {
console.log('[Invidious Debug] Player error: No specific action taken.');
}
});
if (video_data.params.quality === 'dash') {
player.reloadSourceOnError({
errorInterval: 10
try {
player.on('error', function () {
var error = player.error();
console.error('[Invidious Debug] Player Error Event:', error ? error.message : 'Unknown error', 'Code:', error ? error.code : 'N/A', 'Type:', error ? error.type : 'N/A', error);
// Only reload if not dash or an ad is playing.
// Don't reload if the error is Source Not Supported type, as this is used when
});
console.log('[Invidious Debug] Generic player error listener attached.');
// Add listener for tech errors (might give more specific MSE/network errors)
player.ready(function() {
const tech = player.tech_;
if (tech) {
tech.on('error', function(event) {
console.error('[Invidious Debug] Tech Error Event:', event);
if (tech.error) {
console.error('[Invidious Debug] Tech Error Details:', tech.error().message, 'Code:', tech.error().code);
}
});
console.log('[Invidious Debug] Tech error listener attached.');
} else {
console.warn('[Invidious Debug] Could not attach tech error listener.');
}
});
} catch (e) {
console.error('[Invidious Debug] FAILED attaching error listeners:', e);
}
if (player.error) {
console.log('[Invidious Debug] Player error listener attached.');
}
if (video_data.params.quality === 'dash') {
console.log('[Invidious Debug] Initializing reloadSourceOnError...');
try {
player.reloadSourceOnError({
errorInterval: 10
});
console.log('[Invidious Debug] reloadSourceOnError initialized.');
} catch (e) {
console.error('[Invidious Debug] reloadSourceOnError FAILED:', e);
}
}
/**
@ -162,116 +224,6 @@ player.on('timeupdate', function () {
}
});
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
get url() {
return addCurrentTimeToURL(short_url);
},
title: player_data.title,
description: player_data.description,
image: player_data.thumbnail,
get embedCode() {
// Single quotes inside here required. HTML inserted as is into value attribute of input
return "<iframe id='ivplayer' width='640' height='360' src='" +
addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>";
}
};
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>';
player.overlay({
overlays: [
{ start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'},
{ start: 'pause', content: overlay_content, end: 'playing', align: 'top'}
]
});
}
// Detect mobile users and initialize mobileUi for better UX
// Detection code taken from https://stackoverflow.com/a/20293441
function isMobile() {
try{ document.createEvent('TouchEvent'); return true; }
catch(e){ return false; }
}
if (isMobile()) {
player.mobileUi({ touchControls: { seekSeconds: 5 * player.playbackRate() } });
var buttons = ['playToggle', 'volumePanel', 'captionsButton'];
if (!video_data.params.listen && video_data.params.quality === 'dash') buttons.push('audioTrackButton');
if (video_data.params.listen || video_data.params.quality !== 'dash') buttons.push('qualitySelector');
// Create new control bar object for operation buttons
const ControlBar = videojs.getComponent('controlBar');
let operations_bar = new ControlBar(player, {
children: [],
playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
});
buttons.slice(1).forEach(function (child) {operations_bar.addChild(child);});
// Remove operation buttons from primary control bar
var primary_control_bar = player.getChild('controlBar');
buttons.forEach(function (child) {primary_control_bar.removeChild(child);});
var operations_bar_element = operations_bar.el();
operations_bar_element.classList.add('mobile-operations-bar');
player.addChild(operations_bar);
// Playback menu doesn't work when it's initialized outside of the primary control bar
var playback_element = document.getElementsByClassName('vjs-playback-rate')[0];
operations_bar_element.append(playback_element);
// The share and http source selector element can't be fetched till the players ready.
player.one('playing', function () {
var share_element = document.getElementsByClassName('vjs-share-control')[0];
operations_bar_element.append(share_element);
if (!video_data.params.listen && video_data.params.quality === 'dash') {
var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0];
operations_bar_element.append(http_source_selector);
}
});
}
// Enable VR video support
if (!video_data.params.listen && video_data.vr && video_data.params.vr_mode) {
player.crossOrigin('anonymous');
switch (video_data.projection_type) {
case 'EQUIRECTANGULAR':
player.vr({projection: 'equirectangular'});
default: // Should only be 'MESH' but we'll use this as a fallback.
player.vr({projection: 'EAC'});
}
}
// Add markers
if (video_data.params.video_start > 0 || video_data.params.video_end > 0) {
var markers = [{ time: video_data.params.video_start, text: 'Start' }];
if (video_data.params.video_end < 0) {
markers.push({ time: video_data.length_seconds - 0.5, text: 'End' });
} else {
markers.push({ time: video_data.params.video_end, text: 'End' });
}
player.markers({
onMarkerReached: function (marker) {
if (marker.text === 'End')
player.loop() ? player.markers.prev('Start') : player.pause();
},
markers: markers
});
player.currentTime(video_data.params.video_start);
}
player.volume(video_data.params.volume / 100);
player.playbackRate(video_data.params.speed);
/**
* Method for getting the contents of a cookie
*
@ -351,16 +303,12 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data.
}
if (video_data.params.save_player_pos) {
const url = new URL(location);
const hasTimeParam = url.searchParams.has('t');
const rememberedTime = get_video_time();
let lastUpdated = 0;
if(!hasTimeParam) {
if (rememberedTime >= video_data.length_seconds - 20)
set_seconds_after_start(0);
else
set_seconds_after_start(rememberedTime);
console.log('[Invidious Debug] Setting up player position save/restore...');
var lastUpdated = 0;
var save_video_time = function(time) {
const all_video_times = get_all_video_times();
all_video_times[video_data.id] = time;
helpers.storage.set(save_player_pos_key, all_video_times);
}
player.on('timeupdate', function () {
@ -376,64 +324,138 @@ if (video_data.params.save_player_pos) {
else remove_all_video_times();
if (video_data.params.autoplay) {
console.log('[Invidious Debug] Handling autoplay setup...');
var bpb = player.getChild('bigPlayButton');
bpb.hide();
player.ready(function () {
console.log('[Invidious Debug] Player ready for autoplay.');
new Promise(function (resolve, reject) {
setTimeout(function () {resolve(1);}, 1);
}).then(function (result) {
console.log('[Invidious Debug] Attempting autoplay...');
console.log('[Invidious Debug] Calling player.play() for autoplay...');
var promise = player.play();
if (promise !== undefined) {
promise.then(function () {
console.log('[Invidious Debug] Autoplay successful.');
}).catch(function (error) {
console.error('[Invidious Debug] Autoplay FAILED:', error);
bpb.show();
});
} else {
console.log('[Invidious Debug] Autoplay started (no promise returned).');
}
});
});
}
if (!video_data.params.listen && video_data.params.quality === 'dash') {
player.httpSourceSelector();
console.log('[Invidious Debug] Initializing httpSourceSelector...');
console.log('[Invidious Debug] Player sources BEFORE httpSourceSelector init:', JSON.stringify(player.currentSources()));
if (video_data.params.quality_dash !== 'auto') {
// --- START Pre-Plugin Environment Check ---
console.log('[Invidious Debug] Checking environment before httpSourceSelector call:');
console.log('[Invidious Debug] - typeof window.fetch:', typeof window.fetch);
console.log('[Invidious Debug] - typeof window.XMLHttpRequest:', typeof window.XMLHttpRequest);
console.log('[Invidious Debug] - typeof window.Promise:', typeof window.Promise);
console.log('[Invidious Debug] - typeof window.URL:', typeof window.URL);
console.log('[Invidious Debug] - typeof window.DOMParser:', typeof window.DOMParser);
console.log('[Invidious Debug] - videojs.options:', JSON.stringify(videojs.options));
// --- END Pre-Plugin Environment Check ---
try {
player.httpSourceSelector(); // This triggers manifest fetch & parsing
console.log('[Invidious Debug] httpSourceSelector initialized (call successful).'); // Log after call
console.log('[Invidious Debug] Player sources AFTER httpSourceSelector init call:', JSON.stringify(player.currentSources())); // Sources might not update yet
// Add listener for source changes after httpSourceSelector
player.on('sourceset', function() {
console.log('[Invidious Debug] Event: sourceset triggered. Current sources:', JSON.stringify(player.currentSources())); // Log when sources actually change
});
console.log('[Invidious Debug] sourceset listener attached.'); // Added
// Listen for potential errors during quality level processing
player.ready(function () {
player.on('loadedmetadata', function () {
const qualityLevels = Array.from(player.qualityLevels()).sort(function (a, b) {return a.height - b.height;});
let targetQualityLevel;
switch (video_data.params.quality_dash) {
case 'best':
targetQualityLevel = qualityLevels.length - 1;
break;
case 'worst':
targetQualityLevel = 0;
break;
default:
const targetHeight = parseInt(video_data.params.quality_dash);
for (let i = 0; i < qualityLevels.length; i++) {
if (qualityLevels[i].height <= targetHeight)
targetQualityLevel = i;
else
// Add listener for quality level errors
const qualityLevels = player.qualityLevels();
if (qualityLevels) {
qualityLevels.on('error', function(event) {
console.error('[Invidious Debug] QualityLevels Error Event:', event);
});
console.log('[Invidious Debug] QualityLevels error listener attached.'); // Added
// Log initial quality levels once metadata is loaded
player.on('loadedmetadata', function() {
try {
console.log('[Invidious Debug] Event: loadedmetadata triggered.'); // Added
const levels = Array.from(player.qualityLevels());
// Log detailed info about each level
console.log('[Invidious Debug] Quality levels after loadedmetadata:', JSON.stringify(levels.map(l => ({id: l.id, width: l.width, height: l.height, bitrate: l.bitrate, enabled: l.enabled}))));
} catch (e) {
console.error('[Invidious Debug] Error logging/processing quality levels:', e); // Added
}
});
console.log('[Invidious Debug] loadedmetadata listener attached for quality level logging.'); // Added
} else {
console.warn('[Invidious Debug] Could not attach QualityLevels error listener or get levels.'); // Added
}
});
if (video_data.params.quality_dash !== 'auto') {
console.log('[Invidious Debug] Setting DASH quality:', video_data.params.quality_dash);
player.ready(function () {
player.on('loadedmetadata', function () {
try {
const qualityLevels = Array.from(player.qualityLevels()).sort(function (a, b) {return a.height - b.height;});
let targetQualityLevel;
switch (video_data.params.quality_dash) {
case 'best':
targetQualityLevel = qualityLevels.length - 1;
break;
case 'worst':
targetQualityLevel = 0;
break;
default:
const targetHeight = parseInt(video_data.params.quality_dash);
for (let i = 0; i < qualityLevels.length; i++) {
if (qualityLevels[i].height <= targetHeight)
targetQualityLevel = i;
else
break;
}
}
}
qualityLevels.forEach(function (level, index) {
level.enabled = (index === targetQualityLevel);
qualityLevels.forEach(function (level, index) {
level.enabled = (index === targetQualityLevel);
});
console.log('[Invidious Debug] DASH quality levels set.');
} catch (e) {
console.error('[Invidious Debug] Error setting DASH quality levels:', e);
}
});
});
});
}
} catch (e) {
console.error('[Invidious Debug] httpSourceSelector FAILED (call threw error):', e); // Log if the call itself fails
}
}
player.vttThumbnails({
src: '/api/v1/storyboards/' + video_data.id + '?height=90',
showTimestamp: true
});
console.log('[Invidious Debug] Initializing vttThumbnails...');
try {
player.vttThumbnails({
src: '/api/v1/storyboards/' + video_data.id + '?height=90',
showTimestamp: true
});
console.log('[Invidious Debug] vttThumbnails initialized.');
} catch (e) {
console.error('[Invidious Debug] vttThumbnails FAILED:', e);
}
// Enable annotations
if (!video_data.params.listen && video_data.params.annotations) {
console.log('[Invidious Debug] Initializing annotations...');
addEventListener('load', function (e) {
addEventListener('__ar_annotation_click', function (e) {
const url = e.detail.url,
@ -468,65 +490,215 @@ if (!video_data.params.listen && video_data.params.annotations) {
} else {
player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container });
}
console.log('[Invidious Debug] Annotations initialized.');
}
});
});
}
function change_volume(delta) {
const curVolume = player.volume();
let newVolume = curVolume + delta;
newVolume = helpers.clamp(newVolume, 0, 1);
player.volume(newVolume);
player.on('play', function() {
console.log('[Invidious Debug] Event: play triggered. Current source:', JSON.stringify(player.currentSource()));
});
player.on('playing', function() {
console.log('[Invidious Debug] Event: playing triggered. Playback has started.');
});
player.on('waiting', function() {
console.log('[Invidious Debug] Event: waiting triggered. Buffering or waiting for data.');
});
var shareOptions = {
socials: ['fbFeed', 'tw', 'reddit', 'email'],
get url() {
return addCurrentTimeToURL(short_url);
},
title: player_data.title,
description: player_data.description,
image: player_data.thumbnail,
get embedCode() {
// Single quotes inside here required. HTML inserted as is into value attribute of input
return "<iframe id='ivplayer' width='640' height='360' src='" +
addCurrentTimeToURL(embed_url) + "' style='border:none;'></iframe>";
}
};
if (location.pathname.startsWith('/embed/')) {
console.log('[Invidious Debug] Initializing overlay...');
try {
var overlay_content = '<h1><a rel="noopener" target="_blank" href="' + location.origin + '/watch?v=' + video_data.id + '">' + player_data.title + '</a></h1>';
player.overlay({
overlays: [
{ start: 'loadstart', content: overlay_content, end: 'playing', align: 'top'},
{ start: 'pause', content: overlay_content, end: 'playing', align: 'top'}
]
});
console.log('[Invidious Debug] Overlay initialized.');
} catch (e) {
console.error('[Invidious Debug] Overlay FAILED:', e);
}
}
function toggle_muted() {
player.muted(!player.muted());
// Detect mobile users and initialize mobileUi for better UX
// Detection code taken from https://stackoverflow.com/a/20293441
function isMobile() {
return typeof window.orientation !== 'undefined' || navigator.userAgent.indexOf('IEMobile') !== -1;
}
function skip_seconds(delta) {
const duration = player.duration();
const curTime = player.currentTime();
let newTime = curTime + delta;
newTime = helpers.clamp(newTime, 0, duration);
player.currentTime(newTime);
if (isMobile()) {
console.log('[Invidious Debug] Mobile detected, initializing mobile UI...');
try {
player.mobileUi({ touchControls: { seekSeconds: 5, tapTimeout: 200, disableOnEnd: true } });
console.log('[Invidious Debug] Mobile UI initialized.');
} catch (e) {
console.error('[Invidious Debug] Mobile UI FAILED:', e);
}
}
function set_seconds_after_start(delta) {
const start = video_data.params.video_start;
player.currentTime(start + delta);
if (player.markers && !video_data.params.listen && video_data.params.chapters) {
console.log('[Invidious Debug] Initializing markers...');
var markers = [];
for (var i = 0; i < video_data.params.chapters.length; i++) {
var marker = video_data.params.chapters[i];
markers.push({
time: marker.start,
text: marker.title,
});
}
if (markers.length > 1 && markers[0].time === 0 && markers[0].text === 'Intro') {
markers.shift();
}
markers.sort((a,b) => (a.time > b.time) ? 1 : ((b.time > a.time) ? -1 : 0));
var unique_markers = [];
var last_time = -1;
for (var i = 0; i < markers.length; i++) {
var marker = markers[i];
if (marker.time <= last_time) {
continue;
}
unique_markers.push(marker);
last_time = marker.time;
}
markers = unique_markers;
// Add "end" time information to each marker
for (var i = 0; i < markers.length; i++) {
var marker = markers[i];
if (i < markers.length - 1) {
marker.end = markers[i+1].time - 0.001;
} else {
marker.end = player.duration();
}
}
player.markers.removeAll();
// Callback function when a marker is reached
function onMarkerReached(marker) {
//console.log("marker reached: " + marker.text + " at " + marker.time);
}
try {
player.markers({
onMarkerReached: onMarkerReached,
markers: markers
});
console.log('[Invidious Debug] Markers initialized.');
} catch (e) {
console.error('[Invidious Debug] Markers FAILED:', e);
}
player.currentTime(video_data.params.video_start);
}
player.volume(video_data.params.volume / 100);
player.playbackRate(video_data.params.speed);
console.log('[Invidious Debug] Volume and playback rate set.');
if (player.share) {
console.log('[Invidious Debug] Initializing share plugin...');
try {
player.share(shareOptions);
console.log('[Invidious Debug] Share plugin initialized.');
} catch(e) {
console.error('[Invidious Debug] Share plugin FAILED:', e);
}
}
/**
* Method for saving the current video time
*
* @param {number} seconds
*/
function save_video_time(seconds) {
const all_video_times = get_all_video_times();
all_video_times[video_data.id] = seconds;
helpers.storage.set(save_player_pos_key, all_video_times);
}
/**
* Method for getting the saved video time
*
* @returns {number}
*/
function get_video_time() {
return get_all_video_times()[video_data.id] || 0;
}
/**
* Method for getting all saved video times
*
* @returns {Object}
*/
function get_all_video_times() {
return helpers.storage.get(save_player_pos_key) || {};
}
/**
* Method for removing all saved video times
*/
function remove_all_video_times() {
helpers.storage.remove(save_player_pos_key);
}
/**
* Method for setting the video time to a certain percentage
*
* @param {number} percent
*/
function set_time_percent(percent) {
const duration = player.duration();
const newTime = duration * (percent / 100);
player.currentTime(newTime);
}
/**
* Method for playing the video
*/
function play() { player.play(); }
/**
* Method for pausing the video
*/
function pause() { player.pause(); }
/**
* Method for stopping the video
*/
function stop() { player.pause(); player.currentTime(0); }
/**
* Method for toggling play/pause
*/
function toggle_play() { player.paused() ? play() : pause(); }
/**
* Method for toggling captions
*/
const toggle_captions = (function () {
let toggledTrack = null;
@ -585,10 +757,18 @@ const toggle_captions = (function () {
};
})();
/**
* Method for toggling fullscreen
*/
function toggle_fullscreen() {
player.isFullscreen() ? player.exitFullscreen() : player.requestFullscreen();
}
/**
* Method for increasing playback rate
*
* @param {number} steps
*/
function increase_playback_rate(steps) {
const maxIndex = options.playbackRates.length - 1;
const curIndex = options.playbackRates.indexOf(player.playbackRate());
@ -597,6 +777,7 @@ function increase_playback_rate(steps) {
player.playbackRate(options.playbackRates[newIndex]);
}
// Add event listener for keydown events
addEventListener('keydown', function (e) {
if (e.target.tagName.toLowerCase() === 'input') {
// Ignore input when focus is on certain elements, e.g. form fields.
@ -685,7 +866,7 @@ addEventListener('keydown', function (e) {
break;
// TODO: More precise step. Now FPS is taken equal to 29.97
// Common FPS: https://forum.videohelp.com/threads/81868#post323588
// Common FPS: https://forum.videohelp.com/threads/81864#post323588
// Possible solution is new HTMLVideoElement.requestVideoFrameCallback() https://wicg.github.io/video-rvfc/
case ',': action = function () { pause(); skip_seconds(-1/29.97); }; break;
case '.': action = function () { pause(); skip_seconds( 1/29.97); }; break;
@ -731,9 +912,6 @@ addEventListener('keydown', function (e) {
player.on('DOMMouseScroll', mouseScroll);
}());
// Since videojs-share can sometimes be blocked, we defer it until last
if (player.share) player.share(shareOptions);
// show the preferred caption by default
if (player_data.preferred_caption_found) {
player.ready(function () {
@ -748,6 +926,7 @@ if (player_data.preferred_caption_found) {
// Safari audio double duration fix
if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) {
console.log('[Invidious Debug] Applying Safari listen mode duration fix...');
player.on('loadedmetadata', function () {
player.on('timeupdate', function () {
if (player.remainingTime() < player.duration() / 2 && player.remainingTime() >= 2) {
@ -759,6 +938,7 @@ if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) {
// Safari screen timeout on looped video playback fix
if (navigator.vendor === 'Apple Computer, Inc.' && !video_data.params.listen && video_data.params.video_loop) {
console.log('[Invidious Debug] Applying Safari loop mode screen timeout fix...');
player.loop(false);
player.ready(function () {
player.on('ended', function () {
@ -770,19 +950,25 @@ if (navigator.vendor === 'Apple Computer, Inc.' && !video_data.params.listen &&
// Watch on Invidious link
if (location.pathname.startsWith('/embed/')) {
const Button = videojs.getComponent('Button');
let watch_on_invidious_button = new Button(player);
console.log('[Invidious Debug] Adding Watch on Invidious button...');
try {
const Button = videojs.getComponent('Button');
let watch_on_invidious_button = new Button(player);
// Create hyperlink for current instance
var redirect_element = document.createElement('a');
redirect_element.setAttribute('href', location.pathname.replace('/embed/', '/watch?v='));
redirect_element.appendChild(document.createTextNode('Invidious'));
// Create hyperlink for current instance
var redirect_element = document.createElement('a');
redirect_element.setAttribute('href', location.pathname.replace('/embed/', '/watch?v='));
redirect_element.appendChild(document.createTextNode('Invidious'));
watch_on_invidious_button.el().appendChild(redirect_element);
watch_on_invidious_button.addClass('watch-on-invidious');
watch_on_invidious_button.el().appendChild(redirect_element);
watch_on_invidious_button.addClass('watch-on-invidious');
var cb = player.getChild('ControlBar');
cb.addChild(watch_on_invidious_button);
var cb = player.getChild('ControlBar');
cb.addChild(watch_on_invidious_button);
console.log('[Invidious Debug] Watch on Invidious button added.');
} catch (e) {
console.error('[Invidious Debug] Watch on Invidious button FAILED:', e);
}
}
addEventListener('DOMContentLoaded', function () {
@ -792,3 +978,5 @@ addEventListener('DOMContentLoaded', function () {
changeInstanceLink.href = addCurrentTimeToURL(changeInstanceLink.href);
});
});
console.log('[Invidious Debug] player.js finished loading.');

View File

@ -90,14 +90,14 @@ db:
##
## API key for Invidious companion, used for securing the communication
## between Invidious and Invidious companion.
## The size of the key needs to be more or equal to 16.
## The key needs to be exactly 16 characters long.
##
## Note: This parameter is mandatory when Invidious companion is enabled
## and should be a random string.
## Such random string can be generated on linux with the following
## command: `pwgen 16 1`
##
## Accepted values: a string
## Accepted values: a string (of length 16)
## Default: <none>
##
#invidious_companion_key: "CHANGE_ME!!"
@ -206,7 +206,7 @@ https_only: false
#disable_proxy: false
##
## Max size of the HTTP pool used to connect to youtube. Each
## Size of the HTTP pool used to connect to youtube. Each
## domain ('youtube.com', 'ytimg.com', ...) has its own pool.
##
## Accepted values: a positive integer
@ -214,16 +214,6 @@ https_only: false
##
#pool_size: 100
##
## Amount of seconds to wait for a client to be free from the pool
## before raising an error
##
##
## Accepted values: a positive integer
## Default: 5
##
#pool_checkout_timeout: 5
##
## Additional cookies to be sent when requesting the youtube API.

View File

@ -1,112 +0,0 @@
# Due to the way that specs are handled this file cannot be run
# together with everything else without causing a compile time error
#
# TODO: Allow running different isolated spec through make
#
# For now run this with `crystal spec -p spec/helpers/networking/connection_pool_spec.cr -Drunning_by_self`
{% skip_file unless flag?(:running_by_self) %}
# Based on https://github.com/jgaskins/http_client/blob/958cf56064c0d31264a117467022b90397eb65d7/spec/http_client_spec.cr
require "wait_group"
require "uri"
require "http"
require "http/server"
require "http_proxy"
require "db"
require "pg"
require "spectator"
require "../../load_config_helper"
require "../../../src/invidious/helpers/crystal_class_overrides"
require "../../../src/invidious/connection/*"
TEST_SERVER_URL = URI.parse("http://localhost:12345")
server = HTTP::Server.new do |context|
request = context.request
response = context.response
case {request.method, request.path}
when {"GET", "/get"}
response << "get"
when {"POST", "/post"}
response.status = :created
response << "post"
when {"GET", "/sleep"}
duration = request.query_params["duration_sec"].to_i.seconds
sleep duration
end
end
spawn server.listen 12345
Fiber.yield
Spectator.describe Invidious::ConnectionPool do
describe "Pool" do
it "Can make a requests through standard HTTP methods" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect(pool.get("/get").body).to eq("get")
expect(pool.post("/post").body).to eq("post")
end
it "Can make streaming requests" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect(pool.get("/get", &.body_io.gets_to_end)).to eq("get")
expect(pool.get("/post", &.body)).to eq("")
expect(pool.post("/post", &.body_io.gets_to_end)).to eq("post")
end
it "Allows more than one clients to be checked out (if applicable)" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
pool.checkout do |_|
expect(pool.post("/post").body).to eq("post")
end
end
it "Can make multiple requests with the same client" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
pool.checkout do |client|
expect(client.get("/get").body).to eq("get")
expect(client.post("/post").body).to eq("post")
expect(client.get("/get").body).to eq("get")
end
end
it "Allows concurrent requests" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
responses = [] of HTTP::Client::Response
WaitGroup.wait do |wg|
100.times do
wg.spawn { responses << pool.get("/get") }
end
end
expect(responses.map(&.body)).to eq(["get"] * 100)
end
it "Raises on checkout timeout" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 2, timeout: 0.01) { next make_client(TEST_SERVER_URL) }
# Long running requests
2.times do
spawn { pool.get("/sleep?duration_sec=2") }
end
Fiber.yield
expect { pool.get("/get") }.to raise_error(Invidious::ConnectionPool::Error)
end
it "Raises when an error is encountered" do
pool = Invidious::ConnectionPool::Pool.new(max_capacity: 100) { next make_client(TEST_SERVER_URL) }
expect { pool.get("/get") { raise IO::Error.new } }.to raise_error(Invidious::ConnectionPool::Error)
end
end
end

View File

@ -1,15 +0,0 @@
require "yaml"
require "log"
abstract class Kemal::BaseLogHandler
end
require "../src/invidious/config"
require "../src/invidious/jobs/base_job"
require "../src/invidious/jobs.cr"
require "../src/invidious/user/preferences.cr"
require "../src/invidious/helpers/logger"
require "../src/invidious/helpers/utils"
CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
HMAC_KEY = CONFIG.hmac_key

View File

@ -35,7 +35,6 @@ require "protodec/utils"
require "./invidious/database/*"
require "./invidious/database/migrations/*"
require "./invidious/connection/*"
require "./invidious/http_server/*"
require "./invidious/helpers/*"
require "./invidious/yt_backend/*"
@ -92,31 +91,15 @@ SOFTWARE = {
"branch" => "#{CURRENT_BRANCH}",
}
YT_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(YT_URL, force_resolve: true)
end
YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size)
# Image request pool
GGPHT_URL = URI.parse("https://yt3.ggpht.com")
GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size)
GGPHT_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(GGPHT_URL, force_resolve: true)
end
COMPANION_POOL = Invidious::ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
reinitialize_proxy: false
) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
end
COMPANION_POOL = CompanionConnectionPool.new(
capacity: CONFIG.pool_size
)
# CLI
Kemal.config.extra_options do |parser|

View File

@ -166,7 +166,7 @@ def fetch_channel(ucid, pull_all_videos : Bool)
}
LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed")
rss = YT_POOL.get("/feeds/videos.xml?channel_id=#{ucid}").body
rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body
LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed")
rss = XML.parse(rss)

View File

@ -157,13 +157,8 @@ class Config
property host_binding : String = "0.0.0.0"
# Path and permissions to make Invidious listen on a UNIX socket instead of a TCP port
property socket_binding : SocketBindingConfig? = nil
# Max pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool)
# Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`)
property pool_size : Int32 = 100
# Amount of seconds to wait for a client to be free from the pool before rasing an error
property pool_checkout_timeout : Float64 = 5
# HTTP Proxy configuration
property http_proxy : HTTPProxyConfig? = nil

View File

@ -1,53 +0,0 @@
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
# Force the usage of a specific configured IP Family
if force_resolve
client.family = CONFIG.force_resolve
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
return client
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
begin
yield client
ensure
client.close
end
end
def make_configured_http_proxy_client
# This method is only called when configuration for an HTTP proxy are set
config_proxy = CONFIG.http_proxy.not_nil!
return HTTP::Proxy::Client.new(
config_proxy.host,
config_proxy.port,
username: config_proxy.user,
password: config_proxy.password,
)
end

View File

@ -1,116 +0,0 @@
module Invidious::ConnectionPool
# A connection pool to reuse `HTTP::Client` connections
struct Pool
getter pool : DB::Pool(HTTP::Client)
# Creates a connection pool with the provided options, and client factory block.
def initialize(
*,
max_capacity : Int32 = 5,
timeout : Float64 = 5.0,
@reinitialize_proxy : Bool = true, # Whether or not http-proxy should be reinitialized on checkout
&client_factory : -> HTTP::Client
)
pool_options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: max_capacity,
max_idle_pool_size: max_capacity,
checkout_timeout: timeout
)
@pool = DB::Pool(HTTP::Client).new(pool_options, &client_factory)
end
{% for method in %w[get post put patch delete head options] %}
# Streaming API for {{method.id.upcase}} request.
# The response will have its body as an `IO` accessed via `HTTP::Client::Response#body_io`.
def {{method.id}}(*args, **kwargs, &)
self.checkout do | client |
client.{{method.id}}(*args, **kwargs) do | response |
result = yield response
return result
ensure
response.body_io?.try &.skip_to_end
end
end
end
# Executes a {{method.id.upcase}} request.
# The response will have its body as a `String`, accessed via `HTTP::Client::Response#body`.
def {{method.id}}(*args, **kwargs)
self.checkout do | client |
return client.{{method.id}}(*args, **kwargs)
end
end
{% end %}
# Checks out a client in the pool
def checkout(&)
# If a client has been deleted from the pool
# we won't try to release it
client_exists_in_pool = true
http_client = pool.checkout
# When the HTTP::Client connection is closed, the automatic reconnection
# feature will create a new IO to connect to the server with
#
# This new TCP IO will be a direct connection to the server and will not go
# through the proxy. As such we'll need to reinitialize the proxy connection
http_client.proxy = make_configured_http_proxy_client() if @reinitialize_proxy && CONFIG.http_proxy
response = yield http_client
rescue ex : DB::PoolTimeout
# Failed to checkout a client
raise ConnectionPool::PoolCheckoutError.new(ex.message)
rescue ex
# An error occurred with the client itself.
# Delete the client from the pool and close the connection
if http_client
client_exists_in_pool = false
@pool.delete(http_client)
http_client.close
end
# Raise exception for outer methods to handle
raise ConnectionPool::Error.new(ex.message, cause: ex)
ensure
pool.release(http_client) if http_client && client_exists_in_pool
end
end
class Error < Exception
end
# Raised when the pool failed to get a client in time
class PoolCheckoutError < Error
end
# Mapping of subdomain => Invidious::ConnectionPool::Pool
# This is needed as we may need to access arbitrary subdomains of ytimg
private YTIMG_POOLS = {} of String => ConnectionPool::Pool
# Fetches a HTTP pool for the specified subdomain of ytimg.com
#
# Creates a new one when the specified pool for the subdomain does not exist
def self.get_ytimg_pool(subdomain)
if pool = YTIMG_POOLS[subdomain]?
return pool
else
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
url = URI.parse("https://#{subdomain}.ytimg.com")
pool = ConnectionPool::Pool.new(
max_capacity: CONFIG.pool_size,
timeout: CONFIG.pool_checkout_timeout
) do
next make_client(url, force_resolve: true)
end
YTIMG_POOLS[subdomain] = pool
return pool
end
end
end

View File

@ -23,10 +23,16 @@ module Invidious::Frontend::WatchPage
return "<p id=\"download\">#{translate(locale, "Download is disabled")}</p>"
end
url = "/download"
if (CONFIG.invidious_companion.present?)
invidious_companion = CONFIG.invidious_companion.sample
url = "#{invidious_companion.public_url}/download?check=#{invidious_companion_encrypt(video.id)}"
end
return String.build(4000) do |str|
str << "<form"
str << " class=\"pure-form pure-form-stacked\""
str << " action='/download'"
str << " action='#{url}'"
str << " method='post'"
str << " rel='noopener'"
str << " target='_blank'>"

View File

@ -26,7 +26,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil)
end
video_id = "CvFH_6DNRCY" if rdid.starts_with? "OLAK5uy_"
response = YT_POOL.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
response = YT_POOL.client &.get("/watch?v=#{video_id}&list=#{rdid}&gl=US&hl=en", headers)
initial_data = extract_initial_data(response.body)
if !initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]?

View File

@ -26,7 +26,7 @@ module Invidious::Routes::API::Manifest
end
if dashmpd = video.dash_manifest_url
response = YT_POOL.get(URI.parse(dashmpd).request_target)
response = YT_POOL.client &.get(URI.parse(dashmpd).request_target)
if response.status_code != 200
haltf env, status_code: response.status_code
@ -167,7 +167,7 @@ module Invidious::Routes::API::Manifest
# /api/manifest/hls_playlist/*
def self.get_hls_playlist(env)
response = YT_POOL.get(env.request.path)
response = YT_POOL.client &.get(env.request.path)
if response.status_code != 200
haltf env, status_code: response.status_code
@ -223,7 +223,7 @@ module Invidious::Routes::API::Manifest
# /api/manifest/hls_variant/*
def self.get_hls_variant(env)
response = YT_POOL.get(env.request.path)
response = YT_POOL.client &.get(env.request.path)
if response.status_code != 200
haltf env, status_code: response.status_code

View File

@ -106,7 +106,7 @@ module Invidious::Routes::API::V1::Videos
# Auto-generated captions often have cues that aren't aligned properly with the video,
# as well as some other markup that makes it cumbersome, so we try to fix that here
if caption.name.includes? "auto-generated"
caption_xml = YT_POOL.get(url).body
caption_xml = YT_POOL.client &.get(url).body
settings_field = {
"Kind" => "captions",
@ -147,7 +147,7 @@ module Invidious::Routes::API::V1::Videos
query_params = uri.query_params
query_params["fmt"] = "vtt"
uri.query_params = query_params
webvtt = YT_POOL.get(uri.request_target).body
webvtt = YT_POOL.client &.get(uri.request_target).body
if webvtt.starts_with?("<?xml")
webvtt = caption.timedtext_to_vtt(webvtt)
@ -300,7 +300,7 @@ module Invidious::Routes::API::V1::Videos
cache_annotation(id, annotations)
end
else # "youtube"
response = YT_POOL.get("/annotations_invideo?video_id=#{id}")
response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}")
if response.status_code != 200
haltf env, response.status_code

View File

@ -392,7 +392,7 @@ module Invidious::Routes::Channels
value = env.request.resource.split("/")[2]
body = ""
{"channel", "user", "c"}.each do |type|
response = YT_POOL.get("/#{type}/#{value}/live?disable_polymer=1")
response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1")
if response.status_code == 200
body = response.body
end

View File

@ -92,7 +92,7 @@ module Invidious::Routes::Embed
return env.redirect url
when "live_stream"
response = YT_POOL.get("/embed/live_stream?channel=#{env.params.query["channel"]? || ""}")
response = YT_POOL.client &.get("/embed/live_stream?channel=#{env.params.query["channel"]? || ""}")
video_id = response.body.match(/"video_id":"(?<video_id>[a-zA-Z0-9_-]{11})"/).try &.["video_id"]
env.params.query.delete_all("channel")

View File

@ -9,10 +9,10 @@ module Invidious::Routes::ErrorRoutes
item = md["id"]
# Check if item is branding URL e.g. https://youtube.com/gaming
response = YT_POOL.get("/#{item}")
response = YT_POOL.client &.get("/#{item}")
if response.status_code == 301
response = YT_POOL.get(URI.parse(response.headers["Location"]).request_target)
response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target)
end
if response.body.empty?
@ -40,7 +40,7 @@ module Invidious::Routes::ErrorRoutes
end
# Check if item is video ID
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.head("/watch?v=#{item}").status_code != 404
if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404
env.response.headers["Location"] = url
haltf env, status_code: 302
end

View File

@ -160,9 +160,8 @@ module Invidious::Routes::Feeds
"default" => "http://www.w3.org/2005/Atom",
}
response = YT_POOL.get("/feeds/videos.xml?channel_id=#{ucid}")
response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}")
return error_atom(404, NotFoundException.new("Channel does not exist.")) if response.status_code == 404
rss = XML.parse(response.body)
videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry|
@ -305,7 +304,7 @@ module Invidious::Routes::Feeds
end
end
response = YT_POOL.get("/feeds/videos.xml?playlist_id=#{plid}")
response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}")
return error_atom(404, NotFoundException.new("Playlist does not exist.")) if response.status_code == 404
document = XML.parse(response.body)

View File

@ -12,7 +12,7 @@ module Invidious::Routes::Images
end
begin
GGPHT_POOL.get(url, headers) do |resp|
GGPHT_POOL.client &.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
@ -42,7 +42,7 @@ module Invidious::Routes::Images
end
begin
ConnectionPool.get_ytimg_pool(authority).get(url, headers) do |resp|
get_ytimg_pool(authority).client &.get(url, headers) do |resp|
env.response.headers["Connection"] = "close"
return self.proxy_image(env, resp)
end
@ -65,7 +65,7 @@ module Invidious::Routes::Images
end
begin
ConnectionPool.get_ytimg_pool("i9").get(url, headers) do |resp|
get_ytimg_pool("i9").client &.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex
@ -81,7 +81,7 @@ module Invidious::Routes::Images
end
begin
YT_POOL.get(env.request.resource, headers) do |response|
YT_POOL.client &.get(env.request.resource, headers) do |response|
env.response.status_code = response.status_code
response.headers.each do |key, value|
if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase)
@ -111,7 +111,7 @@ module Invidious::Routes::Images
if name == "maxres.jpg"
build_thumbnails(id).each do |thumb|
thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg"
if ConnectionPool.get_ytimg_pool("i").head(thumbnail_resource_path, headers).status_code == 200
if get_ytimg_pool("i").client &.head(thumbnail_resource_path, headers).status_code == 200
name = thumb[:url] + ".jpg"
break
end
@ -127,7 +127,7 @@ module Invidious::Routes::Images
end
begin
ConnectionPool.get_ytimg_pool("i").get(url, headers) do |resp|
get_ytimg_pool("i").client &.get(url, headers) do |resp|
return self.proxy_image(env, resp)
end
rescue ex

View File

@ -464,7 +464,7 @@ module Invidious::Routes::Playlists
# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos
def self.watch_videos(env)
response = YT_POOL.get(env.request.resource)
response = YT_POOL.client &.get(env.request.resource)
if url = response.headers["Location"]?
url = URI.parse(url).request_target
return env.redirect url

View File

@ -21,7 +21,7 @@ module Invidious::Routes::VideoPlayback
end
# Sanity check, to avoid being used as an open proxy
if !host.matches?(/[\w-]+.googlevideo.com/)
if !host.matches?(/[\w-]+\.(?:googlevideo|c\.youtube)\.com/)
return error_template(400, "Invalid \"host\" parameter.")
end
@ -37,7 +37,8 @@ module Invidious::Routes::VideoPlayback
# See: https://github.com/iv-org/invidious/issues/3302
range_header = env.request.headers["Range"]?
if range_header.nil?
sq = query_params["sq"]?
if range_header.nil? && sq.nil?
range_for_head = query_params["range"]? || "0-640"
headers["Range"] = "bytes=#{range_for_head}"
end

View File

@ -293,6 +293,9 @@ module Invidious::Routes::Watch
if CONFIG.disabled?("downloads")
return error_template(403, "Administrator has disabled this endpoint.")
end
if CONFIG.invidious_companion.present?
return error_template(403, "Downloads should be routed through Companion when present")
end
title = env.params.body["title"]? || ""
video_id = env.params.body["id"]? || ""
@ -328,13 +331,7 @@ module Invidious::Routes::Watch
env.params.query["title"] = filename
env.params.query["local"] = "true"
if (CONFIG.invidious_companion.present?)
video = get_video(video_id)
invidious_companion = CONFIG.invidious_companion.sample
return env.redirect "#{invidious_companion.public_url}/latest_version?#{env.params.query}"
else
return Invidious::Routes::VideoPlayback.latest_version(env)
end
return Invidious::Routes::VideoPlayback.latest_version(env)
else
return error_template(400, "Invalid label or itag")
end

View File

@ -16,11 +16,11 @@ module Invidious::Search
# Search a youtube channel
# TODO: clean code, and rely more on YoutubeAPI
def channel(query : Query) : Array(SearchItem)
response = YT_POOL.get("/channel/#{query.channel}")
response = YT_POOL.client &.get("/channel/#{query.channel}")
if response.status_code == 404
response = YT_POOL.get("/user/#{query.channel}")
response = YT_POOL.get("/c/#{query.channel}") if response.status_code == 404
response = YT_POOL.client &.get("/user/#{query.channel}")
response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404
initial_data = extract_initial_data(response.body)
ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?)
raise ChannelSearchException.new(query.channel) if !ucid

View File

@ -109,27 +109,20 @@ def extract_video_info(video_id : String)
params["reason"] = JSON::Any.new(reason) if reason
if !CONFIG.invidious_companion.present?
new_player_response = nil
# Don't use Android test suite client if po_token is passed because po_token doesn't
# work for Android test suite client.
if reason.nil? && CONFIG.po_token.nil?
# Fetch the video streams using an Android client in order to get the
# decrypted URLs and maybe fix throttling issues (#2194). See the
# following issue for an explanation about decrypted URLs:
# https://github.com/TeamNewPipe/NewPipeExtractor/issues/562
client_config.client_type = YoutubeAPI::ClientType::AndroidTestSuite
new_player_response = try_fetch_streaming_data(video_id, client_config)
end
# Replace player response and reset reason
if !new_player_response.nil?
# Preserve captions & storyboard data before replacement
new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]?
new_player_response["captions"] = player_response["captions"] if player_response["captions"]?
player_response = new_player_response
params.delete("reason")
if player_response["streamingData"]? && 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::WebMobile, YoutubeAPI::ClientType::TvHtml5]
players_fallback.each do |player_fallback|
client_config.client_type = player_fallback
player_fallback_response = try_fetch_streaming_data(video_id, client_config)
if player_fallback_response && player_fallback_response["streamingData"]? &&
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
end
end
end

View File

@ -0,0 +1,153 @@
# Mapping of subdomain => YoutubeConnectionPool
# This is needed as we may need to access arbitrary subdomains of ytimg
private YTIMG_POOLS = {} of String => YoutubeConnectionPool
struct YoutubeConnectionPool
property! url : URI
property! capacity : Int32
property! timeout : Float64
property pool : DB::Pool(HTTP::Client)
def initialize(url : URI, @capacity = 5, @timeout = 5.0)
@url = url
@pool = build_pool()
end
def client(&)
conn = pool.checkout
# Proxy needs to be reinstated every time we get a client from the pool
conn.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy
begin
response = yield conn
rescue ex
conn.close
conn = make_client(url, force_resolve: true)
response = yield conn
ensure
pool.release(conn)
end
response
end
private def build_pool
options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: capacity,
max_idle_pool_size: capacity,
checkout_timeout: timeout
)
DB::Pool(HTTP::Client).new(options) do
next make_client(url, force_resolve: true)
end
end
end
struct CompanionConnectionPool
property pool : DB::Pool(HTTP::Client)
def initialize(capacity = 5, timeout = 5.0)
options = DB::Pool::Options.new(
initial_pool_size: 0,
max_pool_size: capacity,
max_idle_pool_size: capacity,
checkout_timeout: timeout
)
@pool = DB::Pool(HTTP::Client).new(options) do
companion = CONFIG.invidious_companion.sample
next make_client(companion.private_url, use_http_proxy: false)
end
end
def client(&)
conn = pool.checkout
begin
response = yield conn
rescue ex
conn.close
companion = CONFIG.invidious_companion.sample
conn = make_client(companion.private_url, use_http_proxy: false)
response = yield conn
ensure
pool.release(conn)
end
response
end
end
def add_yt_headers(request)
request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal"
request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7"
request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
request.headers["Accept-Language"] ||= "en-us,en;q=0.5"
# Preserve original cookies and add new YT consent cookie for EU servers
request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}"
if !CONFIG.cookies.empty?
request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}"
end
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false, use_http_proxy : Bool = true)
client = HTTP::Client.new(url)
client.proxy = make_configured_http_proxy_client() if CONFIG.http_proxy && use_http_proxy
# Force the usage of a specific configured IP Family
if force_resolve
client.family = CONFIG.force_resolve
client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC
end
client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers
client.read_timeout = 10.seconds
client.connect_timeout = 10.seconds
return client
end
def make_client(url : URI, region = nil, force_resolve : Bool = false, use_http_proxy : Bool = true, &)
client = make_client(url, region, force_resolve: force_resolve, use_http_proxy: use_http_proxy)
begin
yield client
ensure
client.close
end
end
def make_configured_http_proxy_client
# This method is only called when configuration for an HTTP proxy are set
config_proxy = CONFIG.http_proxy.not_nil!
return HTTP::Proxy::Client.new(
config_proxy.host,
config_proxy.port,
username: config_proxy.user,
password: config_proxy.password,
)
end
# Fetches a HTTP pool for the specified subdomain of ytimg.com
#
# Creates a new one when the specified pool for the subdomain does not exist
def get_ytimg_pool(subdomain)
if pool = YTIMG_POOLS[subdomain]?
return pool
else
LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"")
pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size)
YTIMG_POOLS[subdomain] = pool
return pool
end
end

View File

@ -639,13 +639,15 @@ module YoutubeAPI
LOGGER.trace("YoutubeAPI: POST data: #{data}")
# Send the POST request
body = YT_POOL.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
body = YT_POOL.client() do |client|
client.post(url, headers: headers, body: data.to_json) do |response|
if response.status_code != 200
raise InfoException.new("Error: non 200 status code. Youtube API returned \
status code #{response.status_code}. See <a href=\"https://docs.invidious.io/youtube-errors-explained/\"> \
https://docs.invidious.io/youtube-errors-explained/</a> for troubleshooting.")
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end
self._decompress(response.body_io, response.headers["Content-Encoding"]?)
end
# Convert result to Hash
@ -693,7 +695,7 @@ module YoutubeAPI
# Send the POST request
begin
response = COMPANION_POOL.post(endpoint, headers: headers, body: data.to_json)
response = COMPANION_POOL.client &.post(endpoint, headers: headers, body: data.to_json)
body = response.body
if (response.status_code != 200)
raise Exception.new(