diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 100e296..89d0367 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -659,8 +659,11 @@ def clear_video_state( class VideoProgressIn(BaseModel): - position_seconds: int = 0 - duration_seconds: int = 0 + # Accept a float and truncate (below) — the player may post a raw currentTime (a float), which + # the old `int(payload.get(...))` silently truncated. A plain `int` field would 422 that on the + # 10s progress hot path, losing the resume position; `float` keeps the old tolerance. + position_seconds: float = 0 + duration_seconds: float = 0 @router.post("/videos/{video_id}/progress") @@ -674,8 +677,8 @@ def set_video_progress( Stores a per-user position on the video_states row without touching watch status, so a partially-watched video can render a progress bar and match the "in progress" filter. Trivially-early and near-finished positions clear the position rather than store it.""" - position = body.position_seconds - duration = body.duration_seconds + position = int(body.position_seconds) + duration = int(body.duration_seconds) if position < 0: position = 0 diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 2cb148a..8c67e0e 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -1943,8 +1943,10 @@ def _push_watch( class ItemProgressIn(BaseModel): - position_seconds: int = 0 - duration_seconds: int = 0 + # float, truncated in the handler — the player may post a raw currentTime (a float); a plain + # `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated). + position_seconds: float = 0 + duration_seconds: float = 0 final: bool = False @@ -1962,8 +1964,8 @@ def item_progress( debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds.""" it = _item_or_404(db, rating_key) - position = max(0, body.position_seconds) - duration = body.duration_seconds + position = max(0, int(body.position_seconds)) + duration = int(body.duration_seconds) final = body.final now = datetime.now(timezone.utc) st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() diff --git a/backend/tests/test_request_models.py b/backend/tests/test_request_models.py index f45f73c..5819de0 100644 --- a/backend/tests/test_request_models.py +++ b/backend/tests/test_request_models.py @@ -6,6 +6,7 @@ from pydantic import ValidationError from app.routes.channels import AttachTagIn, ChannelUpdateIn from app.routes.downloads import AdminSetQuotaIn +from app.routes.feed import VideoProgressIn from app.routes.setup import SetupAdminIn from app.routes.tags import TagUpdateIn @@ -46,6 +47,15 @@ def test_admin_quota_rejects_non_numeric_limit(): AdminSetQuotaIn(max_bytes="lots") +def test_progress_accepts_fractional_seconds(): + # The player may post a raw currentTime (a float). The field must accept it (truncated in the + # handler) — a plain `int` field would 422 on the progress hot path. (code-review round 1.) + assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7 + assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12 + with pytest.raises(ValidationError): + VideoProgressIn(position_seconds="not-a-number") + + # --- PATCH models: model_fields_set separates "absent" from "explicit null". --- def test_tag_update_absent_vs_null_color():