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)