fix(api): progress checkpoints past the int4 column are a 422, and idle HLS sessions are reaped on a timer

Deep-review round 3 of bug hunt 2026-09.

- The YouTube and Plex progress bodies are bounded by POSITION_MAX_S (the int4 position columns): a
  finite position of 2^31 or more was a DataError, i.e. a 500 — the sibling of round 2's non-finite
  fix.
- plex/stream.py promised "a periodic reaper", but reap_idle ran only inside the NEXT session
  request: after the last playback an abandoned session (its ffmpeg still working, its directory a
  whole segmented copy of the film) lived until someone pressed play again. A lifespan task reaps
  every 60 s.
- Tests for what round 2 left untested: the 422's body keeps the list shape the UI localizes, and
  stream_session refuses non-finite start/aoff.
This commit is contained in:
2026-09-11 18:05:30 +02:00
parent 785080f3da
commit 86971c574d
6 changed files with 108 additions and 9 deletions
+22 -3
View File
@@ -81,6 +81,7 @@ async def lifespan(app: FastAPI):
# alone doesn't buy that; not waiting does. The sweep only touches directories no live session
# owns, so racing it against early playback is safe.
sweep = asyncio.create_task(_sweep_hls_orphans())
reaper = asyncio.create_task(_reap_hls_sessions())
start_scheduler()
try:
yield
@@ -90,15 +91,33 @@ async def lifespan(app: FastAPI):
# pending ("Task was destroyed but it is pending!" — noise that reads like a bug during
# triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own
# bookkeeping honest.
sweep.cancel()
with suppress(asyncio.CancelledError):
await sweep
for task in (sweep, reaper):
task.cancel()
with suppress(asyncio.CancelledError):
await task
shutdown_scheduler()
# Kills a running subtitle extraction: its worker would otherwise hold interpreter exit for
# the rest of the read (see plex_subtitles.shutdown).
plex_subtitles.shutdown()
# How often idle HLS sessions are reaped (see plex/stream.py `reap_idle`).
_HLS_REAP_EVERY_S = 60.0
async def _reap_hls_sessions() -> None:
"""The periodic reaper plex/stream.py's docstring promises. `reap_idle` used to run only inside
the NEXT session request, so after the last playback of the day an abandoned session — its
ffmpeg still working through the film, its directory a whole segmented copy of it — lived until
somebody pressed play again or the process restarted."""
while True:
await asyncio.sleep(_HLS_REAP_EVERY_S)
try:
await asyncio.to_thread(plex_stream.reap_idle)
except Exception:
log.exception("HLS session reap failed")
async def _sweep_hls_orphans() -> None:
"""Background half of the startup HLS sweep (see lifespan). Owns its own errors: a failure here
must never take the app down, and a cancelled shutdown is not one."""
+6
View File
@@ -24,6 +24,11 @@ from app.db import Base
from app.utils import canonical_email
# The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint
# routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500.
POSITION_MAX_S = 2**31 - 1
class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
@@ -423,6 +428,7 @@ class VideoState(Base, UpdatedAtMixin):
# status: a partially-watched video keeps status "new" but a non-zero position, so
# it can render a progress bar and match the "in progress" feed filter. 0 = not
# started / finished (trivially-early and near-finished positions are not stored).
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
position_seconds: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
+5 -3
View File
@@ -1,7 +1,7 @@
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -22,6 +22,7 @@ from app.feedquery import (
sort_keys,
)
from app.models import (
POSITION_MAX_S,
ChannelTag,
Tag,
User,
@@ -310,8 +311,9 @@ class VideoProgressIn(BaseModel):
# 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
# 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)
@router.post("/videos/{video_id}/progress")
+5 -3
View File
@@ -15,13 +15,14 @@ from fastapi import (
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
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,
@@ -293,8 +294,9 @@ class ItemProgressIn(BaseModel):
# 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
# 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