diff --git a/static/app.js b/static/app.js index 3cae04b..443e462 100644 --- a/static/app.js +++ b/static/app.js @@ -635,6 +635,10 @@ function playerHideOverlay() { function playerFail(msg) { if (!currentEp) return; playerBroken = true; + // a fatal error stops the periodic saves (timeupdate/pause never come + // again); persist the position now or the user loses everything since + // the last 10s save when they leave + savePos(); const { ep_id, ep_no } = currentEp; postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang}); playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no)); diff --git a/tests/test_browser.py b/tests/test_browser.py index 327cceb..6327a59 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -157,22 +157,47 @@ async def _stream_failure_retry(): 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) + # detail auto-plays ep1. Wait only for playlist metadata (duration + # comes from the VOD playlist, NOT from segment bytes) — this must + # work even when the CDN is slow/flaky on segments await page.wait_for_function( - "document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1", + "document.querySelector('video')?.duration > 0", timeout=120000, polling=250) + # wipe this show's history: the initial play() write (and any entry + # left by earlier tests) would otherwise mask whether the error-time + # save happened — from here on the only write can be that save + slug = await page.evaluate("new URLSearchParams(location.search).get('anime')") + h0 = await page.evaluate("fetch('api/history?all=1').then(r => r.json())") + for e in (x for x in h0 if x["slug_id"] == slug): + await page.evaluate( + "([s, ep]) => fetch('api/history?slug_id=' + encodeURIComponent(s) + '&ep_id=' + ep," + " {method:'DELETE', headers:{'X-Aniweb':'1'}})", [e["slug_id"], e["ep_id"]]) + # seek to 6s: below the 10s periodic-save threshold (and above the + # 3s minimum), so NO periodic save can fire — only an error-time + # save could persist ~6s. The seek reflects immediately even though + # the bytes never arrive (we kill /stream next) + await page.evaluate("document.querySelector('video').currentTime = 6") + # kill the stream: hls.js's frag loads abort, retries exhaust, + # fatal error -> playerFail, which must save the position + # (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 page.wait_for_selector(".pl-overlay button", timeout=90000) + # pre-fix nothing was written after the DELETE above + hist = await page.evaluate("fetch('api/history?all=1').then(r => r.json())") + assert any(e["slug_id"] == slug and e["pos"] >= 5 for e in hist), hist + # and the player is recoverable: Retry re-invokes play(), which + # starts with a fresh token fetch (assert that — not segment bytes, + # which a hanging CDN would make environmental; real playback is + # covered by the other video tests) + await page.unroute("**/stream/*", abort_stream) + async with page.expect_request("**/api/episode/*/stream*", timeout=30000): + await page.click(".pl-overlay button", force=True) await browser.close()