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.
This commit is contained in:
1 parent
19e878800c
commit
2ec0f8a034
10 files changed
+414
-20
No files matched your search
@@ -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 `<p class="error">` **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`: `<div id="toasts"></div>`.
|
||||
- `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 `<a>` 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=<slug>`; 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`.
|
||||
@@ -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/<user>.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
|
||||
|
||||
+17
-4
@@ -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")
|
||||
|
||||
+105
-8
@@ -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); }
|
||||
})();
|
||||
+2
-1
@@ -8,7 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="logo" href="/" onclick="location.replace('/');return false">ani-web</a>
|
||||
<a class="logo" href="/">ani-web</a>
|
||||
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
|
||||
<input id="q" placeholder="search anime…" autocomplete="off">
|
||||
</form>
|
||||
@@ -22,6 +22,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
|
||||
<div id="toasts"></div>
|
||||
<script src="hls.min.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p class="banner" id="banner" hidden></p>
|
||||
<form onsubmit="event.preventDefault(); login()">
|
||||
<h1>ani-web</h1>
|
||||
<input id="user" placeholder="username" autocomplete="username">
|
||||
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
-2
@@ -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())
|
||||
Reference in new issue
Block a user