From 81d21fdeac7f298649a810b9bc2721758ac8a85a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 26 Jul 2026 05:39:32 +0200 Subject: [PATCH] fix(api): accept fractional progress seconds (R6 S1b code-review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /code-review high round 1 on S1b — 5 findings, 1 fixed, 4 by-design/deferred: - FIXED: VideoProgressIn / ItemProgressIn position_seconds & duration_seconds typed `int` would 422 a raw float currentTime on the 10s progress checkpoint (the old `int(payload.get(...))` truncated it). Back to `float`, truncated in the handler; +test. The live frontend floors so this was latent, not live. By-design / deferred (no change): the 422 detail-array isn't localized by the FE (filed under R7); PATCH is-not-none (channels/quota, null-safe) vs model_fields_set (nullable-clear) is a deliberate split; missing-field→422 is the point of the epic; me/switch_account's numeric-string coercion is more correct and still session-gated. Gate: ruff clean, pytest 172 green. --- backend/app/routes/feed.py | 11 +++++++---- backend/app/routes/plex.py | 10 ++++++---- backend/tests/test_request_models.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) 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():