hianime's HD-1 slot rotated to a megaplay chain whose CDN (norami.top)
is Cloudflare-blocked for us, which killed stream resolution (CDN probe
sat at 0 KB/s). The ZokoAnime server still serves the working
zokoanime chain. _master_m3u8 now walks every listed server of the
wanted type; _embed_payload handles both chains (window.__P blob and
megaplay data-id/getSourcesNew with re-roll). stream_meta returns {}
when the host is ours but no referer is needed, so the /stream
allowlist still holds.
hianime's subtitle 'lang' field is useless (everything says 'en'); the
real language is the label, and it is not always first — Chainsaw Man
E1 leads with Arabic. Pick label=='English', fall back to first track.
- hianime.py: capture the first .vtt from the deobfuscated embed JSON
(SUBS), expose subtitles(ep_id)
- upstream.py: subtitles() dispatcher (None for anidb, which bakes subs in)
- app.py: /api/episode/{id}/stream also returns a 'sub' stream token
- app.js: add a native <track> after hls.attachMedia (hls.js _cleanTracks
disables tracks present at attach time — that silently killed earlier
attempts) and set mode=showing so Chrome fetches the cues
- providers/{anidb,anikoto,hianime}.py: per-source modules behind the
ANI_SOURCE fallback dispatch (upstream.py _call/_providers)
- hianime.py: pystardust/ani-cli#1897 flow — search/episode-list/
servers API, HD-1 hash -> base64 embed url, window.__P blob
(base64 + XOR 'otaku-embed-v1') -> master m3u8; registers CDN hosts
and the required Referer via stream_meta()
- upstream.py: get_stream/fetch_parallel accept extra headers;
default source order hianime,anidb,anikoto (anidb.app is 503)
- app.py: /stream allowlist accepts provider-registered hosts and
sends their Referer; status probe follows the primary provider
(hianime variant URIs are relative to the variant path)
- app.js: error-time position save floors at furthest point reached
(hls.js resets currentTime to 0 before playerFail on fatal errors)
- tests: default-order + hianime import checks; live browser suite green
- _origin_probe(): 3-min check of anidb.app pages/API; OK/DOWN + reason
in GET /api/cdn and server.log ([probe] ORIGIN ...)
- header pill: upstream-site dot (red = down) + CDN dot + label;
per-dot hover tooltips, tap -> toast legend (mobile has no hover)
- ?v={{VERSION}} injected from 'git describe --always --dirty' at
startup; JSV reads it from its own script URL - replaces hand-bumped
numbers (JSV/?v drift) and gives play-probes the running commit
README: storage is SQLite (aniweb.db), not JSON files; CDN serves a
single quality (no ABR) — replaced the wrong 1080/720/360 claim;
document the 16-lane parallel fetch, intro-skip, episode names,
buffering UI, /api/cdn + background probe, the ?v= cache-busting rule,
and the client self-report diagnostics.
SPEC: same single-rendition correction (2 places).
PLAN: marked complete (all items shipped; deferred list was stale).
The live timeline caught it: iPhone buffered 513s, readyState stuck at
1, canplay NEVER fired -> play() never called -> eternal Loading.
Now: first BUFFER_APPENDED (hls.js's own 'data is in the pipe' signal)
triggers play, canplay still works, plus a 3s safety retry if still
paused with a non-empty buffer. play() calls are guarded + idempotent.
Desktop + mobile emu regression: playing. assets v=14.
Every step now beacons with millisecond offsets:
branch-decision -> new-hls-ok -> loadsource-called -> attach-ok ->
manifest-loaded -> level-loaded -> frag-loaded(1..2) -> buffer-appended
-> do-play -> play-resolved/play-rejected(name) + EVERY hls.js error
(fatal and silent retries). Plus a watchdog: if not playing within 15s,
it snapshots {readyState, networkState, paused, buffered, hls.state,
overlay text} every 15s (x6) — so a silent death anywhere produces a
state dump instead of nothing.
Verified on desktop: healthy timeline lands end-to-end, playback OK,
18/18 tests. assets v=13.
The play-probe now lands (iPhone: iOS 18.7 Safari, hls.js path, MSE ok),
but the /stream XHR never reaches the server on the failing attempts —
impossible under the known code path. Instrument each step
(new-hls-ok / loadsource-called / attach-ok) and log EVERY hls.js error
(fatal+non-fatal, with retry counter) so the client timeline is visible
server-side. Also fixed JSV constant drift (was stuck at 10).
events endpoint: accept hls-err, forward step/fatal/details/buffer/retry/http.
The iPhone error 'Playback failed: FragLoadTimeout' + 74 all-200
segment requests = the phone's downlink can't deliver a ~700KB
segment within the 10s cap; hls.js measured the load client-side
while the server finished instantly. Raise maxLoadTimeMs to 30s
(first-byte 20s) so slow-but-alive phone links complete; retries
still bound the truly-dead case. assets v=11.
The diagnostic beacons were rejected server-side (only 'error' was
whitelisted) — which is why the iPhone's fingerprint never arrived.
Now: error / client-fail / play-probe accepted, payload fields
forwarded; anything else still 422.
iPhone stays on 'Loading…' with zero /stream requests and no error
event — impossible under any known code path, so instrument the
branch decision itself: which branch (hlsjs/native/none-throw),
hls.js presence, MSE support, native-HLS capability, full UA.
showStreamError + playerFail now POST the failing browser's UA,
hls.js presence, MSE support and native-HLS capability to the
events table — a broken mobile browser becomes diagnosable from
the server side instead of by guessing.
Audited on iPhone SE (320px), iPhone 13, Pixel 5 emulations:
- FOUND: login/search inputs at 13.3px (< 16px) -> iOS Safari
auto-zooms the page on focus. Fixed: 16px in style.css AND
login.html (separate page with own CSS).
- FOUND: no touch-action anywhere -> double-tap-zoom tap delay on
buttons. Fixed: touch-action: manipulation on interactive elements
in both stylesheets.
- FOUND: .pl-spin missing from prefers-reduced-motion. Added.
- VERIFIED CLEAN: no horizontal overflow at 320/390/393px, controls
row fits, chip+gauge in viewport, episode grid 66x31 ok,
Tap-to-play gesture flow works on BOTH hls.js and native paths
(simulated iOS autoplay block), toast fits, zero JS errors,
desktop playback regression OK, 18/18 API tests.
assets v=7; login.html is no-store so updates without a bump.
Root cause of 'eternal spinner': a browser without working hls.js
(no MSE, or hls.min.js blocked) threw 'HLS not supported' which the
old showStreamError hid UNDER the spinner overlay, and there was no
fallback — so the player sat on Loading… forever with no /stream
request ever leaving the browser (matched the prod log exactly).
- no-hls.js + canPlayType(mpegurl) -> v.src = proxied m3u8, native
<video> plays it (iOS < 17 path). Verified: readyState 4, playing,
no hls.js at all.
- no-hls.js + no native HLS -> VISIBLE 'Stream error: ...'
overlay with Retry (was: hidden under the spinner)
- every play() catch routes through the visible overlay now
Desktop hls.js regression: still plays. assets v=6.
- Loading/Buffering overlays now show a rotating ring, not just text
- thin gauge under the video: how much is buffered ahead (0-30s scale),
fills while loading, drains as it plays — you can see a stall building
- corner chip: live CDN KB/s from /api/cdn, green dot OK / amber degraded,
refreshed on every stall — explains WHY it's buffering
Verified in-browser (CDN-independent): chip reflects live state (0 KB/s
degraded), gauge fills from video.buffered, stall overlay + ring,
playing clears. 18/18 API tests. assets v=5.
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.
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.
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).
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.
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.
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.
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.
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.
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
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.
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.
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
- 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)
- 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
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.
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.
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.
- 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 ->')
- 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)
- 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
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.
- 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
- 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
- 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
- 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)
- 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