features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches
This commit is contained in:
1 parent
9fdf3944fc
commit
98fb4e5461
6 files changed
+269
-25
No files matched your search
@@ -175,6 +175,40 @@ def api_history_post(entry: dict = Body(...), request: Request = None):
|
|||||||
return entry
|
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:
|
def _b64(url: str) -> str:
|
||||||
return base64.urlsafe_b64encode(url.encode()).decode()
|
return base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
|
||||||
|
|||||||
+134
-24
@@ -138,7 +138,13 @@ function showHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- search ----------
|
// ---------- search ----------
|
||||||
|
function saveSearch(q) {
|
||||||
|
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({q})}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
async function showSearch(q) {
|
async function showSearch(q) {
|
||||||
|
saveSearch(q);
|
||||||
skel(8);
|
skel(8);
|
||||||
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
@@ -155,6 +161,31 @@ async function showSearch(q) {
|
|||||||
main.append(grid);
|
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 ----------
|
// ---------- detail + player ----------
|
||||||
async function showAnime(slug_id, opts = {}) {
|
async function showAnime(slug_id, opts = {}) {
|
||||||
skel();
|
skel();
|
||||||
@@ -177,13 +208,34 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
|
|
||||||
const controls = el("div", "controls");
|
const controls = el("div", "controls");
|
||||||
const langBtn = el("button", null, langLabel());
|
const langBtn = el("button", null, langLabel());
|
||||||
const setLang = l => { lang = l; langBtn.textContent = langLabel(); };
|
|
||||||
langBtn.onclick = () => {
|
langBtn.onclick = () => {
|
||||||
setLang(lang === "eng" ? "jpn" : "eng");
|
lang = lang === "eng" ? "jpn" : "eng";
|
||||||
|
langBtn.textContent = langLabel();
|
||||||
const cur = main.querySelector(".ep.active");
|
const cur = main.querySelector(".ep.active");
|
||||||
if (cur) play(cur.dataset.ep, cur.dataset.no);
|
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);
|
main.append(controls);
|
||||||
|
|
||||||
const tabs = el("div", "tabs");
|
const tabs = el("div", "tabs");
|
||||||
@@ -203,27 +255,14 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
eps.append(el("p", "muted", "loading episodes\u2026"));
|
eps.append(el("p", "muted", "loading episodes\u2026"));
|
||||||
main.append(eps);
|
main.append(eps);
|
||||||
|
|
||||||
const list = await api(`/api/anime/${seasonId}/episodes`);
|
// default: resume last watched ep of this season, else start at ep 1
|
||||||
eps.innerHTML = "";
|
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
||||||
for (const e of list) {
|
renderEps();
|
||||||
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
|
||||||
b.textContent = "E" + e.number;
|
const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id;
|
||||||
if (e.filler) b.append(el("span", "badge", " filler"));
|
if (start) {
|
||||||
b.dataset.ep = e.id; b.dataset.no = e.number;
|
const cur = [...eps.children].find(b => +b.dataset.ep === start);
|
||||||
b.onclick = () => { markActive(b); play(e.id, e.number); };
|
if (cur) { markActive(cur); play(start, cur.dataset.no); }
|
||||||
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); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
||||||
@@ -275,6 +314,33 @@ function markActive(b) {
|
|||||||
b.classList.add("active");
|
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() {
|
function videoEl() {
|
||||||
const v = document.createElement("video");
|
const v = document.createElement("video");
|
||||||
v.controls = true; v.autoplay = true;
|
v.controls = true; v.autoplay = true;
|
||||||
@@ -307,12 +373,54 @@ function playerFail(msg) {
|
|||||||
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
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) {
|
async function play(ep_id, ep_no) {
|
||||||
playerBroken = false;
|
playerBroken = false;
|
||||||
try {
|
try {
|
||||||
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
||||||
stopHls();
|
stopHls();
|
||||||
const v = videoEl();
|
const v = videoEl();
|
||||||
|
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
|
||||||
document.getElementById("player").replaceChildren(v);
|
document.getElementById("player").replaceChildren(v);
|
||||||
playerOverlay("Loading\u2026");
|
playerOverlay("Loading\u2026");
|
||||||
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
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", () => {
|
v.addEventListener("waiting", () => {
|
||||||
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
|
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
|
||||||
});
|
});
|
||||||
|
v.addEventListener("ended", onEnded);
|
||||||
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
||||||
watchPos(v);
|
watchPos(v);
|
||||||
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||||||
@@ -378,6 +487,7 @@ function showPlayOverlay(v) {
|
|||||||
try {
|
try {
|
||||||
const me = await api("/api/me");
|
const me = await api("/api/me");
|
||||||
document.getElementById("me").textContent = "hi, " + me.user;
|
document.getElementById("me").textContent = "hi, " + me.user;
|
||||||
|
initRecent();
|
||||||
const lo = document.getElementById("logout");
|
const lo = document.getElementById("logout");
|
||||||
lo.onclick = async () => {
|
lo.onclick = async () => {
|
||||||
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1";
|
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1";
|
||||||
|
|||||||
+16
-1
@@ -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; }
|
.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%);
|
.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); }
|
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);
|
button { background: var(--card); color: var(--fg); border: 1px solid var(--bd);
|
||||||
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
|
||||||
button:hover { border-color: var(--acc); }
|
button:hover { border-color: var(--acc); }
|
||||||
|
|
||||||
.tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; }
|
.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; }
|
.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); }
|
.tab.active { border-color: var(--acc); color: var(--acc); }
|
||||||
|
|
||||||
/* loading + feedback */
|
/* loading + feedback */
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ def main():
|
|||||||
asyncio.run(test_browser.test_video_plays_behind_auth())
|
asyncio.run(test_browser.test_video_plays_behind_auth())
|
||||||
asyncio.run(test_browser.test_history_page_and_delete())
|
asyncio.run(test_browser.test_history_page_and_delete())
|
||||||
asyncio.run(test_browser.test_stream_failure_retry())
|
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")
|
print("ALL TESTS PASSED")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -138,6 +138,21 @@ def test_history_all_and_delete(base=BASE):
|
|||||||
jar=jar, base=base)["removed"] == 0
|
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):
|
def test_self_delete(base=BASE):
|
||||||
jar = login("dave", "davepw12345", base)
|
jar = login("dave", "davepw12345", base)
|
||||||
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
||||||
|
|||||||
@@ -159,6 +159,72 @@ async def test_stream_failure_retry():
|
|||||||
print("ok 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 __name__ == "__main__":
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
BASE = sys.argv[1]
|
BASE = sys.argv[1]
|
||||||
@@ -166,3 +232,5 @@ if __name__ == "__main__":
|
|||||||
asyncio.run(test_video_plays_behind_auth())
|
asyncio.run(test_video_plays_behind_auth())
|
||||||
asyncio.run(test_history_page_and_delete())
|
asyncio.run(test_history_page_and_delete())
|
||||||
asyncio.run(test_stream_failure_retry())
|
asyncio.run(test_stream_failure_retry())
|
||||||
|
asyncio.run(test_recent_searches())
|
||||||
|
asyncio.run(test_auto_next_countdown())
|
||||||
Reference in new issue
Block a user