From 98fb4e5461652551ced7732bc88e1664e81e8c52 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 19 Aug 2026 18:41:13 +0000 Subject: [PATCH] features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches --- server/app.py | 34 +++++++++ static/app.js | 158 +++++++++++++++++++++++++++++++++++------- static/style.css | 17 ++++- tests/run.py | 2 + tests/test_api.py | 15 ++++ tests/test_browser.py | 68 ++++++++++++++++++ 6 files changed, 269 insertions(+), 25 deletions(-) diff --git a/server/app.py b/server/app.py index 49a9b06..c4db404 100644 --- a/server/app.py +++ b/server/app.py @@ -175,6 +175,40 @@ def api_history_post(entry: dict = Body(...), request: Request = None): return entry +SEARCH_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "searches" + + +def _load_searches(user: str): + try: + return json.loads((SEARCH_DIR / f"{user}.json").read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + +def _save_searches(user: str, items): + SEARCH_DIR.mkdir(exist_ok=True) + (SEARCH_DIR / f"{user}.json").write_text(json.dumps(items, indent=1)) + + +@app.get("/api/searches") +def api_searches(request: Request): + return JSONResponse(_load_searches(request.state.user), + headers={"Cache-Control": "no-store"}) + + +@app.post("/api/searches") +def api_searches_post(item: dict = Body(...), request: Request = None): + q = str(item.get("q", "")).strip()[:60] + if not q: + raise HTTPException(422, "q required") + u = request.state.user + # dedupe case-insensitively, newest first, cap 8 + items = [x for x in _load_searches(u) if x.lower() != q.lower()] + items.insert(0, q) + _save_searches(u, items[:8]) + return {"ok": True} + + def _b64(url: str) -> str: return base64.urlsafe_b64encode(url.encode()).decode() diff --git a/static/app.js b/static/app.js index add44af..e783f64 100644 --- a/static/app.js +++ b/static/app.js @@ -138,7 +138,13 @@ function showHome() { } // ---------- search ---------- +function saveSearch(q) { + fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({q})}).catch(() => {}); +} + async function showSearch(q) { + saveSearch(q); skel(8); const results = await api(`/api/search?q=${encodeURIComponent(q)}`); main.innerHTML = ""; @@ -155,6 +161,31 @@ async function showSearch(q) { main.append(grid); } +// ---------- recent searches dropdown ---------- +function initRecent() { + const q = document.getElementById("q"); + const box = el("div", "recent"); + q.closest("form").append(box); + let items = []; + const render = () => { + const v = q.value.trim().toLowerCase(); + const list = items.filter(s => !v || s.toLowerCase().includes(v)).slice(0, 8); + box.replaceChildren(); + if (!list.length) { box.classList.remove("open"); return; } + for (const s of list) { + const b = el("button", null, s); + b.onclick = () => { q.value = s; location.href = "?q=" + encodeURIComponent(s); }; + box.append(b); + } + box.classList.add("open"); + }; + const hide = () => box.classList.remove("open"); + q.addEventListener("focus", () => api("/api/searches").then(r => { items = r; render(); }).catch(hide)); + q.addEventListener("input", render); + document.addEventListener("click", e => { if (!e.target.closest("header form")) hide(); }); + document.addEventListener("keydown", e => { if (e.key === "Escape") hide(); }); +} + // ---------- detail + player ---------- async function showAnime(slug_id, opts = {}) { skel(); @@ -177,13 +208,34 @@ async function showAnime(slug_id, opts = {}) { const controls = el("div", "controls"); const langBtn = el("button", null, langLabel()); - const setLang = l => { lang = l; langBtn.textContent = langLabel(); }; langBtn.onclick = () => { - setLang(lang === "eng" ? "jpn" : "eng"); + lang = lang === "eng" ? "jpn" : "eng"; + langBtn.textContent = langLabel(); const cur = main.querySelector(".ep.active"); if (cur) play(cur.dataset.ep, cur.dataset.no); }; - controls.append(langBtn, el("span", "muted", " — click an episode to play")); + const spd = el("select", "spd"); + for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r)); + spd.value = localStorage.getItem("aniweb_spd") || "1"; + spd.title = "playback speed"; + spd.onchange = () => { + localStorage.setItem("aniweb_spd", spd.value); + const v = document.querySelector("#player video"); + if (v) v.playbackRate = +spd.value; + }; + const prevB = el("button", "tiny", "◀ prev"); + const nextB = el("button", "tiny", "next ▶"); + prevB.onclick = () => stepEp(-1); + nextB.onclick = () => stepEp(1); + const fillB = el("button", "tiny", "hide fillers"); + fillB.onclick = () => { + hideFiller = !hideFiller; + fillB.textContent = hideFiller ? "show fillers" : "hide fillers"; + fillB.classList.toggle("on", hideFiller); + renderEps(); + }; + controls.append(langBtn, spd, prevB, nextB, fillB, + el("span", "muted", " — click an episode to play")); main.append(controls); const tabs = el("div", "tabs"); @@ -203,27 +255,14 @@ async function showAnime(slug_id, opts = {}) { 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; - if (e.filler) b.append(el("span", "badge", " filler")); - b.dataset.ep = e.id; b.dataset.no = e.number; - b.onclick = () => { markActive(b); play(e.id, e.number); }; - eps.append(b); - } - if (opts.ep) { - const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep); - if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); } - } else { - // default: resume last watched ep of this season, else start at ep 1 - const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId); - const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep; - if (auto) { - const cur = [...eps.children].find(b => +b.dataset.ep === auto); - if (cur) { markActive(cur); play(auto, cur.dataset.no); } - } + // default: resume last watched ep of this season, else start at ep 1 + epsList = await api(`/api/anime/${seasonId}/episodes`); + renderEps(); + const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId); + const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id; + if (start) { + const cur = [...eps.children].find(b => +b.dataset.ep === start); + if (cur) { markActive(cur); play(start, cur.dataset.no); } } if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis)); @@ -275,6 +314,33 @@ function markActive(b) { b.classList.add("active"); } +let epsList = [], hideFiller = false; + +function renderEps() { + const eps = document.getElementById("eps"); + if (!eps) return; + eps.innerHTML = ""; + for (const e of epsList) { + if (hideFiller && e.filler) continue; + const b = el("button", "ep" + (e.filler ? " filler" : "")); + b.textContent = "E" + e.number; + if (e.filler) b.append(el("span", "badge", " filler")); + b.dataset.ep = e.id; b.dataset.no = e.number; + b.onclick = () => { markActive(b); play(e.id, e.number); }; + eps.append(b); + } +} + +function stepEp(d) { + const vis = epsList.filter(e => !(hideFiller && e.filler)); + if (!vis.length || !currentEp) return; + const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id)); + const n = vis[i + d]; + if (!n) return; + const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === n.id); + if (b) { markActive(b); play(n.id, n.number); } +} + function videoEl() { const v = document.createElement("video"); v.controls = true; v.autoplay = true; @@ -307,12 +373,54 @@ function playerFail(msg) { playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no)); } +// ---------- auto-next on episode end (5s countdown) ---------- +let nextTimer = null; +function onEnded() { + if (!currentEp || playerBroken) return; + const vis = epsList.filter(e => !(hideFiller && e.filler)); + const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id)); + const nxt = vis[i + 1]; + if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; } + nextCountdown(nxt); +} + +function nextCountdown(nxt) { + const p = document.getElementById("player"); + let o = p.querySelector(".pl-overlay"); + if (!o) { o = el("div", "pl-overlay"); p.append(o); } + let n = 5; + const done = () => { + if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; } + const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id); + if (b) markActive(b); + play(nxt.id, nxt.number); + }; + const cancel = () => { + if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; } + playerHideOverlay(); + }; + const draw = () => { + o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`)); + const s = el("button", "btn", "Skip"); + s.onclick = done; + const x = el("button", "cnt-x", "\u2715"); + x.title = "cancel auto-next"; + x.onclick = cancel; + o.append(s, x); + if (n <= 0) { done(); return; } + n--; + nextTimer = setTimeout(draw, 1000); + }; + draw(); +} + 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(); + v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1); document.getElementById("player").replaceChildren(v); playerOverlay("Loading\u2026"); if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser"); @@ -338,6 +446,7 @@ async function play(ep_id, ep_no) { v.addEventListener("waiting", () => { if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026"); }); + v.addEventListener("ended", onEnded); 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, @@ -378,6 +487,7 @@ function showPlayOverlay(v) { try { const me = await api("/api/me"); document.getElementById("me").textContent = "hi, " + me.user; + initRecent(); const lo = document.getElementById("logout"); lo.onclick = async () => { await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1"; diff --git a/static/style.css b/static/style.css index cafbbd9..dfcb2dd 100644 --- a/static/style.css +++ b/static/style.css @@ -65,13 +65,28 @@ h2 { margin: 0.4rem 0; } .player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; } .play-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 1.1rem; padding: 0.8rem 1.4rem; background: rgba(0, 0, 0, 0.75); } -.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; } +.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; flex-wrap: wrap; } button { background: var(--card); color: var(--fg); border: 1px solid var(--bd); border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; } button:hover { border-color: var(--acc); } .tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; } .tab { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* recent searches dropdown + player extras */ +header form { position: relative; } +.recent { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; + background: var(--card); border: 1px solid var(--bd); border-radius: 8px; + margin-top: .3rem; display: none; max-height: 240px; overflow: auto; } +.recent.open { display: block; } +.recent button { display: block; width: 100%; text-align: left; padding: .5rem .8rem; + background: none; border: 0; color: var(--fg); cursor: pointer; font-size: .9rem; } +.recent button:hover { background: rgba(255, 255, 255, 0.06); } +.spd { padding: .2rem .3rem; background: var(--card); color: var(--fg); + border: 1px solid var(--bd); border-radius: 6px; font-size: .8rem; } +.cnt-x { background: none; border: 0; color: var(--muted); font-size: 1.1rem; + cursor: pointer; padding: .2rem .5rem; } +button.tiny.on { border-color: var(--acc); color: var(--acc); } + .tab.active { border-color: var(--acc); color: var(--acc); } /* loading + feedback */ diff --git a/tests/run.py b/tests/run.py index 40dc43c..c070705 100644 --- a/tests/run.py +++ b/tests/run.py @@ -76,6 +76,8 @@ def main(): 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()) + asyncio.run(test_browser.test_recent_searches()) + asyncio.run(test_browser.test_auto_next_countdown()) print("ALL TESTS PASSED") finally: diff --git a/tests/test_api.py b/tests/test_api.py index 028e9d6..9fca1d6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -138,6 +138,21 @@ def test_history_all_and_delete(base=BASE): jar=jar, base=base)["removed"] == 0 +def test_recent_searches(base=BASE): + a = login("alice", "alicepw12345", base) + assert req("POST", "/api/searches", {"q": "naruto"}, jar=a, base=base) == 200 + assert req("POST", "/api/searches", {"q": "frieren"}, jar=a, base=base) == 200 + # case-insensitive dedupe, newest first + assert req("POST", "/api/searches", {"q": "NARUTO"}, jar=a, base=base) == 200 + assert _json("GET", "/api/searches", jar=a, base=base) == ["NARUTO", "frieren"] + # per-user isolation (browser tests write admin's searches, but only after + # the API phase, so admin is clean here) + b = login("admin", "adminpw12345", base) + assert _json("GET", "/api/searches", jar=b, base=base) == [] + # blank q rejected + assert req("POST", "/api/searches", {"q": " "}, jar=a, base=base) == 422 + + 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 2122456..20aca78 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -159,6 +159,72 @@ async def test_stream_failure_retry(): print("ok test_stream_failure_retry") +async def test_recent_searches(): + # UI: focus the (empty) search box -> dropdown of saved searches; + # typing filters; clicking one runs the search. + jar = http.cookiejar.CookieJar() + op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + op.open(urllib.request.Request(BASE + "/login", + data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(), + headers={"Content-Type": "application/json"}, method="POST")) + op.open(urllib.request.Request(BASE + "/api/searches", + data=json.dumps({"q": "chainsaw"}).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, "alice", "alicepw12345") + await page.focus("#q") + await page.wait_for_selector(".recent.open", timeout=10000) + assert "chainsaw" in await page.inner_text(".recent") + # typing narrows the list to the matching entry + await page.fill("#q", "cha") + await page.wait_for_function( + "document.querySelectorAll('.recent button').length === 1", + timeout=5000, polling=POLL) + await page.click(".recent button", force=True) + await page.wait_for_selector("a.card", timeout=30000) + assert "q=chainsaw" in page.url, page.url + assert not errors, errors + await browser.close() + print("ok test_recent_searches") + + +async def test_auto_next_countdown(): + # playing a full episode to its natural end is too slow; simulate the + # `ended` event and verify the countdown overlay + cancel path. + 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, "admin", "adminpw12345") + # straight to the 12-ep series (the first search card is the 1-ep + # movie — no next episode there) + await page.goto(BASE + "/?anime=chainsaw-man-922") + await page.wait_for_selector("video", timeout=30000) + await page.wait_for_function( + "document.querySelector('video').readyState >= 4", + timeout=120000, polling=250) # cold CDN/upstream starts can be slow + await page.evaluate( + "document.querySelector('video').dispatchEvent(new Event('ended'))") + await page.wait_for_selector(".pl-overlay .btn", timeout=10000) + assert "Next: E2" in await page.inner_text(".pl-overlay") + # cancel (x): overlay gone, still on the same episode + await page.click(".pl-overlay .cnt-x", force=True) + await page.wait_for_function( + "!document.querySelector('.pl-overlay')", timeout=5000, polling=POLL) + assert "E1" in await page.inner_text(".ep.active") + assert not errors, errors + await browser.close() + print("ok test_auto_next_countdown") + + if __name__ == "__main__": if len(sys.argv) > 1: BASE = sys.argv[1] @@ -166,3 +232,5 @@ if __name__ == "__main__": asyncio.run(test_video_plays_behind_auth()) asyncio.run(test_history_page_and_delete()) asyncio.run(test_stream_failure_retry()) + asyncio.run(test_recent_searches()) + asyncio.run(test_auto_next_countdown())