Commit Graph
113 Commits
Author SHA1 Message Date
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 778bf2bfc1 status: consolidate CDN/upstream status into the header pill + tap panel
- pill label now shows KB/s when degraded ('cdn 23 KB/s'); tap opens a
  panel (replaces the transient toast) with origin/CDN state, KB/s,
  a sparkline of the ~1h /api/cdn hist, and last-updated age
- 60s poll while the page is visible (was: load/play/failure only)
- playback failure overlay appends CDN context when degraded/down
- buffer gauge + spinner untouched: client buffer state, not CDN state
- server unchanged: /api/cdn already returned everything
- tests: 3 hermetic browser tests (mocked /api/cdn via page.route —
  the throwaway server's probe runs live upstream, so its data is
  mocked out of the assertions); 37 passed
- docs: README + SPEC synced
2026-09-20 23:53:47 +00:00
jozamudi 63a677f028 remove docs/CHANGES-2026-09.md: one-off audit-batch record, every fix already in the git log with explanatory commits; drop its mention from SPEC milestones 2026-09-20 23:40:26 +00:00
jozamudi de0bf8b156 remove PLAN.md: one-shot planning record for a shipped bundle; its content is superseded by git log + docs/CHANGES-2026-09.md 2026-09-20 23:37:20 +00:00
jozamudi 66a0bb16e6 remove ani-web.service: no code or docs reference the systemd unit 2026-09-20 23:36:28 +00:00
jozamudi 2fb3743b3b remove unused files: 3.6MB curl-impersonate tarball (covered by *.tar.gz gitignore, nothing references it) and the vendored ani-cli v5.0.0 script (upstream's, not used by this project) 2026-09-20 23:36:24 +00:00
jozamudi 816e945018 docs: slim README to what it uniquely owns (run, features, dev commands); implementation detail already lived in SPEC.md — moved the two stragglers (anidb self-check, cache-busting) over 2026-09-20 23:34:32 +00:00
jozamudi cea04a20f6 player: fullscreen button (scales with the window)
'⛶ 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.
2026-09-19 00:11:45 +00:00
jozamudi 379f591180 history: flag rows whose slug no longer means the same anime
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.
2026-09-18 23:25:35 +00:00
jozamudi 0f6bd0a52a docs: sync README + SPEC with the shipped reality
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.
2026-09-16 21:14:26 +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 cbfb2f96c8 pin exact dependency versions: curl_cffi impersonation churn must never update silently 2026-09-16 02:41:25 +00:00
jozamudi d02cb017d3 gate live-upstream browser e2e behind ANIWEB_LIVE_TESTS=1: provider rotation/takedowns were turning the suite red for non-regressions; default suite is hermetic (33 tests) again 2026-09-16 02:41:25 +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 c5c4dff851 Pin dependencies (full env freeze): curl_cffi/curl-impersonate churn breaks impersonation silently 2026-09-16 02:06:56 +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 826ee9165d run.sh: PID-file restart; pkill fallback scoped to :8321 so test servers on :8399 are never hit 2026-09-16 00:56:48 +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 9a4b8fb299 Docs: audit fix batch plan (sync login, scoped restart, per-IP budget, login CSRF, pinned deps) 2026-09-16 00:43:02 +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 3f715434f0 PWA + test: manifest, icon, live-search test (were left uncommitted) 2026-09-03 16:33:51 +00:00
jozamudi 67274aed7d Docs: last two stale storage refs in SPEC (JSON -> SQLite) 2026-09-01 21:25:30 +00:00
jozamudi 14bdb04ef6 Docs: sync with reality
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).
2026-09-01 21:25:13 +00:00
jozamudi 0315356f89 iOS root cause: start playback on BUFFER_APPENDED, not canplay
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.
2026-08-31 21:01:26 +00:00
jozamudi daac8d5256 JSV constant: 12 -> 13 (was left behind; stale-cache detection depends on it) 2026-08-31 20:56:08 +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 3df074a5fe hls.js: 10s -> 30s segment load cap (iPhone FragLoadTimeout)
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.
2026-08-31 20:32:40 +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 66a6ed5d93 play-probe: include JS version (detect stale-cache phones) 2026-08-31 20:12:50 +00:00
jozamudi c7f5ad4c7a play-probe: report branch + browser capabilities on every play attempt
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.
2026-08-31 20:05:34 +00:00
jozamudi be47ced521 Report client fingerprint on playback failure
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.
2026-08-31 19:57:57 +00:00
jozamudi 8f6d4dcf77 Mobile audit fixes: iOS input auto-zoom + tap delay
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.
2026-08-31 19:40:07 +00:00
jozamudi 9b90be3a10 Player: native-HLS fallback + visible stream errors
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.
2026-08-31 19:26:53 +00:00
jozamudi 733fa673c2 Player: buffering visuals — spinner ring, buffer-ahead gauge, CDN chip
- 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.
2026-08-31 15:37:06 +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 2576546b43 hls.js: drop segment load cap 30s -> 10s
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.
2026-08-31 14:10:11 +00:00