From 2ec0f8a0348a9b61c84b7a754f54a0c728e7efd5 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 19 Aug 2026 18:20:42 +0000 Subject: [PATCH] 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. --- PLAN.md | 156 ++++++++++++++++++++++++++++++++++++++++++ README.md | 11 +-- server/app.py | 21 ++++-- static/app.js | 113 +++++++++++++++++++++++++++--- static/index.html | 3 +- static/login.html | 9 +++ static/style.css | 22 ++++++ tests/run.py | 4 +- tests/test_api.py | 23 +++++++ tests/test_browser.py | 72 ++++++++++++++++++- 10 files changed, 414 insertions(+), 20 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..81a2473 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,156 @@ +# Plan — UI/UX polish bundle (items 1–4 + 7) + +The agreed bundle from the polish list: + +1. **Loading states** — skeleton cards + player spinner +2. **Player failure + buffering overlay** +3. **Toasts / confirmations** +4. **Back/breadcrumb nav + logo fix** +7. **Full history page + delete entries** + +Out of scope (deferred): favicon, search-box clear/count, synopsis clamp, a11y pass, +resume toast, sub fallback, autoplay-next, light theme. + +## Current state (grounding) + +- SPA with three views: `?q=` search, `?anime=&season=&ep=&lang=` detail+player + (player is inline on the detail page), no-param = home. +- No loading feedback anywhere: `main` is blank during fetches; the player is a + black box until the first frame. +- Stream errors render as a `

` **below** the video — easy to + miss, no retry. +- No in-app way back to home (browser back only). **The logo links to + hardcoded `/` — breaks behind the subpath reverse proxy** (everything else + uses `BASE`). +- History: `GET /api/history` returns only the top 5; no delete endpoint; + no UI to remove an entry (only nuke the whole account). + +## Item 1 — loading states + +- `app.js`: + - `skel(n)` helper → shimmering `.card.skel` grid in `main`. + - `showSearch`: render `skel(8)` before `await api()`. + - `showAnime`: `skel()` before the detail fetch; "loading episodes…" + placeholder in `#eps` until the episode list resolves. + - Player: `.pl-overlay` (shared with item 2) shows "Loading…" right after + the video element is attached; hidden on `playing`, "Buffering…" on + `waiting` (guarded so it never clobbers the failure overlay). +- `style.css`: `@keyframes shimmer`, `.skel`, `.pl-overlay` + (absolute inset-0 over the video — `.player` is already `position:relative`). + +**Accept:** no blank `main` during a search; skeleton visible; player shows +Loading → (Buffering) → hidden once the first frame plays. + +## Item 2 — player failure + retry + +- `app.js`: + - `playerFail(msg)` → overlay on the video: message + **Retry** button. + - hls.js `fatal` error → `stopHls()` + `playerFail(details + HTTP code)`. + - `video.onerror` → `playerFail("media error")`. + - Retry re-runs `play(ep_id, ep_no)` — fresh stream fetch; the server-side + cold-cache retry already recovers rotated playlist URLs. + - `showStreamError` stays only for pre-play fetch errors (e.g. 404 "no + eng stream"). +- `showPlayOverlay` (autoplay-blocked prompt) hides the loading overlay first. + +**Accept:** headless: abort `**/stream/**` → detail page auto-plays → overlay +with Retry appears; unroute → click Retry → `video.readyState >= 4`. + +## Item 3 — toasts / confirmations + +- `index.html`: `

`. +- `app.js`: `toast(msg)` → `.toast`, auto-removed after 2.6 s. + - Used for history-entry removal (stays on the page). + - Logout / delete account navigate away, so they use one-shot banners + instead: `login?signedout=1` / `login?deleted=1`; `login.html` shows a + fixed `.banner` ("Signed out." / "Account deleted.") when present. +- `style.css`: `.toast` (fixed bottom-center, fade-in), login `.banner`. + +**Accept:** entry removal → toast appears then disappears; logout → banner on +the login page; account delete → banner. + +## Item 4 — back nav + logo fix + +- `index.html`: drop the inline `onclick` from the logo (keep `href` fallback). +- `app.js` boot: `logo.href = BASE` (BASE ends in `/` → home, clears query) — + same proxy-safe mechanism as all other navigation. +- Detail view: `← home` crumb link in `.detail-head`, before the title. +- `Esc` closes the hamburger menu. + +**Accept:** from `?anime=…`, logo click → home hero visible; crumb works. + +## Item 7 — full history page + delete entries + +- `server/app.py`: + - `GET /api/history?all=1` → full list (no top-5 cap), same no-store header. + - `DELETE /api/history?slug_id=…&ep_id=…` → removes all entries with + `str(slug_id) ==` and `str(ep_id) ==` (loose match on purpose: legacy rows + store the same ep as int *and* string — one click clears both ghost rows); + returns `{"removed": n}`. +- `app.js`: + - Boot: `?history` → `showHistory()`: skeleton → grid of cards (poster, + title, `E# · dub/sub · time-ago`, progress bar when `dur` known) each with + a `×` delete button (top-right; stopPropagation). Cards are clickable + divs (valid HTML — a button inside `` is not). + - Delete → `DELETE` fetch → `toast("Removed from history")` → re-render. + - Home: when the top-5 cap is reached, "Recently played" gets a + `view all →` link to `?history`. +- `style.css`: `.del` (absolute top-right circle, danger on hover), + `.sec-head` (flex row for section title + link), `.card.hist`. + +**Accept:** API: `?all=1` returns everything, DELETE removes only the matching +entry and is idempotent. Browser: `?history` lists entries; `×` removes one +card and shows a toast. + +## New tests + +- `tests/test_api.py::test_history_all_and_delete` (alice): POST 2 entries + (one int `ep_id`, one string) → `?all=1` sees both → plain GET still caps at + 5 → DELETE the int row via string query param (loose match) → `removed ≥ 1`, + row gone, other row untouched → second DELETE → `removed == 0`. +- `tests/test_browser.py::test_history_page_and_delete` (new seeded user + **erin**, self-contained entries via urllib): `?history` → 2 `.card.hist` + cards → click `.del` (force) → 1 card + `.toast` visible. +- `tests/test_browser.py::test_stream_failure_retry` (admin): `page.route` + aborts `**/stream/**` → search + open detail (auto-play fails) → wait for + `.pl-overlay button` (Retry) → `page.unroute` → click Retry → video plays + (`readyState >= 4`, 120 s timeout for cold upstream). +- `tests/run.py`: seed erin; register both new browser tests. + +All waits use explicit `polling=` (headless rAF stall) and `force=True` +clicks, per the established Chromium 151 quirk. + +## Verification + +- `.venv/bin/python tests/run.py` → all green (11 existing + 3 new). +- Ad-hoc Playwright screenshots: home, history page, failure overlay, mobile + 390 px — eyeballed. +- `git diff` reviewed before committing (no stray rewrites). + +## Execution order + +1. Backend: `app.py` history endpoints + API test +2. `app.js`: items 1, 2, 3, 4, 7 +3. `index.html` + `login.html` +4. `style.css` +5. Browser tests + `run.py` +6. Full suite + screenshots +7. Restart production, commit (includes this plan) + +## Outcome + +Shipped. Full suite green (10 API + 4 browser tests). + +Bugs found while implementing/testing (fixed): + +- `?history` is a valueless param — `p.get("history")` is `""` (falsy); + boot routing had to use `p.has("history")`. +- Delete button click bubbled to the card's `onclick` → navigated to + `?anime=`; fixed with `stopPropagation()`. +- After a fatal stream error, `v.play()` rejects (no media) and + `showPlayOverlay` clobbered the failure overlay with "click to play"; + guarded by `playerBroken`. +- Test route pattern `**/stream/**` also matched the token endpoint + `/api/episode/{id}/stream` — narrowed to `**/stream/*`. +- Skeleton `.card.skel` matched the tests' `.card` selector — tests now + use `a.card`. diff --git a/README.md b/README.md index 94cc688..7170a98 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,14 @@ the app or CLI are rejected immediately, even with an old cookie. PBKDF2-SHA256 hashes in `server/data/users.json` (0600). - Per-user watch history (`server/data/history/.json`): position saved every ~10s, resume where you left off, top-5 "recently played" - on the home page. Nothing stored in the browser — new devices pick - up your watch state after login, isolated per account. - In-header chip shows the user, with logout + delete-account. + on the home page, full history page with per-entry delete. Nothing + stored in the browser — new devices pick up your watch state after + login, isolated per account. In-header chip shows the user, with logout + + delete-account (hamburger menu). - Search with poster grid, detail view with synopsis + season tabs -- HLS playback (vendored hls.js), browser ABR across 1080/720/360p +- HLS playback (vendored hls.js), browser ABR across 1080/720/360p; + loading/buffering overlay, and a failure overlay with Retry when a + stream can't be played - Dub/sub toggle - Fast repeat navigation: upstream data is TTL-cached and HLS playlists/segments are LRU-cached, so re-opening a show or re-seeking is diff --git a/server/app.py b/server/app.py index 0ff1774..49a9b06 100644 --- a/server/app.py +++ b/server/app.py @@ -139,10 +139,23 @@ def _save_hist(user: str, entries): @app.get("/api/history") def api_history(request: Request): # no-store: this JSON mutates while a tab is open (position saves); - # a stale copy has caused "poster gone" confusion before - return JSONResponse( - sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True)[:5], - headers={"Cache-Control": "no-store"}) + # a stale copy has caused "poster gone" confusion before. + # ?all=1: full list for the /history page (no top-5 cap) + entries = sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True) + if request.query_params.get("all") != "1": + entries = entries[:5] + return JSONResponse(entries, headers={"Cache-Control": "no-store"}) + + +@app.delete("/api/history") +def api_history_delete(request: Request, slug_id: str, ep_id: str): + # loose str() match on purpose: legacy rows store the same ep_id as int + # AND as string — one delete clears both ghost rows + entries = _load_hist(request.state.user) + keep = [e for e in entries + if not (str(e.get("slug_id")) == slug_id and str(e.get("ep_id")) == ep_id)] + _save_hist(request.state.user, keep) + return {"removed": len(entries) - len(keep)} @app.post("/api/history") diff --git a/static/app.js b/static/app.js index e544930..add44af 100644 --- a/static/app.js +++ b/static/app.js @@ -34,6 +34,19 @@ function fail(err) { main.append(el("p", "error", "Error: " + err.message)); } +function skel(n = 8) { + main.innerHTML = ""; + const g = el("div", "grid"); + for (let i = 0; i < n; i++) g.append(el("div", "card skel")); + main.append(g); +} + +function toast(msg) { + const t = el("div", "toast", msg); + document.getElementById("toasts").append(t); + setTimeout(() => t.remove(), 2600); +} + function showStreamError(msg) { const p = el("p", "error", "Stream error: " + msg); const player = document.getElementById("player"); @@ -94,7 +107,14 @@ function showHome() { main.append(sec); // 2-4. the rest as mini poster cards with progress + time-ago if (history.length > 1) { - main.append(el("h2", null, "Recently played")); + const head = el("div", "sec-head"); + head.append(el("h2", null, "Recently played")); + if (history.length >= 5) { + const va = el("a", null, "view all \u2192"); + va.href = BASE + "history"; + head.append(va); + } + main.append(head); const grid = el("div", "grid mini"); for (const x of history.slice(1, 6)) { const c = el("a", "card"); @@ -119,6 +139,7 @@ function showHome() { // ---------- search ---------- async function showSearch(q) { + skel(8); const results = await api(`/api/search?q=${encodeURIComponent(q)}`); main.innerHTML = ""; if (!results.length) { main.append(el("p", "muted", "No results.")); return; } @@ -136,6 +157,7 @@ async function showSearch(q) { // ---------- detail + player ---------- async function showAnime(slug_id, opts = {}) { + skel(); detail = await api(`/api/anime/${slug_id}`); lang = opts.lang || "eng"; seasonId = opts.season || detail.id; @@ -143,7 +165,9 @@ async function showAnime(slug_id, opts = {}) { main.innerHTML = ""; const head = el("section", "detail-head"); - head.append(el("h2", null, detail.name)); + const crumb = el("a", "crumb", "\u2190 home"); + crumb.href = BASE; + head.append(crumb, el("h2", null, detail.name)); main.append(head); const player = el("div", "player"); @@ -176,9 +200,11 @@ async function showAnime(slug_id, opts = {}) { const eps = el("div", "eps"); eps.id = "eps"; + eps.append(el("p", "muted", "loading episodes\u2026")); main.append(eps); const list = await api(`/api/anime/${seasonId}/episodes`); + eps.innerHTML = ""; for (const e of list) { const b = el("button", "ep" + (e.filler ? " filler" : "")); b.textContent = "E" + e.number; @@ -205,6 +231,45 @@ async function showAnime(slug_id, opts = {}) { function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } +// ---------- history page (full list + delete) ---------- +async function showHistory() { + skel(10); + const items = await api("/api/history?all=1"); + main.innerHTML = ""; + main.append(el("h2", null, "History")); + if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; } + const grid = el("div", "grid"); + for (const x of items) grid.append(histCard(x)); + main.append(grid); +} + +function histCard(x) { + const c = el("div", "card hist"); + c.onclick = () => location.href = histHref(x); + if (x.poster) { + const i = el("img"); i.src = x.poster; i.alt = ""; + c.append(i); + } + const info = el("div", "mini-info"); + info.append(el("div", "card-name", x.title), + el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}`)); + const pb = bar(x, "small"); + if (pb) info.append(pb); + c.append(info); + const d = el("button", "del", "\u00d7"); + d.title = "remove from history"; + d.onclick = async e => { + e.preventDefault(); + e.stopPropagation(); + await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) + + "&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE"}); + toast("Removed from history"); + await showHistory(); + }; + c.append(d); + return c; +} + function markActive(b) { for (const x of document.querySelectorAll(".ep")) x.classList.remove("active"); b.classList.add("active"); @@ -218,21 +283,44 @@ function videoEl() { function stopHls() { if (hls) { hls.destroy(); hls = null; } } -let currentEp = null, lastSavedPos = 0; +let currentEp = null, lastSavedPos = 0, playerBroken = false; + +function playerOverlay(text, onRetry) { + const p = document.getElementById("player"); + let o = p.querySelector(".pl-overlay"); + if (!o) { o = el("div", "pl-overlay"); p.append(o); } + o.replaceChildren(el("span", null, text)); + if (onRetry) { + const b = el("button", "btn", "Retry"); + b.onclick = onRetry; + o.append(b); + } +} +function playerHideOverlay() { + const o = document.querySelector(".pl-overlay"); + if (o) o.remove(); +} +function playerFail(msg) { + if (!currentEp) return; + playerBroken = true; + const { ep_id, ep_no } = currentEp; + playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no)); +} async function play(ep_id, ep_no) { + playerBroken = false; try { const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`); stopHls(); const v = videoEl(); document.getElementById("player").replaceChildren(v); + playerOverlay("Loading\u2026"); if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser"); hls = new Hls(); hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { - showStreamError(`${data.type}: ${data.details}` + - (data.response ? ` (HTTP ${data.response.code})` : "")); stopHls(); + playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : "")); } }); hls.loadSource("/stream/" + s.token); @@ -245,7 +333,11 @@ async function play(ep_id, ep_no) { if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos; }, {once: true}); } - v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code)); + v.onerror = () => playerFail("media error"); + v.addEventListener("playing", playerHideOverlay); + v.addEventListener("waiting", () => { + if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026"); + }); v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt watchPos(v); postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, @@ -273,6 +365,8 @@ function watchPos(v) { } function showPlayOverlay(v) { + if (playerBroken) return; // failure overlay (with Retry) takes precedence + playerHideOverlay(); const o = el("button", "play-overlay", "▶ click to play"); o.onclick = () => { v.play(); o.remove(); }; v.parentElement.append(o); @@ -280,12 +374,13 @@ function showPlayOverlay(v) { // ---------- boot ---------- (async () => { + document.querySelector(".logo").href = BASE; try { const me = await api("/api/me"); document.getElementById("me").textContent = "hi, " + me.user; const lo = document.getElementById("logout"); lo.onclick = async () => { - await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login"; + await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1"; }; const mb = document.getElementById("menubox"); mb.style.display = ""; @@ -293,10 +388,11 @@ function showPlayOverlay(v) { e.stopPropagation(); mb.classList.toggle("open"); }; document.addEventListener("click", () => mb.classList.remove("open")); + document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); }); const da = document.getElementById("delacct"); da.onclick = async () => { if (!confirm("Delete your account and watch history?")) return; - await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login"; + await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login?deleted=1"; }; } catch { /* not logged in: api() already redirected to /login */ } await refreshHistory(); @@ -308,6 +404,7 @@ function showPlayOverlay(v) { ep: p.get("ep") ? +p.get("ep") : null, lang: p.get("lang") || "eng", }); + else if (p.has("history")) await showHistory(); else showHome(); } catch (e) { fail(e); } })(); diff --git a/static/index.html b/static/index.html index ebc7aa9..2e332d5 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@
- +
@@ -22,6 +22,7 @@

Search for an anime to get started.

+
diff --git a/static/login.html b/static/login.html index 5028991..9c85249 100644 --- a/static/login.html +++ b/static/login.html @@ -16,9 +16,13 @@ input { width:100%; box-sizing:border-box; padding:.55rem .8rem; border-radius:8 button { width:100%; margin-top:.4rem; padding:.55rem; border:0; border-radius:8px; background:var(--acc); color:#000; font-weight:600; cursor:pointer; } .err { color:#f87171; font-size:.85rem; margin:.6rem 0 0; min-height:1.2em; } +.banner { position:fixed; top:1rem; left:50%; transform:translateX(-50%); background:var(--card); + border:1px solid var(--bd); border-radius:8px; padding:.4rem .9rem; color:var(--muted); + font-size:.85rem; } +

ani-web

@@ -35,6 +39,11 @@ const BASE = (() => { })(); const $ = id => document.getElementById(id); const next = new URLSearchParams(location.search).get("next"); +(() => { // one-shot confirmations for logout / account delete + const s = new URLSearchParams(location.search); + const note = s.get("signedout") ? "Signed out." : s.get("deleted") ? "Account deleted." : null; + if (note) { const b = $("banner"); b.textContent = note; b.hidden = false; } +})(); async function login() { $("err").textContent = ""; const r = await fetch(BASE + "login", {method: "POST", diff --git a/static/style.css b/static/style.css index 631e121..e349182 100644 --- a/static/style.css +++ b/static/style.css @@ -69,6 +69,28 @@ button:hover { border-color: var(--acc); } .tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; } .tab.active { border-color: var(--acc); color: var(--acc); } +/* loading + feedback */ +@keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } +.skel { aspect-ratio: 2/3; border: 1px solid var(--bd); border-radius: 10px; + background: linear-gradient(90deg, var(--card) 25%, #1f2030 37%, var(--card) 63%); + background-size: 400% 100%; animation: shimmer 1.4s ease infinite; } +.pl-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; gap: .6rem; + align-items: center; justify-content: center; background: rgba(0, 0, 0, .72); + border-radius: 10px; color: var(--fg); font-size: .95rem; z-index: 1; } +#toasts { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); + display: flex; flex-direction: column; gap: .4rem; z-index: 9; } +@keyframes toast-in { from { opacity: 0; transform: translateY(.4rem); } } +.toast { background: var(--card); border: 1px solid var(--acc); color: var(--fg); + padding: .45rem .9rem; border-radius: 8px; font-size: .85rem; animation: toast-in .2s ease; } +.crumb { display: inline-block; font-size: .85rem; color: var(--muted); margin-bottom: .2rem; } +.crumb:hover { color: var(--acc); } +.sec-head { display: flex; align-items: baseline; gap: .8rem; } +.card.hist { position: relative; cursor: pointer; } +.del { position: absolute; top: .35rem; right: .35rem; width: 1.5rem; height: 1.5rem; + border-radius: 50%; border: 1px solid var(--bd); background: rgba(14, 15, 19, .8); + color: var(--muted); font-size: .9rem; line-height: 1; padding: 0; cursor: pointer; } +.del:hover { border-color: #f87171; color: #f87171; } + .eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; } .ep { position: relative; font-size: 0.9rem; } .ep.active { border-color: var(--acc); color: var(--acc); } diff --git a/tests/run.py b/tests/run.py index 187fe18..40dc43c 100644 --- a/tests/run.py +++ b/tests/run.py @@ -23,7 +23,7 @@ ROOT = Path(__file__).parent.parent PORT = 8399 BASE = f"http://127.0.0.1:{PORT}" USERS = (("admin", "adminpw12345"), ("alice", "alicepw12345"), ("bob", "bobpw12345"), - ("dave", "davepw12345")) + ("dave", "davepw12345"), ("erin", "erinpw12345")) def _wait_ready(): @@ -74,6 +74,8 @@ def main(): print("ok browser (playwright)") asyncio.run(test_browser.test_login_ui()) asyncio.run(test_browser.test_video_plays_behind_auth()) + asyncio.run(test_browser.test_history_page_and_delete()) + asyncio.run(test_browser.test_stream_failure_retry()) print("ALL TESTS PASSED") finally: diff --git a/tests/test_api.py b/tests/test_api.py index 4cf61a1..028e9d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -115,6 +115,29 @@ def test_history_per_user(base=BASE): assert all(e.get("ep_id") != "111" for e in got_b), "leak: admin entry in alice's history" +def test_history_all_and_delete(base=BASE): + jar = login("alice", "alicepw12345", base) + e1 = {"ep_id": 201, "slug_id": "del-a", "title": "Del A", "season_id": 1, + "ep": "1", "lang": "eng", "pos": 5} + e2 = {"ep_id": "202", "slug_id": "del-b", "title": "Del B", "season_id": 1, + "ep": "2", "lang": "eng", "pos": 5} + assert req("POST", "/api/history", e1, jar=jar, base=base) == 200 + assert req("POST", "/api/history", e2, jar=jar, base=base) == 200 + all_ = _json("GET", "/api/history?all=1", jar=jar, base=base) + assert {e["slug_id"] for e in all_} >= {"del-a", "del-b"} + # plain GET still caps at 5 + assert len(_json("GET", "/api/history", jar=jar, base=base)) <= 5 + # loose str match: the int ep_id row is deleted via a string query param + removed = _json("DELETE", "/api/history?slug_id=del-a&ep_id=201", jar=jar, base=base) + assert removed["removed"] >= 1, removed + all_ = _json("GET", "/api/history?all=1", jar=jar, base=base) + assert all(e["slug_id"] != "del-a" for e in all_) + assert any(e["slug_id"] == "del-b" for e in all_) + # idempotent + assert _json("DELETE", "/api/history?slug_id=del-a&ep_id=201", + jar=jar, base=base)["removed"] == 0 + + def test_self_delete(base=BASE): jar = login("dave", "davepw12345", base) assert req("POST", "/account/delete", jar=jar, base=base) == 200 diff --git a/tests/test_browser.py b/tests/test_browser.py index ab72f11..2122456 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -5,7 +5,10 @@ Covers what HTTP tests can't: the login UI, the user chip, and real video playback behind auth. Needs: pip install playwright && playwright install chromium """ import asyncio +import http.cookiejar +import json import sys +import urllib.request from playwright.async_api import async_playwright @@ -81,8 +84,8 @@ async def test_video_plays_behind_auth(): await _login(page, "alice", "alicepw12345") await page.fill("#q", "chainsaw") await page.keyboard.press("Enter") - await page.wait_for_selector(".card", timeout=30000) - await page.click(".card", force=True) + await page.wait_for_selector("a.card", timeout=30000) + await page.click("a.card", force=True) await page.wait_for_selector("video", timeout=30000) await page.wait_for_function( "document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1", @@ -93,8 +96,73 @@ async def test_video_plays_behind_auth(): print("ok test_video_plays_behind_auth") +async def test_history_page_and_delete(): + # self-contained: seed erin's own entries over HTTP first + jar = http.cookiejar.CookieJar() + op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + op.open(urllib.request.Request(BASE + "/login", + data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(), + headers={"Content-Type": "application/json"}, method="POST")) + for e in ({"title": "Hist A", "slug_id": "hist-a", "season_id": 1, "ep_id": 7, + "lang": "eng", "ep": 7, "pos": 5}, + {"title": "Hist B", "slug_id": "hist-b", "season_id": 2, "ep_id": 9, + "lang": "jpn", "ep": 9, "pos": 30, "dur": 600}): + op.open(urllib.request.Request(BASE + "/api/history", + data=json.dumps(e).encode(), + headers={"Content-Type": "application/json"}, method="POST")) + + async with async_playwright() as p: + browser = await p.chromium.launch(args=LAUNCH_ARGS) + page = await (await browser.new_context()).new_page() + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + + await _login(page, "erin", "erinpw12345") + await page.goto(BASE + "/?history") + await page.wait_for_selector(".card.hist", timeout=15000) + await page.wait_for_function( + "document.querySelectorAll('.card.hist').length == 2", timeout=15000, polling=POLL) + await page.click(".card.hist .del", force=True) + await page.wait_for_function( + "document.querySelectorAll('.card.hist').length == 1", timeout=15000, polling=POLL) + await page.wait_for_selector(".toast", timeout=5000) + assert not errors, errors + await browser.close() + print("ok test_history_page_and_delete") + + +async def test_stream_failure_retry(): + async with async_playwright() as p: + browser = await p.chromium.launch(args=LAUNCH_ARGS) + page = await (await browser.new_context()).new_page() + + async def abort_stream(route): + await route.abort() + + # note: **/stream/* (single star) — must NOT match the + # /api/episode/{id}/stream token endpoint, only the /stream/{token} proxy + await page.route("**/stream/*", abort_stream) + await _login(page, "admin", "adminpw12345") + await page.fill("#q", "chainsaw") + await page.keyboard.press("Enter") + await page.wait_for_selector("a.card", timeout=30000) + await page.click("a.card", force=True) + # detail auto-plays ep1; with /stream aborted hls.js exhausts its + # retries and surfaces the fatal error -> failure overlay with Retry + await page.wait_for_selector(".pl-overlay button", timeout=90000) + await page.unroute("**/stream/*", abort_stream) + await page.click(".pl-overlay button", force=True) + await page.wait_for_function( + "document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1", + timeout=120000, polling=250) + await browser.close() + print("ok test_stream_failure_retry") + + if __name__ == "__main__": if len(sys.argv) > 1: BASE = sys.argv[1] asyncio.run(test_login_ui()) asyncio.run(test_video_plays_behind_auth()) + asyncio.run(test_history_page_and_delete()) + asyncio.run(test_stream_failure_retry())