Play favorites from freshly resolved formats, not the page URL
Streaming a page URL makes /api/stream re-run yt-dlp on every request -- slow, and a 500 on some sites -- so the player and the reels feed now wait for App.videos.ensureFormats() when an item has no formats (favorites, or a card clicked before its hover-resolve landed) and play a real media URL with the extractor's headers, the same path a hovered card takes. Favorites' download does the same. The page URL survives only as a last resort when resolution yields nothing. Two things in the proxy kept this site broken either way: heavyfetish serves media from paths with a trailing slash (/get_file/.../11097_720p.mp4/), which missed every extension test in stream_video and sent even a resolved media URL down the yt-dlp branch -- a full extraction per request, including every seek. Its CDN (st17.heavyfetish.com) also serves a certificate that expired 2026-02-16, so the upstream fetch failed verification and returned 500. A browser can't play such a host at all, which is much of why this proxy exists, so impersonate_get() now retries once without verification, logs it, and remembers the host so the doomed handshake isn't repeated for every range request. STREAM_TLS_VERIFY_ONLY=1 restores the hard failure. Verified in headless Chrome against the real site: a favorite holding only the page URL now resolves, streams (206, video/mp4, duration 3167.8s, readyState 4, no error) and shows "720p mp4 | 480p mp4" in the quality menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -33,6 +33,39 @@ def get_impersonate_session():
|
|||||||
_thread_local.session = sess
|
_thread_local.session = sess
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tls_verify_error(err):
|
||||||
|
message = str(err).lower()
|
||||||
|
return 'certificate' in message or 'curl: (60)' in message or 'ssl: ' in message
|
||||||
|
|
||||||
|
|
||||||
|
# Hosts already proven to fail certificate verification. A video is fetched in
|
||||||
|
# many range requests, so remembering the host keeps us from paying for a
|
||||||
|
# doomed TLS handshake on every one of them.
|
||||||
|
_tls_unverified_hosts = set()
|
||||||
|
|
||||||
|
|
||||||
|
def impersonate_get(url, **kwargs):
|
||||||
|
"""Upstream GET that survives an origin with a broken certificate.
|
||||||
|
|
||||||
|
Some media hosts serve expired certs (heavyfetish's stNN CDN, for one), which
|
||||||
|
a browser refuses outright -- part of why this proxy exists. The viewer's
|
||||||
|
connection to *us* stays verified either way, so rather than failing the
|
||||||
|
stream we retry once with verification off, and say so in the log. Set
|
||||||
|
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
|
||||||
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
if host in _tls_unverified_hosts:
|
||||||
|
return get_impersonate_session().get(url, verify=False, **kwargs)
|
||||||
|
try:
|
||||||
|
return get_impersonate_session().get(url, **kwargs)
|
||||||
|
except Exception as err:
|
||||||
|
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
if strict or not _is_tls_verify_error(err):
|
||||||
|
raise
|
||||||
|
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
|
||||||
|
_tls_unverified_hosts.add(host)
|
||||||
|
return get_impersonate_session().get(url, verify=False, **kwargs)
|
||||||
|
|
||||||
# Stream params that have dedicated meaning and must never be treated as headers.
|
# Stream 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
|
# `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.
|
# `live` is purely a playback hint and must not leak upstream as a header.
|
||||||
@@ -438,14 +471,21 @@ def stream_video():
|
|||||||
|
|
||||||
dbg(f"method={request.method} url={video_url} live={live_hint}")
|
dbg(f"method={request.method} url={video_url} live={live_hint}")
|
||||||
|
|
||||||
|
def media_path(url):
|
||||||
|
# Some sites serve media from a path with a trailing slash
|
||||||
|
# (heavyfetish: /get_file/.../11097_720p.mp4/). Without stripping it,
|
||||||
|
# every extension test below misses and the URL takes the yt-dlp branch
|
||||||
|
# instead -- a full extraction per request, including every seek.
|
||||||
|
return urllib.parse.urlparse(url).path.lower().rstrip('/')
|
||||||
|
|
||||||
def is_hls(url):
|
def is_hls(url):
|
||||||
return '.m3u8' in urllib.parse.urlparse(url).path
|
return '.m3u8' in media_path(url)
|
||||||
|
|
||||||
def is_dash(url):
|
def is_dash(url):
|
||||||
return urllib.parse.urlparse(url).path.lower().endswith('.mpd')
|
return media_path(url).endswith('.mpd')
|
||||||
|
|
||||||
def guess_content_type(url):
|
def guess_content_type(url):
|
||||||
path = urllib.parse.urlparse(url).path.lower()
|
path = media_path(url)
|
||||||
if path.endswith('.m3u8'):
|
if path.endswith('.m3u8'):
|
||||||
return 'application/vnd.apple.mpegurl'
|
return 'application/vnd.apple.mpegurl'
|
||||||
if path.endswith('.mpd'):
|
if path.endswith('.mpd'):
|
||||||
@@ -467,7 +507,7 @@ def stream_video():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def is_direct_media(url):
|
def is_direct_media(url):
|
||||||
path = urllib.parse.urlparse(url).path.lower()
|
path = media_path(url)
|
||||||
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
|
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
|
||||||
|
|
||||||
def looks_like_m3u8_bytes(chunk):
|
def looks_like_m3u8_bytes(chunk):
|
||||||
@@ -563,7 +603,7 @@ def stream_video():
|
|||||||
if 'Range' in request.headers:
|
if 'Range' in request.headers:
|
||||||
safe_request_headers['Range'] = request.headers['Range']
|
safe_request_headers['Range'] = request.headers['Range']
|
||||||
|
|
||||||
resp = get_impersonate_session().get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
|
resp = impersonate_get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
|
||||||
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
|
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
|
||||||
# inverted hotlink protection: they 403 any request that carries a
|
# inverted hotlink protection: they 403 any request that carries a
|
||||||
# Referer/Origin and only serve referer-less ones. Other CDNs require
|
# Referer/Origin and only serve referer-less ones. Other CDNs require
|
||||||
@@ -572,7 +612,7 @@ def stream_video():
|
|||||||
dbg("upstream 403 with referer; retrying without referer/origin")
|
dbg("upstream 403 with referer; retrying without referer/origin")
|
||||||
resp.close()
|
resp.close()
|
||||||
referer_less = {k: v for k, v in safe_request_headers.items() if k not in ('Referer', 'Origin')}
|
referer_less = {k: v for k, v in safe_request_headers.items() if k not in ('Referer', 'Origin')}
|
||||||
resp = get_impersonate_session().get(target_url, headers=referer_less, stream=True, timeout=30, allow_redirects=True)
|
resp = impersonate_get(target_url, headers=referer_less, stream=True, timeout=30, allow_redirects=True)
|
||||||
if debug_enabled:
|
if debug_enabled:
|
||||||
dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}")
|
dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}")
|
||||||
|
|
||||||
@@ -685,14 +725,14 @@ def stream_video():
|
|||||||
for key, value in upstream_headers.items():
|
for key, value in upstream_headers.items():
|
||||||
if value:
|
if value:
|
||||||
headers[key] = value
|
headers[key] = value
|
||||||
resp = get_impersonate_session().get(playlist_url, headers=headers, stream=True, timeout=30)
|
resp = impersonate_get(playlist_url, headers=headers, stream=True, timeout=30)
|
||||||
# See proxy_response: retry without referer for inverted hotlink
|
# See proxy_response: retry without referer for inverted hotlink
|
||||||
# protection that 403s any refered request.
|
# protection that 403s any refered request.
|
||||||
if resp.status_code == 403 and ('Referer' in headers or 'Origin' in headers):
|
if resp.status_code == 403 and ('Referer' in headers or 'Origin' in headers):
|
||||||
dbg("playlist upstream 403 with referer; retrying without referer/origin")
|
dbg("playlist upstream 403 with referer; retrying without referer/origin")
|
||||||
resp.close()
|
resp.close()
|
||||||
referer_less = {k: v for k, v in headers.items() if k not in ('Referer', 'Origin')}
|
referer_less = {k: v for k, v in headers.items() if k not in ('Referer', 'Origin')}
|
||||||
resp = get_impersonate_session().get(playlist_url, headers=referer_less, stream=True, timeout=30)
|
resp = impersonate_get(playlist_url, headers=referer_less, stream=True, timeout=30)
|
||||||
base_url = resp.url
|
base_url = resp.url
|
||||||
|
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
|
|||||||
@@ -187,8 +187,10 @@ App.favorites = App.favorites || {};
|
|||||||
if (downloadBtn) {
|
if (downloadBtn) {
|
||||||
downloadBtn.onclick = (event) => {
|
downloadBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.videos.downloadVideo(item);
|
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
|
// Same as playback: resolve a real media URL first rather
|
||||||
|
// than pointing the download at the page URL.
|
||||||
|
App.videos.ensureFormats(item).then(() => App.videos.downloadVideo(item));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const uploaderBtn = card.querySelector('.uploader-link');
|
const uploaderBtn = card.querySelector('.uploader-link');
|
||||||
|
|||||||
@@ -331,6 +331,21 @@ App.feed = App.feed || {};
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A slide whose formats haven't been resolved yet carries only a page
|
||||||
|
// URL, and handing that to /api/stream makes the backend re-run yt-dlp
|
||||||
|
// per request (slow, and a hard failure on some sites). Resolve once,
|
||||||
|
// then load for real -- `_awaitingFormats` keeps a failed resolve from
|
||||||
|
// looping, so we still fall back to the page URL as a last resort.
|
||||||
|
const meta = videoData && (videoData.meta || videoData);
|
||||||
|
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
||||||
|
if (!hasFormats && !slide._awaitingFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
|
||||||
|
slide._awaitingFormats = true;
|
||||||
|
App.videos.ensureFormats(videoData).then(() => {
|
||||||
|
if (slide._videoData === videoData) loadSlideSource(slide, videoData, autoplay);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
slide.classList.add('is-loaded');
|
slide.classList.add('is-loaded');
|
||||||
|
|
||||||
const resolved = slide._formatOverride
|
const resolved = slide._formatOverride
|
||||||
|
|||||||
@@ -815,19 +815,28 @@ App.player = App.player || {};
|
|||||||
});
|
});
|
||||||
let destroyFormatMenu = bindFormats();
|
let destroyFormatMenu = bindFormats();
|
||||||
addCleanup(() => destroyFormatMenu());
|
addCleanup(() => destroyFormatMenu());
|
||||||
|
const meta = (source && typeof source === 'object') ? (source.meta || source) : null;
|
||||||
|
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
||||||
// Items that arrive without formats -- a listing card clicked before its
|
// Items that arrive without formats -- a listing card clicked before its
|
||||||
// hover-resolve finished, or a favorite (which deliberately stores no
|
// hover-resolve finished, or a favorite (which deliberately stores no
|
||||||
// resolved formats, since their URLs expire) -- would otherwise show an
|
// resolved formats, since their URLs expire) -- carry only a page URL,
|
||||||
// empty quality menu. Playback already starts from the page URL via the
|
// and they'd also show an empty quality menu. Resolve them first: the
|
||||||
// proxy, so resolve in the background and rebuild the menu with the real
|
// page-URL fallback makes /api/stream re-run yt-dlp on every single
|
||||||
// qualities as soon as they land.
|
// request, which is slow and fails outright on some sites, whereas a
|
||||||
if (App.videos && typeof App.videos.ensureFormats === 'function') {
|
// resolved format is a real media URL with the extractor's headers --
|
||||||
App.videos.ensureFormats(source).then((meta) => {
|
// the same path a hovered card plays through.
|
||||||
// A later open() may have taken over in the meantime; its own
|
let deferredStart = false;
|
||||||
// bindFormats() owns the menu then.
|
if (!hasFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
|
||||||
if (!meta || cp.source !== source) return;
|
deferredStart = true;
|
||||||
|
App.videos.ensureFormats(source).then((resolved) => {
|
||||||
|
// A later open() (or a close) may have taken over in the
|
||||||
|
// meantime; that session owns the player now.
|
||||||
|
if (cp.source !== source) return;
|
||||||
|
if (resolved) {
|
||||||
destroyFormatMenu();
|
destroyFormatMenu();
|
||||||
destroyFormatMenu = bindFormats();
|
destroyFormatMenu = bindFormats();
|
||||||
|
}
|
||||||
|
playSources(source, { originEl: cp.originEl });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
bindGestures(cp.video);
|
bindGestures(cp.video);
|
||||||
@@ -840,7 +849,9 @@ App.player = App.player || {};
|
|||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
wakeHud();
|
wakeHud();
|
||||||
|
|
||||||
playSources(source, { originEl: cp.originEl });
|
// Already-resolved sources start immediately; unresolved ones start from
|
||||||
|
// the ensureFormats() callback above (the spinner is already up).
|
||||||
|
if (!deferredStart) playSources(source, { originEl: cp.originEl });
|
||||||
};
|
};
|
||||||
|
|
||||||
App.player.close = function(opts) {
|
App.player.close = function(opts) {
|
||||||
|
|||||||
Reference in New Issue
Block a user