diff --git a/tests/test_watch_formats.py b/tests/test_watch_formats.py
new file mode 100644
index 0000000..85a103b
--- /dev/null
+++ b/tests/test_watch_formats.py
@@ -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'
\ No newline at end of file
diff --git a/youtube/constants.py b/youtube/constants.py
new file mode 100644
index 0000000..780cb10
--- /dev/null
+++ b/youtube/constants.py
@@ -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
diff --git a/youtube/static/js/watch.dash.js b/youtube/static/js/watch.dash.js
index cb02421..5bbc7a2 100644
--- a/youtube/static/js/watch.dash.js
+++ b/youtube/static/js/watch.dash.js
@@ -31,9 +31,34 @@ if (data.using_pair_sources) {
avMerge = new AVMerge(video, srcPair, 0);
}
-// Quality selector
+// Quality selector — populate with available sources
const qs = document.getElementById('quality-select');
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) {
changeQuality(JSON.parse(this.value))
});
diff --git a/youtube/static/js/watch.hls.js b/youtube/static/js/watch.hls.js
index a924fdb..38e80a9 100644
--- a/youtube/static/js/watch.hls.js
+++ b/youtube/static/js/watch.hls.js
@@ -26,7 +26,17 @@ function initHLSNative(manifestUrl) {
lowLatencyMode: false,
maxBufferLength: 30,
maxMaxBufferLength: 60,
+ maxBufferHole: 0.5,
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;
@@ -89,15 +99,26 @@ function initHLSNative(manifestUrl) {
console.error('HLS fatal error:', data.type, data.details);
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
+ console.warn('HLS network error, attempting recovery...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
+ console.warn('HLS media error, attempting recovery...');
hls.recoverMediaError();
break;
default:
hls.destroy();
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);
const qualitySelect = document.getElementById('quality-select');
+ // Set initial Auto option while manifest loads
+ if (qualitySelect) {
+ qualitySelect.innerHTML = '';
+ }
if (qualitySelect) {
qualitySelect.addEventListener('change', function () {
const level = parseInt(this.value);
if (hls) {
- hls.currentLevel = level;
- console.log('Quality:', level === -1 ? 'Auto' : hls.levels[level]?.height + 'p');
+ const currentTime = video.currentTime;
+ 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);
+ }
}
});
}
diff --git a/youtube/templates/watch.html b/youtube/templates/watch.html
index 079a01c..6b4f48f 100644
--- a/youtube/templates/watch.html
+++ b/youtube/templates/watch.html
@@ -75,14 +75,11 @@
{% if settings.use_video_player < 2 %}
-
+
{% else %}
{% endif %}
{% if settings.use_video_player != 2 %}
diff --git a/youtube/util.py b/youtube/util.py
index 7901a89..007ec07 100644
--- a/youtube/util.py
+++ b/youtube/util.py
@@ -23,6 +23,9 @@ import stem
import stem.control
import traceback
+from youtube.yt_data_extract.common import concat_or_none
+from youtube import constants
+
logger = logging.getLogger(__name__)
# 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)}')
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_ua = (('User-Agent', mobile_user_agent),)
-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),)
-json_header = (('Content-Type', 'application/json'),)
+mobile_user_agent = constants.MOBILE_USER_AGENT
+mobile_ua = constants.mobile_ua
+desktop_user_agent = constants.desktop_user_agent
+desktop_ua = constants.desktop_ua
+json_header = constants.json_header
desktop_xhr_headers = (
('Accept', '*/*'),
('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)
-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'|'.join(map(re.escape, YOUTUBE_DOMAINS))
YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?'
@@ -668,16 +671,6 @@ def left_remove(string, substring):
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):
if settings.proxy_images:
try:
@@ -726,24 +719,8 @@ def check_gevent_exceptions(*tasks):
# https://stackoverflow.com/a/62888
-replacement_map = collections.OrderedDict([
- ('<', '_'),
- ('>', '_'),
- (': ', ' - '),
- (':', '-'),
- ('"', "'"),
- ('/', '_'),
- ('\\', '_'),
- ('|', '-'),
- ('?', ''),
- ('*', '_'),
- ('\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'}
+replacement_map = constants.REPLACEMENT_MAP
+DOS_names = constants.DOS_RESERVED_NAMES
def to_valid_filename(name):
@@ -785,143 +762,7 @@ def to_valid_filename(name):
return name
-# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L72
-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
- },
-}
+INNERTUBE_CLIENTS = constants.INNERTUBE_CLIENTS
def get_visitor_data():
visitor_data = None
diff --git a/youtube/watch.py b/youtube/watch.py
index 9d1e442..ec446f4 100644
--- a/youtube/watch.py
+++ b/youtube/watch.py
@@ -17,8 +17,16 @@ from flask import request
import youtube
from youtube import yt_app
from youtube import util, comments, local_playlist, yt_data_extract
+from youtube import watch_formats
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__)
@@ -29,15 +37,7 @@ except FileNotFoundError:
decrypt_cache = {}
-def codec_name(vcodec):
- if vcodec.startswith('avc'):
- return 'h264'
- elif vcodec.startswith('av01'):
- return 'av1'
- elif vcodec.startswith('vp'):
- return 'vp'
- else:
- return 'unknown'
+# codec_name imported from watch_formats
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'] = {}
hls_data = None
hls_client_used = None
- for hls_client in ('ios', 'ios_vr', 'android'):
+ for hls_client in ('ios', 'android'):
try:
resp = fetch_player_response(hls_client, video_id) or {}
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
-def video_quality_string(format):
- if format['vcodec']:
- result = f"{format['width'] or '?'}x{format['height'] or '?'}"
- if format['fps']:
- 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))
+# video_quality_string imported from watch_formats
+# short_video_quality_string imported from watch_formats
+# audio_quality_string imported from watch_formats
+# format_bytes imported from watch_formats
suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
converted = float(bytes) / float(1024 ** exponent)
return '%.2f%s' % (converted, suffix)
@@ -832,14 +787,12 @@ def get_audio_track():
# This is an actual segment - fetch and serve it
try:
- headers = (
- ('User-Agent', 'Mozilla/5.0'),
- ('Accept', '*/*'),
- )
- content = util.fetch_url(seg_url, headers=headers,
- debug_name='hls_seg', report_text=None)
+ headers_dict = {
+ 'User-Agent': 'Mozilla/5.0',
+ 'Accept': '*/*',
+ }
- # 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)
if '.mp4' in seg_url or '.m4s' in seg_url or seg_url.lower().endswith('.mp4'):
content_type = 'video/mp4'
@@ -849,7 +802,23 @@ def get_audio_track():
# Default to MPEG-TS for HLS
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={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
diff --git a/youtube/watch_formats.py b/youtube/watch_formats.py
new file mode 100644
index 0000000..7dad325
--- /dev/null
+++ b/youtube/watch_formats.py
@@ -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',
+]
\ No newline at end of file
diff --git a/youtube/yt_data_extract/__init__.py b/youtube/yt_data_extract/__init__.py
index 63b1b37..1e7fdea 100644
--- a/youtube/yt_data_extract/__init__.py
+++ b/youtube/yt_data_extract/__init__.py
@@ -1,7 +1,8 @@
from .common import (get, multi_get, deep_get, multi_deep_get,
liberal_update, conservative_update, remove_redirect, normalize_url,
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,
extract_playlist_metadata, extract_playlist_info, extract_comments_info)