Compare commits

...

2 Commits

Author SHA1 Message Date
Simon
74b719b2ea Race the CDN and the proxy, for thumbnails and for playback
A thumbnail used to try the provider and only ask /api/image once that
had failed, so every hotlink-blocked host cost a wasted request per card
before anything appeared. Both routes now go out together for the first
thumbnail of a host, and the rest of the batch waits on that one answer
rather than each rediscovering it. Speed decides which image is shown;
capability decides what the host is remembered as, since the proxy tends
to win first contact merely for being same-origin -- pinning a host to it
over that would push a whole page of thumbnails through our own server.

Playback asks the same question, but per video and at play time: one
provider can spread its media over several CDNs, so there is nothing
useful to pre-compute, and the old per-card probe answered for whichever
card happened to scroll past. The direct route is now tested alongside
the proxied playback and takes over if it answers before a frame is
decoded. Whatever loses is cancelled -- the token guards stopped stale
callbacks but left their requests running, so the losing route kept
pulling bytes and the server kept an upstream connection open for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-08 12:48:18 +00:00
Simon
e2632c962d some more features and fixes (title and show info) 2026-09-07 11:50:45 +00:00
10 changed files with 807 additions and 218 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():

View File

@@ -857,6 +857,8 @@ body.favorites-view-open .favorites-empty {
padding: 10px 12px 12px 12px;
}
/* One line, always. A title too long for the card scrolls across it (see
App.marquee) rather than wrapping the card to an uneven height. */
.favorite-info h4 {
font-size: 13px;
font-weight: 600;
@@ -864,6 +866,20 @@ body.favorites-view-open .favorites-empty {
color: var(--text-primary);
font-family: var(--font-display);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.favorite-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.favorite-card.is-title-active .favorite-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
}
.favorites-empty {
@@ -1387,6 +1403,31 @@ body.theme-light .favorite-btn {
gap: 10px;
}
/* The panel shows every field the client has, which for a resolved video is the
extractor's whole payload -- dozens of rows. Give the list its own scroll so
the card stays inside the viewport and the close button stays put. */
.info-list {
max-height: min(70vh, 620px);
overflow-y: auto;
padding-right: 4px;
}
.info-section {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.8px;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
padding: 6px 0;
}
.info-pending {
font-size: 12px;
color: var(--text-secondary);
padding-top: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
@@ -2162,7 +2203,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
transform: translateX(0);
}
.feed-title.is-marquee .feed-title-text {
.feed-title.has-marquee .feed-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
}

View File

@@ -202,6 +202,7 @@
</div>
<script src="static/js/state.js"></script>
<script src="static/js/marquee.js"></script>
<script src="static/js/storage.js"></script>
<script src="static/js/customPlayer.js"></script>
<script src="static/js/player.js"></script>

View File

@@ -79,7 +79,7 @@ App.enhance = App.enhance || {};
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
if (!ready) {
// Not resolved yet: kick it off so the *next* hover can preview.
if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v);
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
return;
}
let url = '';

View File

@@ -274,6 +274,61 @@ App.favorites = App.favorites || {};
const BAR_PAGE_AHEAD_PX = 800;
const barPage = { items: [], rendered: 0 };
// Bar titles are one line that scrolls when it doesn't fit, the same as the
// grid card's. Which one scrolls follows the grid too: with a real pointer
// it's the card under it, on touch the card nearest the middle of the strip.
// Animating every overflowing title at once turns the bar into a wall of
// moving text.
const barTitleEnv = {
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
};
const setBarTitleActive = function(card, active) {
const title = card && card.querySelector('.favorite-title');
if (!title) return;
card.classList.toggle('is-title-active', !!active && title.classList.contains('has-marquee'));
};
// Touch: the card nearest the centre of the visible strip is the one being
// read, so it is the one whose title scrolls.
const syncBarTitleActive = function(list) {
if (!list) return;
const listRect = list.getBoundingClientRect();
const centre = listRect.left + listRect.width / 2;
let best = null;
let bestDistance = Infinity;
const cards = list.querySelectorAll('.favorite-card');
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
if (rect.right <= listRect.left || rect.left >= listRect.right) return;
const distance = Math.abs((rect.left + rect.width / 2) - centre);
if (distance < bestDistance) {
bestDistance = distance;
best = card;
}
});
cards.forEach((card) => setBarTitleActive(card, card === best));
};
// Measures every card in the strip. Cheap enough to redo wholesale: a card's
// width is fixed, so this only really runs when cards are added.
const measureBarTitles = function(list) {
const target = list || document.getElementById('favorites-list');
if (!target) return;
target.querySelectorAll('.favorite-card').forEach((card) => {
App.marquee.measure(card.querySelector('.favorite-title'),
card.querySelector('.favorite-title-text'));
});
if (!barTitleEnv.useHoverFocus) syncBarTitleActive(target);
};
// The display font arrives after the first render, and it changes how wide
// every title is -- so whatever was measured against the fallback font has
// to be measured again once the real one is in.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => measureBarTitles()).catch(() => {});
}
App.favorites.renderBar = function() {
const bar = document.getElementById('favorites-bar');
const list = document.getElementById('favorites-list');
@@ -296,7 +351,16 @@ App.favorites = App.favorites || {};
// Assignment rather than addEventListener: renderBar runs on every
// favorite change, and this must not stack up handlers.
let scrollRaf = null;
list.onscroll = () => {
// Which card is centred changes as the strip moves, but only once
// per frame is worth measuring.
if (!barTitleEnv.useHoverFocus && !scrollRaf) {
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
syncBarTitleActive(list);
});
}
if (barPage.rendered >= barPage.items.length) return;
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
@@ -328,7 +392,7 @@ App.favorites = App.favorites || {};
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div>
<div class="video-thumb">
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async">
<img alt="${item.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
@@ -336,12 +400,12 @@ App.favorites = App.favorites || {};
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
</div>
<div class="favorite-info">
<h4>${item.title}</h4>
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
</div>
`;
const thumb = card.querySelector('img');
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
App.videos.attachNoReferrerRetry(thumb);
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
App.videos.attachThumbnail(thumb, item.thumb);
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;
@@ -372,9 +436,10 @@ App.favorites = App.favorites || {};
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.videos.closeAllMenus();
// Favorites deliberately store no resolved metadata, so pull
// it fresh before showing the full info dump.
App.videos.ensureFormats(item).then(() => App.ui.showInfo(item));
// Favorites deliberately store no resolved metadata; the
// panel opens on what the entry holds and resolves the rest
// itself.
App.ui.openInfo(item);
};
}
if (downloadBtn) {
@@ -394,7 +459,17 @@ App.favorites = App.favorites || {};
App.videos.handleSearch(uploader);
};
}
if (barTitleEnv.useHoverFocus) {
card.addEventListener('pointerenter', () => setBarTitleActive(card, true));
card.addEventListener('pointerleave', () => setBarTitleActive(card, false));
}
// Keyboard: tabbing to a card's heart should reveal its whole title
// too, on touch devices as much as on desktop.
card.addEventListener('focusin', () => setBarTitleActive(card, true));
card.addEventListener('focusout', () => setBarTitleActive(card, false));
list.appendChild(card);
});
// Widths only exist once the cards are laid out.
requestAnimationFrame(() => measureBarTitles(list));
}
})();

View File

@@ -159,26 +159,11 @@ App.feed = App.feed || {};
slide.classList.remove('is-loaded');
};
// Single-line feed title that scrolls horizontally when it overflows.
// Driven off the overflow distance so every title scrolls at the same
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
// Single-line slide title that scrolls when it overflows. Only the active
// slide's title is measured, so like the player it always scrolls.
const measureFeedTitle = function(slide) {
if (!slide) return;
const wrap = slide.querySelector('.feed-title');
const text = slide.querySelector('.feed-title-text');
if (!wrap || !text) return;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow > 4) {
const distance = overflow + 16;
const MARQUEE_SPEED = 28; // px per second
const duration = Math.max(6, distance / MARQUEE_SPEED);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('is-marquee');
} else {
wrap.classList.remove('is-marquee');
text.style.removeProperty('--marquee-distance');
}
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
};
const setTimelinePosition = function(slide, ratio) {
@@ -439,7 +424,7 @@ App.feed = App.feed || {};
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
const favKey = App.favorites ? App.favorites.getKey(v) : null;
slide.innerHTML = `
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
<img class="feed-poster" alt="" loading="lazy" decoding="async">
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
${liveBadge}
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
@@ -461,7 +446,7 @@ App.feed = App.feed || {};
</div>
`;
const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster);
App.videos.attachThumbnail(poster, v.thumb);
const slideVideo = slide.querySelector('.feed-video');
bindTimeline(slide, slideVideo);
bindSharedControls(slide, slideVideo, v);

51
frontend/js/marquee.js Normal file
View File

@@ -0,0 +1,51 @@
window.App = window.App || {};
App.marquee = App.marquee || {};
// A title is always one line. When it doesn't fit its box it scrolls sideways
// instead of wrapping or being silently cut off.
//
// Four surfaces show a title that way -- the grid card, the favorites bar card,
// the fullscreen player, the reels slide -- and they must scroll at the same
// speed to look like one app, so the measurement lives here rather than being
// written out again next to each of them. Whether a given title is *currently*
// scrolling is the caller's business (see the `is-title-active` handling in
// videos.js and favorites.js): a whole grid of marching text is unreadable, so
// most callers animate only the title the reader is actually looking at.
(function() {
// Drive the duration off the distance so every title scrolls at the same
// gentle rate rather than a fixed duration, which made longer titles whip
// past. The floor keeps short ones from snapping.
const SPEED_PX_PER_SEC = 28;
const MIN_DURATION_S = 6;
// Sub-pixel rounding isn't overflow worth animating.
const OVERFLOW_SLACK_PX = 4;
// Trailing space so the last word clears the edge before it wraps around.
const TAIL_GAP_PX = 12;
// Measures `text` inside `wrap` and prepares the animation: sets
// --marquee-distance / --marquee-duration on `text`, and marks `wrap` with
// `has-marquee` so CSS can decide what to do about it. Returns whether the
// title overflows -- callers use that to skip the bookkeeping (scroll
// observers, hover handlers) that only scrolling titles need.
//
// Reads layout, so call it when the element is in the document and visible;
// a hidden element measures as zero-width and reports no overflow.
App.marquee.measure = function(wrap, text) {
if (!wrap || !text) return false;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow <= OVERFLOW_SLACK_PX) {
wrap.classList.remove('has-marquee');
text.style.removeProperty('--marquee-distance');
text.style.removeProperty('--marquee-duration');
return false;
}
const distance = overflow + TAIL_GAP_PX;
const duration = Math.max(MIN_DURATION_S, distance / SPEED_PX_PER_SEC);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('has-marquee');
return true;
};
})();

View File

@@ -25,9 +25,40 @@ App.player = App.player || {};
originEl: null,
hudHovered: false, // mouse resting on the controls (desktop)
activeUrl: '', // media URL actually playing, for the format menu's tick
attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
fetchAbort: null // aborts the current attempt's own requests
};
// Stops everything the current attempt has in flight. The token guards keep
// stale *callbacks* from acting, but they don't stop the requests those
// callbacks were waiting on: hls.js goes on pulling segments through the
// proxy, the media element keeps its connection open, and the content-type
// sniff keeps a whole upstream fetch alive on the server. When an attempt is
// superseded -- most of all when the direct route wins the race and the
// proxy has nothing left to do -- that work is pure waste at both ends.
function cancelInFlight() {
if (cp.fetchAbort) {
cp.fetchAbort.abort();
cp.fetchAbort = null;
}
if (state.hlsPlayer) {
state.hlsPlayer.stopLoad();
state.hlsPlayer.detachMedia();
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
const video = cp.video;
if (video) {
video.onerror = null;
video.pause();
// Dropping the source is what closes the connection the media
// element is holding; load() makes the element let go of it now
// rather than whenever it next feels like it.
video.removeAttribute('src');
video.load();
}
}
const addCleanup = (fn) => cp.cleanups.push(fn);
const runCleanups = () => {
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
@@ -111,26 +142,11 @@ App.player = App.player || {};
}
// ---------------------------------------------------------------------
// Title marquee (mirrors App.videos' card marquee math, scoped locally
// since the player title isn't a `.video-card`).
// Title marquee. Only one title is on screen here, so unlike the grid it
// always scrolls when it overflows -- there's nothing to pick between.
// ---------------------------------------------------------------------
function measureTitle() {
const wrap = q('.cp-title');
const text = q('.cp-title-text');
if (!wrap || !text) return;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow > 4) {
const distance = overflow + 12;
const MARQUEE_SPEED = 28;
const MARQUEE_MIN_DURATION = 6;
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('has-marquee');
} else {
wrap.classList.remove('has-marquee');
text.style.removeProperty('--marquee-distance');
}
App.marquee.measure(q('.cp-title'), q('.cp-title-text'));
}
// ---------------------------------------------------------------------
@@ -667,6 +683,10 @@ App.player = App.player || {};
function playSources(videoData, opts) {
const video = cp.video;
const token = ++cp.attemptToken;
// Every route into here supersedes whatever was playing or loading: the
// direct route winning its race, a quality switch, a retry, a re-open.
// Void the old attempt's callbacks, then stop its requests.
cancelInFlight();
const resumeAt = (opts && opts.resumeAt) || 0;
// Captured once per call rather than read from the shared `cp`
// object later: if open() is ever re-entered for a different video
@@ -694,6 +714,39 @@ App.player = App.player || {};
plan.push({ resolved, direct: false });
});
// Whether a CDN will serve the browser directly is asked here, at play
// time, about this video's own media URL -- not in advance about the
// listing's. One provider can spread its media across several CDNs, so
// there is no single answer to pre-compute, and any answer taken from
// another video may not hold for this one.
//
// The question runs *alongside* the proxied playback rather than ahead
// of it, so it never delays anything: the proxy is already carrying the
// video while the direct route is being tested. If the answer comes
// back before any frame has been decoded, the attempt restarts on the
// direct URL -- nothing is on screen yet, so there is nothing to
// interrupt. If playback has already begun, the answer is kept, and the
// next video from that CDN starts direct without asking again.
const raceDirect = function(resolved) {
if (!App.videos || typeof App.videos.probeDirect !== 'function') return;
if (!resolved.url || resolved.isLive) return;
// An origin that demands a Referer can never be fetched directly by
// a browser, so there is nothing to find out.
if (resolved.refererRequired) return;
if (directProven(resolved.url)) return;
App.videos.probeDirect(resolved.url).then((ok) => {
if (!ok || token !== cp.attemptToken) return;
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
// playing video alone rather than trading a visible stall for a
// saved hop.
if (!cp.video || cp.video.readyState >= 2) return;
// Direct won. Restarting cancels the proxy's fetch on the way
// in (see cancelInFlight), so the losing route stops pulling
// bytes instead of running to completion behind the winner.
playSources(videoData, Object.assign({}, opts, { resumeAt: resumeAt }));
});
};
const attempt = async (index) => {
if (token !== cp.attemptToken) return;
const entry = plan[index];
@@ -738,16 +791,13 @@ App.player = App.player || {};
let isHls = kind.isHls;
let isDirectMedia = kind.isDirectMedia;
video.onerror = null;
if (state.hlsPlayer) {
state.hlsPlayer.stopLoad();
state.hlsPlayer.detachMedia();
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
video.pause();
video.removeAttribute('src');
video.load();
cancelInFlight();
const attemptAbort = new AbortController();
cp.fetchAbort = attemptAbort;
// Going out through the proxy: find out in parallel whether this
// CDN would have taken the browser directly.
if (!entry.direct) raceDirect(resolved);
// Last resort only: a HEAD through the proxy is a whole upstream
// connection (handshake included) before the first byte of video is
@@ -755,14 +805,23 @@ App.player = App.player || {};
// extractor's protocol says what this source is.
if (!isHls && !isDirectMedia && !entry.direct) {
try {
const headResp = await fetch(streamUrl, { method: 'HEAD' });
if (token !== cp.attemptToken) return;
const headResp = await fetch(streamUrl, {
method: 'HEAD',
signal: attemptAbort.signal
});
const contentType = headResp.headers.get('Content-Type') || '';
if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true;
else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true;
} catch (err) {
// Best-effort sniff only.
// Best-effort sniff only -- including the abort that
// cancelInFlight fires, which lands here rather than at the
// guard below.
}
// Outside the catch on purpose: an aborted sniff means this
// attempt has been superseded, and swallowing that with the
// failure of a best-effort sniff would let a dead attempt walk
// on and attach a stream to the player that replaced it.
if (token !== cp.attemptToken) return;
}
const startPlayback = () => {
@@ -860,14 +919,7 @@ App.player = App.player || {};
const reopening = !!(cp.container && cp.container.classList.contains('open'));
if (reopening) {
cp.attemptToken++;
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
if (cp.video) {
cp.video.onerror = null;
cp.video.pause();
}
cancelInFlight();
clearIdleTimer();
if (cp.originEl) cp.originEl.classList.remove('is-loading');
}
@@ -959,17 +1011,10 @@ App.player = App.player || {};
App.player.close = function(opts) {
if (!cp.container || !cp.container.classList.contains('open')) return;
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
if (cp.video) {
cp.video.onerror = null;
cp.video.pause();
cp.video.removeAttribute('src');
cp.video.load();
}
// Closing the player must also stop what it was fetching -- otherwise a
// proxied stream keeps being pulled, and the server keeps an upstream
// connection open, for a video nobody is watching any more.
cancelInFlight();
clearIdleTimer();
runCleanups();

View File

@@ -66,25 +66,23 @@ App.ui = App.ui || {};
}, 4000);
};
App.ui.showInfo = function(video) {
const modal = document.getElementById('info-modal');
if (!modal) return;
const title = document.getElementById('info-title');
const list = document.getElementById('info-list');
const empty = document.getElementById('info-empty');
// Which video the panel is currently showing, so a slow resolve that lands
// after the user moved on doesn't redraw someone else's panel.
let infoVideo = null;
const data = video && video.meta ? video.meta : video;
const titleText = data && data.title ? data.title : 'Video Info';
if (title) title.textContent = titleText;
const appendInfoHeading = function(list, label) {
const heading = document.createElement('div');
heading.className = 'info-section';
heading.textContent = label;
list.appendChild(heading);
};
if (list) {
list.innerHTML = "";
}
let hasRows = false;
if (data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
if (!list) return;
// One row per field, whatever the field is. Objects and arrays are printed
// as JSON rather than summarised: the panel is the place to see exactly what
// the server said, so nothing is dropped or abbreviated here.
const appendInfoRows = function(list, data) {
let count = 0;
Object.entries(data || {}).forEach(([key, value]) => {
const row = document.createElement('div');
row.className = 'info-row';
@@ -106,21 +104,81 @@ App.ui = App.ui || {};
row.appendChild(label);
row.appendChild(valueNode);
list.appendChild(row);
hasRows = true;
count++;
});
return count;
};
// Shows every field the client holds for a video: the listing item's own
// (id, title, uploader, duration, tags, ...) and then the extractor's, which
// arrive separately. It used to show `video.meta` *instead of* the item once
// one had been resolved, which silently hid everything the listing knew the
// moment a card had been hovered.
// `options.info` is the full extractor payload (App.videos.fetchFullInfo);
// `options.pending` notes that it's still on its way.
App.ui.showInfo = function(video, options) {
const modal = document.getElementById('info-modal');
if (!modal) return;
const opts = options || {};
const title = document.getElementById('info-title');
const list = document.getElementById('info-list');
const empty = document.getElementById('info-empty');
const item = (video && typeof video === 'object') ? video : {};
// `meta` is the trimmed playback payload; the full extractor info is a
// superset of it, so only one of the two is ever shown.
const resolved = opts.info || item.meta || null;
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
let rows = 0;
if (list) {
list.innerHTML = "";
// `meta` gets its own section below rather than a row of JSON.
const own = Object.assign({}, item);
delete own.meta;
rows += appendInfoRows(list, own);
if (resolved && typeof resolved === 'object') {
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
rows += appendInfoRows(list, resolved);
}
if (opts.pending) {
const pending = document.createElement('div');
pending.className = 'info-pending';
pending.textContent = 'Resolving full metadata…';
list.appendChild(pending);
}
}
if (empty) {
empty.style.display = hasRows ? 'none' : 'block';
empty.style.display = rows ? 'none' : 'block';
}
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
};
// Opens the panel on what the client already has, then redraws it with the
// extractor's full payload once that lands. Resolution runs yt-dlp against
// the source site and can take seconds; there's no reason to stare at
// nothing (or at a spinner) while it does.
App.ui.openInfo = function(video) {
infoVideo = video;
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
App.ui.showInfo(video, { pending: canResolve });
if (!canResolve) return;
App.videos.fetchFullInfo(video).then((info) => {
if (infoVideo !== video) return; // the panel moved on, or closed
App.ui.showInfo(video, { info: info });
});
};
App.ui.closeInfo = function() {
const modal = document.getElementById('info-modal');
if (!modal) return;
infoVideo = null;
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
};

View File

@@ -52,7 +52,8 @@ App.videos = App.videos || {};
};
const updateTitleActive = function(card) {
if (!card || !card.classList.contains('has-marquee')) {
const titleWrap = card && card.querySelector('.video-title');
if (!titleWrap || !titleWrap.classList.contains('has-marquee')) {
if (card) card.classList.remove('is-title-active');
return;
}
@@ -68,25 +69,13 @@ App.videos = App.videos || {};
const titleWrap = card.querySelector('.video-title');
const titleText = card.querySelector('.video-title-text');
if (!titleWrap || !titleText) return;
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
if (overflow > 4) {
card.classList.add('has-marquee');
const distance = overflow + 12;
titleText.style.setProperty('--marquee-distance', `${distance}px`);
// Drive the duration off the distance so every title scrolls at the
// same gentle speed (px/sec) instead of a fixed duration that made
// longer titles whip past. A floor keeps short titles from snapping.
const MARQUEE_SPEED = 28; // px per second
const MARQUEE_MIN_DURATION = 6; // seconds
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
if (App.marquee.measure(titleWrap, titleText)) {
// Only marquee cards need the scroll-position observer that picks the
// centered card to animate; observing every card made scrolling a
// large grid needlessly expensive.
if (titleObserver) titleObserver.observe(card);
} else {
card.classList.remove('has-marquee', 'is-title-active');
titleText.style.removeProperty('--marquee-distance');
card.classList.remove('is-title-active');
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
@@ -134,27 +123,290 @@ App.videos = App.videos || {};
}
};
App.videos.attachNoReferrerRetry = function(img) {
if (!img) return;
if (!img.dataset.originalSrc) {
img.dataset.originalSrc = img.currentSrc || img.src || '';
// ---------------------------------------------------------------------
// Thumbnails
//
// A thumbnail can come from two places: the provider's own CDN, or our
// /api/image proxy. Neither is reliably the faster one -- the CDN is a hop
// closer, but plenty of them hotlink-block or rate-limit, and learning that
// used to cost a whole failed request before the proxy was even asked. That
// serial retry is the wait worth removing.
//
// So the first thumbnail from a host is raced: both requests go out at once
// and whichever answers first is the one displayed. The winner is then
// remembered per host -- hotlink and CORS policy are origin-level, the same
// assumption the direct-playability probe below makes -- so every later
// thumbnail from that host goes straight down the route that already
// worked. One race per host, not one per image: racing every card would
// double the image traffic of a whole grid to learn something we already
// know by the second card.
// ---------------------------------------------------------------------
const IMAGE_DIRECT = 'direct';
const IMAGE_PROXY = 'proxy';
const imageRoutes = new Map(); // host -> winning route; absent = unknown
const imageRacing = new Set(); // hosts with a race already deciding
const imageWaiting = new Map(); // host -> images held until it decides
// A page of cards is built in one go, so every thumbnail from a host is
// attached before the first one has come back. Sending them all down the
// optimistic route is how the old serial retry hurt: on a host that blocks
// us, each card paid its own failed request before asking the proxy. So
// while a host is being decided the rest of its images wait for the answer
// -- at most as long as the fastest route takes -- and then load once, the
// right way round. If the race somehow stalls they go anyway.
const RACE_PATIENCE_MS = 2500;
// Direct is the route we'd rather settle on, so when the proxy comes home
// first the held images give direct this much longer to answer before
// committing to the proxy. Long enough that a provider merely a little
// slower than same-origin still wins its hosts; short enough that one which
// hangs -- the case this whole thing exists for -- doesn't hold up a grid.
const DIRECT_GRACE_MS = 200;
const imageHostOf = function(url) {
try {
return new URL(url, window.location.href).host;
} catch (err) {
return '';
}
img.dataset.noReferrerRetry = '0';
img.addEventListener('error', () => {
if (img.dataset.noReferrerRetry === '1') return;
img.dataset.noReferrerRetry = '1';
img.referrerPolicy = 'no-referrer';
img.removeAttribute('crossorigin');
const original = img.dataset.originalSrc || img.currentSrc || img.src || '';
const proxyUrl = App.videos.buildImageProxyUrl(original);
if (proxyUrl) {
img.src = proxyUrl;
} else if (original) {
img.src = original;
};
// Where a thumbnail from this host should be fetched from. Answers with the
// provider while the host is still unknown -- the optimistic route, and the
// one a race starts on anyway.
App.videos.thumbnailUrl = function(url) {
if (!url) return '';
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
? (App.videos.buildImageProxyUrl(url) || url)
: url;
};
// A src-less <img> counts as "unavailable", and the browser paints its alt
// text across the thumbnail box. Since a thumbnail now waits for its host's
// route before it gets a src, the caption is held back in a data attribute
// and put on only once there is an image to caption -- otherwise every card
// spells out its own title over the placeholder while the host is being
// decided, and permanently for an item that has no thumbnail at all.
const showThumbnail = function(img, url) {
if (img.dataset.alt !== undefined) {
img.alt = img.dataset.alt;
delete img.dataset.alt;
}
img.src = url;
};
// Last resort on a route that normally works: one expired or missing image
// shouldn't be left broken just because its host is fine in general.
const attachProxyFallback = function(img, proxyUrl) {
if (!proxyUrl) return;
img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true });
};
// Releases the images held for `host`. `route` is the winner, or null when
// the race told us nothing (both routes failed, or it stalled) -- in which
// case they take the optimistic route with the proxy behind it, exactly as
// an undecided host used to.
const releaseWaiting = function(host, route) {
const waiting = imageWaiting.get(host);
if (!waiting) return;
imageWaiting.delete(host);
waiting.forEach((entry) => {
if (route === IMAGE_PROXY) {
showThumbnail(entry.img, entry.proxyUrl);
return;
}
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
showThumbnail(entry.img, entry.directUrl);
});
};
// Two questions, and they don't have the same answer:
//
// which is quicker *now* -> what this image should display
// does direct work at all -> what the host is remembered as
//
// The proxy often wins the first question purely because it's same-origin:
// the browser already holds that connection, while the provider costs a
// fresh DNS lookup and TLS handshake. That says nothing about the provider,
// and pinning a host to the proxy over it would push every thumbnail on the
// page through our own server for no reason. So speed decides the pixels,
// and capability decides the memory: direct is remembered whenever it works
// at all, because it costs no server hop.
//
// Both routes are therefore fetched off-screen, and the visible image is
// pointed at the first one home. Racing on the visible element instead
// would abort the loser -- and the loser is the request that answers the
// second question.
const raceThumbnail = function(img, directUrl, proxyUrl, host) {
let shown = false;
let outstanding = 2;
let routeFinal = false; // the direct verdict is in; nothing can revise it
let abandoned = false; // took too long; a later race owns the host now
let directSettled = false;
let patience = null;
let grace = null;
const show = function(url) {
if (shown) return;
shown = true;
showThumbnail(img, url); // a cache hit; the probe has the bytes
};
// Records the host's route and lets go of everything held for it. The
// direct verdict is final; a route taken because direct was too slow to
// wait for is not, so a direct probe that comes home late still upgrades
// the host rather than leaving it on the proxy for the whole session.
//
// Crucially this happens the moment direct answers, not when both probes
// have finished: every thumbnail attached in the meantime is queued on
// exactly this answer, and making them wait on the *other* probe too
// leaves them blank for no reason.
const settleRoute = function(route, final) {
if (abandoned || routeFinal) return;
routeFinal = !!final;
if (final && patience) { clearTimeout(patience); patience = null; }
imageRoutes.set(host, route);
imageRacing.delete(host);
releaseWaiting(host, route);
};
// Nothing usable came home in time. Show whatever the host settled on --
// broken rather than blank, as it would have been without any of this --
// and keep the proxy behind an untested direct.
const giveUp = function() {
if (shown) return;
shown = true;
if (imageRoutes.get(host) === IMAGE_PROXY) {
showThumbnail(img, proxyUrl);
return;
}
attachProxyFallback(img, proxyUrl);
showThumbnail(img, directUrl);
};
const decide = function() {
if (--outstanding > 0) return;
if (patience) { clearTimeout(patience); patience = null; }
if (grace) { clearTimeout(grace); grace = null; }
giveUp(); // no-op if either route came home
};
// Off-screen, and low priority: neither may take bandwidth from anything
// the reader is already looking at.
const newProbe = function() {
const image = new Image();
image.decoding = 'async';
image.fetchPriority = 'low';
return image;
};
const directProbe = newProbe();
directProbe.referrerPolicy = 'no-referrer';
// The direct verdict alone decides the route -- either way round. One
// probe discovers the host's answer and every held image acts on it,
// instead of each rediscovering it at the cost of its own request.
directProbe.onload = function() {
directSettled = true;
if (grace) { clearTimeout(grace); grace = null; }
show(directUrl);
settleRoute(IMAGE_DIRECT, true);
decide();
};
directProbe.onerror = function() {
directSettled = true;
if (grace) { clearTimeout(grace); grace = null; }
// Direct is out for this host, so the proxy is the answer even if it
// hasn't reported yet -- there is nothing else left to be.
settleRoute(IMAGE_PROXY, true);
decide();
};
const proxyProbe = newProbe();
proxyProbe.onload = function() {
show(proxyUrl); // first one home gets the pixels on screen
// Don't strand anything behind a provider that may never answer:
// give it the grace window, then take the route that works. Marked
// provisional, so a slow-but-working provider still wins its host
// when it finally reports.
if (!directSettled && !grace) {
grace = setTimeout(function() {
grace = null;
settleRoute(IMAGE_PROXY, false);
}, DIRECT_GRACE_MS);
}
decide();
};
proxyProbe.onerror = function() { decide(); };
imageRacing.add(host);
// A probe that never answers -- a hung connection rather than a refused
// one -- must not leave the host mid-race forever, with every later
// thumbnail queueing behind a decision that will never come. Once that
// happens this race stops touching the shared maps entirely: the next
// thumbnail starts a fresh one, and a late answer here must not reach in
// and overwrite what *that* race decides.
patience = setTimeout(function() {
patience = null;
if (grace) { clearTimeout(grace); grace = null; }
if (!routeFinal) {
abandoned = true;
imageRacing.delete(host);
releaseWaiting(host, imageRoutes.get(host) || null);
}
giveUp();
}, RACE_PATIENCE_MS);
directProbe.src = directUrl;
proxyProbe.src = proxyUrl;
};
// Points `img` at `url` by whichever route is known to work for its host,
// racing the two the first time that host is seen.
App.videos.attachThumbnail = function(img, url) {
const directUrl = url || (img && img.dataset.thumb) || '';
if (!img) return;
// Held back until there is an image to caption -- see showThumbnail. An
// item with no thumbnail keeps an empty alt: the card's own title sits
// directly beneath the box, so there is nothing for it to add.
if (img.alt && img.dataset.alt === undefined) {
img.dataset.alt = img.alt;
img.alt = '';
}
if (!directUrl) return;
// A cross-origin Referer is what most hotlink protection keys on, and an
// image needs none. Sending none is what lets the direct route work at
// all on a fair number of providers -- and the direct route is the one
// that costs us no server hop.
img.referrerPolicy = 'no-referrer';
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
const host = imageHostOf(directUrl);
const route = imageRoutes.get(host);
if (route === IMAGE_PROXY) {
showThumbnail(img, proxyUrl || directUrl);
return;
}
if (imageRacing.has(host)) {
// A race is already deciding for this host. Wait for it rather than
// guessing: guessing wrong costs this image a whole failed request
// before it even asks the route that was about to be proven.
const waiting = imageWaiting.get(host) || [];
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl });
imageWaiting.set(host, waiting);
return;
}
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
// Known good, or nothing to race against: take the provider and keep
// the proxy as this image's own fallback.
attachProxyFallback(img, proxyUrl);
showThumbnail(img, directUrl);
return;
}
raceThumbnail(img, directUrl, proxyUrl, host);
};
// Each channel in a group sends back a different number of videos per
// page, so a small per-channel count keeps any one channel from
// dominating a single interleaved batch.
@@ -290,7 +542,11 @@ App.videos = App.videos || {};
};
const warmThumbnails = function(items) {
const urls = (items || []).map((v) => v && v.thumb).filter(Boolean);
// Warm down the route the host has already settled on -- warming a URL
// the cards won't ask for would leave them waiting anyway.
const urls = (items || [])
.map((v) => v && App.videos.thumbnailUrl(v.thumb))
.filter(Boolean);
if (!urls.length) return Promise.resolve();
const loads = urls.map((url) => new Promise((resolve) => {
// Off-screen and not needed yet: low priority, started when the
@@ -300,6 +556,12 @@ App.videos = App.videos || {};
const img = new Image();
img.decoding = 'async';
img.fetchPriority = 'low';
// Same terms the card and the probe fetch on. Warming with a
// Referer the real request won't send would warm the wrong
// thing: on a hotlink-protecting host it earns a 403, which is
// both a wasted warm and a cached refusal the probe may then be
// handed -- pinning a host to the proxy that works direct.
img.referrerPolicy = 'no-referrer';
img.onload = resolve;
img.onerror = resolve; // the card's own retry handles failures
img.src = url;
@@ -451,7 +713,7 @@ App.videos = App.videos || {};
// Builds a fully-wired video card element for `v`. Kept separate from
// mounting so the virtualizer can create a card the moment it needs to be
// on screen and throw it away once it scrolls out of the window.
App.videos.buildCard = function(v) {
App.videos.buildCard = function(v, options) {
const favoritesSet = App.favorites.getSet();
const card = document.createElement('div');
card.className = 'video-card';
@@ -473,7 +735,7 @@ App.videos = App.videos || {};
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div>
<div class="video-thumb">
<img src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
<img alt="${v.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div>
</div>
@@ -484,7 +746,13 @@ App.videos = App.videos || {};
${tagsMarkup}
`;
const thumb = card.querySelector('img');
App.videos.attachNoReferrerRetry(thumb);
// The layout probe (see shapeHeight) needs the card's shape, never its
// pixels: it measures against the CSS 16:9 placeholder and is removed in
// the same frame, so loading a thumbnail for it -- let alone racing one
// -- would be pure waste.
if (!(options && options.skipThumbnail)) {
App.videos.attachThumbnail(thumb, v.thumb);
}
const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn && favoriteKey) {
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
@@ -548,7 +816,7 @@ App.videos = App.videos || {};
if (showInfoBtn) {
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.ui.showInfo(v);
App.ui.openInfo(v);
App.videos.closeAllMenus();
};
}
@@ -565,7 +833,7 @@ App.videos = App.videos || {};
App.player.open(v, { originEl: card });
};
cardVideo.set(card, v);
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true });
return card;
};
@@ -766,7 +1034,7 @@ App.videos = App.videos || {};
if (cached != null) return cached;
const el = grid();
if (!el) return 240;
const probe = App.videos.buildCard(v);
const probe = App.videos.buildCard(v, { skipThumbnail: true });
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.style.left = '-99999px';
@@ -897,13 +1165,13 @@ App.videos = App.videos || {};
}
// Marquee + direct-playability probe only matter for on-screen cards.
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
probeObserver.observe(card);
resolveObserver.observe(card);
};
const unmount = function(i) {
const card = mounted.get(i);
if (!card) return;
probeObserver.unobserve(card);
resolveObserver.unobserve(card);
if (titleObserver) {
titleObserver.unobserve(card);
titleVisibility.delete(card);
@@ -1401,22 +1669,42 @@ App.videos = App.videos || {};
let ok = false;
let detail = '';
try {
// A simple GET (no custom headers) avoids a CORS preflight. If
// the response is readable and successful, CORS + reachability
// are both proven; we abort immediately so the body isn't
// downloaded (it can be a whole video file).
// A simple GET (no custom headers) avoids a CORS preflight.
const res = await fetch(url, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal: controller.signal
});
ok = res.ok || res.status === 206;
if (!(res.ok || res.status === 206)) {
ok = false;
detail = `HTTP ${res.status}`;
} else if (res.body && typeof res.body.getReader === 'function') {
// Run it until actual media bytes arrive. A readable status
// line is weaker evidence than it looks: the question is
// whether this origin will hand *the player* video data
// cross-origin, and that isn't settled until some has
// arrived. Then stop -- the rest of the file is not our
// business, and it can be a whole film.
const reader = res.body.getReader();
const chunk = await reader.read();
const bytes = (!chunk.done && chunk.value && chunk.value.length) || 0;
ok = bytes > 0;
detail = `HTTP ${res.status}, ${bytes} bytes`;
reader.cancel().catch(() => {});
} else {
// No readable stream to sample (an old browser): the status
// line is all the evidence on offer.
ok = true;
detail = `HTTP ${res.status}, headers only`;
}
controller.abort();
} catch (err) {
// A CORS refusal lands here as a TypeError with no status --
// the browser won't say more than "failed" about a response it
// wouldn't let us read.
ok = false;
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed';
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
} finally {
clearTimeout(timer);
}
@@ -1429,37 +1717,18 @@ App.videos = App.videos || {};
return promise;
};
// Kicks off a background probe of a video's best (first-played) source so a
// later playback can skip the proxy if its host is proven reachable. Only
// runs once the video has resolved formats (see resolveAndProbe): those are
// real media URLs (or redirects to them), whereas a bare listing item only
// carries a page URL that the player can't use directly.
App.videos.probeVideoSources = function(video) {
if (!video || typeof video !== 'object') return;
const meta = video.meta || video;
if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return;
let sources;
try {
sources = App.videos.resolveStreamSources(video);
} catch (err) {
return;
}
const best = sources && sources[0];
if (!best || !best.url || best.isLive) return;
// Sources that require a specific upstream Referer can't be fetched
// directly by the browser (it can't forge a cross-origin Referer), so a
// probe would always fail -- leave them to the proxy.
if (best.refererRequired) return;
App.videos.probeDirect(best.url);
};
// Listing items arrive without formats (meta is null) -- only a page URL --
// so there's nothing direct-playable to probe up front. This resolves a
// video's real media formats via the backend (yt-dlp), attaches them as
// `video.meta` so the player and probe can use them, then probes the best
// source. Resolution is per-video and deduped: it runs at most once per
// video, triggered lazily by hover/scroll so we don't resolve cards the
// user never looks at.
// so this resolves a video's real media formats via the backend (yt-dlp) and
// attaches them as `video.meta`, which is what playback, the quality menu
// and the hover preview all need. Resolution is per-video and deduped: it
// runs at most once per video, triggered lazily by hover/scroll so we don't
// resolve cards the user never looks at.
//
// It deliberately does *not* test direct playability. That question belongs
// to the video actually being played (see raceDirect in player.js): a
// provider can spread its media over several CDNs, so an answer taken from
// whichever card happened to scroll past need not hold for the one the
// reader picks.
const cardVideo = new WeakMap();
// Session cache of `/api/resolve` results, keyed by video id (falling back
@@ -1530,21 +1799,56 @@ App.videos = App.videos || {};
});
};
const probeObserver = new IntersectionObserver((entries) => {
// Everything the extractor knows about a video, for the Show info panel:
// description, dates, counts, categories, thumbnails, every field of every
// format. Deliberately not `ensureFormats`' payload and deliberately not
// attached to `video.meta` -- meta is fetched for every card that scrolls
// past and is kept small on purpose, and it names things playback's way
// (`isLive`) rather than the extractor's (`is_live`). Cached per video for
// the session, like meta, so reopening the panel is free.
const fullInfoCache = new Map();
App.videos.fetchFullInfo = function(video) {
if (!video || typeof video !== 'object' || !video.url) return Promise.resolve(null);
// Same reasoning as ensureFormats: a URL that already names a media file
// has nothing to extract, and re-fetching a signed one can burn a
// single-use link that playback still needs.
if (isDirectMediaUrl(video.url)) return Promise.resolve(null);
const cacheKey = video.id || video.url;
let promise = fullInfoCache.get(cacheKey);
if (!promise) {
promise = (async () => {
try {
const response = await fetch('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: video.url, full: '1' })
});
if (!response.ok) return null;
const data = await response.json();
if (!data || typeof data !== 'object' || !Object.keys(data).length) return null;
return data;
} catch (err) {
// Best-effort: the panel still shows what the client holds.
return null;
}
})();
fullInfoCache.set(cacheKey, promise);
}
return promise;
};
const resolveObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
probeObserver.unobserve(entry.target);
resolveObserver.unobserve(entry.target);
const video = cardVideo.get(entry.target);
if (video) App.videos.resolveAndProbe(video);
if (video) App.videos.ensureFormats(video);
});
}, { rootMargin: '200px' });
App.videos.resolveAndProbe = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve();
return App.videos.ensureFormats(video).then((meta) => {
if (meta) App.videos.probeVideoSources(video);
});
};
// Builds a proxied stream URL. Extra params other than `url` are forwarded
// by the backend as request headers, so use real header names here.