some more features and fixes (title and show info)

This commit is contained in:
Simon
2026-09-07 11:50:45 +00:00
parent 0009574b77
commit e2632c962d
9 changed files with 363 additions and 109 deletions

View File

@@ -134,10 +134,12 @@ def impersonate_get(url, **kwargs):
_discard_session(sess)
raise
# Stream params that have dedicated meaning and must never be treated as headers.
# Request params that have dedicated meaning and must never be treated as headers.
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
# `live` is purely a playback hint and must not leak upstream as a header.
STREAM_RESERVED_PARAMS = {'url', 'live'}
# `live` is purely a playback hint and must not leak upstream as a header. `full`
# is /api/resolve's "give me everything" switch and is likewise ours, not the
# origin's.
STREAM_RESERVED_PARAMS = {'url', 'live', 'full'}
# Headers that affect the transport layer rather than the resource itself; allowing
# these to be forwarded could enable request smuggling or vhost-routing abuse.
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'}
@@ -297,6 +299,24 @@ _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
'protocol', 'format_note')
def _trim_resolve_info(info):
"""The lean payload playback needs: the media URLs, the headers that make
them work, and just enough per-format detail to rank them. This is what
every hovered card asks for, so it stays small."""
formats = []
for fmt in ((info.get('formats') if info else None) or []):
if not fmt.get('url'):
continue
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
return {
'url': info.get('url') if info else None,
'http_headers': (info.get('http_headers') if info else None) or {},
'isLive': bool(info.get('is_live')) if info else False,
'formats': formats,
}
# Some channels surface pages that yt-dlp can't extract because the video is
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
# by tube.perverzija.com). The player page declares its HLS playlist URL as
@@ -354,7 +374,13 @@ def resolve_video():
"""Resolve a page URL to its playable formats via yt-dlp and return them as
JSON. The frontend calls this on demand (when a card is hovered or scrolled
into view) to learn the real media URLs so it can background-probe them for
direct, proxy-free playability."""
direct, proxy-free playability.
`full=1` returns the extractor's whole info dict instead of the trimmed
playback payload -- everything it knows about the video (description, dates,
counts, tags, thumbnails, every format field), which is what the Show info
panel exists to display. Both views come from one extraction and one cache
entry, so asking for the full one costs no extra work upstream."""
if request.method == 'POST':
source = request.json or {}
video_url = source.get('url')
@@ -365,11 +391,19 @@ def resolve_video():
if not video_url:
return jsonify({"error": "No URL provided"}), 400
want_full = str(source.get('full', '')).strip().lower() in ('1', 'true', 'yes', 'on')
def view_of(info):
if not want_full:
return _trim_resolve_info(info)
# Nothing to show, but answer in the same shape rather than `null`.
return info if info else {}
now = time.time()
with _resolve_cache_lock:
cached = _resolve_cache.get(video_url)
if cached and cached[0] > now:
return jsonify(cached[1])
return jsonify(view_of(cached[1]))
ydl_opts = {
'quiet': True,
@@ -386,6 +420,10 @@ def resolve_video():
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False)
# The raw info dict holds objects that don't survive JSON (and
# internal `__`-prefixed bookkeeping). This is the same pass yt-dlp
# itself runs behind --dump-json.
info = ydl.sanitize_info(info, remove_private_keys=True)
except Exception as e:
# Many channels point at sites yt-dlp can't extract ("Unsupported URL").
# That's not fatal here -- the embed fallback below may still find a
@@ -400,26 +438,17 @@ def resolve_video():
if embed:
info = embed
formats = []
for fmt in ((info.get('formats') if info else None) or []):
if not fmt.get('url'):
continue
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
result = {
'url': info.get('url') if info else None,
'http_headers': (info.get('http_headers') if info else None) or {},
'isLive': bool(info.get('is_live')) if info else False,
'formats': formats,
}
# The extraction is cached whole, and each caller is served the view it
# asked for. A failed extraction (info is None) is cached the same way, so a
# video that can't be resolved is attempted once per TTL rather than on
# every hover.
with _resolve_cache_lock:
# Drop expired entries so the cache doesn't grow without bound.
for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
_resolve_cache.pop(key, None)
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, result)
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, info)
return jsonify(result)
return jsonify(view_of(info))
@app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy():