Provider switch (anidb -> hianime-first) left anidb-era rows whose slugs
200 on hianime as a DIFFERENT anime: /watch/jujutsu-kaisen-2552 is now
Burning Kabaddi (the trailing number is a hianime id), and the stored
anidb ep id doesn't even exist in that anime's list — the row rendered
as watched JJK but linked to the wrong show.
Client-side validation (no data migration): every history row's slug is
checked against GET /api/anime/{slug} once per slug; title mismatch (or
a dead slug) marks the entry stale. Stale rows:
- home: one card per stale anime above the hero + 'history ->' link
- history page: separate 'Moved or renamed on the source' section
Each card: title, 'find it ->' (search by stored title) and x (remove,
same endpoint as regular cards). Current rows are untouched: a hianime
slug whose details name matches the stored title passes.
Tests: API contract for a stale row (deletable while on the board);
live browser test (gated) with the real jujutsu-kaisen-2552 row:
remap card renders, card doesn't link to the wrong slug, find-it
lands on the title's search.
Both docs still described the pre-provider-layer single-source app:
- README: anidb-only framing, missing stats page, 'upstream.py is the
ONLY Cloudflare-facing code', and a self-check command that does not
work (plain script execution breaks the providers' relative imports;
the asserts are anidb-specific under the hianime-first default) —
now documents ANI_SOURCE=anidb + the -m form. Dropped the stale
'episode titles are not available' note (Jikan titles shipped).
- SPEC: 'no database' -> SQLite, architecture -> 3 providers, full
endpoint list (intros/searches/events/stats/cdn, DELETE history,
?all=1, {sub}), auth hardening (per-IP budget, X-Aniweb on /login),
tests/run.py -> pytest + ANIWEB_LIVE_TESTS gating, out-of-scope list
(countdown/intro-skip/titles shipped), risks updated for the
fallback layer, milestone 8 pointer.
- app.js: pause/pagehide listeners passed the Event into savePos(floor),
making pos NaN -> serialized as null -> every pause/unload clobbered the
saved position. Wrap in arrows; pagehide now posts keepalive so the final
save survives page unload
- app.js: play() re-fetches history on a miss (boot fetch swallows errors)
and only bumps an existing row instead of posting pos:0 at start-of-play
- app.py: POST /api/history requires slug_id (was stored as "None"),
validates/clamps pos; upsert keeps the old payload when the incoming
pos is null so stale clients can't corrupt a row
- tests: +3 API tests (slug required, pos clamping, null-pos keeps saved),
+1 end-to-end browser test (resume ~400s with a failed boot fetch);
lockout test now uses a throwaway username so it can't lock out users
the browser tests log in with; hardened the flaky autoplay test
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