diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 72dcd58c..3ecada48 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -15,7 +15,7 @@ from fastapi import ( Response, ) from fastapi.responses import FileResponse -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel from sqlalchemy import select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -23,7 +23,6 @@ from sqlalchemy.orm import Session from app.auth import current_user, resolved_user_id from app.db import SessionLocal, get_db from app.models import ( - POSITION_MAX_S, PlexItem, PlexSeason, PlexShow, @@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.routes.plex._shared import item_or_404 from app.refusal import refused -from app.routes._common import not_found +from app.routes._common import ProgressIn, not_found router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -160,11 +159,11 @@ def subtitle( return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt") -# Resume/watched thresholds. _PROGRESS_MIN_S matches the feed's PROGRESS_MIN_SECONDS (5), but -# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is -# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas -# the feed's margin only clears a short video's resume bar — different domains, intentional divergence, -# not drift. Keep them separate on purpose (see routes/feed.py). +# Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is +# DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content +# (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the +# library's margin only clears a resume bar — different domains, intentional divergence, not drift. +# Keep them separate on purpose (see routes/_common.py). _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 @@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo return False -class ItemProgressIn(BaseModel): - # Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned - # one into a 500. - model_config = ConfigDict(allow_inf_nan=False) +class ItemProgressIn(ProgressIn): + """The shared checkpoint (its bounds included), plus whether the player is leaving the item.""" - # 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). - # Bounded above by the int4 column (see POSITION_MAX_S): past it the flush was a 500. - position_seconds: float = Field(0, le=POSITION_MAX_S) - duration_seconds: float = Field(0, le=POSITION_MAX_S) final: bool = False diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 2a058a51..a31ffd93 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -720,10 +720,6 @@ export const plexApi = { method: "POST", body: JSON.stringify({ position_seconds, duration_seconds, final }), }), - // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is - // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position - // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we - // replicate req()'s credentials + account header (no retry — there's no page left to retry on). // The player no longer wants this HLS session: its ffmpeg stops now instead of at the idle reaper. // A keepalive POST, so it also goes out from a page being unloaded. The server only stops it while // it is still the viewer's registered session for the item. @@ -732,6 +728,10 @@ export const plexApi = { `/api/plex/stream/${encodeURIComponent(id)}/session/${encodeURIComponent(sessionId)}/stop`, {}, ), + // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is + // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position + // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we + // replicate req()'s credentials + account header (no retry — there's no page left to retry on). plexProgressBeacon: ( id: string, position_seconds: number,