live stream support
This commit is contained in:
136
backend/main.py
136
backend/main.py
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user