'⛶ full' in the controls: native requestFullscreen on the video (it fills
the viewport and rescales on window resize for free), with the iOS
webkitEnterFullscreen fallback where the Fullscreen API doesn't exist.
Click again to exit. Live browser test: enter + exit via the button.
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.
- 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
- 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
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).
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.
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.
- 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)
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 ->')
- 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
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.
- /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
Headless Chromium 151 stops generating animation frames on idle pages,
which stalls Playwright's rAF-based waits (wait_for_function default
polling, click actionability 'stable' check). Fixes:
- wait_for_function: explicit timer polling (100ms/250ms)
- video readyState wait: 120s (cold upstream/CDN starts)
- .card click: force=True
- login: form submit handler (Enter key works, was button-only)
- test asserts the seeded entry, not a section header that only
appeared via an API-test side effect
- showHome(): most recent entry renders as a hero card (poster,
episode/lang/position, progress bar, Resume); the rest become
mini poster cards with progress + relative timestamp
- history entries now persist dur (video.duration) and poster;
/api/anime returns poster (parsed from the details page)
- old entries without dur render without a bar (backward compatible)
- browser test asserts the seeded entry, not a section header that
only existed via a cross-test side effect
- 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