Compare commits

...

6 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
Simon
0009574b77 Keep favorites reachable with the bar hidden
The favorites bar carries the "Browse all" button and the sort control, so
switching the bar off in settings hid the way in to both. The command
palette now offers "Browse favorites" (or "Back to videos") and each sort
order, and opening the grid re-renders the bar so its header -- the way back
out -- is mounted even when settings say hidden.

Tests (scratchpad): a new palette test that starts with the bar switched
off, opens the grid through the palette, re-sorts through it, and returns to
the listing. It caught the second half of this: opening from the palette did
not re-render the bar, leaving no visible way out.

Also fixes the favorites playback test, which had been wedging headless
Chrome all session. It was reloading by navigating to the URL already
loaded; the first evaluate after that is answered by the outgoing execution
context and every one after it hangs forever. It now does its second visit
in a fresh tab -- localStorage is shared per origin, so it models "next day"
the same way -- and asserts what it had only been printing. It also runs
hermetically now (no favorites left over from another test, CDN icons and
fonts blocked) and no longer runs its whole body on import, which is what
made it hijack a debugging session earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 11:34:45 +00:00
Simon
d4ed9dce5d Browse favorites like a listing, sorted, and page them as you scroll
Favorites now carry `favoriteDate`. Ones saved before this had no way of
knowing when they were saved, so they are all stamped with the moment the
client first reads them -- they sort together as one batch, at the point
favorites learned to keep dates. An import brings the date the other client
recorded instead, so a restored library keeps its history.

The bar used to build a card per favorite, which an import of several
hundred made an expensive way to open the app. It now renders a screenful
and appends more as the strip is scrolled.

"Browse all" turns the whole grid into favorites: the same cards, the same
virtualized masonry, the same infinite scroll and reels mode as a channel
listing -- App.videos.loadVideos simply pages out of localStorage instead of
the server while that view is open. Sort applies to the bar and the grid
together: recently added (default), oldest, title, longest, shortest, and a
shuffle for rediscovering a long list.

Tests (scratchpad): dates backfilled onto undated favorites, the bar paging
as it scrolls rather than building every card, the grid paging to the full
list with zero server calls, each sort order reordering it, and the way back
to the channel listing. Also measured: 515 imported favorites load with the
page responsive and 24 bar cards built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 10:46:47 +00:00
Simon
c54d0889c1 Import favorites from a Hot Tub backup
Settings gains a file picker that reads an exported Hot Tub database and
merges its favorites into this client's. The file never leaves the device:
sqlite.js is a small read-only reader -- header, schema, table b-trees,
record decoding, and the overflow pages that real rows here spill onto --
which is all it takes to walk one table, and avoids putting a wasm SQLite
behind a CDN fetch.

The two sides don't agree on what identifies a video. The app keys one by a
hash it computes locally (a 64-hex string); the server, and so this client,
keys it as something like "reddit-1rdudss". So the merge matches on
normalized URL: entries already saved here are left exactly as they are,
keeping the server id that makes a listing card's heart light up, and only
genuinely new videos are appended.

That means an imported favorite has no server id, so hearts now also match
by URL (`data-fav-url` on the card, feed slide and favorites bar). Without
it an imported favorite would look unsaved on its own card, and clicking
the heart would file a second copy of the same video.

Only the columns a favorite needs are read. `allFormats` is deliberately
left behind: it holds resolved, signed URLs, which is exactly what
favorites must not store (they expire -- see App.favorites.normalize).

Tests (scratchpad): the reader checked against Python's sqlite3 on a real
12MB backup -- table list, every table's row count, all 515 favorites with
their fields and order, and the 25 longest records byte-for-byte, which is
where a wrong overflow split shows up; and the Settings control driven
end-to-end, covering the merge, an existing favorite keeping its id, a
re-import adding nothing, and a listing card recognising an imported
favorite and unfavoriting it cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 08:32:44 +00:00
Simon
b48d7aa161 Reuse upstream connections, and stop sniffing what we already know
Two costs sat in front of every video: a TLS handshake per upstream request,
and a round trip spent asking the proxy what kind of file it was about to
play.

The session cache was a thread-local, which never once hit -- the server
gives each connection a fresh thread, so every request found empty storage
and built a session, and with it a new connection to the CDN. Instrumented,
that was one session per request; a video is dozens of range requests and an
HLS stream one per segment. Sessions now live in a shared pool, checked out
for a request and returned when its response closes (for a streamed body,
after the last byte), so the connection stays warm. Measured against a
nearby CDN: 32-45ms per request becomes 9-11ms.

The player then HEADed the proxy before playback to sniff a content type --
and that HEAD ran a full upstream GET server-side, so two connections were
opened before the first byte of video was asked for. It now sniffs only when
neither the URL's extension nor yt-dlp's `protocol` says what the source is,
which is nearly never; `protocol` is newly carried through /api/resolve for
exactly this. A HEAD that does still happen asks upstream for one byte and
restates the 206 as a 200 describing the whole resource.

`format_note` joins the resolved fields too: the quality menu has been
reading it since 52d7802, but the backend was dropping it, so no note could
ever have been shown.

Tests (scratchpad, headless): sessions reused across requests, never shared
by two at once, returned after a client aborts mid-stream; HEAD probes one
byte while ranged and plain GETs are byte-identical; no HEAD for an mp4 or a
protocol-bearing URL, and one for a URL with neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 23:27:24 +00:00
15 changed files with 1551 additions and 281 deletions

View File

@@ -10,6 +10,7 @@ import yt_dlp
from yt_dlp.networking.impersonate import ImpersonateTarget
from curl_cffi import requests as impersonate_requests
import threading
import queue
import io
import time
import hashlib
@@ -20,18 +21,75 @@ from urllib.parse import urljoin
# that isn't a real browser, so impersonation must be on by default.
IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome'
# curl_cffi sessions wrap a single libcurl handle and are not safe to share
# across threads; keep one per worker thread so the Flask `threaded=True`
# server can proxy concurrent segments without corrupting state.
_thread_local = threading.local()
# curl_cffi sessions wrap a single libcurl handle: they can't be shared by two
# requests at once, but reusing one *across* requests is what keeps the upstream
# connection alive, and with it the TLS handshake we already paid for. A video
# arrives as dozens of range requests (and an HLS stream as one request per
# segment), so a handshake per request is the difference between a stall and a
# seek.
#
# This used to be a thread-local, which never actually hit: the development
# server gives every connection a brand-new thread, so each request found empty
# thread-local storage and built a session from scratch. Sessions live in a
# shared pool instead -- checked out for the duration of one request, returned
# when its response is closed (which, for a streamed body, is when the last byte
# has been sent). LIFO so the hottest connection is the one handed out next.
try:
_SESSION_POOL_SIZE = max(1, int(os.getenv('STREAM_SESSION_POOL', '') or 8))
except ValueError:
_SESSION_POOL_SIZE = 8
_session_pool = queue.LifoQueue(maxsize=_SESSION_POOL_SIZE)
def get_impersonate_session():
sess = getattr(_thread_local, 'session', None)
if sess is None:
sess = impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
_thread_local.session = sess
return sess
def _borrow_session():
"""A session nobody else is using: from the pool, or a fresh one."""
try:
return _session_pool.get_nowait()
except queue.Empty:
return impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
def _return_session(sess):
"""Hand a session back. Beyond the pool's size the extras are closed, so a
burst of concurrency doesn't leave idle connections open forever."""
try:
_session_pool.put_nowait(sess)
except queue.Full:
try:
sess.close()
except Exception:
pass
def _discard_session(sess):
"""Drop a session that raised, rather than pooling a possibly-poisoned handle."""
try:
sess.close()
except Exception:
pass
def _release_when_closed(resp, sess):
"""Return `sess` to the pool once `resp` is closed.
Every caller either closes the response outright or streams it through a
generator that closes in a `finally`, so this is where a request's exclusive
hold on a session ends. Idempotent: a double close must not put the same
session in the pool twice."""
original_close = resp.close
released = False
def close():
nonlocal released
try:
original_close()
finally:
if not released:
released = True
_return_session(sess)
resp.close = close
return resp
def _is_tls_verify_error(err):
@@ -54,22 +112,34 @@ def impersonate_get(url, **kwargs):
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
sess = _borrow_session()
if host in _tls_unverified_hosts:
return get_impersonate_session().get(url, verify=False, **kwargs)
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_discard_session(sess)
raise
try:
return get_impersonate_session().get(url, **kwargs)
return _release_when_closed(sess.get(url, **kwargs), sess)
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):
_discard_session(sess)
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)
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_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'}
@@ -80,6 +150,10 @@ STREAM_IMPERSONATION_MANAGED_HEADERS = {
'user-agent', 'accept', 'accept-encoding', 'accept-language',
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
}
# `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*'
# total (an origin that won't say) deliberately doesn't match, so the length is
# then simply left out rather than guessed at.
_CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I)
# RFC 7230 token charset for header field-names.
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
@@ -217,8 +291,31 @@ _resolve_cache_lock = threading.Lock()
# Per-format fields the frontend needs to rank formats and build stream/probe
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
# yt-dlp format dict is dropped to keep the payload small.
# `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native',
# 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip
# against the proxy -- and with it a whole upstream connection -- for URLs whose
# extension doesn't say what they are, which is most signed CDN links.
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality')
'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
@@ -233,8 +330,11 @@ def resolve_unsupported_embed(page_url):
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
single format is the embed's HLS playlist, or None if nothing was found."""
# Both fetches are small and fully buffered, so this holds one pooled session
# for the whole scrape rather than going through impersonate_get (whose
# release is tied to closing a streamed response).
sess = _borrow_session()
try:
sess = get_impersonate_session()
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
embed_url = None
for src in _EMBED_IFRAME_RE.findall(page.text):
@@ -262,14 +362,25 @@ def resolve_unsupported_embed(page_url):
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
}
except Exception:
_discard_session(sess)
sess = None
return None
finally:
if sess is not None:
_return_session(sess)
@app.route('/api/resolve', methods=['POST', 'GET'])
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')
@@ -280,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,
@@ -301,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
@@ -315,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():
@@ -634,6 +748,15 @@ def stream_video():
if 'Range' in request.headers:
safe_request_headers['Range'] = request.headers['Range']
# A HEAD wants headers, not video -- but we don't send a HEAD upstream
# here (hotlink-protected origins routinely answer one method and not
# the other, and the GET is the one we know works). Ask for a single
# byte instead: same headers, none of the transfer. The response is
# restated as a description of the whole resource further down.
head_probe = request.method == 'HEAD' and 'Range' not in safe_request_headers
if head_probe:
safe_request_headers['Range'] = 'bytes=0-0'
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
# inverted hotlink protection: they 403 any request that carries a
@@ -694,8 +817,32 @@ def stream_video():
)
if request.method == 'HEAD':
status = resp.status_code
# Read from the headers we already copied: curl_cffi doesn't keep a
# response's headers readable once it has been closed.
content_range = next((value for name, value in forwarded_headers
if name.lower() == 'content-range'), '')
resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers)
if head_probe and status == 206:
# We asked for one byte; the caller asked about the resource.
# Restate the 206 as a 200 describing the whole thing, taking
# the real length out of `Content-Range: bytes 0-0/<total>`.
# (An origin that ignored the range answered 200 already, and
# its headers need no fixing.)
match = _CONTENT_RANGE_TOTAL_RE.match(content_range or '')
total = match.group(1) if match else None
forwarded_headers = [(name, value) for name, value in forwarded_headers
if name.lower() not in ('content-range', 'content-length')]
head_response = Response("", status=200, headers=forwarded_headers)
if total:
# A HEAD carries the entity headers its GET would, with no
# body -- so the length is the resource's, not the zero
# bytes we're sending. Werkzeug derives Content-Length from
# the body unless told not to.
head_response.automatically_set_content_length = False
head_response.headers['Content-Length'] = total
return head_response
return Response("", status=status, headers=forwarded_headers)
def generate():
try:

View File

@@ -398,6 +398,15 @@ body.theme-light .sidebar {
margin-bottom: 8px;
}
/* Explanatory line under a control -- and where the import reports back. Not a
`label`, so it keeps sentence case and normal letter spacing. */
.setting-note {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.setting-label-row label {
margin-bottom: 0;
}
@@ -766,6 +775,35 @@ body.theme-light .setting-item select option {
font-family: var(--font-display);
}
.favorites-actions {
display: flex;
align-items: center;
gap: 8px;
}
.favorites-sort {
height: 28px;
padding: 0 8px;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.favorites-actions .btn-secondary {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* Browsing favorites as a grid: the bar above it would be the same list twice,
so it collapses to its header (which carries the way back out). */
body.favorites-view-open .favorites-list,
body.favorites-view-open .favorites-empty {
display: none;
}
.favorites-list {
display: flex;
gap: 12px;
@@ -819,6 +857,8 @@ body.theme-light .setting-item select option {
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;
@@ -826,6 +866,20 @@ body.theme-light .setting-item select option {
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 {
@@ -1349,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;
@@ -2124,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

@@ -34,6 +34,10 @@
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
<div class="favorites-header">
<h3>Favorites</h3>
<div class="favorites-actions">
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
</div>
</div>
<div id="favorites-list" class="favorites-list"></div>
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
@@ -133,6 +137,17 @@
</div>
<div id="sources-list" class="sources-list"></div>
</div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
<div class="setting-item">
<label for="import-favorites-btn">Import Favorites</label>
<input id="import-favorites-file" type="file" accept=".sqlite3,.sqlite,.db" hidden>
<button id="import-favorites-btn" class="btn-secondary" type="button">Choose backup file…</button>
<p id="import-favorites-status" class="setting-note" role="status" aria-live="polite">
Reads the favorites out of an exported Hot Tub database. The file stays on this device.
</p>
</div>
</div>
</div>
</aside>
@@ -187,10 +202,14 @@
</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>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>
<script src="static/js/feed.js"></script>
<script src="static/js/ui.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 = '';
@@ -151,6 +151,26 @@ App.enhance = App.enhance || {};
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
}});
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
// The favorites bar carries these controls, but it can be switched
// off in settings -- in which case the palette is the way in.
if (App.favoritesView && App.favorites) {
const browsing = App.favoritesView.isActive();
out.push({
label: browsing ? 'Back to videos' : 'Browse favorites',
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
run: () => App.favoritesView.toggle()
});
const currentSort = App.favorites.getSort();
App.favorites.SORTS.forEach((sort) => {
if (sort.id === currentSort) return;
out.push({
label: `Sort favorites: ${sort.label}`,
hint: 'Favorites',
run: () => App.favoritesView.applySort(sort.id)
});
});
}
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });

View File

@@ -10,23 +10,82 @@ App.favorites = App.favorites || {};
const raw = localStorage.getItem(FAVORITES_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
// Favorites saved by older versions carry a `meta` blob of resolved
// formats whose URLs are signed and long expired. Drop it on the way
// in so no code path can reach for one; everything re-resolves from
// `url` at play time, and normalize() no longer stores it.
return parsed.map((item) => {
if (item && typeof item === 'object' && item.meta) {
const clean = Object.assign({}, item);
delete clean.meta;
return clean;
}
return item;
// Two things are repaired on the way in, and written back once if
// anything changed, so the fix happens exactly one time:
//
// `meta`, from older versions, is a blob of resolved formats whose
// URLs are signed and long expired -- dropped so no code path can
// reach for one; everything re-resolves from `url` at play time.
//
// `favoriteDate` didn't exist before sorting needed it. There's no
// way to recover when an old favorite was actually saved, so it
// gets now: they sort together, as one batch, at the point the
// client learned to keep dates.
let repaired = false;
const now = new Date().toISOString();
const items = parsed.map((item) => {
if (!item || typeof item !== 'object') return item;
if (!item.meta && item.favoriteDate) return item;
const clean = Object.assign({}, item);
delete clean.meta;
if (!clean.favoriteDate) clean.favoriteDate = now;
repaired = true;
return clean;
});
if (repaired) App.favorites.setAll(items);
return items;
} catch (err) {
return [];
}
};
// Sort orders offered for the favorites bar and the favorites grid.
// `random` reshuffles on every read by design -- it's for rediscovering a
// long list, so landing somewhere different each time is the point.
App.favorites.SORTS = [
{ id: 'recent', label: 'Recently added' },
{ id: 'oldest', label: 'Oldest first' },
{ id: 'title', label: 'Title A-Z' },
{ id: 'longest', label: 'Longest' },
{ id: 'shortest', label: 'Shortest' },
{ id: 'random', label: 'Shuffle' }
];
App.favorites.DEFAULT_SORT = 'recent';
App.favorites.getSort = function() {
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
};
App.favorites.setSort = function(sort) {
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
};
const dateValue = function(item) {
const parsed = Date.parse((item && item.favoriteDate) || '');
return isNaN(parsed) ? 0 : parsed;
};
App.favorites.sorted = function(sort) {
const items = App.favorites.getAll();
const mode = sort || App.favorites.getSort();
if (mode === 'random') {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
const comparators = {
recent: (a, b) => dateValue(b) - dateValue(a),
oldest: (a, b) => dateValue(a) - dateValue(b),
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
};
return items.sort(comparators[mode] || comparators.recent);
};
App.favorites.setAll = function(items) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
};
@@ -53,7 +112,10 @@ App.favorites = App.favorites || {};
channel: video.channel || (meta && meta.channel) || '',
uploader: video.uploader || (meta && meta.uploader) || '',
duration: video.duration || (meta && meta.duration) || 0,
isLive: !!(video.isLive || (meta && meta.isLive))
isLive: !!(video.isLive || (meta && meta.isLive)),
// When it was saved. An import carries the date the other client
// recorded; anything saved here is saved now.
favoriteDate: video.favoriteDate || new Date().toISOString()
// No `meta` field: persisting resolved formats would freeze their
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
// favorite look like a fresh, unresolved listing item again, so
@@ -63,10 +125,90 @@ App.favorites = App.favorites || {};
};
};
// Identity across sources. Favorites added here are keyed by the server's
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
// keys videos by a hash of its own). Comparing normalized URLs is what
// stops the same video being listed twice under two different keys.
App.favorites.urlKey = function(url) {
const raw = String(url || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw, window.location.href);
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname.replace(/\/+$/, '');
return `${host}${path}${parsed.search}`;
} catch (err) {
return raw.toLowerCase();
}
};
// Adds favorites from an import, skipping any this client already has.
// Existing entries are left exactly as they are -- they carry the server id
// that makes a listing card's heart light up, which an imported entry has
// no way to know -- and new ones are appended after them.
App.favorites.mergeImported = function(entries) {
const incoming = Array.isArray(entries) ? entries : [];
const favorites = App.favorites.getAll();
const keys = new Set();
const urls = new Set();
favorites.forEach((item) => {
if (!item) return;
if (item.key) keys.add(item.key);
const urlKey = App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
let added = 0;
let skipped = 0;
incoming.forEach((entry) => {
if (!entry || !entry.key) return;
const urlKey = App.favorites.urlKey(entry.url);
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
skipped++;
return;
}
keys.add(entry.key);
if (urlKey) urls.add(urlKey);
favorites.push(entry);
added++;
});
if (added) {
App.favorites.setAll(favorites);
App.favorites.renderBar();
App.favorites.syncButtons();
}
return { added, skipped, total: favorites.length };
};
App.favorites.getSet = function() {
return new Set(App.favorites.getAll().map((item) => item.key));
};
// Same set, addressed by URL. Imported favorites are keyed by URL rather
// than by a server id, so a listing card can only recognise one this way.
App.favorites.getUrlSet = function() {
const urls = new Set();
App.favorites.getAll().forEach((item) => {
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
return urls;
};
// Is this video already a favorite, whichever way it got saved? Checked by
// key first, then by URL, so a card and an imported entry for the same
// video are recognised as one thing.
App.favorites.indexOfEntry = function(favorites, video) {
const key = App.favorites.getKey(video);
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
if (byKey >= 0) return byKey;
const meta = (video && video.meta) || video || {};
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
if (!urlKey) return -1;
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
};
App.favorites.isVisible = function() {
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
};
@@ -85,10 +227,12 @@ App.favorites = App.favorites || {};
App.favorites.syncButtons = function() {
const favoritesSet = App.favorites.getSet();
const favoriteUrls = App.favorites.getUrlSet();
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
const key = button.dataset.favKey;
if (!key) return;
App.favorites.setButtonState(button, favoritesSet.has(key));
const urlKey = App.favorites.urlKey(button.dataset.favUrl);
if (!key && !urlKey) return;
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
});
};
@@ -96,7 +240,9 @@ App.favorites = App.favorites || {};
const key = App.favorites.getKey(video);
if (!key) return;
const favorites = App.favorites.getAll();
const existingIndex = favorites.findIndex((item) => item.key === key);
// By key or by URL: unfavoriting a card whose video came in from a
// backup must remove that entry, not add a second one beside it.
const existingIndex = App.favorites.indexOfEntry(favorites, video);
const becameFavorite = existingIndex < 0;
if (existingIndex >= 0) {
favorites.splice(existingIndex, 1);
@@ -118,18 +264,117 @@ App.favorites = App.favorites || {};
}
};
// The bar is a horizontal strip, and a long favorites list is hundreds of
// cards. Only a screenful or so is built up front; the rest arrives as the
// strip is scrolled, which keeps opening the app cheap no matter how many
// favorites are saved (an import can add hundreds at once).
const BAR_PAGE_SIZE = 24;
// How close to the right end the strip has to get before the next page is
// appended -- roughly a screen's worth of cards ahead of the reader.
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');
const empty = document.getElementById('favorites-empty');
if (!bar || !list) return;
const favorites = App.favorites.getAll();
const visible = App.favorites.isVisible();
bar.style.display = visible ? 'block' : 'none';
const favorites = App.favorites.sorted();
// While the favorites grid is open the bar is kept mounted even if it's
// switched off in settings: its header is what leads back out.
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
list.innerHTML = "";
favorites.forEach((item) => {
barPage.items = favorites;
barPage.rendered = 0;
// While the favorites grid is open the strip is hidden -- the grid is
// the same list, larger -- so don't build cards nobody can see. The
// header stays, because it carries the way back out.
if (!browsing) appendBarPage(list);
// 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);
};
if (empty) {
empty.style.display = favorites.length > 0 ? 'none' : 'block';
}
};
function appendBarPage(list) {
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
barPage.rendered += slice.length;
slice.forEach((item) => {
const card = document.createElement('div');
card.className = 'favorite-card';
card.dataset.favKey = item.key;
@@ -140,14 +385,14 @@ App.favorites = App.favorites || {};
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<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>
@@ -155,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;
@@ -191,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) {
@@ -213,11 +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);
});
if (empty) {
empty.style.display = favorites.length > 0 ? 'none' : 'block';
}
};
// Widths only exist once the cards are laid out.
requestAnimationFrame(() => measureBarTitles(list));
}
})();

View File

@@ -0,0 +1,110 @@
window.App = window.App || {};
App.favoritesView = App.favoritesView || {};
// Browsing favorites as a full grid, the same way the channel listing is
// browsed: same cards, same virtualized masonry, same infinite scroll, same
// reels mode. The only difference is where the pages come from -- localStorage
// instead of the server -- so App.videos.loadVideos routes here while this view
// is active and every page hands its slice to App.videos.renderVideos.
(function() {
const state = App.state;
// A page of a local list can be bigger than a page from the server: there's
// no request behind it, only the cost of building cards, which the
// virtualizer already keeps to what's on screen.
const PAGE_SIZE = 24;
const view = {
active: false,
queue: [], // the sorted favorites still to be handed to the grid
offset: 0
};
// A favorite as the grid expects a video: `id` has to be unique per card
// (the virtualizer and renderedVideoIds key on it), and an imported
// favorite has no server id -- its key, which is the URL, stands in.
const toVideo = function(entry) {
return Object.assign({}, entry, { id: entry.key, tags: [] });
};
App.favoritesView.isActive = function() {
return view.active;
};
App.favoritesView.loadNext = function() {
if (!view.active) return false;
const slice = view.queue.slice(view.offset, view.offset + PAGE_SIZE);
view.offset += slice.length;
state.hasNextPage = view.offset < view.queue.length;
if (!slice.length) {
App.videos.updateLoadMoreState();
return false;
}
App.videos.renderVideos({ items: slice.map(toVideo) });
App.videos.updateLoadMoreState();
return true;
};
// Starts (or restarts, after a sort change) the favorites grid.
App.favoritesView.open = function(options) {
const sort = (options && options.sort) || App.favorites.getSort();
const favorites = App.favorites.sorted(sort);
if (!favorites.length) {
App.ui.showError('No favorites yet. Tap the heart on a video to save one.');
return false;
}
App.videos.resetGrid();
view.active = true;
view.queue = favorites;
view.offset = 0;
state.hasNextPage = true;
document.body.classList.add('favorites-view-open');
// Re-render the bar so it re-decides whether to be mounted: hidden in
// settings or not, its header has to be on screen now, since that's
// where the way back out lives (the palette can open this view too).
App.favorites.renderBar();
App.favoritesView.syncControls();
App.favoritesView.loadNext();
window.scrollTo({ top: 0, behavior: 'auto' });
return true;
};
App.favoritesView.close = function(options) {
if (!view.active) return;
view.active = false;
view.queue = [];
view.offset = 0;
document.body.classList.remove('favorites-view-open');
// Back to whatever the settings say, and with its cards built again.
App.favorites.renderBar();
App.favoritesView.syncControls();
// Back to the channel listing, unless the caller is about to load
// something itself (a search, a channel switch).
if (!(options && options.silent)) App.videos.resetAndReload();
};
App.favoritesView.toggle = function() {
if (view.active) App.favoritesView.close();
else App.favoritesView.open();
};
// Re-pages the grid under a new order, and re-renders the bar so both show
// favorites the same way round.
App.favoritesView.applySort = function(sort) {
App.favorites.setSort(sort);
App.favorites.renderBar();
if (view.active) App.favoritesView.open({ sort });
};
App.favoritesView.syncControls = function() {
const button = document.getElementById('favorites-browse-btn');
if (button) {
button.textContent = view.active ? 'Back to videos' : 'Browse all';
button.setAttribute('aria-pressed', view.active ? 'true' : 'false');
}
const select = document.getElementById('favorites-sort');
if (select && select.value !== App.favorites.getSort()) {
select.value = App.favorites.getSort();
}
};
})();

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) {
@@ -365,7 +350,7 @@ App.feed = App.feed || {};
// What's actually on screen, so the quality menu can tick it.
slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
const isHls = App.videos.classifySource(resolved).isHls;
video.muted = state.feedMuted;
video.preload = 'auto';
@@ -439,10 +424,10 @@ 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}"></button>` : ''}
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
</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);
@@ -484,7 +469,7 @@ App.feed = App.feed || {};
const favBtn = slide.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) {
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey));
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
favBtn.addEventListener('click', (event) => {
event.stopPropagation();
App.favorites.toggle(v);

View File

@@ -0,0 +1,79 @@
window.App = window.App || {};
App.hottubBackup = App.hottubBackup || {};
// Reads a Hot Tub app backup (an exported SQLite database) and turns the videos
// it has flagged as favorites into this client's favorites.
//
// The app and this client don't agree on identity: the app keys a video by a
// hash it computes locally, while the server -- and so this client -- keys it by
// something like "reddit-1rdudss". So an imported favorite is matched to an
// existing one by URL, and carries no id of its own; see App.favorites.mergeImported.
(function() {
// The app stores a comma-separated set here ("favorite", "recent", ...).
// It also keeps a `favoriteDate` on rows it no longer flags -- a leftover
// from unfavoriting -- so the flag, not the date, is what counts.
const FAVORITE_FLAG = 'favorite';
// Only what a favorite needs. Skipping the rest matters: `allFormats` alone
// is kilobytes of resolved-format JSON per row, and it is exactly the kind
// of thing this client must not store -- those URLs are signed and expire
// (see App.favorites.normalize).
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
// The app writes a local-time stamp with no zone ("2025-04-25T20:26:02.144").
// Read it as local time (which is what it was) and keep it as an instant, so
// imported favorites sort against ones saved here. Unparseable or missing
// dates fall back to now rather than to 1970, which would bury them.
const toIsoDate = function(value) {
const parsed = Date.parse(value || '');
return isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
};
const hasFavoriteFlag = function(flags) {
if (!flags) return false;
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
};
// Newest first, matching how favorites are ordered when added by hand.
const byNewest = function(a, b) {
return String(b.favoriteDate || '').localeCompare(String(a.favoriteDate || ''));
};
App.hottubBackup.readFavorites = function(buffer) {
const db = App.sqlite.open(buffer);
if (db.tableNames().indexOf('video_details') < 0) {
throw new Error('This database has no video_details table -- is it a Hot Tub backup?');
}
const rows = db.readTable('video_details', { columns: COLUMNS });
return rows
.filter((row) => row.url && hasFavoriteFlag(row.flags))
.sort(byNewest)
.map((row) => ({
// No id: the app's own is meaningless to this client, and the
// URL is what both sides agree on.
key: row.url,
id: null,
url: row.url,
title: row.title || '',
thumb: row.thumb || '',
channel: '',
uploader: row.uploader || '',
duration: Number(row.duration) || 0,
isLive: false,
favoriteDate: toIsoDate(row.favoriteDate)
}));
};
App.hottubBackup.readFile = function(file) {
return file.arrayBuffer().then((buffer) => App.hottubBackup.readFavorites(buffer));
};
// Reads the file and merges what it finds. Resolves to the merge summary
// ({found, added, skipped, total}) so the caller can report it.
App.hottubBackup.importFile = function(file) {
return App.hottubBackup.readFile(file).then((entries) => {
const result = App.favorites.mergeImported(entries);
return Object.assign({ found: entries.length }, result);
});
};
})();

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];
@@ -734,31 +787,41 @@ App.player = App.player || {};
};
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
const kind = App.videos.classifySource(resolved);
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;
if (!isHls && !entry.direct) {
// 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
// ever requested, so it runs only when neither the URL nor the
// 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 = () => {
@@ -856,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');
}
@@ -955,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();

BIN
frontend/js/sqlite.js Normal file

Binary file not shown.

View File

@@ -22,6 +22,7 @@ App.state = {
App.constants = {
FAVORITES_KEY: 'favorites',
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
FAVORITES_SORT_KEY: 'favoritesSort',
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
};

View File

@@ -66,61 +66,119 @@ App.ui = App.ui || {};
}, 4000);
};
App.ui.showInfo = function(video) {
// 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 appendInfoHeading = function(list, label) {
const heading = document.createElement('div');
heading.className = 'info-section';
heading.textContent = label;
list.appendChild(heading);
};
// 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';
const label = document.createElement('span');
label.className = 'info-label';
label.textContent = key;
let valueNode;
if (value && typeof value === 'object') {
valueNode = document.createElement('pre');
valueNode.className = 'info-json';
valueNode.textContent = JSON.stringify(value, null, 2);
} else {
valueNode = document.createElement('span');
valueNode.className = 'info-value';
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
}
row.appendChild(label);
row.appendChild(valueNode);
list.appendChild(row);
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 data = video && video.meta ? video.meta : video;
const titleText = data && data.title ? data.title : 'Video Info';
if (title) title.textContent = titleText;
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);
let hasRows = false;
if (data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
if (!list) return;
const row = document.createElement('div');
row.className = 'info-row';
if (resolved && typeof resolved === 'object') {
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
rows += appendInfoRows(list, resolved);
}
const label = document.createElement('span');
label.className = 'info-label';
label.textContent = key;
let valueNode;
if (value && typeof value === 'object') {
valueNode = document.createElement('pre');
valueNode.className = 'info-json';
valueNode.textContent = JSON.stringify(value, null, 2);
} else {
valueNode = document.createElement('span');
valueNode.className = 'info-value';
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
}
row.appendChild(label);
row.appendChild(valueNode);
list.appendChild(row);
hasRows = true;
});
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');
};
@@ -616,7 +674,75 @@ App.ui = App.ui || {};
};
// Expose inline handlers + keyboard shortcuts.
// Settings -> Hot Tub Backup: pick an exported database and merge the
// favorites out of it. Bound once (unlike the controls in renderMenu, which
// are re-assigned on every render) because a file input mid-read must not
// have its handler swapped underneath it.
App.ui.bindBackupImport = function() {
const button = document.getElementById('import-favorites-btn');
const input = document.getElementById('import-favorites-file');
const status = document.getElementById('import-favorites-status');
if (!button || !input) return;
const say = (message) => { if (status) status.textContent = message; };
button.addEventListener('click', () => {
// Cleared first so picking the same file twice still fires change.
input.value = '';
input.click();
});
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
button.disabled = true;
say('Reading backup…');
App.hottubBackup.importFile(file).then((result) => {
if (!result.found) {
say('No favorites found in that backup.');
} else if (!result.added) {
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
} else {
const plural = result.added === 1 ? 'favorite' : 'favorites';
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
say(`Imported ${result.added} ${plural}.${already}`);
}
}).catch((err) => {
say('Could not read that file.');
App.ui.showError((err && err.message) || 'Could not read that backup.');
}).then(() => {
button.disabled = false;
});
});
};
// Favorites bar header: the sort order (which applies to the bar and the
// favorites grid alike) and the toggle into that grid.
App.ui.bindFavoritesControls = function() {
const select = document.getElementById('favorites-sort');
const button = document.getElementById('favorites-browse-btn');
if (select && !select.options.length) {
App.favorites.SORTS.forEach((sort) => {
const option = document.createElement('option');
option.value = sort.id;
option.textContent = sort.label;
select.appendChild(option);
});
select.value = App.favorites.getSort();
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
}
if (button) {
button.addEventListener('click', () => App.favoritesView.toggle());
}
App.favoritesView.syncControls();
};
App.ui.bindGlobalHandlers = function() {
App.ui.bindBackupImport();
App.ui.bindFavoritesControls();
window.toggleDrawer = App.ui.toggleDrawer;
window.closeDrawers = App.ui.closeDrawers;
window.handleSearch = App.videos.handleSearch;

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;
@@ -403,6 +665,12 @@ App.videos = App.videos || {};
// button being pressed.
App.videos.loadVideos = async function(opts) {
const force = !!(opts && opts.force);
// The favorites grid pages out of localStorage, not the server, but
// rides the same sentinel and load-more button to get there.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.loadNext();
return;
}
const session = App.storage.getSession();
if (!session || !session.channel) return;
if (loadRunning || state.isLoading) return;
@@ -445,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';
@@ -460,14 +728,14 @@ App.videos = App.videos || {};
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = `
${liveBadge}
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.url || ''}">♡</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<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>
@@ -478,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));
@@ -542,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();
};
}
@@ -559,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;
};
@@ -630,6 +904,17 @@ App.videos = App.videos || {};
clearBtn.disabled = !hasValue;
}
}
// A search is a new result set, so it leaves the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.resetGrid();
App.videos.loadVideos();
};
// Empties the grid back to "nothing loaded yet", without deciding what
// fills it next -- the caller does that.
App.videos.resetGrid = function() {
// The held/in-flight page belongs to the old result set.
App.videos.resetPrefetch();
state.currentPage = 1;
@@ -642,7 +927,6 @@ App.videos = App.videos || {};
App.feed.reset();
}
App.videos.updateLoadMoreState();
App.videos.loadVideos();
};
App.videos.resetAndReload = function() {
@@ -651,18 +935,11 @@ App.videos = App.videos || {};
state.currentLoadController = null;
state.isLoading = false;
}
// The held/in-flight page belongs to the old result set.
App.videos.resetPrefetch();
state.currentPage = 1;
state.hasNextPage = true;
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
// Switching source/channel/filters means leaving the favorites grid.
if (App.favoritesView && App.favoritesView.isActive()) {
App.favoritesView.close({ silent: true });
}
App.videos.updateLoadMoreState();
App.videos.resetGrid();
App.videos.loadVideos();
};
@@ -757,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';
@@ -888,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);
@@ -1274,7 +1551,15 @@ App.videos = App.videos || {};
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
// `protocol` is the extractor's own word for how this format is
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
// the player skip its content-type sniff -- a full round trip
// through the proxy -- for URLs whose extension gives nothing away.
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
});
if (!sources.length) {
@@ -1312,7 +1597,37 @@ App.videos = App.videos || {};
const referer = explicitReferer || deriveReferer(fmt.url);
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
return {
url: fmt.url, referer, userAgent, headers, isLive,
refererRequired: !!explicitReferer,
protocol: fmt.protocol || ''
};
};
// How does this source play -- an HLS manifest, or a media file the <video>
// element can take directly? Answered from the strongest evidence at hand:
// a live stream is always a manifest here, then the extractor's `protocol`,
// then the URL's extension. When none of them says (a signed CDN link with
// no extension and no protocol), the caller is left to sniff the content
// type over the network, which is why `protocol` is worth carrying around.
App.videos.classifySource = function(resolved) {
const url = (resolved && resolved.url) || '';
let isHls = /\.m3u8($|\?)/i.test(url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
if (protocol.indexOf('m3u8') >= 0) {
isHls = true;
isDirectMedia = false;
} else if (protocol === 'https' || protocol === 'http') {
// A plain HTTP(S) download: one file, played as-is. Anything else
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
isDirectMedia = true;
}
if (resolved && resolved.isLive) {
isHls = true;
isDirectMedia = false;
}
return { isHls, isDirectMedia };
};
// Background "direct playability" probe. The backend proxy exists to work
@@ -1354,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;
detail = `HTTP ${res.status}`;
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);
}
@@ -1382,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
@@ -1483,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.

1
media_srv2.log Normal file
View File

@@ -0,0 +1 @@
/bin/bash: line 1: cd: too many arguments