refactor(plex): the item progress body extends the shared checkpoint

ItemProgressIn repeated ProgressIn's config, fields and bounds; it now adds only `final`. The
threshold comment points where the constants live now, and plexProgressBeacon's comment is back
on it.
This commit is contained in:
2026-10-07 23:16:38 +02:00
parent dd6a666ad1
commit ee5290ba01
2 changed files with 13 additions and 21 deletions
+9 -17
View File
@@ -15,7 +15,7 @@ from fastapi import (
Response, Response,
) )
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session 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.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import ( from app.models import (
POSITION_MAX_S,
PlexItem, PlexItem,
PlexSeason, PlexSeason,
PlexShow, PlexShow,
@@ -39,7 +38,7 @@ from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.plex._shared import item_or_404 from app.routes.plex._shared import item_or_404
from app.refusal import refused 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"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -160,11 +159,11 @@ def subtitle(
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt") 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 # Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is
# _FINISH_MARGIN_S is DELIBERATELY larger than the feed's FINISH_MARGIN_SECONDS (30 vs 10): this is # DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content
# long-form content (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas # (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the
# the feed's margin only clears a short video's resume bar — different domains, intentional divergence, # library's margin only clears a resume bar — different domains, intentional divergence, not drift.
# not drift. Keep them separate on purpose (see routes/feed.py). # Keep them separate on purpose (see routes/_common.py).
_PROGRESS_MIN_S = 5 _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30 _FINISH_MARGIN_S = 30
@@ -337,16 +336,9 @@ def _mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bo
return False return False
class ItemProgressIn(BaseModel): class ItemProgressIn(ProgressIn):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned """The shared checkpoint (its bounds included), plus whether the player is leaving the item."""
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# 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 final: bool = False
+4 -4
View File
@@ -720,10 +720,6 @@ export const plexApi = {
method: "POST", method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds, final }), 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. // 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 // 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. // 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`, `/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: ( plexProgressBeacon: (
id: string, id: string,
position_seconds: number, position_seconds: number,