Add HLS support to multi-audio
This commit is contained in:
629
youtube/watch.py
629
youtube/watch.py
@@ -42,73 +42,68 @@ def codec_name(vcodec):
|
||||
|
||||
|
||||
def get_video_sources(info, target_resolution):
|
||||
'''return dict with organized sources: {
|
||||
'uni_sources': [{}, ...], # video and audio in one file
|
||||
'uni_idx': int, # default unified source index
|
||||
'pair_sources': [{video: {}, audio: {}, quality: ..., ...}, ...],
|
||||
'pair_idx': int, # default pair source index
|
||||
}
|
||||
'''
|
||||
audio_sources = []
|
||||
'''return dict with organized sources'''
|
||||
audio_by_track = {}
|
||||
video_only_sources = {}
|
||||
uni_sources = []
|
||||
pair_sources = []
|
||||
|
||||
|
||||
for fmt in info['formats']:
|
||||
if not all(fmt[attr] for attr in ('ext', 'url', 'itag')):
|
||||
continue
|
||||
|
||||
# unified source
|
||||
if fmt['acodec'] and fmt['vcodec']:
|
||||
source = {
|
||||
'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt),
|
||||
}
|
||||
if fmt.get('audio_track_is_default', True) is False:
|
||||
continue
|
||||
source = {'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt)}
|
||||
source['quality_string'] += ' (integrated)'
|
||||
source.update(fmt)
|
||||
uni_sources.append(source)
|
||||
continue
|
||||
|
||||
if not (fmt['init_range'] and fmt['index_range']):
|
||||
continue
|
||||
|
||||
# audio source
|
||||
if fmt['acodec'] and not fmt['vcodec'] and (
|
||||
fmt['audio_bitrate'] or fmt['bitrate']):
|
||||
if fmt['bitrate']: # prefer this one, more accurate right now
|
||||
# Allow HLS-backed audio tracks (served locally, no init/index needed)
|
||||
if not fmt.get('url', '').startswith('http://127.') and not '/ytl-api/' in fmt.get('url', ''):
|
||||
continue
|
||||
# Mark as HLS for frontend
|
||||
fmt['is_hls'] = True
|
||||
if fmt['acodec'] and not fmt['vcodec'] and (fmt['audio_bitrate'] or fmt['bitrate']):
|
||||
if fmt['bitrate']:
|
||||
fmt['audio_bitrate'] = int(fmt['bitrate']/1000)
|
||||
source = {
|
||||
'type': 'audio/' + fmt['ext'],
|
||||
'quality_string': audio_quality_string(fmt),
|
||||
}
|
||||
source = {'type': 'audio/' + fmt['ext'],
|
||||
'quality_string': audio_quality_string(fmt)}
|
||||
source.update(fmt)
|
||||
source['mime_codec'] = (source['type'] + '; codecs="'
|
||||
+ source['acodec'] + '"')
|
||||
audio_sources.append(source)
|
||||
# video-only source
|
||||
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps',
|
||||
'file_size')):
|
||||
source['mime_codec'] = source['type'] + '; codecs="' + source['acodec'] + '"'
|
||||
tid = fmt.get('audio_track_id') or 'default'
|
||||
if tid not in audio_by_track:
|
||||
audio_by_track[tid] = {
|
||||
'name': fmt.get('audio_track_name') or 'Default',
|
||||
'is_default': fmt.get('audio_track_is_default', True),
|
||||
'sources': [],
|
||||
}
|
||||
audio_by_track[tid]['sources'].append(source)
|
||||
elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps', 'file_size')):
|
||||
if codec_name(fmt['vcodec']) == 'unknown':
|
||||
continue
|
||||
source = {
|
||||
'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt),
|
||||
}
|
||||
source = {'type': 'video/' + fmt['ext'],
|
||||
'quality_string': short_video_quality_string(fmt)}
|
||||
source.update(fmt)
|
||||
source['mime_codec'] = (source['type'] + '; codecs="'
|
||||
+ source['vcodec'] + '"')
|
||||
source['mime_codec'] = source['type'] + '; codecs="' + source['vcodec'] + '"'
|
||||
quality = str(fmt['quality']) + 'p' + str(fmt['fps'])
|
||||
if quality in video_only_sources:
|
||||
video_only_sources[quality].append(source)
|
||||
else:
|
||||
video_only_sources[quality] = [source]
|
||||
video_only_sources.setdefault(quality, []).append(source)
|
||||
|
||||
audio_sources.sort(key=lambda source: source['audio_bitrate'])
|
||||
audio_tracks = []
|
||||
default_track_id = 'default'
|
||||
for tid, ti in audio_by_track.items():
|
||||
audio_tracks.append({'id': tid, 'name': ti['name'], 'is_default': ti['is_default']})
|
||||
if ti['is_default']:
|
||||
default_track_id = tid
|
||||
audio_tracks.sort(key=lambda t: (not t['is_default'], t['name']))
|
||||
|
||||
default_audio = audio_by_track.get(default_track_id, {}).get('sources', [])
|
||||
default_audio.sort(key=lambda s: s['audio_bitrate'])
|
||||
uni_sources.sort(key=lambda src: src['quality'])
|
||||
|
||||
webm_audios = [a for a in audio_sources if a['ext'] == 'webm']
|
||||
mp4_audios = [a for a in audio_sources if a['ext'] == 'mp4']
|
||||
webm_audios = [a for a in default_audio if a['ext'] == 'webm']
|
||||
mp4_audios = [a for a in default_audio if a['ext'] == 'mp4']
|
||||
|
||||
for quality_string, sources in video_only_sources.items():
|
||||
# choose an audio source to go with it
|
||||
@@ -166,11 +161,19 @@ def get_video_sources(info, target_resolution):
|
||||
break
|
||||
pair_idx = i
|
||||
|
||||
audio_track_sources = {}
|
||||
for tid, ti in audio_by_track.items():
|
||||
srcs = ti['sources']
|
||||
srcs.sort(key=lambda s: s.get('audio_bitrate', 0))
|
||||
audio_track_sources[tid] = srcs
|
||||
|
||||
return {
|
||||
'uni_sources': uni_sources,
|
||||
'uni_idx': uni_idx,
|
||||
'pair_sources': pair_sources,
|
||||
'pair_idx': pair_idx,
|
||||
'audio_tracks': audio_tracks,
|
||||
'audio_track_sources': audio_track_sources,
|
||||
}
|
||||
|
||||
|
||||
@@ -423,8 +426,115 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
|
||||
'captionTracks', default=[])
|
||||
info['_android_caption_tracks'] = android_caption_tracks
|
||||
|
||||
# Save streamingData for multi-audio extraction
|
||||
pr_streaming_data = pr_data.get('streamingData', {})
|
||||
info['_streamingData'] = pr_streaming_data
|
||||
|
||||
yt_data_extract.update_with_new_urls(info, player_response)
|
||||
|
||||
# HLS manifest - try multiple clients in case one is blocked
|
||||
info['hls_manifest_url'] = None
|
||||
info['hls_audio_tracks'] = {}
|
||||
hls_data = None
|
||||
hls_client_used = None
|
||||
for hls_client in ('ios', 'ios_vr', 'android'):
|
||||
try:
|
||||
resp = fetch_player_response(hls_client, video_id) or {}
|
||||
hls_data = json.loads(resp) if isinstance(resp, str) else resp
|
||||
hls_manifest_url = (hls_data.get('streamingData') or {}).get('hlsManifestUrl', '')
|
||||
if hls_manifest_url:
|
||||
hls_client_used = hls_client
|
||||
break
|
||||
except Exception as e:
|
||||
print(f'HLS fetch with {hls_client} failed: {e}')
|
||||
|
||||
if hls_manifest_url:
|
||||
info['hls_manifest_url'] = hls_manifest_url
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
hls_manifest = util.fetch_url(hls_manifest_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='hls_manifest').decode('utf-8')
|
||||
|
||||
# Parse EXT-X-MEDIA audio tracks from HLS manifest
|
||||
for line in hls_manifest.split('\n'):
|
||||
if '#EXT-X-MEDIA' not in line or 'TYPE=AUDIO' not in line:
|
||||
continue
|
||||
name_m = _re.search(r'NAME="([^"]+)"', line)
|
||||
lang_m = _re.search(r'LANGUAGE="([^"]+)"', line)
|
||||
default_m = _re.search(r'DEFAULT=(YES|NO)', line)
|
||||
group_m = _re.search(r'GROUP-ID="([^"]+)"', line)
|
||||
uri_m = _re.search(r'URI="([^"]+)"', line)
|
||||
if not uri_m or not lang_m:
|
||||
continue
|
||||
lang = lang_m.group(1)
|
||||
is_default = default_m and default_m.group(1) == 'YES'
|
||||
group = group_m.group(1) if group_m else '0'
|
||||
key = lang
|
||||
absolute_hls_url = urljoin(hls_manifest_url, uri_m.group(1))
|
||||
if key not in info['hls_audio_tracks'] or group > info['hls_audio_tracks'][key].get('group', '0'):
|
||||
info['hls_audio_tracks'][key] = {
|
||||
'name': name_m.group(1) if name_m else lang,
|
||||
'lang': lang,
|
||||
'hls_url': absolute_hls_url,
|
||||
'group': group,
|
||||
'is_default': is_default,
|
||||
}
|
||||
|
||||
# Register HLS audio tracks for proxy access
|
||||
added = 0
|
||||
for lang, track in info['hls_audio_tracks'].items():
|
||||
ck = video_id + '_' + lang
|
||||
from youtube.hls_cache import register_track
|
||||
register_track(ck, track['hls_url'],
|
||||
video_id=video_id, track_id=lang)
|
||||
|
||||
fmt = {
|
||||
'audio_track_id': lang,
|
||||
'audio_track_name': track['name'],
|
||||
'audio_track_is_default': track['is_default'],
|
||||
'itag': 'hls_' + lang,
|
||||
'ext': 'mp4',
|
||||
'audio_bitrate': 128,
|
||||
'bitrate': 128000,
|
||||
'acodec': 'mp4a.40.2',
|
||||
'vcodec': None,
|
||||
'width': None,
|
||||
'height': None,
|
||||
'file_size': None,
|
||||
'audio_sample_rate': 44100,
|
||||
'duration_ms': None,
|
||||
'fps': None,
|
||||
'init_range': {'start': 0, 'end': 0},
|
||||
'index_range': {'start': 0, 'end': 0},
|
||||
'url': '/ytl-api/audio-track?id=' + urllib.parse.quote(ck),
|
||||
's': None,
|
||||
'sp': None,
|
||||
'quality': None,
|
||||
'type': 'audio/mp4',
|
||||
'quality_string': track['name'],
|
||||
'mime_codec': 'audio/mp4; codecs="mp4a.40.2"',
|
||||
'is_hls': True,
|
||||
}
|
||||
info['formats'].append(fmt)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
print(f"Added {added} HLS audio tracks (via {hls_client_used})")
|
||||
else:
|
||||
print("No HLS manifest available from any client")
|
||||
info['hls_manifest_url'] = None
|
||||
info['hls_audio_tracks'] = {}
|
||||
info['hls_unavailable'] = True
|
||||
|
||||
# Register HLS manifest for proxying
|
||||
if info['hls_manifest_url']:
|
||||
ck = video_id + '_video'
|
||||
from youtube.hls_cache import register_track
|
||||
register_track(ck, info['hls_manifest_url'], video_id=video_id, track_id='video')
|
||||
# Use proxy URL instead of direct Google Video URL
|
||||
info['hls_manifest_url'] = '/ytl-api/hls-manifest?id=' + urllib.parse.quote(ck)
|
||||
|
||||
# Fallback to 'ios' if no valid URLs are found
|
||||
if not info.get('formats') or info.get('player_urls_missing'):
|
||||
print(f"No URLs found in '{primary_client}', attempting with '{fallback_client}'.")
|
||||
@@ -556,6 +666,339 @@ def format_bytes(bytes):
|
||||
return '%.2f%s' % (converted, suffix)
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/audio-track-proxy')
|
||||
def audio_track_proxy():
|
||||
"""Proxy for DASH audio tracks to avoid throttling."""
|
||||
cache_key = request.args.get('id', '')
|
||||
audio_url = request.args.get('url', '')
|
||||
|
||||
if not audio_url:
|
||||
flask.abort(400, 'Missing URL')
|
||||
|
||||
try:
|
||||
headers = (
|
||||
('User-Agent', 'Mozilla/5.0'),
|
||||
('Accept', '*/*'),
|
||||
)
|
||||
content = util.fetch_url(audio_url, headers=headers,
|
||||
debug_name='audio_dash', report_text=None)
|
||||
return flask.Response(content, mimetype='audio/mp4',
|
||||
headers={'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=3600'})
|
||||
except Exception as e:
|
||||
flask.abort(502, f'Audio fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/audio-track')
|
||||
def get_audio_track():
|
||||
"""Proxy HLS audio/video: playlist or individual segment."""
|
||||
from youtube.hls_cache import get_hls_url, _tracks
|
||||
|
||||
cache_key = request.args.get('id', '')
|
||||
seg_url = request.args.get('seg', '')
|
||||
playlist_url = request.args.get('url', '')
|
||||
|
||||
# Handle playlist/manifest URL (used for audio track playlists)
|
||||
if playlist_url:
|
||||
# Unwrap if double-proxied
|
||||
if '/ytl-api/audio-track' in playlist_url:
|
||||
import urllib.parse as _up
|
||||
parsed = _up.parse_qs(_up.urlparse(playlist_url).query)
|
||||
if 'url' in parsed:
|
||||
playlist_url = parsed['url'][0]
|
||||
|
||||
try:
|
||||
playlist = util.fetch_url(playlist_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='audio_playlist').decode('utf-8')
|
||||
|
||||
# Rewrite segment URLs
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
base_url = request.url_root.rstrip('/')
|
||||
playlist_base = playlist_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
# Resolve and proxy segment URL
|
||||
seg = line if line.startswith('http') else urljoin(playlist_base, line)
|
||||
# Always use &seg= parameter, never &url= for segments
|
||||
playlist_lines.append(
|
||||
base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(seg, safe='')
|
||||
)
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Playlist fetch failed: {e}')
|
||||
|
||||
# Handle individual segment or nested playlist
|
||||
if seg_url:
|
||||
# Check if seg_url is already a proxied URL
|
||||
if '/ytl-api/audio-track' in seg_url:
|
||||
import urllib.parse as _up
|
||||
parsed = _up.parse_qs(_up.urlparse(seg_url).query)
|
||||
if 'seg' in parsed:
|
||||
seg_url = parsed['seg'][0]
|
||||
elif 'url' in parsed:
|
||||
seg_url = parsed['url'][0]
|
||||
|
||||
# Check if this is a nested playlist (m3u8) that needs rewriting
|
||||
# Playlists END with .m3u8 (optionally followed by query params)
|
||||
# Segments may contain /index.m3u8/ in their path but end with .ts or similar
|
||||
url_path = urllib.parse.urlparse(seg_url).path
|
||||
|
||||
# Only treat as playlist if path ends with .m3u8
|
||||
# Don't use 'in' check because segments can have /index.m3u8/ in their path
|
||||
is_playlist = url_path.endswith('.m3u8')
|
||||
|
||||
if is_playlist:
|
||||
# This is a variant playlist - fetch and rewrite it
|
||||
try:
|
||||
raw_content = util.fetch_url(seg_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='nested_playlist')
|
||||
|
||||
# Check if this is actually binary data (segment) misidentified as playlist
|
||||
try:
|
||||
playlist = raw_content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
is_playlist = False # Fall through to segment handler
|
||||
|
||||
if is_playlist:
|
||||
# Rewrite segment URLs in this playlist
|
||||
from urllib.parse import urljoin
|
||||
import re as _re
|
||||
base_url = request.url_root.rstrip('/')
|
||||
playlist_base = seg_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
def proxy_url(url):
|
||||
"""Rewrite a single URL to go through the proxy"""
|
||||
if not url or url.startswith('/ytl-api/'):
|
||||
return url
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = urljoin(playlist_base, url)
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(url, safe=''))
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
# Handle tags with URI attributes (EXT-X-MAP, EXT-X-KEY, etc.)
|
||||
if line.startswith('#') and 'URI=' in line:
|
||||
def rewrite_uri_attr(match):
|
||||
uri = match.group(1)
|
||||
return 'URI="' + proxy_url(uri) + '"'
|
||||
line = _re.sub(r'URI="([^"]+)"', rewrite_uri_attr, line)
|
||||
playlist_lines.append(line)
|
||||
elif line.startswith('#'):
|
||||
# Other tags pass through unchanged
|
||||
playlist_lines.append(line)
|
||||
else:
|
||||
# This is a segment URL line
|
||||
seg = line if line.startswith('http') else urljoin(playlist_base, line)
|
||||
playlist_lines.append(proxy_url(seg))
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Nested playlist fetch failed: {e}')
|
||||
|
||||
# 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)
|
||||
|
||||
# Determine content type based on URL or content
|
||||
# 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'
|
||||
elif '.webm' in seg_url or seg_url.lower().endswith('.webm'):
|
||||
content_type = 'video/webm'
|
||||
else:
|
||||
# Default to MPEG-TS for HLS
|
||||
content_type = 'video/mp2t'
|
||||
|
||||
return flask.Response(content, mimetype=content_type,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||
'Cache-Control': 'max-age=3600',
|
||||
'Content-Type': content_type,
|
||||
})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Segment fetch failed: {e}')
|
||||
|
||||
# Legacy: Proxy the HLS playlist for audio tracks (using get_hls_url)
|
||||
hls_url = get_hls_url(cache_key)
|
||||
if not hls_url:
|
||||
flask.abort(404, 'Audio track not found')
|
||||
|
||||
try:
|
||||
playlist = util.fetch_url(hls_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='audio_hls_playlist').decode('utf-8')
|
||||
|
||||
# Rewrite segment URLs to go through our proxy endpoint
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
|
||||
|
||||
def make_proxy_url(segment_url):
|
||||
if segment_url.startswith('/ytl-api/audio-track'):
|
||||
return segment_url
|
||||
base_url = request.url_root.rstrip('/')
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(segment_url))
|
||||
|
||||
playlist_lines = []
|
||||
for line in playlist.split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
playlist_lines.append(line)
|
||||
continue
|
||||
|
||||
if line.startswith('http://') or line.startswith('https://'):
|
||||
segment_url = line
|
||||
else:
|
||||
segment_url = urljoin(hls_base_url, line)
|
||||
|
||||
playlist_lines.append(make_proxy_url(segment_url))
|
||||
|
||||
playlist = '\n'.join(playlist_lines)
|
||||
|
||||
return flask.Response(playlist, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={'Access-Control-Allow-Origin': '*'})
|
||||
except Exception as e:
|
||||
flask.abort(502, f'Playlist fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/hls-manifest')
|
||||
def get_hls_manifest():
|
||||
"""Proxy HLS video manifest, rewriting ALL URLs including audio tracks."""
|
||||
from youtube.hls_cache import get_hls_url
|
||||
|
||||
cache_key = request.args.get('id', '')
|
||||
is_audio = '_audio_' in cache_key or cache_key.endswith('_audio')
|
||||
print(f'[hls-manifest] Request: id={cache_key[:40] if cache_key else ""}... (audio={is_audio})')
|
||||
|
||||
hls_url = get_hls_url(cache_key)
|
||||
print(f'[hls-manifest] HLS URL: {hls_url[:80] if hls_url else None}...')
|
||||
if not hls_url:
|
||||
flask.abort(404, 'HLS manifest not found')
|
||||
|
||||
try:
|
||||
print(f'[hls-manifest] Fetching HLS manifest...')
|
||||
manifest = util.fetch_url(hls_url,
|
||||
headers=(('User-Agent', 'Mozilla/5.0'),),
|
||||
debug_name='hls_manifest').decode('utf-8')
|
||||
print(f'[hls-manifest] Successfully fetched manifest ({len(manifest)} bytes)')
|
||||
|
||||
# Rewrite all URLs in the manifest to go through our proxy
|
||||
import re as _re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
# Get the base URL for resolving relative URLs
|
||||
hls_base_url = hls_url.rsplit('/', 1)[0] + '/'
|
||||
base_url = request.url_root.rstrip('/')
|
||||
|
||||
# Rewrite URLs - handle both segment URLs and audio track URIs
|
||||
def rewrite_url(url, is_audio_track=False):
|
||||
if not url or url.startswith('/ytl-api/'):
|
||||
return url
|
||||
|
||||
# Resolve relative URLs
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = urljoin(hls_base_url, url)
|
||||
|
||||
if is_audio_track:
|
||||
# Audio track playlist - proxy through audio-track endpoint
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&url=' + urllib.parse.quote(url, safe=''))
|
||||
else:
|
||||
# Video segment or variant playlist - proxy through audio-track endpoint
|
||||
return (base_url + '/ytl-api/audio-track?id='
|
||||
+ urllib.parse.quote(cache_key)
|
||||
+ '&seg=' + urllib.parse.quote(url, safe=''))
|
||||
|
||||
# Parse and rewrite the manifest
|
||||
manifest_lines = []
|
||||
rewritten_count = 0
|
||||
for line in manifest.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
manifest_lines.append(line)
|
||||
continue
|
||||
|
||||
# Handle EXT-X-MEDIA tags with URI (audio tracks)
|
||||
if line.startswith('#EXT-X-MEDIA:') and 'URI=' in line:
|
||||
# Extract and rewrite the URI attribute
|
||||
def rewrite_media_uri(match):
|
||||
nonlocal rewritten_count
|
||||
uri = match.group(1)
|
||||
rewritten_count += 1
|
||||
return 'URI="' + rewrite_url(uri, is_audio_track=True) + '"'
|
||||
line = _re.sub(r'URI="([^"]+)"', rewrite_media_uri, line)
|
||||
manifest_lines.append(line)
|
||||
elif line.startswith('#'):
|
||||
# Other tags pass through
|
||||
manifest_lines.append(line)
|
||||
else:
|
||||
# This is a URL (segment or variant playlist)
|
||||
if line.startswith('http://') or line.startswith('https://'):
|
||||
url = line
|
||||
else:
|
||||
url = urljoin(hls_base_url, line)
|
||||
rewritten_count += 1
|
||||
manifest_lines.append(rewrite_url(url))
|
||||
|
||||
manifest = '\n'.join(manifest_lines)
|
||||
print(f'[hls-manifest] Rewrote manifest with {len(manifest_lines)} lines, {rewritten_count} URLs rewritten')
|
||||
|
||||
return flask.Response(manifest, mimetype='application/vnd.apple.mpegurl',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
})
|
||||
except Exception as e:
|
||||
print(f'[hls-manifest] Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flask.abort(502, f'Manifest fetch failed: {e}')
|
||||
|
||||
|
||||
@yt_app.route('/ytl-api/storyboard.vtt')
|
||||
def get_storyboard_vtt():
|
||||
"""
|
||||
@@ -731,47 +1174,50 @@ def get_watch_page(video_id=None):
|
||||
if (settings.route_tor == 2) or info['tor_bypass_used']:
|
||||
target_resolution = 240
|
||||
else:
|
||||
target_resolution = settings.default_resolution
|
||||
res = settings.default_resolution
|
||||
target_resolution = 1080 if res == 'auto' else int(res)
|
||||
|
||||
source_info = get_video_sources(info, target_resolution)
|
||||
uni_sources = source_info['uni_sources']
|
||||
pair_sources = source_info['pair_sources']
|
||||
uni_idx, pair_idx = source_info['uni_idx'], source_info['pair_idx']
|
||||
# Get video sources for no-JS fallback and DASH (av-merge) fallback
|
||||
video_sources = get_video_sources(info, target_resolution)
|
||||
uni_sources = video_sources['uni_sources']
|
||||
pair_sources = video_sources['pair_sources']
|
||||
pair_idx = video_sources['pair_idx']
|
||||
audio_track_sources = video_sources['audio_track_sources']
|
||||
|
||||
pair_quality = yt_data_extract.deep_get(pair_sources, pair_idx, 'quality')
|
||||
uni_quality = yt_data_extract.deep_get(uni_sources, uni_idx, 'quality')
|
||||
# Build audio tracks list from HLS
|
||||
audio_tracks = []
|
||||
hls_audio_tracks = info.get('hls_audio_tracks', {})
|
||||
hls_manifest_url = info.get('hls_manifest_url')
|
||||
if hls_audio_tracks:
|
||||
# Prefer "original" audio track
|
||||
original_lang = None
|
||||
for lang, track in hls_audio_tracks.items():
|
||||
if 'original' in (track.get('name') or '').lower():
|
||||
original_lang = lang
|
||||
break
|
||||
|
||||
pair_error = abs((pair_quality or 360) - target_resolution)
|
||||
uni_error = abs((uni_quality or 360) - target_resolution)
|
||||
if uni_error == pair_error:
|
||||
# use settings.prefer_uni_sources as a tiebreaker
|
||||
closer_to_target = 'uni' if settings.prefer_uni_sources else 'pair'
|
||||
elif uni_error < pair_error:
|
||||
closer_to_target = 'uni'
|
||||
# Add tracks, preferring original as default
|
||||
for lang, track in hls_audio_tracks.items():
|
||||
is_default = (lang == original_lang) if original_lang else track['is_default']
|
||||
if is_default:
|
||||
audio_tracks.insert(0, {
|
||||
'id': lang,
|
||||
'name': track['name'],
|
||||
'is_default': True,
|
||||
})
|
||||
else:
|
||||
audio_tracks.append({
|
||||
'id': lang,
|
||||
'name': track['name'],
|
||||
'is_default': False,
|
||||
})
|
||||
else:
|
||||
closer_to_target = 'pair'
|
||||
# Fallback: single default audio track
|
||||
audio_tracks = [{'id': 'default', 'name': 'Default', 'is_default': True}]
|
||||
|
||||
if settings.prefer_uni_sources == 2:
|
||||
# Use uni sources unless there's no choice.
|
||||
using_pair_sources = (
|
||||
bool(pair_sources) and (not uni_sources)
|
||||
)
|
||||
else:
|
||||
# Use the pair sources if they're closer to the desired resolution
|
||||
using_pair_sources = (
|
||||
bool(pair_sources)
|
||||
and (not uni_sources or closer_to_target == 'pair')
|
||||
)
|
||||
if using_pair_sources:
|
||||
video_height = pair_sources[pair_idx]['height']
|
||||
video_width = pair_sources[pair_idx]['width']
|
||||
else:
|
||||
video_height = yt_data_extract.deep_get(
|
||||
uni_sources, uni_idx, 'height', default=360
|
||||
)
|
||||
video_width = yt_data_extract.deep_get(
|
||||
uni_sources, uni_idx, 'width', default=640
|
||||
)
|
||||
# Get video dimensions
|
||||
video_height = info.get('height') or 360
|
||||
video_width = info.get('width') or 640
|
||||
|
||||
|
||||
|
||||
@@ -818,7 +1264,14 @@ def get_watch_page(video_id=None):
|
||||
other_downloads = other_downloads,
|
||||
video_info = json.dumps(video_info),
|
||||
hls_formats = info['hls_formats'],
|
||||
hls_manifest_url = hls_manifest_url,
|
||||
audio_tracks = audio_tracks,
|
||||
subtitle_sources = subtitle_sources,
|
||||
uni_sources = uni_sources,
|
||||
pair_sources = pair_sources,
|
||||
pair_idx = pair_idx,
|
||||
hls_unavailable = info.get('hls_unavailable', False),
|
||||
playback_mode = settings.playback_mode,
|
||||
related = info['related_videos'],
|
||||
playlist = info['playlist'],
|
||||
music_list = info['music_list'],
|
||||
@@ -855,16 +1308,20 @@ def get_watch_page(video_id=None):
|
||||
'video_duration': info['duration'],
|
||||
'settings': settings.current_settings_dict,
|
||||
'has_manual_captions': any(s.get('on') for s in subtitle_sources),
|
||||
**source_info,
|
||||
'using_pair_sources': using_pair_sources,
|
||||
'audio_tracks': audio_tracks,
|
||||
'hls_manifest_url': hls_manifest_url,
|
||||
'time_start': time_start,
|
||||
'playlist': info['playlist'],
|
||||
'related': info['related_videos'],
|
||||
'playability_error': info['playability_error'],
|
||||
'hls_unavailable': info.get('hls_unavailable', False),
|
||||
'pair_sources': pair_sources,
|
||||
'pair_idx': pair_idx,
|
||||
'uni_sources': uni_sources,
|
||||
'uni_idx': video_sources['uni_idx'],
|
||||
'using_pair_sources': bool(pair_sources),
|
||||
},
|
||||
font_family = youtube.font_choices[settings.font], # for embed page
|
||||
**source_info,
|
||||
using_pair_sources = using_pair_sources,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user