Fix episode position logging/resume: NaN savePos, clobber-on-boot, unvalidated history writes

- app.js: pause/pagehide listeners passed the Event into savePos(floor),
  making pos NaN -> serialized as null -> every pause/unload clobbered the
  saved position. Wrap in arrows; pagehide now posts keepalive so the final
  save survives page unload
- app.js: play() re-fetches history on a miss (boot fetch swallows errors)
  and only bumps an existing row instead of posting pos:0 at start-of-play
- app.py: POST /api/history requires slug_id (was stored as "None"),
  validates/clamps pos; upsert keeps the old payload when the incoming
  pos is null so stale clients can't corrupt a row
- tests: +3 API tests (slug required, pos clamping, null-pos keeps saved),
  +1 end-to-end browser test (resume ~400s with a failed boot fetch);
  lockout test now uses a throwaway username so it can't lock out users
  the browser tests log in with; hardened the flaky autoplay test
This commit is contained in:
jozamudi committed 2026-09-14 00:54:31 +00:00
1 parent 7444e29592
commit d90b1af8d6
4 files changed
+156 -23

No files matched your search

+23 -2
View File
@@ -10,6 +10,7 @@ import os
import subprocess
import threading
import time
from math import isfinite
from collections import Counter, OrderedDict
from datetime import datetime
from pathlib import Path
@@ -271,6 +272,25 @@ def api_history_post(entry: dict = Body(...), request: Request = None):
entry["ep_id"] = int(entry["ep_id"])
except (TypeError, ValueError):
raise HTTPException(422, "ep_id must be an integer")
slug = str(entry.get("slug_id") or "").strip()
if not slug:
raise HTTPException(422, "slug_id required") # a missing slug used to store a "None" row
entry["slug_id"] = slug
if entry.get("pos") is not None:
try:
pos = max(0.0, float(entry["pos"]))
except (TypeError, ValueError):
raise HTTPException(422, "pos must be a number")
if not isfinite(pos):
raise HTTPException(422, "pos not finite")
if entry.get("dur"): # clamp above-duration junk out of the progress UI
try:
pos = min(pos, float(entry["dur"]))
except (TypeError, ValueError):
pass
entry["pos"] = pos
# a missing/null pos stays null: the upsert keeps the existing row's
# payload, so a stale client can never clobber the saved position
entry["t"] = time.time()
u = request.state.user
c = db.connect()
@@ -278,8 +298,9 @@ def api_history_post(entry: dict = Body(...), request: Request = None):
# read-modify-write (and no lost-update race between the read and write)
c.execute("INSERT INTO history (user, slug_id, ep_id, t, payload) VALUES (?,?,?,?,?) "
"ON CONFLICT(user, slug_id, ep_id) DO UPDATE SET "
"t=excluded.t, payload=excluded.payload",
(u, str(entry.get("slug_id")), entry["ep_id"], entry["t"], json.dumps(entry)))
"t=excluded.t, payload=CASE WHEN json_extract(excluded.payload, '$.pos') IS NULL "
"THEN history.payload ELSE excluded.payload END",
(u, entry["slug_id"], entry["ep_id"], entry["t"], json.dumps(entry)))
# cap: newest 50 per user
c.execute("DELETE FROM history WHERE user=? AND rowid NOT IN "
"(SELECT rowid FROM history WHERE user=? ORDER BY t DESC LIMIT 50)", (u, u))