live stream support

This commit is contained in:
Simon
2026-06-22 12:34:47 +00:00
parent a97f7e7b0f
commit b5b3e13dd0
9 changed files with 271 additions and 35 deletions

View File

@@ -210,13 +210,15 @@ def stream_video():
video_url = ""
if request.method == 'POST':
video_url = request.json.get('url')
live_hint = bool((request.json or {}).get('live'))
else:
video_url = request.args.get('url')
live_hint = str(request.args.get('live', '')).strip().lower() in ('1', 'true', 'yes', 'on')
if not video_url:
return jsonify({"error": "No URL provided"}), 400
dbg(f"method={request.method} url={video_url}")
dbg(f"method={request.method} url={video_url} live={live_hint}")
def is_hls(url):
return '.m3u8' in urllib.parse.urlparse(url).path
@@ -529,11 +531,96 @@ def stream_video():
return None
return headers.get('Referer') or headers.get('referer')
def build_master_playlist(info, referer):
"""Synthesize an HLS master playlist from yt-dlp's parsed formats.
yt-dlp already downloads and parses the upstream master during
extraction, so we reconstruct an equivalent master that points each
variant playlist at our proxy. This avoids re-fetching the upstream
master (some sites issue a single-use session token on it, which the
extraction already consumed) and works generically for any HLS source
that exposes separate audio/video renditions. Returns the playlist
text, or None if there aren't enough HLS formats to build one.
"""
formats = info.get('formats') or []
def codec_present(value):
return value not in (None, '', 'none')
def is_hls_format(fmt):
url = fmt.get('url') or ''
return bool(url) and ('m3u8' in str(fmt.get('protocol') or '') or is_hls(url))
audio_fmts, video_fmts = [], []
for fmt in formats:
if not is_hls_format(fmt):
continue
if codec_present(fmt.get('vcodec')):
video_fmts.append(fmt)
elif codec_present(fmt.get('acodec')):
audio_fmts.append(fmt)
if not video_fmts:
return None
def proxied(url):
return (f"/api/stream?url={urllib.parse.quote(url, safe='')}"
f"&referer={urllib.parse.quote(referer, safe='')}")
lines = ['#EXTM3U', '#EXT-X-VERSION:3']
audio_group = None
if audio_fmts:
audio_group = 'aud'
for index, fmt in enumerate(audio_fmts):
name = (fmt.get('format_note') or fmt.get('language')
or fmt.get('format_id') or f'audio{index}')
attrs = [
'TYPE=AUDIO',
f'GROUP-ID="{audio_group}"',
f'NAME="{name}"',
f'DEFAULT={"YES" if index == 0 else "NO"}',
'AUTOSELECT=YES',
]
if fmt.get('language'):
attrs.append(f'LANGUAGE="{fmt["language"]}"')
attrs.append(f'URI="{proxied(fmt["url"])}"')
lines.append('#EXT-X-MEDIA:' + ','.join(attrs))
for fmt in video_fmts:
bitrate = fmt.get('tbr') or fmt.get('vbr')
bandwidth = int(float(bitrate) * 1000) if bitrate else 1000000
codecs = []
if codec_present(fmt.get('vcodec')):
codecs.append(fmt['vcodec'])
if audio_group and codec_present(audio_fmts[0].get('acodec')):
codecs.append(audio_fmts[0]['acodec'])
elif codec_present(fmt.get('acodec')):
codecs.append(fmt['acodec'])
attrs = [f'BANDWIDTH={bandwidth}']
if fmt.get('width') and fmt.get('height'):
attrs.append(f'RESOLUTION={int(fmt["width"])}x{int(fmt["height"])}')
if fmt.get('fps'):
attrs.append(f'FRAME-RATE={float(fmt["fps"]):.3f}')
if codecs:
attrs.append(f'CODECS="{",".join(codecs)}"')
if audio_group:
attrs.append(f'AUDIO="{audio_group}"')
lines.append('#EXT-X-STREAM-INF:' + ','.join(attrs))
lines.append(proxied(fmt['url']))
return '\n'.join(lines) + '\n'
try:
# Configure yt-dlp options
ydl_opts = {
# Prefer HLS when available to enable chunked streaming in the browser.
'format': 'best[protocol*=m3u8]/best[ext=mp4]/best',
# Live cam streams expose only separate video-only and audio-only HLS
# tracks (no muxed format), so `best` alone raises "Requested format
# is not available". Fall back to the best video-only rendition; we
# then hand the browser the master manifest below so its HLS player
# can pull in the matching audio track.
'format': 'best[protocol*=m3u8]/best[ext=mp4]/best/bestvideo[protocol*=m3u8]/bestvideo',
'format_sort': ['res', 'fps', 'vcodec:avc1', 'acodec:aac'],
'quiet': False,
'no_warnings': False,
@@ -587,26 +674,55 @@ def stream_video():
elif isinstance(info.get('http_headers'), dict):
upstream_headers = info['http_headers']
if request.method == 'HEAD' and selected_format:
manifest_url = selected_format.get('manifest_url')
if manifest_url:
return Response("", status=301, headers=[('Location', f"/api/stream?url={manifest_url}")])
referer_hint = None
if upstream_headers:
referer_hint = extract_referer(upstream_headers)
if not referer_hint:
parsed = urllib.parse.urlparse(video_url)
referer_hint = f"{parsed.scheme}://{parsed.netloc}/"
# When the chosen rendition carries no muxed audio (live cam
# streams) or the source is live, the browser needs a *master*
# playlist so its HLS player can combine the separate audio + video
# renditions. We synthesize that master from yt-dlp's already-parsed
# formats rather than re-fetching the upstream master.
def format_lacks_audio(fmt):
if not isinstance(fmt, dict):
return False
acodec = str(fmt.get('acodec') or '').lower()
vcodec = str(fmt.get('vcodec') or '').lower()
return vcodec not in ('', 'none') and acodec in ('', 'none')
needs_master = bool(info.get('is_live') or live_hint or format_lacks_audio(selected_format))
master_playlist = build_master_playlist(info, referer_hint) if needs_master else None
dbg(f"is_live={info.get('is_live')} needs_master={needs_master} synthesized={bool(master_playlist)}")
if master_playlist:
if request.method == 'HEAD':
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
return Response(master_playlist, status=200, content_type='application/vnd.apple.mpegurl')
# Synthesis unavailable (e.g. a single muxed variant): fall back to
# the upstream master URL when one exists, else the variant itself.
master_fallback = None
if needs_master:
master_fallback = (selected_format or {}).get('manifest_url') or info.get('manifest_url')
if request.method == 'HEAD' and selected_format:
head_manifest = master_fallback or selected_format.get('manifest_url')
if head_manifest:
location = f"/api/stream?url={urllib.parse.quote(head_manifest, safe='')}"
return Response("", status=301, headers=[('Location', location)])
dbg(f"resolved stream_url={stream_url} referer_hint={referer_hint}")
if protocol and 'm3u8' in protocol:
dbg("protocol indicates hls")
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers)
if is_hls(stream_url):
dbg("stream_url is hls")
return proxy_hls_playlist(stream_url, referer_hint, upstream_headers=upstream_headers)
return proxy_hls_playlist(master_fallback or stream_url, referer_hint, upstream_headers=upstream_headers)
if is_dash(stream_url):
dbg("stream_url is dash")