Commit Graph
46 Commits
Author SHA1 Message Date
jozamudi e3ef51d141 Stop spamming upstream: drop video download from probe, kill re-rolls
- CDN probe no longer downloads 64KB of video (or even a variant
  playlist): one small master-playlist fetch proves reachability.
  State stays OK/DEGRADED, kbps is no longer measured, and the pill/
  panel only show a rate when one exists.
- hianime: 5x getSourcesNew re-roll -> 1 attempt. The enc payload is
  deterministic per data-id (observed identical across calls), so
  re-rolling only hammered the provider.
- anikoto: 2x re-roll -> 1 attempt per server, same reason.

Kept: idle-gated + 30-min degraded backoff from the previous commit,
the single _get retry (protects real user playback from CF blips),
and the origin page check (the 'down' signal). Net upstream traffic is
now what an actual viewer would generate, nothing more.
2026-09-29 17:33:54 +00:00
jozamudi 1154203569 CDN probe: skip while idle, back off while degraded
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.
2026-09-29 17:23:18 +00:00
jozamudi 0cd79dd701 Decode MegaPlay's encrypted getSourcesNew payload
MegaPlay (behind both hianime and anikoto) stopped returning
sources.file in plaintext and now ships the stream URL in an
AES-256-CBC 'enc' field. Key/IV are static, hardcoded in their public
player JS (newclient.min.js) — anti-scrape obscurity, not access
control. Shared megaplay_sources() in upstream.py tolerates both
shapes, so a revert upstream breaks nothing.

Also: hianime master_m3u8 now raises when servers are listed but none
resolve (403s, stale format) instead of returning None — a listed-but
dead server is a provider fault, not 'no dub', and the old None
silently short-circuited the ANI_SOURCE fallback chain.
2026-09-29 17:17:33 +00:00
jozamudi 731f44b99c watched: single DONE_RATIO source constant replaces the seven hardcoded 0.95 literals
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.
2026-09-21 01:41:44 +00:00
jozamudi 5f7aa010f1 status: consolidate the pill to one dot + one word; upgrade the panel
- 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)
2026-09-21 00:10:10 +00:00
jozamudi 4aca78cab8 docs(db): legacy JSON files are deletable once tables are populated: the on-disk copies of users/history/searches were verified superseded by SQLite and removed; the docstring claimed they were kept as a snapshot 2026-09-16 21:14:26 +00:00
jozamudi 882263d276 login POST now requires the X-Aniweb header: cross-site forced-login (attacker-form session swap) dies at the 403; login.html sends the header like the SPA 2026-09-16 02:41:25 +00:00
jozamudi 3e5c7062ab login: per-source-IP failure budget (auth.py) — username rotation no longer dodges the lockout; sliding window, XFF trusted only behind the proxy (ANIWEB_TRUST_XFF); lockout logic now unit-testable (tests/test_auth.py) 2026-09-16 02:31:23 +00:00
jozamudi 7f531fc309 CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb 2026-09-16 02:02:09 +00:00
jozamudi b70ef403a9 Lockout -> server/auth.py + per-IP failure budget (XFF trusted only behind the proxy); unit tests 2026-09-16 01:31:40 +00:00
jozamudi 673aca827f login: sync def so the 0.5s brute-force sleep runs on the threadpool, not the event loop (+latency regression test) 2026-09-16 00:49:22 +00:00
jozamudi d90b1af8d6 Fix episode position logging/resume: NaN savePos, clobber-on-boot, unvalidated history writes
- 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
2026-09-14 00:54:31 +00:00
jozamudi 7444e29592 Keep hianime alive across server rotation: try every listed server
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.
2026-09-08 22:16:38 +00:00
jozamudi 65da3e848f Prefer the English caption track (label-based, not first-track)
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.
2026-09-07 03:01:38 +00:00
jozamudi 5b8170ece0 Subtitles: wire hianime's external .vtt into the player
- 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
2026-09-07 02:46:28 +00:00
jozamudi 834ca80a8c Provider layer + hianime source (primary)
- 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
2026-09-07 01:38:53 +00:00
jozamudi ff8311c1a8 Status pill + git-SHA asset versioning
- _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
2026-09-03 16:33:51 +00:00
jozamudi aaaf8e9308 iPhone: full client timeline + self-diagnosing watchdog
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.
2026-08-31 20:53:18 +00:00
jozamudi aba266ee4b iPhone: step probes + full hls.js error stream to server
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.
2026-08-31 20:48:02 +00:00
jozamudi 5268e77d00 events: accept client-fail + play-probe types (was 422)
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.
2026-08-31 20:22:53 +00:00
jozamudi a61e7dd0e7 GET /api/cdn: probe status endpoint (public, read-only)
Latest state + kbps + ~1h history (20 samples). curl it to check the
CDN without digging through server.log.
2026-08-31 15:23:36 +00:00
jozamudi eea538ab17 Background CDN probe: log throughput + state flips every 3 min
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.
2026-08-31 15:17:20 +00:00
jozamudi 4c5dbab4b0 Proxy: 16 parallel lanes (was 4), like yt-dlp -N 16
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.
2026-08-31 15:00:21 +00:00
jozamudi ee2a888aca Proxy: fetch segments across 4 parallel byte-range lanes
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).
2026-08-31 14:51:55 +00:00
jozamudi 343628a2e3 Episode names: render MAL titles on the episode grid
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.
2026-08-31 01:52:38 +00:00
jozamudi 83a2953a1c Proxy: log slow segment transfers (TTFB>3s or total>8s)
Distinguishes CDN-side stalls (server sees slow TTFB) from client-path
stalls (server fast, user still times out) when fragLoadTimeOut recurs.
2026-08-31 01:16:38 +00:00
jozamudi 19bc2afb32 Proxy: stream HLS segments through instead of buffering
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.
2026-08-31 01:05:00 +00:00
jozamudi 27de918cb0 Intro skip: parse Jikan v4's actual response shape
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.
2026-08-31 00:24:37 +00:00
jozamudi 0613cf7e27 Intro skip: MAL timings via keyless Jikan + player button
- 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)
2026-08-30 21:01:24 +00:00
jozamudi 0fd06f036a Playback: retry flaky upstream, faster proxy fail, cache /stream in browser
- 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 ->')
2026-08-30 17:23:57 +00:00
jozamudi 7b670cff24 Stats: GitHub-style watch-time heatmap
- api/stats gains by_day: local-date -> seconds (same watch-time
  heuristic as by_weekday)
- ?stats renders a Mon..Sun x week grid (30d window, matches the
  page header) with 4 intensity levels + tooltip per cell + legend;
  replaces the by-weekday bar chart the heatmap subsumes
- test_stats covers by_day (sums, slug-scoped, empty case)
2026-08-24 22:24:16 +00:00
jozamudi 1be3f4f307 Stats UI: per-anime progress rows, status chips, on-hold, streak, detail 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
2026-08-24 17:30:01 +00:00
jozamudi 2f373b9bba Add per-user stats: play/error events + /api/stats + ?stats page
- 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)
2026-08-24 17:09:27 +00:00
jozamudi 202bfa41c3 storage: JSON files -> SQLite (users/history/searches/events)
- 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
2026-08-24 16:02:27 +00:00
jozamudi aae5857dba harden: stream-token host allowlist (SSRF), CSRF header on mutating routes, atomic JSON writes
- /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
2026-08-23 23:22:09 +00:00
jozamudi d39ee838ff api: dedup ghost history rows (int vs string ep_id); normalize ep_id on write 2026-08-23 21:29:01 +00:00
jozamudi 50d30a8d3f ui: default to sub (jpn) instead of dub for playback 2026-08-23 21:28:24 +00:00
jozamudi 98fb4e5461 features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches 2026-08-19 18:41:13 +00:00
jozamudi 2ec0f8a034 ui: loading states, player failure overlay + retry, toasts, back nav, full history page with delete
Bundle 1-4+7 from PLAN.md: skeleton loaders, player Loading/Buffering/fatal overlay with Retry, action toasts, back-to-home crumb + logo fix, ?history full-history view with per-entry delete (GET /api/history?all=1, DELETE /api/history). 14/14 tests green.
2026-08-19 18:20:42 +00:00
jozamudi 19e878800c perf: keep-alive upstream sessions, TTL cache on upstream data, /stream LRU, cache hls.min.js
- shared thread-local curl_cffi sessions (no TLS handshake per call)
- in-memory TTL cache: search 5min, details/episodes/languages/
  playlist-URLs 1h; api_stream retries once with a cold cache on 502
  (recovers rotated playlist URLs)
- /stream LRU (24 entries) serves re-seeks and playlist re-fetches from memory
- hls.min.js: Cache-Control public max-age=30d (was no-store -> 500KB
  re-download per page load on mobile)

Measured (click->play chain, cold -> warm):
  search    0.45s -> 0.00s
  detail    0.46s -> 0.00s
  episodes  0.40s -> 0.00s
  stream    1.06s -> 0.00s
  m3u8 x2   0.64s -> 0.00s
  segment   0.20s -> 0.00s
2026-08-19 17:46:17 +00:00
jozamudi ca86daf9a6 api: Cache-Control no-store on /api/history (mutable JSON must not be cached) 2026-08-19 17:38:04 +00:00
jozamudi 2efb537edc home polish: continue-watching hero, progress bars, posters, time-ago
- 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
2026-08-19 17:01:04 +00:00
jozamudi cdb25b1aba security: Secure cookie flag (ANIWEB_SECURE, on in run.sh) + kill sessions of deleted accounts
- 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
2026-08-19 16:29:03 +00:00
jozamudi c697500c7d multi-user auth (CLI-created accounts), per-user history, test suite
- 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
2026-08-19 16:17:56 +00:00
jozamudi 76c45a8f29 single-user login screen
- server/credentials.py: PBKDF2 password + session secret, 0600 json,
  one-time setup command (server refuses to start without it)
- app.py: signed 30-day cookie (itsdangerous), middleware 401s API /
  redirects pages to /login?next=, 5-fail/15-min lockout, /logout
- static/login.html: dark-themed form, inline error display
- app.js: api() 401 -> redirect to /login preserving current URL
- docs: README quick start + features, SPEC auth section
2026-08-19 15:54:50 +00:00
jozamudi 2c203990bc move server code into server/, update docs
- app.py + upstream.py -> server/ (relative import, __init__.py)
- static/ resolved relative to repo root, works from any CWD
- watch history migrated to server/data/history.json
- README.md: quick start, features, layout, dev notes
- SPEC.md: marked implemented, updated endpoints/run/deviations
- ignore *.tar.gz (local curl-impersonate download at repo root)
2026-08-19 15:33:31 +00:00