The 3-min 64KB video download ran on a timer regardless of whether
anyone was using the app — wasteful upstream bandwidth, and pure churn
while the CDN 403s our IP. Now: no fetch while no stream has been
requested in 15 min (api_stream bumps _LAST_PLAY), and 30-min retry
while DEGRADED. Pill state goes stale while idle, which is fine — the
next viewer's own request is the true signal, and the probe resumes
within a minute of it.
server/app.py DONE_RATIO is injected into index.html like {{VERSION}}, app.js
reads it as window.DONE_RATIO (fallback = same default). Change the threshold
in exactly one place now.
- pill: two host dots -> ONE consolidated state (down > slow > up) —
the at-a-glance question is 'can I watch?', which is one state;
per-host detail belongs in the tap panel
- panel: dashed slow-threshold line in the CDN sparkline (drawn at the
server's real threshold, not a client copy) + origin up/down step
sparkline so 'how long has the origin been down' is visible
- server: origin probe keeps a 20-sample hist like the CDN probe;
DEGRADED threshold is now ANIWEB_CDN_KBPS (default 40) and is
returned in /api/cdn so the UI draws the line at the live value
- tests: pill assertions switched to consolidated states; fixtures
carry origin hist + threshold; assert both sparklines + threshold line
- docs: README + SPEC synced; preview on :8322 restarted (pid 44382)
- 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.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
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 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.
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.
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).
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.
- 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)
- 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 ->')
- 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
- 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)
- 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
- /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
- middleware re-verifies the username against users.json per request:
deleted accounts' signed cookies are rejected immediately
- set_cookie/delete_cookie honor the Secure flag; run.sh sets
ANIWEB_SECURE=1 so sessions only work through the HTTPS proxy
- server/users.py replaces credentials.py: users.json (0600), PBKDF2,
one-time migration of the legacy single password to user admin
(history.json -> history/admin.json)
- accounts are CLI-managed only: python -m server.users add|rm|list
(no self-signup on an internet-exposed app); /signup route removed,
login page back to username + password
- per-user 30-day signed session cookie (payload carries the
username), per-user lockout, GET /api/me, POST /account/delete
- per-user watch history: server/data/history/<user>.json; ANIWEB_DATA
env override for the data dir
- header chip: logged-in user, logout, delete account
- tests/: throwaway-server suite (port 8399, temp data dir) — 9 HTTP
tests + 2 Playwright browser tests (login UI, real video playback
behind auth); runs via python tests/run.py