fix: update innertube clients and fix HLS/DASH quality switching
All checks were successful
CI / test (push) Successful in 53s

- Update innertube client versions to match yt-dlp (android 21.02.35,
  ios 21.02.3, web 2.20260114.08.00, android_vr 1.65.10)
- Remove obsolete clients (android-test-suite, ios_vr)
- Replace tv_embedded with TVHTML5_SIMPLY (cn 75)
- Add new clients: web_embedded, mweb, tv
- Fix HLS freeze on quality switch: use nextLevel instead of
  currentLevel, handle bufferStalledError, stream proxy segments
  instead of buffering in memory
- Populate DASH quality selector with actual sources (no Auto)
- Render quality-select empty in template, let JS populate per mode
This commit is contained in:
2026-05-03 12:32:55 -05:00
parent 50ad959a80
commit 8d66143c90
9 changed files with 467 additions and 246 deletions

View File

@@ -0,0 +1,72 @@
import pytest
from youtube import watch_formats
class TestCodecName:
def test_avc_returns_h264(self):
assert watch_formats.codec_name('avc1.64001F') == 'h264'
def test_av01_returns_av1(self):
assert watch_formats.codec_name('av01.0.05M.08') == 'av1'
def test_vp9_returns_vp(self):
assert watch_formats.codec_name('vp9') == 'vp'
def test_unknown_returns_unknown(self):
assert watch_formats.codec_name('unknown_codec') == 'unknown'
class TestVideoQualityString:
def test_with_vcodec(self):
fmt = {'vcodec': 'avc1', 'width': 1920, 'height': 1080, 'fps': 30}
assert watch_formats.video_quality_string(fmt) == '1920x1080 30fps'
def test_with_vcodec_no_fps(self):
fmt = {'vcodec': 'avc1', 'width': 1280, 'height': 720}
assert watch_formats.video_quality_string(fmt) == '1280x720'
def test_with_acodec_only(self):
fmt = {'acodec': 'mp4a.40.2'}
assert watch_formats.video_quality_string(fmt) == 'audio only'
def test_empty(self):
fmt = {}
assert watch_formats.video_quality_string(fmt) == '?'
class TestShortVideoQualityString:
def test_with_fps(self):
fmt = {'quality': 1080, 'fps': 60, 'vcodec': 'av01.0.05M.08'}
assert watch_formats.short_video_quality_string(fmt) == '1080p60 AV1'
def test_h264(self):
fmt = {'quality': 720, 'fps': 30, 'vcodec': 'avc1.64001E'}
assert watch_formats.short_video_quality_string(fmt) == '720p30 h264'
class TestAudioQualityString:
def test_with_bitrate(self):
fmt = {'acodec': 'mp4a.40.2', 'audio_bitrate': 128}
assert watch_formats.audio_quality_string(fmt) == '128k'
def test_with_sample_rate(self):
fmt = {'acodec': 'mp4a.40.2', 'audio_bitrate': 128, 'audio_sample_rate': 44100}
assert watch_formats.audio_quality_string(fmt) == '128k 44.1kHz'
def test_video_only(self):
fmt = {'vcodec': 'avc1'}
assert watch_formats.audio_quality_string(fmt) == 'video only'
class TestFormatBytes:
def test_none(self):
assert watch_formats.format_bytes(None) == 'N/A'
def test_bytes(self):
assert watch_formats.format_bytes(512) == '512.00B'
def test_kibibytes(self):
assert watch_formats.format_bytes(1024) == '1.00KiB'
def test_mebibytes(self):
assert watch_formats.format_bytes(1048576) == '1.00MiB'

190
youtube/constants.py Normal file
View File

@@ -0,0 +1,190 @@
"""Constants used across yt-local application."""
import collections
YOUTUBE_DOMAINS = ('youtube.com', 'youtu.be', 'youtube-nocookie.com')
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)'
MOBILE_USER_AGENT = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36'
REPLACEMENT_MAP = collections.OrderedDict([
('<', '_'),
('>', '_'),
(': ', ' - '),
(':', '-'),
('"', "'"),
('/', '_'),
('\\', '_'),
('|', '-'),
('?', ''),
('*', '_'),
('\t', ' '),
])
DOS_RESERVED_NAMES = frozenset({
'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3',
'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt0',
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7',
'lpt8', 'lpt9'
})
INNERTUBE_CLIENTS = {
'android': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'ANDROID',
'clientVersion': '21.02.35',
'osName': 'Android',
'osVersion': '11',
'androidSdkVersion': 30,
'platform': 'MOBILE',
'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip'
},
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
'REQUIRE_JS_PLAYER': False,
},
'ios': {
'INNERTUBE_API_KEY': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'IOS',
'clientVersion': '21.02.3',
'deviceMake': 'Apple',
'deviceModel': 'iPhone16,2',
'osName': 'iPhone',
'osVersion': '18.3.2.22D82',
'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)'
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
'REQUIRE_JS_PLAYER': False
},
'tv_embedded': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'TVHTML5_SIMPLY',
'clientVersion': '1.0',
},
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 75,
'REQUIRE_JS_PLAYER': True,
},
'web': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'clientName': 'WEB',
'clientVersion': '2.20260114.08.00',
'userAgent': DEFAULT_USER_AGENT,
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 1
},
'web_embedded': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'WEB_EMBEDDED_PLAYER',
'clientVersion': '1.20260115.01.00',
},
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 56,
'REQUIRE_JS_PLAYER': True,
},
'mweb': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'MWEB',
'clientVersion': '2.20260115.01.00',
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)',
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 2,
'REQUIRE_JS_PLAYER': True,
},
'tv': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'TVHTML5',
'clientVersion': '7.20260114.12.00',
'userAgent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/25.lts.30.1034943-gold (unlike Gecko), Unknown_TV_Unknown_0/Unknown (Unknown, Unknown)',
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 7,
'REQUIRE_JS_PLAYER': True,
},
'android_vr': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'clientName': 'ANDROID_VR',
'clientVersion': '1.65.10',
'deviceMake': 'Oculus',
'deviceModel': 'Quest 3',
'androidSdkVersion': 32,
'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
'osName': 'Android',
'osVersion': '12L',
},
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
'REQUIRE_JS_PLAYER': False,
},
}
THEME_NAMES = {
0: 'light_theme',
1: 'gray_theme',
2: 'dark_theme',
}
FONT_CHOICES = {
0: 'initial',
1: '"liberation serif", "times new roman", calibri, carlito, serif',
2: 'arial, "liberation sans", sans-serif',
3: 'verdana, sans-serif',
4: 'tahoma, sans-serif',
}
URL_ORIGIN = "/https://www.youtube.com"
MAX_RETRIES = 5
BASE_DELAY = 1.0
TOR_DEFAULT_PORT = 9050
TOR_CONTROL_DEFAULT_PORT = 9151
DEFAULT_PORT = 9010
# Backward compatibility aliases (matching existing code names)
desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0'
desktop_ua = (('User-Agent', desktop_user_agent),)
mobile_ua = (('User-Agent', MOBILE_USER_AGENT),)
json_header = (('Content-Type', 'application/json'),)
# Re-export for convenience
url_origin = URL_ORIGIN

View File

@@ -31,9 +31,34 @@ if (data.using_pair_sources) {
avMerge = new AVMerge(video, srcPair, 0); avMerge = new AVMerge(video, srcPair, 0);
} }
// Quality selector // Quality selector — populate with available sources
const qs = document.getElementById('quality-select'); const qs = document.getElementById('quality-select');
if (qs) { if (qs) {
// Clear the HLS-oriented "Auto" default; DASH has discrete sources
qs.innerHTML = '';
// Add pair_sources (video+audio, used by AVMerge)
if (data['pair_sources'] && data['pair_sources'].length) {
data['pair_sources'].forEach(function(src, i) {
let opt = document.createElement('option');
opt.value = JSON.stringify({type: 'pair', index: i});
opt.textContent = src.quality_string;
if (i === data['pair_idx']) opt.selected = true;
qs.appendChild(opt);
});
}
// Add uni_sources (integrated video+audio, single file)
if (data['uni_sources'] && data['uni_sources'].length) {
data['uni_sources'].forEach(function(src, i) {
let opt = document.createElement('option');
opt.value = JSON.stringify({type: 'uni', index: i});
opt.textContent = src.quality_string;
if (!data['pair_sources'].length && i === data['uni_idx']) opt.selected = true;
qs.appendChild(opt);
});
}
qs.addEventListener('change', function(e) { qs.addEventListener('change', function(e) {
changeQuality(JSON.parse(this.value)) changeQuality(JSON.parse(this.value))
}); });

View File

@@ -26,7 +26,17 @@ function initHLSNative(manifestUrl) {
lowLatencyMode: false, lowLatencyMode: false,
maxBufferLength: 30, maxBufferLength: 30,
maxMaxBufferLength: 60, maxMaxBufferLength: 60,
maxBufferHole: 0.5,
startLevel: -1, startLevel: -1,
// Prevent stalls on quality switch: nudge playback past small gaps
nudgeMaxRetry: 5,
// Allow more time for segments coming through our proxy
fragLoadingTimeOut: 30000,
fragLoadingMaxRetry: 5,
fragLoadingRetryDelay: 1000,
levelLoadingTimeOut: 15000,
levelLoadingMaxRetry: 4,
levelLoadingRetryDelay: 1000,
}); });
window.hls = hls; window.hls = hls;
@@ -89,15 +99,26 @@ function initHLSNative(manifestUrl) {
console.error('HLS fatal error:', data.type, data.details); console.error('HLS fatal error:', data.type, data.details);
switch(data.type) { switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR: case Hls.ErrorTypes.NETWORK_ERROR:
console.warn('HLS network error, attempting recovery...');
hls.startLoad(); hls.startLoad();
break; break;
case Hls.ErrorTypes.MEDIA_ERROR: case Hls.ErrorTypes.MEDIA_ERROR:
console.warn('HLS media error, attempting recovery...');
hls.recoverMediaError(); hls.recoverMediaError();
break; break;
default: default:
hls.destroy(); hls.destroy();
break; break;
} }
} else {
// Non-fatal errors can still cause stalls, especially
// bufferStalledError after a quality switch through our proxy
console.warn('HLS non-fatal error:', data.type, data.details);
if (data.details === 'bufferStalledError') {
// Buffer ran dry — HLS.js is waiting for data.
// Nudge it to retry loading the current fragment.
hls.startLoad();
}
} }
}); });
@@ -122,13 +143,36 @@ function initPlayer() {
initHLSNative(hls_manifest_url); initHLSNative(hls_manifest_url);
const qualitySelect = document.getElementById('quality-select'); const qualitySelect = document.getElementById('quality-select');
// Set initial Auto option while manifest loads
if (qualitySelect) {
qualitySelect.innerHTML = '<option value="-1" selected>Auto</option>';
}
if (qualitySelect) { if (qualitySelect) {
qualitySelect.addEventListener('change', function () { qualitySelect.addEventListener('change', function () {
const level = parseInt(this.value); const level = parseInt(this.value);
if (hls) { if (hls) {
hls.currentLevel = level; const currentTime = video.currentTime;
console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p'); const wasPaused = video.paused;
// Use nextLevel for smoother transition: it waits for the
// current segment to finish before switching, avoiding an
// abrupt buffer flush that starves the player.
if (level === -1) {
// Back to auto — re-enable ABR
hls.currentLevel = -1;
console.log('Quality: Auto (ABR)');
} else {
hls.nextLevel = level;
console.log('Quality: switching to',
hls.levels[level]?.height + 'p');
}
// If the video was already stalled, kick the loader
// so it starts fetching the new level immediately.
if (video.readyState < 3) {
hls.startLoad(currentTime);
}
} }
}); });
} }

View File

@@ -75,14 +75,11 @@
<div class="external-player-controls"> <div class="external-player-controls">
<input class="speed" id="speed-control" type="text" title="Video speed"> <input class="speed" id="speed-control" type="text" title="Video speed">
{% if settings.use_video_player < 2 %} {% if settings.use_video_player < 2 %}
<!-- Native player quality selector --> <!-- Quality selector (populated by JS: HLS adds Auto+levels, DASH adds discrete sources) -->
<select id="quality-select" autocomplete="off"> <select id="quality-select" autocomplete="off">
<option value="-1" selected>Auto</option>
<!-- Quality options will be populated by HLS -->
</select> </select>
{% else %} {% else %}
<select id="quality-select" autocomplete="off" style="display: none;"> <select id="quality-select" autocomplete="off" style="display: none;">
<!-- Quality options will be populated by HLS -->
</select> </select>
{% endif %} {% endif %}
{% if settings.use_video_player != 2 %} {% if settings.use_video_player != 2 %}

View File

@@ -23,6 +23,9 @@ import stem
import stem.control import stem.control
import traceback import traceback
from youtube.yt_data_extract.common import concat_or_none
from youtube import constants
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The trouble with the requests library: It ships its own certificate bundle via certifi # The trouble with the requests library: It ships its own certificate bundle via certifi
@@ -468,11 +471,11 @@ def head(url, use_tor=False, report_text=None, max_redirects=10):
print(f'{report_text} Latency: {round(time.monotonic() - start_time, 3)}') print(f'{report_text} Latency: {round(time.monotonic() - start_time, 3)}')
return response return response
mobile_user_agent = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36' mobile_user_agent = constants.MOBILE_USER_AGENT
mobile_ua = (('User-Agent', mobile_user_agent),) mobile_ua = constants.mobile_ua
desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0' desktop_user_agent = constants.desktop_user_agent
desktop_ua = (('User-Agent', desktop_user_agent),) desktop_ua = constants.desktop_ua
json_header = (('Content-Type', 'application/json'),) json_header = constants.json_header
desktop_xhr_headers = ( desktop_xhr_headers = (
('Accept', '*/*'), ('Accept', '*/*'),
('Accept-Language', 'en-US,en;q=0.5'), ('Accept-Language', 'en-US,en;q=0.5'),
@@ -641,7 +644,7 @@ def update_query_string(query_string, items):
return urllib.parse.urlencode(parameters, doseq=True) return urllib.parse.urlencode(parameters, doseq=True)
YOUTUBE_DOMAINS = ('youtube.com', 'youtu.be', 'youtube-nocookie.com') YOUTUBE_DOMAINS = constants.YOUTUBE_DOMAINS
YOUTUBE_URL_RE_STR = r'https?://(?:[a-zA-Z0-9_-]*\.)?(?:' YOUTUBE_URL_RE_STR = r'https?://(?:[a-zA-Z0-9_-]*\.)?(?:'
YOUTUBE_URL_RE_STR += r'|'.join(map(re.escape, YOUTUBE_DOMAINS)) YOUTUBE_URL_RE_STR += r'|'.join(map(re.escape, YOUTUBE_DOMAINS))
YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?' YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?'
@@ -668,16 +671,6 @@ def left_remove(string, substring):
return string return string
def concat_or_none(*strings):
'''Concatenates strings. Returns None if any of the arguments are None'''
result = ''
for string in strings:
if string is None:
return None
result += string
return result
def prefix_urls(item): def prefix_urls(item):
if settings.proxy_images: if settings.proxy_images:
try: try:
@@ -726,24 +719,8 @@ def check_gevent_exceptions(*tasks):
# https://stackoverflow.com/a/62888 # https://stackoverflow.com/a/62888
replacement_map = collections.OrderedDict([ replacement_map = constants.REPLACEMENT_MAP
('<', '_'), DOS_names = constants.DOS_RESERVED_NAMES
('>', '_'),
(': ', ' - '),
(':', '-'),
('"', "'"),
('/', '_'),
('\\', '_'),
('|', '-'),
('?', ''),
('*', '_'),
('\t', ' '),
])
DOS_names = {'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3',
'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt0',
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7',
'lpt8', 'lpt9'}
def to_valid_filename(name): def to_valid_filename(name):
@@ -785,143 +762,7 @@ def to_valid_filename(name):
return name return name
# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L72 INNERTUBE_CLIENTS = constants.INNERTUBE_CLIENTS
INNERTUBE_CLIENTS = {
'android': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'ANDROID',
'clientVersion': '19.09.36',
'osName': 'Android',
'osVersion': '12',
'androidSdkVersion': 31,
'platform': 'MOBILE',
'userAgent': 'com.google.android.youtube/19.09.36 (Linux; U; Android 12; US) gzip'
},
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
#'thirdParty': {
# 'embedUrl': 'https://google.com', # Can be any valid URL
#}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
'REQUIRE_JS_PLAYER': False,
},
'android-test-suite': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'ANDROID_TESTSUITE',
'clientVersion': '1.9',
'osName': 'Android',
'osVersion': '12',
'androidSdkVersion': 31,
'platform': 'MOBILE',
'userAgent': 'com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip'
},
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
#'thirdParty': {
# 'embedUrl': 'https://google.com', # Can be any valid URL
#}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
'REQUIRE_JS_PLAYER': False,
},
'ios': {
'INNERTUBE_API_KEY': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'IOS',
'clientVersion': '21.03.2',
'deviceMake': 'Apple',
'deviceModel': 'iPhone16,2',
'osName': 'iPhone',
'osVersion': '18.7.2.22H124',
'userAgent': 'com.google.ios.youtube/21.03.2 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
'REQUIRE_JS_PLAYER': False
},
# This client can access age restricted videos (unless the uploader has disabled the 'allow embedding' option)
# See: https://github.com/zerodytrash/YouTube-Internal-Clients
'tv_embedded': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
'clientVersion': '2.0',
'clientScreen': 'EMBED',
},
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
'thirdParty': {
'embedUrl': 'https://google.com', # Can be any valid URL
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 85,
'REQUIRE_JS_PLAYER': True,
},
'web': {
'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
'INNERTUBE_CONTEXT': {
'client': {
'clientName': 'WEB',
'clientVersion': '2.20220801.00.00',
'userAgent': desktop_user_agent,
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 1
},
'android_vr': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'clientName': 'ANDROID_VR',
'clientVersion': '1.60.19',
'deviceMake': 'Oculus',
'deviceModel': 'Quest 3',
'androidSdkVersion': 32,
'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
'osName': 'Android',
'osVersion': '12L',
},
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
'REQUIRE_JS_PLAYER': False,
},
'ios_vr': {
'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
'INNERTUBE_CONTEXT': {
'client': {
'hl': 'en',
'gl': 'US',
'clientName': 'IOS_VR',
'clientVersion': '1.0',
'deviceMake': 'Apple',
'deviceModel': 'iPhone16,2',
'osName': 'iPhone',
'osVersion': '18.7.2.22H124',
'userAgent': 'com.google.ios.youtube/1.0 (iPhone16,2; U; CPU iOS 18_7_2 like Mac OS X)'
}
},
'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
'REQUIRE_JS_PLAYER': False
},
}
def get_visitor_data(): def get_visitor_data():
visitor_data = None visitor_data = None

View File

@@ -17,8 +17,16 @@ from flask import request
import youtube import youtube
from youtube import yt_app from youtube import yt_app
from youtube import util, comments, local_playlist, yt_data_extract from youtube import util, comments, local_playlist, yt_data_extract
from youtube import watch_formats
import settings import settings
# Backward compatibility aliases
codec_name = watch_formats.codec_name
video_quality_string = watch_formats.video_quality_string
short_video_quality_string = watch_formats.short_video_quality_string
audio_quality_string = watch_formats.audio_quality_string
format_bytes = watch_formats.format_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,15 +37,7 @@ except FileNotFoundError:
decrypt_cache = {} decrypt_cache = {}
def codec_name(vcodec): # codec_name imported from watch_formats
if vcodec.startswith('avc'):
return 'h264'
elif vcodec.startswith('av01'):
return 'av1'
elif vcodec.startswith('vp'):
return 'vp'
else:
return 'unknown'
def get_video_sources(info, target_resolution): def get_video_sources(info, target_resolution):
@@ -446,7 +446,7 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
info['hls_audio_tracks'] = {} info['hls_audio_tracks'] = {}
hls_data = None hls_data = None
hls_client_used = None hls_client_used = None
for hls_client in ('ios', 'ios_vr', 'android'): for hls_client in ('ios', 'android'):
try: try:
resp = fetch_player_response(hls_client, video_id) or {} resp = fetch_player_response(hls_client, video_id) or {}
hls_data = json.loads(resp) if isinstance(resp, str) else resp hls_data = json.loads(resp) if isinstance(resp, str) else resp
@@ -621,55 +621,10 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
return info return info
def video_quality_string(format): # video_quality_string imported from watch_formats
if format['vcodec']: # short_video_quality_string imported from watch_formats
result = f"{format['width'] or '?'}x{format['height'] or '?'}" # audio_quality_string imported from watch_formats
if format['fps']: # format_bytes imported from watch_formats
result += f" {format['fps']}fps"
return result
elif format['acodec']:
return 'audio only'
return '?'
def short_video_quality_string(fmt):
result = f"{fmt['quality'] or '?'}p"
if fmt['fps']:
result += str(fmt['fps'])
if fmt['vcodec'].startswith('av01'):
result += ' AV1'
elif fmt['vcodec'].startswith('avc'):
result += ' h264'
else:
result += f" {fmt['vcodec']}"
return result
def audio_quality_string(fmt):
if fmt['acodec']:
if fmt['audio_bitrate']:
result = f"{fmt['audio_bitrate']}k"
else:
result = '?k'
if fmt['audio_sample_rate']:
result += f" {'%.3G' % (fmt['audio_sample_rate']/1000)}kHz"
return result
elif fmt['vcodec']:
return 'video only'
return '?'
# from https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/utils.py
def format_bytes(bytes):
if bytes is None:
return 'N/A'
if type(bytes) is str:
bytes = float(bytes)
if bytes == 0.0:
exponent = 0
else:
exponent = int(math.log(bytes, 1024.0))
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent] suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
converted = float(bytes) / float(1024 ** exponent) converted = float(bytes) / float(1024 ** exponent)
return '%.2f%s' % (converted, suffix) return '%.2f%s' % (converted, suffix)
@@ -832,14 +787,12 @@ def get_audio_track():
# This is an actual segment - fetch and serve it # This is an actual segment - fetch and serve it
try: try:
headers = ( headers_dict = {
('User-Agent', 'Mozilla/5.0'), 'User-Agent': 'Mozilla/5.0',
('Accept', '*/*'), 'Accept': '*/*',
) }
content = util.fetch_url(seg_url, headers=headers,
debug_name='hls_seg', report_text=None)
# Determine content type based on URL or content # Determine content type based on URL
# HLS segments are usually MPEG-TS (.ts) but can be MP4 (.mp4, .m4s) # HLS segments are usually MPEG-TS (.ts) but can be MP4 (.mp4, .m4s)
if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'): if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'):
content_type = 'video/mp4' content_type = 'video/mp4'
@@ -849,7 +802,23 @@ def get_audio_track():
# Default to MPEG-TS for HLS # Default to MPEG-TS for HLS
content_type = 'video/mp2t' content_type = 'video/mp2t'
return flask.Response(content, mimetype=content_type, response, cleanup_func = util.fetch_url_response(
seg_url, headers=tuple(headers_dict.items()),
timeout=30, use_tor=settings.route_tor)
def generate():
try:
while True:
chunk = response.read(64 * 1024) # 64 KB chunks
if not chunk:
break
yield chunk
finally:
cleanup_func(response)
return flask.Response(
flask.stream_with_context(generate()),
mimetype=content_type,
headers={ headers={
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Methods': 'GET, OPTIONS',

82
youtube/watch_formats.py Normal file
View File

@@ -0,0 +1,82 @@
"""Video format helpers for yt-local."""
import math
from typing import Any, Dict, Optional
def codec_name(vcodec: str) -> str:
"""Extract codec short name from codec string."""
if vcodec.startswith('avc'):
return 'h264'
elif vcodec.startswith('av01'):
return 'av1'
elif vcodec.startswith('vp'):
return 'vp'
else:
return 'unknown'
def video_quality_string(fmt: Dict[str, Any]) -> str:
"""Return video quality string (e.g., '1920x1080 30fps')."""
if fmt.get('vcodec'):
result = f"{fmt.get('width') or '?'}x{fmt.get('height') or '?'}"
if fmt.get('fps'):
result += f" {fmt['fps']}fps"
return result
elif fmt.get('acodec'):
return 'audio only'
return '?'
def short_video_quality_string(fmt: Dict[str, Any]) -> str:
"""Return short video quality string (e.g., '1080p60 AV1')."""
result = f"{fmt.get('quality') or '?'}p"
if fmt.get('fps'):
result += str(fmt['fps'])
vcodec = fmt.get('vcodec', '')
if vcodec.startswith('av01'):
result += ' AV1'
elif vcodec.startswith('avc'):
result += ' h264'
else:
result += f" {vcodec}"
return result
def audio_quality_string(fmt: Dict[str, Any]) -> str:
"""Return audio quality string (e.g., '128k 44.1kHz')."""
if fmt.get('acodec'):
if fmt.get('audio_bitrate'):
result = f"{fmt['audio_bitrate']}k"
else:
result = '?k'
if fmt.get('audio_sample_rate'):
result += f" {'%.3G' % (fmt['audio_sample_rate']/1000)}kHz"
return result
elif fmt.get('vcodec'):
return 'video only'
return '?'
def format_bytes(bytes_val: Optional[float]) -> str:
"""Convert bytes to human-readable string (e.g., '1.5 MiB')."""
if bytes_val is None:
return 'N/A'
if type(bytes_val) is str:
bytes_val = float(bytes_val)
if bytes_val == 0.0:
exponent = 0
else:
exponent = int(math.log(bytes_val, 1024.0))
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
converted = float(bytes_val) / float(1024 ** exponent)
return '%.2f%s' % (converted, suffix)
__all__ = [
'codec_name',
'video_quality_string',
'short_video_quality_string',
'audio_quality_string',
'format_bytes',
]

View File

@@ -1,7 +1,8 @@
from .common import (get, multi_get, deep_get, multi_deep_get, from .common import (get, multi_get, deep_get, multi_deep_get,
liberal_update, conservative_update, remove_redirect, normalize_url, liberal_update, conservative_update, remove_redirect, normalize_url,
extract_str, extract_formatted_text, extract_int, extract_approx_int, extract_str, extract_formatted_text, extract_int, extract_approx_int,
extract_date, extract_item_info, extract_items, extract_response) extract_date, extract_item_info, extract_items, extract_response,
concat_or_none)
from .everything_else import (extract_channel_info, extract_search_info, from .everything_else import (extract_channel_info, extract_search_info,
extract_playlist_metadata, extract_playlist_info, extract_comments_info) extract_playlist_metadata, extract_playlist_info, extract_comments_info)