Commit Graph
68 Commits
Author SHA1 Message Date
jozamudi a61e7dd0e7 GET /api/cdn: probe status endpoint (public, read-only)
Latest state + kbps + ~1h history (20 samples). curl it to check the
CDN without digging through server.log.
2026-08-31 15:23:36 +00:00
jozamudi eea538ab17 Background CDN probe: log throughput + state flips every 3 min
64KB Range fetch from a fixed episode (JJK S2 E25) on a daemon thread;
logs '[probe] CDN OK/DEGRADED (N KB/s)' on state change so server.log
shows exactly when a degraded window ends. 18/18 tests, verified live.
2026-08-31 15:17:20 +00:00
jozamudi 4c5dbab4b0 Proxy: 16 parallel lanes (was 4), like yt-dlp -N 16
Lane floor dropped 256KB -> 32KB so typical 0.35-2.2MB segments get the
full 16 lanes. Live check: 16 lanes used, 719KB in 0.55s, sha256
identical. Same fallback guarantees as before.
2026-08-31 15:00:21 +00:00
jozamudi ee2a888aca Proxy: fetch segments across 4 parallel byte-range lanes
The slow-segment logs show per-connection CDN throttling (15-40 KB/s
dribbles). yt-dlp's standard fix is -N 16 parallel fragment lanes; this
is the 4-lane version for the proxy: HEAD for content-length, then 4
concurrent Range GETs reassembled byte-exact (verified sha256-identical
to a direct CDN fetch). Small files (<512KB) still use a single GET.

ANY failure (no content-length, 416, lane error, timeout) falls back to
the existing single-stream path, so this is never worse than before.

Caveat (measured live): when the CDN caps SHARED egress rather than
per-connection, lanes just split the same pie — in that window both
1-lane and 4-lane time out. A different source is the only real fix
for those windows.

18/18 tests (new test pins lane stitch + fallback; also fixed a
pre-existing _get stub leak in the Jikan-shape test).
2026-08-31 14:51:55 +00:00
jozamudi 2576546b43 hls.js: drop segment load cap 30s -> 10s
Dribbling CDN segments (logged at 17.8s/59.7s) made the player stare
at 'Buffering…' for half a minute per attempt. Cap total load at 10s
so a retry kicks in sooner (timeoutRetry still 6x, backoff 1-20s);
fast segments (0.1-0.2s) are unaffected.
2026-08-31 14:10:11 +00:00
jozamudi 07554e4aa8 Playback page: show 'E{n} — episode title' line under the video
The name was only in the grid tooltip (hover-only, invisible on touch).
Add a persistent now-playing line between the player and controls:
'E1 — Ryomen Sukuna'. setNowEp() runs on every play() and refreshes
when the async Jikan name lands late. Bumped asset version to v=3.
2026-08-31 02:05:00 +00:00
jozamudi 8840a883c0 Cache-bust app.js/style.css from index.html (?v=2)
index.html is no-store (always fresh) but app.js/style.css are cached
max-age=300 in the browser — and possibly in the HTTPS proxy — so users
kept seeing the pre-fix UI (episode names in buttons) long after the
removal shipped. A versioned URL from the always-fresh index.html
bypasses both caches. Bump ?v= on every app.js/style.css edit.
2026-08-31 01:58:55 +00:00
jozamudi aeab72162e Episode grid: move names out of the button into the tooltip
The visible names made the grid uneven (long titles wrapped to 2-3
lines). Buttons now show only the E-number; the full name lives in
the title attribute (hover tooltip: 'E1 — Ryomen Sukuna'). Dropped
the now-unused .ep-name CSS.
2026-08-31 01:55:10 +00:00
jozamudi 343628a2e3 Episode names: render MAL titles on the episode grid
anidb's episodes API has no names (id/number/filler only), but the
Jikan call we already make for intro timings carries them (title +
title_japanese). _parse_intros now keeps one entry per episode with
title and optional start/length (entries are never null anymore).

Client: attachEpNames() reuses the introCache promise, stamps
epsList[i].name, re-renders; buttons show the name (3-line clamp) +
full tooltip. Jikan down => no names, no error (same graceful
degradation as the skip-intro button).

Verified headless: Jujutsu Kaisen S1 24/24 buttons named
('E1 | Ryomen Sukuna' ...); S2 (Jikan 504 at the time) rendered
gracefully without names. 17/17 API tests.
2026-08-31 01:52:38 +00:00
jozamudi d60524815f hls.js: put retry budget where 1.5.x actually reads it
maxNumRetry was set at the top of the load policy, which hls.js 1.5.x
ignores — it reads the budget from the errorRetry/timeoutRetry
sub-objects. Net effect before: a fragment-load *timeout* (a CDN that
starts sending then stalls) got only the default 1 retry before fatal
fragLoadTimeOut.

Now (verified resolved in-browser):
  maxTimeToFirstByteMs 15s   first byte
  maxLoadTimeMs        30s   total load (finite -> stalls time out)
  timeoutRetry.maxNumRetry 6  was 1
  errorRetry.maxNumRetry   8  our proxy's 502 path
2026-08-31 01:22:22 +00:00
jozamudi 83a2953a1c Proxy: log slow segment transfers (TTFB>3s or total>8s)
Distinguishes CDN-side stalls (server sees slow TTFB) from client-path
stalls (server fast, user still times out) when fragLoadTimeOut recurs.
2026-08-31 01:16:38 +00:00
jozamudi 19bc2afb32 Proxy: stream HLS segments through instead of buffering
fragLoadTimeOut root cause: /stream buffered the whole segment
(r.content) before sending, so the browser's time-to-first-byte = the
CDN's entire upload time. On slow CDN moments that trips hls.js's
first-byte timer even though the segment would have completed.

- upstream.get_stream(): opens the upstream response without reading
  the body (stream=True), same retry/timeout rules as _get
- segments now flow to the browser as the CDN emits them (256KB chunks);
  the 12s cap becomes a total-transfer cap; cache is populated on
  completion only (a truncated body never poisons the LRU)
- playlists keep the buffered path (body needed for URI rewriting)
- cache-hit fast path restored for both (stream() checks the LRU first)

Verified: segment byte-identical to direct CDN (sha256), sequential
keep-alive 3/3 OK, 5 concurrent fetches 200+identical, cache hit
0.148s miss -> 0.001s hit, 17/17 API tests green.

Skipped: raising hls.js retry counts — the first-byte race was the
binding constraint, not the retry budget.
2026-08-31 01:05:00 +00:00
jozamudi 27de918cb0 Intro skip: parse Jikan v4's actual response shape
Jikan /anime/{id}/episodes returns the episode list DIRECTLY under
'data' (flat array), not {'episodes': [...]}. The old code called
.get() on that list -> AttributeError, swallowed by the endpoint's
except -> [] -> the skip button could never appear, even with Jikan
up. Accept both shapes now.

Verified end-to-end with a mocked /api/intros response: the button is
wired into the player, visible exactly while currentTime is inside the
intro window (111-188s test), hidden outside it.

The live button still awaits Jikan/MAL recovery (504ing since this
feature landed); failures are not cached, so it activates on the first
successful fetch.

Skipped: MAL-direct fallback — official API needs a client key
(403 without), and the keyless HTML page carries no intro data.
2026-08-31 00:24:37 +00:00
jozamudi eb5082bd3b Player: defer play() to canplay; friendly tap-to-play fallback
Root cause of the spurious 'Browser blocked playback' on initial play:
play() was called right after hls.js attachMedia, before any data was
buffered. Chrome rejects such early play() calls with NotAllowedError —
indistinguishable from a real autoplay block — so the scary
'allow autoplay in site settings' overlay appeared on every start.

- wait for first data (canplay; hls.js's recommended pattern), then play
- a NotAllowedError at that point is a genuine policy block (common on
  phones): show a simple 'Tap to play' + Play button
- escalate to the 'allow autoplay' settings hint only if the tap is
  itself rejected
- dead-stream case is unaffected: hls.js fatal error still shows its
  own 'Playback failed + Retry' overlay
- also aligns with test_autoplay_blocked's expected 'Tap to play' text
2026-08-30 21:20:39 +00:00
jozamudi 0613cf7e27 Intro skip: MAL timings via keyless Jikan + player button
- upstream: _intros(mal_id) -> per-episode {start,length} (seconds),
  30d TTL cache; Jikan is down or show uncovered -> [] (no button)
- app: GET /api/intros/{mal_id}, never errors into the player
- player: 'skip intro' pill appears while currentTime is inside the
  intro window (+8s grace); click seeks past; reappears if rewound.
  Ep-1 intros are often missing in MAL data, so falls back to the
  show's first known timing (usually identical across eps)
- test: _parse_intros unit test (Jikan itself is 504ing right now,
  live path verified by hand: endpoint returns [] cleanly)
2026-08-30 21:01:24 +00:00
jozamudi c2461f8fba UI/UX polish: a11y, player controls, undo, stats ranges
- toasts: aria-live region + action button (used for history delete Undo)
- history cards: keyboard-reachable title link; delete = remove + Undo
  (re-POSTs the entry; no full list re-render)
- fail(): home/retry affordances instead of a dead-end error line
- per-view document.title + move focus to the new view on render
- player controls: episode jump (E#), auto-next toggle (persisted),
  picture-in-picture (when supported)
- auto-next off: 'Next: En' toast with Play instead of the countdown
- no-results search + empty home: recent-search chips (shared helper)
- stats: 30d/90d/1y period chips (api already supported days=N)
- search input: autocapitalize/spellcheck off; reduced-motion disables
  shimmer/toast animation; username in the menu on narrow screens
2026-08-30 20:53:55 +00:00
jozamudi 8f519b0251 hls.js: outlast CDN segment stalls (TTFT 5s->15s, 8 retries)
The upstream CDN intermittently serves 0 bytes on segments for 10s+
(fragLoadTimeOut errors 17:58-18:14 and 19:16-19:18 today). hls.js's
default 5s time-to-first-byte abort dropped the XHR before the proxy's
12s upstream attempt + 300ms retry could answer with its 502, so the
proxy-side retry never got a chance and stalls went straight to fatal.
fragLoadPolicy: maxTimeToFirstByteMs 15000 (> proxy's 12s timeout),
maxNumRetry 8 — slow-but-alive segments now finish, and stall tolerance
rises to several minutes.
2026-08-30 19:25:49 +00:00
jozamudi e361f0ba0e Autoplay: playsinline + non-fatal tap-to-play fallback
videoEl() lacked playsinline: on iOS Safari (and some Android browsers)
v.play() then rejects with NotAllowedError even on the first,
gesture-backed attempt.

A tap on the 'Tap to play' overlay that is STILL rejected (e.g. the user
has autoplay disallowed for the site) no longer goes to the fatal
'Playback failed: playback blocked by the browser' screen whose Retry
just re-ran play() into the same wall. It stays on the tappable overlay
with a hint to allow autoplay in the site settings.
2026-08-30 19:19:56 +00:00
jozamudi 71a4055e89 Save watch position on fatal player error; make failure test CDN-independent
playerFail() is the single choke point for every fatal error (hls.js fatal,
v.onerror, autoplay block) but never saved the position — periodic saves
(timeupdate) stop when hls is destroyed, so a fatal error at e.g. 40s lost
everything after the last 10s save. Call savePos() there.

test_stream_failure_retry redesigned around that: it no longer needs a
healthy CDN (which hangs on segment bytes intermittently). It waits only for
playlist metadata (duration comes from the VOD playlist, not segment bytes),
wipes the show's history, seeks to 6s (below the 10s periodic-save threshold,
above the 3s minimum — so only an error-time save could persist it), aborts
/stream to force a deterministic fatal, and asserts the position landed.
Retry-recovery now asserts a fresh token fetch instead of segment playback.
2026-08-30 18:48:52 +00:00
jozamudi 0fd06f036a Playback: retry flaky upstream, faster proxy fail, cache /stream in browser
- upstream._get: one 300ms retry on transient errors (CF 403/503, 429,
  5xx); 404 stays final. Fixes the 502 cluster that hls.js couldn't
  recover from (fragLoadTimeOut x3 in error log)
- /stream: upstream timeout 12s < hls.js fragLoadingTimeOut (20s), so a
  hung segment 502s first and hls.js retries instead of fatal-timing-out
- /stream responses: Cache-Control public max-age=86400 (VOD + token is
  the exact upstream URL => immutable); re-opens/re-seeks skip the proxy
- /api/stream: drop unused qualities (frontend never read it; saved a
  ~0.2s cold upstream round-trip per episode)
- app.js/style.css: max-age=300 instead of no-store
- test_history_page_and_delete: stale selector (home redesign removed
  the 'view all' link; use 'history ->')
2026-08-30 17:23:57 +00:00
jozamudi 36be7d0a02 Stats: fix month-label placement, back to orange
- labels now sit on the week CONTAINING the 1st of the month (old
  logic labelled the first column starting in that month, so a month
  beginning mid-week was labelled a whole column late — hovering the
  cells under 'Aug' showed Jul dates); skip labels that would crowd
  the previous one
- label + grid rows are inline-flex so flexbox shrink can't drift
  them apart at narrow widths
- cell scale back to the orange accent (color-mix of --acc)
2026-08-26 19:18:39 +00:00
jozamudi 0afd27d4d2 Stats: match GitHub's contribution-graph look
- GitHub's green scale (#0e4429 -> #39d353) instead of the orange
  accent-mix
- Mon/Wed/Fri row labels down the left (right-aligned, muted),
  month-label row offset to line up with the grid
- legend right-aligned under the grid
2026-08-26 19:14:00 +00:00
jozamudi 20a90a587e Stats: fix month-label alignment on the heatmap
Label slots were 14px wide + 3px gap (17px pitch) vs grid columns
11px + 3px gap (14px pitch), so labels drifted up to ~150px right of
their column by the last month. Label slots are now exactly cell
width with min-width: 0 so the text overflows instead of widening
the slot.
2026-08-26 19:09:17 +00:00
jozamudi 7474d353cd run.sh: actually restart the server
- pkill TERM alone never worked: uvicorn's graceful shutdown waits for
  in-flight /stream requests (minutes long), so the old process held
  the port and each new one died with EADDRINUSE — meaning the live
  server was running days-old code while the static files (read from
  disk per request) were current
- SIGKILL fallback after the 1s grace, and --timeout-graceful-shutdown
  3 so TERM is reliable on its own going forward
- this is why the new heatmap rendered an empty grid: the stale
  process served /api/stats without by_day
2026-08-26 18:36:52 +00:00
jozamudi 27c3783f77 Home: move heatmap above 'Recently finished' 2026-08-25 21:18:13 +00:00
jozamudi 2f68218a8f Home: declutter (keep hero, board, heatmap)
- drop the history widget: the status board already groups the same
  shows by state; full list stays at ?history
- 6 stat cards + top-3 -> one muted line (watch time, eps, active
  days, streak, on hold) with 'see all ->' to ?stats
- section headers now small uppercase muted labels so the hero is
  the only loud element (page h2s unaffected)
- heatmap sits in a rounded card panel
2026-08-25 21:13:32 +00:00
jozamudi 59df3c37c8 Home: watch-time heatmap in the stats area
- extract the 365d fetch+render into heatSection() so ?stats and the
  home stats widget share it (no duplicated async loader)
- home order: stats cards + top 3 -> watch-time heatmap (past year,
  month labels, scrollable) -> history widget
2026-08-25 21:10:22 +00:00
jozamudi df8307bf49 Stats: heatmap to GitHub scale (365 days + month labels)
- heatmap now fetches /api/stats?days=365 (endpoint already caps at
  365; no server change) so the grid spans the past year
- week columns with month labels (Aug, Sep, ...), first column
  labelled by its first in-window day, final column padded
- grid wrapped in a horizontal-scroll container (53 weeks ~ 750px)
2026-08-25 19:52:10 +00:00
jozamudi 7b670cff24 Stats: GitHub-style watch-time heatmap
- api/stats gains by_day: local-date -> seconds (same watch-time
  heuristic as by_weekday)
- ?stats renders a Mon..Sun x week grid (30d window, matches the
  page header) with 4 intensity levels + tooltip per cell + legend;
  replaces the by-weekday bar chart the heatmap subsumes
- test_stats covers by_day (sums, slug-scoped, empty case)
2026-08-24 22:24:16 +00:00
jozamudi 7d9fab6bd9 Home: stats + history widgets inline
- extract shared renderers: statsCards(), topAnime(d, n), histBoard(items, limit)
- home now embeds a compact stats widget (summary cards + top 3 most
  watched, async with loading placeholder) and a history widget
  (filter chips + first 6 cards, data already in memory)
- full ?stats and ?history pages remain as drill-downs, now built on
  the same renderers; showStats also loses a duplicated header it
  rendered twice around the await
2026-08-24 22:18:23 +00:00
jozamudi bb825a3acb Home: status board instead of a feed
- replace 'Recently played' with one card per anime grouped by state:
  Continue watching (<=6, progress + ~Xm left), On hold (<=4, dimmed,
  days ago), Recently finished (<=6, check)
- hero button says 'Next ep' when the last ep is >=95% watched
- empty home shows recent searches as clickable chips (race-guarded:
  only appended while the empty state is still on screen)
- helpers: byShow (latest row per anime), homeCard, showSec, link
2026-08-24 20:09:39 +00:00
jozamudi 1de58629a0 UX: remember sub/dub + filler toggle, hero time-left, next-ep CTA,
history filter chips, per-ep progress bars, stats in user menu

- sub/dub choice persists per anime (localStorage aniweb_lang_<slug>),
  so search/reload no longer drops dub watchers back to sub
- 'hide fillers' toggle persists (aniweb_ff)
- continue-watching hero shows '~Xm left' when position+duration known
- detail page: prominent 'next episode' button when the resumed ep is
  finished (auto-removed on play)
- ?history: all / watching / completed / on-hold filter chips (client-side)
- ep list: thin progress bar on partially-watched episodes
- stats page link moved into the user menu (always reachable)
- search results show a count line
2026-08-24 17:38:11 +00:00
jozamudi 1be3f4f307 Stats UI: per-anime progress rows, status chips, on-hold, streak, detail line
- server: /api/stats top_anime rows enriched — poster, total eps, cur_ep,
  last_seen, status (finished/ongoing/hold heuristic), pace (ep/day),
  sub/dub watch-time split, rewatches (backward jump >60s); new on_hold
  + best_streak top-level keys; optional ?slug= filter (json_extract)
- frontend: 'Most watched' rows = poster + meta + status chip + completion
  bar (ongoing -> hold -> finished order); stat cards swap in on-hold and
  best streak, errors demoted to a one-liner; anime detail page gets a
  progress line (E12/24 · 4h 30m · 2.1 ep/day · status); history cards
  dim + label 'on hold' after 7 days; savePos now includes total eps
- css: .chip, .stat-anime rows, .card.hold, .detail-prog; drop dead .stat-row
- test: test_stats asserts the new fields + slug filter
2026-08-24 17:30:01 +00:00
jozamudi 2f373b9bba Add per-user stats: play/error events + /api/stats + ?stats page
- server: 'play' event on successful stream fetch, 'error' on 404 stream
  failures; new POST /api/events for client-reported playback errors
  (only type=error accepted, fields allowlisted + length-capped)
- server: GET /api/stats?days=N — watch time from position-event pos
  deltas (<=90s cap: bigger jumps are seeks), episodes started/completed
  (>=95% of dur), days active, weekday histogram, top anime/searches,
  sub/dub split, error count
- frontend: ?stats page (cards, weekday bars, most-watched, top
  searches) + 'stats' link on home; playerFail now reports the error
- test: test_stats with synthetic viewing data (deltas, isolation, 422)
2026-08-24 17:09:27 +00:00
jozamudi 202bfa41c3 storage: JSON files -> SQLite (users/history/searches/events)
- one-time import from the legacy JSON (left in place as snapshot);
  ghost rows (int vs str ep_id) deduped on import, unrepresentable after
- history POST is now an upsert: the (user, slug_id, ep_id) PK is the
  merge key, so the unguarded read-modify-write (lost updates) and the
  corruption-swallow (JSONDecodeError -> [] -> fresh overwrite) are gone
- searches: NOCASE PK upsert + cap 8
- events table + position/search logging (watch-time trajectory),
  90-day retention pruned at boot
- account delete cascades all four tables
- 22/22 tests green; old JSON files untouched
2026-08-24 16:02:27 +00:00
jozamudi 2c0ee8eae3 ui: center player overlay buttons (scope hero's align-self to .hero-body) 2026-08-24 16:02:27 +00:00
jozamudi 3f8c387a48 fix: only surface tap-to-play on NotAllowedError (autoplay block)
The first version reacted to any play() rejection, which broke the
stream-failure path: with a dead stream play() rejects with AbortError
(no data), clobbering the hls.js 'Playback failed / Retry' overlay and
stealing the user's one click. Scope the swap to the autoplay policy
rejection; everything else stays with the fatal-error path.

test_stream_failure_retry caught it; test_autoplay_blocked_shows_tap_to_play
still passes (simulates NotAllowedError on the first gesture-less play).
2026-08-24 02:54:40 +00:00
jozamudi db715be54b fix: blocked autoplay left 'Loading…' parked over unreachable controls
The overlay only clears on 'playing', which never fires when the browser
blocks autoplay — and the overlay covers the native controls, so the
'swap to the native play button' assumption from the click-to-play
removal never held. A rejected v.play() is the signal: swap the dead
Loading… overlay for a tappable one (Play), and a second rejection
escalates to the Playback failed overlay.

Browser test fakes the policy (reject first gesture-less play()) since
headless Chromium allows autoplay. Logs showed the shape of it: six
loads of the same ep, 38-40 segments each, all 200, player never started.
2026-08-24 02:51:05 +00:00
jozamudi aae5857dba harden: stream-token host allowlist (SSRF), CSRF header on mutating routes, atomic JSON writes
- /stream/{token}: tokens are plain base64; allowlist https://hls.anidb.app
  so a session can't use the proxy for arbitrary URLs (new test)
- X-Aniweb:1 required on POST/DELETE except /login — SameSite=Lax is
  still sent on cross-site form POSTs, which could hit /account/delete
  (new test; SPA + tests send the header)
- tmp+os.replace writes for users/history/searches: a crash mid-write
  could corrupt the JSON -> load() returned {} -> silent data loss
- resume positions now read from full history (?all=1), not top-5
- pagehide bound once; savePos reads currentEp (no stale closures)
- account delete also removes the searches file; episodes TTL 5m
- ani-web.service: supervised restart (not installed; see header)
- gitignore pi-session dumps + .pytest_cache
2026-08-23 23:22:09 +00:00
jozamudi d39ee838ff api: dedup ghost history rows (int vs string ep_id); normalize ep_id on write 2026-08-23 21:29:01 +00:00
jozamudi 8f57593002 ui: remove click-to-play overlay; native play button covers autoplay-block 2026-08-23 21:29:01 +00:00
jozamudi 2752d49411 fix: "view all" link pointed at /history path (404); use ?history query 2026-08-23 21:28:24 +00:00
jozamudi 50d30a8d3f ui: default to sub (jpn) instead of dub for playback 2026-08-23 21:28:24 +00:00
jozamudi d6bf7e149b docs: README feature walkthrough with screenshots; css: .active wins over .nextup 2026-08-19 20:23:39 +00:00
jozamudi 29fab7ff5f tests: refactor to pytest (conftest session fixture, sync wrappers for async, importorskip for playwright) 2026-08-19 18:59:48 +00:00
jozamudi bb1297f8e2 ui: watched-episode state (dimmed + check, next-up highlight), favicon 2026-08-19 18:49:29 +00:00
jozamudi 98fb4e5461 features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches 2026-08-19 18:41:13 +00:00
jozamudi 9fdf3944fc ui: mobile polish — compact hero row, cropped history posters, wider search, tab ellipsis, header z-index 2026-08-19 18:26:22 +00:00
jozamudi 2ec0f8a034 ui: loading states, player failure overlay + retry, toasts, back nav, full history page with delete
Bundle 1-4+7 from PLAN.md: skeleton loaders, player Loading/Buffering/fatal overlay with Retry, action toasts, back-to-home crumb + logo fix, ?history full-history view with per-entry delete (GET /api/history?all=1, DELETE /api/history). 14/14 tests green.
2026-08-19 18:20:42 +00:00
jozamudi 19e878800c perf: keep-alive upstream sessions, TTL cache on upstream data, /stream LRU, cache hls.min.js
- shared thread-local curl_cffi sessions (no TLS handshake per call)
- in-memory TTL cache: search 5min, details/episodes/languages/
  playlist-URLs 1h; api_stream retries once with a cold cache on 502
  (recovers rotated playlist URLs)
- /stream LRU (24 entries) serves re-seeks and playlist re-fetches from memory
- hls.min.js: Cache-Control public max-age=30d (was no-store -> 500KB
  re-download per page load on mobile)

Measured (click->play chain, cold -> warm):
  search    0.45s -> 0.00s
  detail    0.46s -> 0.00s
  episodes  0.40s -> 0.00s
  stream    1.06s -> 0.00s
  m3u8 x2   0.64s -> 0.00s
  segment   0.20s -> 0.00s
2026-08-19 17:46:17 +00:00