- 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.
- 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
- 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
- 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
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
- 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)
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).
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
- backfill script deduped by slug_id, so a second entry for the same
anime kept no poster -> rendered as a broken <img src="">
- re-backfilled from the already-fetched poster per slug
- showHome now only emits an <img> when the entry has a poster
- 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
login.html/app.js now resolve fetch targets relative to the page's
directory (BASE), so the app works served at / or under a path prefix
(https://host/ani/) as long as the proxy strips the prefix.
- 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
- POST/GET /api/history: JSON file in data/history.json (50 entries
kept, top 5 shown on home), upsert per (anime, episode)
- watch position saved every ~10s of playback + on pause/unload
- resume: opening an anime seeks to the saved position unless
>=95% through (then starts fresh); position shown on home items
- drops localStorage; server is the source of truth (verified:
fresh watch -> new session resumes at saved pos -> home lists it)
- no ?ep= param: resume last watched ep of the season from history,
else start at ep 1 (verified in headless Chromium, no click needed)
- if the browser blocks autoplay (no user gesture yet), show a
'click to play' overlay instead of a silent black screen
- no-store on everything except /stream: stale cached app.js (old CDN
hls.js ref) was the cause of the black screen — video plays fine in a
real Chromium once the fresh JS loads (verified via playwright)
- synopsis moved under the episode grid
- jsdelivr CDN was a silent single point of failure (Hls undefined ->
unhandled rejection -> black screen with no feedback)
- play() now catches API errors; hls fatal + video media errors render
'Stream error: ...' under the player
- server now binds 0.0.0.0 (LAN access); was 127.0.0.1 only