diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a6e808..050c31b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.102.0 — 2026-10-08 + +### New + +- The library and shared-link players answer the keyboard's and the system's media keys, and resume where you stopped. + +### Fixed + +- Plex player: the browser Back closes the panels over the video one at a time, and only the credits at the very end move on to the next episode. Plus smaller fixes around resume points and stopping a stream. + ## 0.101.0 — 2026-10-06 ### New diff --git a/CLAUDE.md b/CLAUDE.md index b5d30889..ae8fa045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `` hand-painted like a button | | FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached | +| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player | | FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded | | FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first | | FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` | diff --git a/README.md b/README.md index 1869a810..80ab33b7 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ blocker and SponsorBlock keep working. - **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions. - **In-app player** with resume, plus keyboard/scroll controls. - **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format - presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save - to your device, share with another user, or hand out a public watch link. + presets, per-user storage quota), trim / crop / split & join them in a built-in editor, play them + where you left off, then save to your device, share with another user, or hand out a public watch + link. - **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its movies and shows alongside your feed, with watch state kept in sync. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. diff --git a/VERSION b/VERSION index 0f00ab7c..7bb21aff 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.101.0 +0.102.0 diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 9ffc4757..1987e095 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -743,14 +743,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 47, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/backend/alembic/versions/0099_library_states.py b/backend/alembic/versions/0099_library_states.py new file mode 100644 index 00000000..9781b912 --- /dev/null +++ b/backend/alembic/versions/0099_library_states.py @@ -0,0 +1,42 @@ +"""library: a resume point per user and library item + +The library player opened every item at 0:00. `library_states` keeps where a user stopped, per job +(their own download, or one shared with them) — the same shape as `video_states`' position, without a +status: the library has no "watched". It starts empty; the downgrade drops it. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0099_library_states" +down_revision: str | None = "0098_drop_demo_account" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "library_states", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "job_id", + sa.Integer(), + sa.ForeignKey("download_jobs.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("position_seconds", sa.Integer(), nullable=False), + sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("user_id", "job_id", name="uq_library_user_job"), + ) + op.create_index("ix_library_states_job_id", "library_states", ["job_id"]) + + +def downgrade() -> None: + op.drop_index("ix_library_states_job_id", table_name="library_states") + op.drop_table("library_states") diff --git a/backend/alembic/versions/0100_drop_plex_item_markers.py b/backend/alembic/versions/0100_drop_plex_item_markers.py new file mode 100644 index 00000000..03991be6 --- /dev/null +++ b/backend/alembic/versions/0100_drop_plex_item_markers.py @@ -0,0 +1,22 @@ +"""plex_items: drop `markers`, a column nothing writes or reads + +Intro/credits markers are read live from Plex for the item the player opens (with Plex's `final` flag +on the block that ends it); this cached copy was never filled — every row is NULL. The downgrade +restores the empty column. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0100_drop_plex_item_markers" +down_revision: str | None = "0099_library_states" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.drop_column("plex_items", "markers") + + +def downgrade() -> None: + op.add_column("plex_items", sa.Column("markers", sa.JSON(), nullable=True)) diff --git a/backend/app/models.py b/backend/app/models.py index 4c799a33..1a4e06db 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -462,6 +462,27 @@ class VideoState(Base, UpdatedAtMixin): progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class LibraryState(Base): + """Where a user stopped in a library item (their own download, or one shared with them) — the + library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry, + a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive + both. A revoked share leaves its recipient's row behind, unread — only a job the user can still + reach is ever read or written — and it is back if the item is shared again. Rows exist only for a + position worth keeping (see `routes._common.kept_position`); no row means "from the start".""" + + __tablename__ = "library_states" + __table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + job_id: Mapped[int] = mapped_column( + ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True + ) + # 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, nullable=False) + progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + class SearchFind(Base, TimestampMixin): """Per-user record of a video the user surfaced via their own live YouTube search. @@ -1253,8 +1274,6 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): # bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are # probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`. media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - # Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...] - markers: Mapped[list | None] = mapped_column(JSON) added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) # Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls; # `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index daf89f94..043539e7 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -744,8 +744,9 @@ def start_session( # `replace_only` makes the supersede identity-aware for the runtime fallback: the whole # check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent # seek-restart) installed between the fallback's decision and here is never clobbered. When the - # registered session is no longer `replace_only`, we abort and return None (the caller yields to - # whoever is registered now). The INITIAL spawn never gets None — see its call site. + # registered session is no longer `replace_only`, we abort and return None, and the caller + # answers with its own dead session — never the newer one, whose id a stale requester would + # stop. The INITIAL spawn never gets None — see its call site. # Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and # remux ignore preset/crf/max_height, and remux must not touch the DB for them). preset, crf, max_height = "veryfast", 20, 0 @@ -840,12 +841,13 @@ def start_session( ) respawned = _spawn(False, False, replace_only=s) if respawned is None: - # Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or - # our own now-dead session if the key was cap-culled. Never None here: the POST /session route - # turns a None return into a "Media file not found on disk" 404 — dishonest for a file that - # exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" / - # "Not available yet") the client re-requests. - return current_session(user_id, key) or s + # Superseded/culled mid-fallback — answer with our own now-dead session. Never the newer one + # registered meanwhile: this answer goes to the request that LOST, whose player drops it as + # stale and stops the session it names (`stop_session`), which stopped the playback that won. + # Never None either: the POST /session route turns a None return into a "Media file not found + # on disk" 404 — dishonest for a file that exists. A dead session instead degrades to a + # retryable 404 ("No active playback session" / "Not available yet") the client re-requests. + return s s = respawned ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) if ready: @@ -1117,6 +1119,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: return dropped +def stop_session(user_id: int, key: str, session_id: str) -> bool: + """Stop this viewer's session for `key` now, because the player that asked for it is gone (left + the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the + film first — measured: ~4 cores for a minute after leaving a resumed 480p film. + + Only while `session_id` is still the registered one: the player names the session it played, and + a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's + current playback, not this request's to end.""" + with _lock: + s = _sessions.get((user_id, key)) + if s is None or s.id != session_id: + return False + _kill(s) + _sessions.pop((user_id, key), None) + return True + + def touch_session(user_id: int, key: str) -> None: """Say this viewer is still there for `key`, without fetching anything. `last_access` used to move only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index 9776427a..51cd22c4 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -9,16 +9,52 @@ one existence-leak policy. from typing import IO, Any, TypeVar from fastapi import HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from app.db import Base -from app.models import User +from app.models import POSITION_MAX_S, User from app.issues import gitea from app.refusal import refused from app.youtube.client import YouTubeError, refusal_reason M = TypeVar("M", bound=Base) +# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the +# library's): positions below this are "didn't really start" and within this of the end are +# "basically finished" — neither is worth storing, so the position is cleared instead (no progress +# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by +# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin +# (30s) because it marks long-form items watched, not just clears a resume bar — see +# routes/plex/playback.py. +PROGRESS_MIN_SECONDS = 5 +FINISH_MARGIN_SECONDS = 10 + + +class ProgressIn(BaseModel): + """A player's resume checkpoint (sent every few seconds while it plays, and as a beacon when the + page goes away).""" + + # 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) + + # Accept a float and truncate (`kept_position`) — 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 progress hot path, losing the resume position; `float` keeps the old tolerance. + # 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) + + +def kept_position(body: ProgressIn) -> int: + """The whole-second resume point worth storing for `body`, or 0 for "clear it" (too early to + count as started, or close enough to the end to count as finished).""" + position = max(0, int(body.position_seconds)) + duration = int(body.duration_seconds) + near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS + return position if position >= PROGRESS_MIN_SECONDS and not near_end else 0 + def not_found(what: str = "generic") -> HTTPException: """The 404 for a row that is gone — structured, so the bilingual UI says it in the reader's diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index a6508e4b..6dcc0784 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -8,7 +8,7 @@ state, serves finished files (range-aware, with the user's custom display name), import os import re import tempfile -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Sequence from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path @@ -17,7 +17,8 @@ from urllib.parse import parse_qs, unquote, urlparse from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse from pydantic import BaseModel -from sqlalchemy import func, select +from sqlalchemy import delete, func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app.mailtext import lang_for_user @@ -39,11 +40,19 @@ from app.models import ( DownloadProfile, DownloadQuota, DownloadShare, + LibraryState, MediaAsset, User, display_text, ) -from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404 +from app.routes._common import ( + ProgressIn, + capped_stream_to, + get_or_404, + kept_position, + not_found, + owned_or_404, +) from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses from app.utils import iso @@ -165,6 +174,19 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]: return {a.id: a for a in rows} +def _positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]: + """`user`'s resume point in each of `jobs` that has one (the library player's `library_states`).""" + ids = [j.id for j in jobs] + if not ids: + return {} + rows = db.execute( + select(LibraryState.job_id, LibraryState.position_seconds).where( + LibraryState.user_id == user.id, LibraryState.job_id.in_(ids) + ) + ).all() + return {job_id: pos for job_id, pos in rows} + + def _serialize_job(db: Session, job: DownloadJob) -> dict: """Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step every single-job handler shares.""" @@ -573,7 +595,14 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get .all() ) assets = _assets_for(db, jobs) - return [_serialize(j, assets.get(j.asset_id)) for j in jobs] + positions = _positions_for(db, user, jobs) + return [ + { + **_serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), + "position_seconds": positions.get(j.id, 0), + } + for j in jobs + ] @router.get("/usage") @@ -634,10 +663,12 @@ def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get .all() ) assets = _assets_for(db, jobs) + positions = _positions_for(db, user, jobs) out = [] for j in jobs: data = _serialize(j, assets.get(j.asset_id)) data["shared"] = True + data["position_seconds"] = positions.get(j.id, 0) out.append(data) return out @@ -825,6 +856,39 @@ def download_file( # --- editor filmstrip (scrub track) -------------------------------------------------------- +@router.post("/{job_id}/progress") +def set_library_progress( + job_id: int, + body: ProgressIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Checkpoint the library player's resume point in a download the caller can play (their own, or + one shared with them). A position too early or too close to the end clears it instead.""" + _accessible_job(db, user, job_id) + position = kept_position(body) + mine = (LibraryState.user_id == user.id) & (LibraryState.job_id == job_id) + if not position: + # A statement, not a loaded row's delete: the periodic checkpoint and the pagehide beacon of + # one stop may both clear it, and the second then deletes nothing rather than failing. + db.execute(delete(LibraryState).where(mine)) + db.commit() + return {"ok": True, "job_id": job_id, "position_seconds": 0} + now = datetime.now(timezone.utc) + # One row per (user, job) even when two checkpoints race (the same pair): an upsert, not a + # read-then-insert that the second would fail on the unique key. + db.execute( + pg_insert(LibraryState) + .values(user_id=user.id, job_id=job_id, position_seconds=position, progress_updated_at=now) + .on_conflict_do_update( + constraint="uq_library_user_job", + set_={"position_seconds": position, "progress_updated_at": now}, + ) + ) + db.commit() + return {"ok": True, "job_id": job_id, "position_seconds": position} + + @router.get("/{job_id}/storyboard") def storyboard_meta( job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 031e2870..15da2ecb 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,7 +1,7 @@ from datetime import date, datetime, timezone from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel from sqlalchemy import and_, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -26,14 +26,19 @@ from app.feedquery import ( windowed_relevance_rows, ) from app.models import ( - POSITION_MAX_S, ChannelTag, Tag, User, Video, VideoState, ) -from app.routes._common import get_or_404, youtube_refusal, not_found +from app.routes._common import ( + ProgressIn, + get_or_404, + kept_position, + not_found, + youtube_refusal, +) from app.sync.videos import parse_iso8601_duration from app.utils import iso from app.youtube.client import YouTubeClient, YouTubeError @@ -44,14 +49,6 @@ router = APIRouter(prefix="/api", tags=["feed"]) # "saved" used to be a status; it's now membership in the built-in Watch later playlist. VALID_STATES = {"new", "watched", "hidden"} -# Resume-position thresholds (mirror the client): positions below this are "didn't -# really start" and within this of the end are "basically finished" — neither is worth -# storing, so we clear the position instead (the video shows no progress bar). -# NB: the Plex player intentionally uses a LARGER finish margin (30s) because it marks -# long-form items watched, not just clears a resume bar — see routes/plex/playback.py. -PROGRESS_MIN_SECONDS = 5 -FINISH_MARGIN_SECONDS = 10 - # Shared query parameters for /feed and /feed/count. def _feed_params( @@ -326,23 +323,10 @@ def clear_video_state( return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0} -class VideoProgressIn(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) - - # 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. - # 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") def set_video_progress( video_id: str, - body: VideoProgressIn, + body: ProgressIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -350,22 +334,15 @@ 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 = int(body.position_seconds) - duration = int(body.duration_seconds) - if position < 0: - position = 0 + position = kept_position(body) get_or_404(db, Video, video_id, what="video") - # Decide whether this position is worth keeping (else clear it). - near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS - keep = position >= PROGRESS_MIN_SECONDS and not near_end - row = db.execute( select(VideoState).where(VideoState.user_id == user.id, VideoState.video_id == video_id) ).scalar_one_or_none() - if not keep: + if not position: # Nothing meaningful to store: drop a status-less row entirely, otherwise just # zero the position (a saved/hidden video keeps its status). if row is not None: diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 94318d1d..99de5974 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -404,6 +404,10 @@ def item_detail( "type": m.get("type"), "start_s": round((m.get("startTimeOffset") or 0) / 1000, 1), "end_s": round((m.get("endTimeOffset") or 0) / 1000, 1), + # An item can carry several credits blocks with scenes between them; Plex + # flags the one that really ends it. Which credits may leave the item is the + # player's rule (`lib/plexMarkers`), so the flag goes over as Plex sent it. + "final": bool(m.get("final")), } ) # Audio + subtitle tracks (ordinals among their stream type — used to -map the selection). diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 92fafd99..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 @@ -637,12 +629,27 @@ def stream_session( # The session id is part of the path (not a query param) so the playlist's RELATIVE segment # names resolve back into the same session — see `stream_hls_file` for why it has to. "url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}", + # What the player names when it no longer wants the session (POST …/session/{id}/stop). + "id": s.id, "transcode": s.transcode, "hw": s.hw, "tonemap": s.tonemap, } +@router.post("/stream/{rating_key}/session/{session_id}/stop") +def stream_session_stop( + rating_key: str, + session_id: str, + user: User = Depends(current_user), +) -> dict: + """The player no longer wants this session (it left the item, closed, or the page unloaded): + its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a + keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and + only while it is still the one registered for the item (see `stop_session`).""" + return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)} + + @router.get("/stream/{rating_key}/file") def stream_file( rating_key: str, diff --git a/backend/tests/test_library_progress.py b/backend/tests/test_library_progress.py new file mode 100644 index 00000000..12228e39 --- /dev/null +++ b/backend/tests/test_library_progress.py @@ -0,0 +1,135 @@ +"""The library player's resume point (`library_states`). + +A checkpoint stores where the caller stopped in a library item they can play — their own download, +or one shared with them — and the two library listings hand it back per item, so the player opens +there. Too early or too close to the end clears it. Nobody else's checkpoint, and no item the caller +cannot reach, is ever read or written. DB-backed (real Postgres). +""" + +import pytest +from sqlalchemy import select + +from app.models import DownloadJob, DownloadShare, LibraryState +from app.routes._common import ProgressIn, kept_position + + +def _done_job(db, user) -> DownloadJob: + job = DownloadJob( + user_id=user.id, + source_kind="url", + source_ref="https://example.com/a.mp4", + status="done", + profile_snapshot={}, + ) + db.add(job) + db.commit() + return job + + +def _checkpoint(client, job_id: int, position: float, duration: float = 3000) -> int: + """Send a checkpoint the caller may make; the point the server kept (0 = cleared).""" + r = client.post( + f"/api/downloads/{job_id}/progress", + json={"position_seconds": position, "duration_seconds": duration}, + ) + assert r.status_code == 200, r.text + return r.json()["position_seconds"] + + +def _states(db) -> list[tuple[int, int, int]]: + db.expire_all() + rows = db.execute( + select(LibraryState.user_id, LibraryState.job_id, LibraryState.position_seconds) + ).all() + return [tuple(r) for r in rows] + + +def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user): + me = make_user() + job = _done_job(db, me) + c = as_user(me) + + r = c.post( + f"/api/downloads/{job.id}/progress", + json={"position_seconds": 754.9, "duration_seconds": 3000}, + ) + assert r.status_code == 200 and r.json() == { + "ok": True, + "job_id": job.id, + "position_seconds": 754, + } + + [item] = c.get("/api/downloads").json() + assert item["position_seconds"] == 754 + # A second checkpoint moves it rather than adding a row. + assert _checkpoint(c, job.id, 900) == 900 + assert _states(db) == [(me.id, job.id, 900)] + + +def test_an_item_never_played_lists_zero(db, make_user, as_user): + me = make_user() + _done_job(db, me) + [item] = as_user(me).get("/api/downloads").json() + assert item["position_seconds"] == 0 + + +def test_too_early_or_near_the_end_clears_it(db, make_user, as_user): + me = make_user() + job = _done_job(db, me) + c = as_user(me) + + assert _checkpoint(c, job.id, 600) == 600 + assert _checkpoint(c, job.id, 2995) == 0 + assert _states(db) == [] + + assert _checkpoint(c, job.id, 600) == 600 + assert _checkpoint(c, job.id, 3) == 0 + assert _states(db) == [] + # Clearing what is not there is not an error (the beacon and the last checkpoint of one stop). + assert _checkpoint(c, job.id, 0) == 0 + + +# The edges themselves, one second either side — the same rows as the client's +# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared +# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side. +@pytest.mark.parametrize( + ("position", "duration", "kept"), + [(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)], +) +def test_the_kept_point_at_its_exact_edges(position, duration, kept): + body = ProgressIn(position_seconds=position, duration_seconds=duration) + assert kept_position(body) == kept + + +def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user): + owner, friend = make_user(), make_user() + job = _done_job(db, owner) + db.add(DownloadShare(job_id=job.id, from_user_id=owner.id, to_user_id=friend.id)) + db.commit() + + assert _checkpoint(as_user(owner), job.id, 100) == 100 + assert _checkpoint(as_user(friend), job.id, 2000) == 2000 + + assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100 + assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000 + + +def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user): + owner, stranger = make_user(), make_user() + job = _done_job(db, owner) + + r = as_user(stranger).post( + f"/api/downloads/{job.id}/progress", + json={"position_seconds": 600, "duration_seconds": 3000}, + ) + assert r.status_code == 404 + assert _states(db) == [] + + +def test_deleting_the_job_deletes_its_places(db, make_user, as_user): + me = make_user() + job = _done_job(db, me) + assert _checkpoint(as_user(me), job.id, 600) == 600 + db.delete(db.get(DownloadJob, job.id)) + db.commit() + assert _states(db) == [] diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index cca81afb..b9bbf62b 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -401,3 +401,38 @@ def test_item_detail_names_tracks_from_their_language_not_plexs_title( ("magyar Forced", "hu", True, False, None), ("English SDH", "en", False, True, None), ] + + +def test_item_detail_markers_carry_plex_final_flag(db, monkeypatch, override_dep): + """An item can carry several credits markers — mid- and post-credit scenes sit between them — + and only the one Plex flags `final` is the real end; the player needs that flag to know which + credits may move on to the next item.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Iron Man")) + db.commit() + user = _user(db, "markers@example.com") + db.commit() + + meta = { + "Marker": [ + {"type": "intro", "startTimeOffset": 1000, "endTimeOffset": 60000}, + {"type": "credits", "startTimeOffset": 6811000, "endTimeOffset": 6831000}, + { + "type": "credits", + "startTimeOffset": 7549000, + "endTimeOffset": 7559000, + "final": True, + }, + {"type": "commercial", "startTimeOffset": 1, "endTimeOffset": 2}, + ] + } + monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta)) + resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m2") + assert resp.status_code == 200, resp.text + assert resp.json()["markers"] == [ + {"type": "intro", "start_s": 1.0, "end_s": 60.0, "final": False}, + {"type": "credits", "start_s": 6811.0, "end_s": 6831.0, "final": False}, + {"type": "credits", "start_s": 7549.0, "end_s": 7559.0, "final": True}, + ] diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index d7339afb..ea823c66 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep): assert body["mode"] == "hls" assert body["transcode"] is False assert captured["transcode"] is False + # The id the player names when it stops the session (POST …/session/{id}/stop). + assert body["id"] == "sid" def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep): diff --git a/backend/tests/test_plex_session_stop.py b/backend/tests/test_plex_session_stop.py new file mode 100644 index 00000000..b375be7d --- /dev/null +++ b/backend/tests/test_plex_session_stop.py @@ -0,0 +1,101 @@ +"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on. + +Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on +localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it +played; the server stops it only while it is still that viewer's registered session for the item, so +a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback. + +No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale). +""" + +import time + +import pytest +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.main import app +from app.models import User +from app.plex import stream as plex_stream + +VIEWER = 41 + + +class _Proc: + def __init__(self): + self.terminated = False + + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def wait(self, timeout=None): + return 0 + + +class _Session: + def __init__(self, session_id: str, directory): + self.id = session_id + self.dir = directory + self.proc = _Proc() + self.last_access = time.time() + + +@pytest.fixture +def sessions(monkeypatch): + live: dict = {} + monkeypatch.setattr(plex_stream, "_sessions", live) + return live + + +@pytest.fixture +def client(monkeypatch, override_dep): + monkeypatch.setattr(state, "_configured_cache", True) + user = User(id=VIEWER, email="stop@example.com") + override_dep(current_user, lambda: user) + return TestClient(app) + + +def _session(tmp_path, sid: str) -> _Session: + d = tmp_path / sid + d.mkdir() + return _Session(sid, d) + + +def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions): + s = _session(tmp_path, "sid-a") + sessions[(VIEWER, "m1")] = s + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.status_code == 200, r.text + assert r.json() == {"ok": True, "stopped": True} + assert s.proc.terminated + assert sessions == {} + assert not s.dir.exists() + + +def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions): + """The stop of the session a seek-restart replaced arrives after the new one registered.""" + newer = _session(tmp_path, "sid-b") + sessions[(VIEWER, "m1")] = newer + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.json() == {"ok": True, "stopped": False} + assert not newer.proc.terminated + assert sessions == {(VIEWER, "m1"): newer} + + +def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions): + theirs = _session(tmp_path, "sid-a") + sessions[(7, "m1")] = theirs + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.json() == {"ok": True, "stopped": False} + assert not theirs.proc.terminated + + +def test_a_stop_with_no_session_left_is_not_an_error(client, sessions): + """The reaper or a cap may have dropped it first; the beacon must not fail loudly for that.""" + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.status_code == 200, r.text + assert r.json() == {"ok": True, "stopped": False} diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index bb0e85c1..baffdf28 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -695,7 +695,9 @@ class TestTranscodeStartSession: # F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid # seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT # trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap. - # The identity guard must detect it's been superseded and yield the currently-registered session. + # The identity guard must detect it's been superseded. It answers with its OWN (dead) session, + # never the newer one: the answer goes to the request that lost, whose player drops it as stale + # and stops the session it names — naming the newer one stopped the playback that won. monkeypatch.setattr( stream, "_select_encode", lambda db, item, transcode, src: (True, False) ) @@ -708,9 +710,12 @@ class TestTranscodeStartSession: newer = _Newer() + captured = {} + def wait_then_supersede(path, timeout=20.0, proc=None): # Simulate the concurrent replacement happening during our out-of-lock wait, then report the # (now-killed) HW proc produced nothing. + captured["s"] = stream._sessions[(7, "12345")] stream._sessions[(7, "12345")] = newer return False @@ -720,7 +725,7 @@ class TestTranscodeStartSession: ) # No SW respawn — only the initial HW _spawn ran; the newer session was left intact. assert len(_CapturingProc.calls) == 1 - assert s is newer # yielded the currently-registered session, did not clobber it + assert s is captured["s"] # its own session, not the newer one assert stream._sessions[(7, "12345")] is newer def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing( diff --git a/backend/tests/test_progress_bounds.py b/backend/tests/test_progress_bounds.py index 7736cb7c..d2d0daf6 100644 --- a/backend/tests/test_progress_bounds.py +++ b/backend/tests/test_progress_bounds.py @@ -23,7 +23,10 @@ def client(monkeypatch, override_dep) -> TestClient: return TestClient(app) -@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"]) +@pytest.mark.parametrize( + "path", + ["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"], +) @pytest.mark.parametrize("raw", ["Infinity", "-Infinity", "NaN"]) def test_a_non_finite_position_is_refused(client, path, raw): r = client.post( @@ -34,7 +37,10 @@ def test_a_non_finite_position_is_refused(client, path, raw): assert r.status_code == 422, r.text -@pytest.mark.parametrize("path", ["/api/videos/v1/progress", "/api/plex/item/98614/progress"]) +@pytest.mark.parametrize( + "path", + ["/api/videos/v1/progress", "/api/plex/item/98614/progress", "/api/downloads/1/progress"], +) @pytest.mark.parametrize("field", ["position_seconds", "duration_seconds"]) def test_a_position_past_the_int4_column_is_refused_not_a_500(client, path, field): """Finite but past the int4 column: the flush was a DataError, i.e. a 500 with a traceback.""" diff --git a/backend/tests/test_request_models.py b/backend/tests/test_request_models.py index 5b5d48b0..ee59168e 100644 --- a/backend/tests/test_request_models.py +++ b/backend/tests/test_request_models.py @@ -7,7 +7,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._common import ProgressIn from app.routes.setup import SetupAdminIn from app.routes.tags import TagUpdateIn @@ -52,10 +52,10 @@ def test_admin_quota_rejects_non_numeric_limit(): 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. - assert VideoProgressIn(position_seconds=12.7).position_seconds == 12.7 - assert int(VideoProgressIn(position_seconds=12.7).position_seconds) == 12 + assert ProgressIn(position_seconds=12.7).position_seconds == 12.7 + assert int(ProgressIn(position_seconds=12.7).position_seconds) == 12 with pytest.raises(ValidationError): - VideoProgressIn(position_seconds="not-a-number") + ProgressIn(position_seconds="not-a-number") # --- PATCH models: model_fields_set separates "absent" from "explicit null". --- diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 544589fb..4a05fd31 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -528,7 +528,9 @@ of a full-width panel and nothing on the right. **Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one. On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart - applies to the item the player opened on (`fromStart`); every item it moves on to resumes. + applies to the item the player opened on (`fromStart`); every item it moves on to resumes. A library + row is not a card and keeps its one play button: started, that button continues, its "…" has Continue + + "Play from the beginning", and its picture carries the cards' progress bar. - **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured live). [WCAG 1.4.13] @@ -541,7 +543,9 @@ of a full-width panel and nothing on the right. fields (the issue composer's Edit / Preview switch); one that loads after opening moves the focus there when it arrives, as long as the focus still rests on the ✕; `action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read, - browse or manage. [WAI-ARIA APG] + browse or manage; `player` — a dialog whose body is a player (the library's): the focus rests on the + panel itself, so Space and the arrows reach the player's keys and no button takes them — Space on a + focused ✕ closed the film. [WAI-ARIA APG] - **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the primary action stays on screen when the on-screen keyboard takes half of it (the viewport's `interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise @@ -706,3 +710,4 @@ of a full-width panel and nothing on the right. | 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height | | 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it | | 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules | +| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) | diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index f3e19550..59b762d8 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -235,6 +235,46 @@ describe("DownloadCenter · the library row", () => { ]); }); + it("a started item continues — its bar shows how far — and its menu can start it over", async () => { + mocks.jobs = [job(2, "done", { position_seconds: 30 })]; + mount("done"); + await screen.findByText("Clip 2"); + expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle"); + // The media cards' bar along the picture's bottom edge: 30 s of the 60 s item. + expect(rowOf("Clip 2").querySelector(".bg-accent")!.style.width).toBe("50%"); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); + + // Starting over opens the player at 0:00, not at the kept point. + fireEvent.click( + screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "card.restartTitle")!, + ); + const video = await waitFor(() => { + const v = document.querySelector("video"); + expect(v).not.toBeNull(); + return v!; + }); + let t = 0; + Object.defineProperties(video, { + currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true }, + duration: { get: () => 60, configurable: true }, + readyState: { get: () => 1, configurable: true }, + }); + video.dispatchEvent(new Event("loadedmetadata")); + expect(video.currentTime).toBe(0); + }); + + it("an item never played offers Play only, with no bar", async () => { + mocks.jobs = [job(2, "done", { position_seconds: 0 })]; + mount("done"); + await screen.findByText("Clip 2"); + expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows()).not.toContain("card.restartTitle"); + }); + it("copies the share link from the row's own Share button", async () => { mocks.jobs = [job(2, "done")]; vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index aa6c5fca..a6f0bea4 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -68,7 +68,9 @@ export default function DownloadCenter() { const [manage, setManage] = useState(false); const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); - const [playJob, setPlayJob] = useState(null); + // What the player opens, and whether over the kept point ("Play from the beginning"). + const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null); + const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart }); const [editJob, setEditJob] = useState(null); const [errorJob, setErrorJob] = useState(null); @@ -276,7 +278,9 @@ export default function DownloadCenter() { setPlayJob(job)} - title={t("downloads.actions.play")} + title={ + job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") + } > @@ -315,7 +319,9 @@ export default function DownloadCenter() { setPlayJob(job)} - title={t("downloads.actions.play")} + title={ + job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") + } > @@ -357,7 +363,13 @@ export default function DownloadCenter() { {manage && setManage(false)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} - {playJob && setPlayJob(null)} />} + {playing && ( + setPlaying(null)} + /> + )} {editJob && setEditJob(null)} />} {errorJob && ( . i18n // is key-passthrough here. import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, cleanup } from "@testing-library/react"; +import { render as rtlRender, cleanup } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -15,7 +17,8 @@ const expose = vi.hoisted(() => vi.fn()); vi.mock("../lib/browserBackgroundPlay", () => ({ exposeVideoToBackgroundPlay: expose })); import LibraryPlayer from "./LibraryPlayer"; -import type { DownloadJob } from "../lib/api"; +import { api, type DownloadJob } from "../lib/api"; +import { qk } from "../lib/queryKeys"; const baseJob: DownloadJob = { id: 7, @@ -75,7 +78,27 @@ const withAsset = (over: Partial>): DownloadJo }, }); -afterEach(cleanup); +// The player writes its resume point into the library listings' cache, so it sits under a client. +const render = (ui: ReactElement, client = new QueryClient()) => + rtlRender({ui}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** The rendered
{ e.stopPropagation(); - setInfoOpen(false); + closePanel("info"); }} >
setInfoOpen(false)} + onClose={() => closePanel("info")} // A collection sibling plays right here, as Next would. onPlayItem={(other, fromZero) => { - setInfoOpen(false); + closePanel("info"); go(other, undefined, fromZero); }} /> diff --git a/frontend/src/components/PlexStatsOverlay.tsx b/frontend/src/components/PlexStatsOverlay.tsx index 5234ee34..8256a6c5 100644 --- a/frontend/src/components/PlexStatsOverlay.tsx +++ b/frontend/src/components/PlexStatsOverlay.tsx @@ -1,8 +1,8 @@ import { type RefObject, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import type Hls from "hls.js"; import { X } from "lucide-react"; -import { buildStatRows, type FragMeter, type HlsLevelInfo, type StatRow } from "../lib/plexStats"; +import type { HlsEngine } from "../lib/hlsEngine"; +import { buildStatRows, type FragMeter, type StatRow } from "../lib/plexStats"; import { IconButton } from "./ui/IconButton"; // "Stats for nerds" debug overlay. Samples the live HTML5
+
+ {src ? ( { const actual = await importOriginal(); return { ...actual, - useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + // The key, and the episode a "next" line names after a colon (`{ name }`). + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: { name?: string }) => (o?.name ? `${key}:${o.name}` : key), + }), }; }); @@ -155,8 +159,8 @@ describe("PlexPosterCard with a next episode", () => { />, ); expect(playGlyph(container)).not.toBeNull(); - expect(screen.getByText("plex.nextUp")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" })); + expect(screen.getByText("plex.nextUp:S6·E16")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched:S6·E16" })); expect(onToggleWatched).toHaveBeenCalledWith(next); // The episode's own add ("Add episode"), not the show's. fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" })); @@ -370,6 +374,34 @@ describe("PlexPosterCard on a shelf", () => { }); }); +// An episode Plex has no number for (a special) is named by its title alone, as the player's own +// header names it — the cards printed a question mark where the number was missing. +describe("an episode card with no number for the episode", () => { + const special: PlexCard = { + ...episode(), + title: "The Special", + show_id: "sh", + show_title: "Show", + season_number: 0, + episode_number: null, + }; + + it("names a show's episode by its title alone", () => { + render(); + expect(screen.queryByText("plex.episodeLine")).toBeNull(); + expect(screen.getByText("The Special")).toBeTruthy(); + }); + + it("names the next episode by its title alone", () => { + const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 }; + render( + , + ); + expect(screen.getByText("plex.nextUp:The Special")).toBeTruthy(); + expect(screen.getByRole("button", { name: "plex.markNextWatched:The Special" })).toBeTruthy(); + }); +}); + // A card fetched the detail page's 400px poster for its ~144px picture. It offers the // proxy's small cut too and lets the browser pick by its width. describe("a Plex card's picture", () => { diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 8ab9c281..8fdfaeb5 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -5,7 +5,7 @@ import { type ReactNode, useRef } from "react"; import { formatDecimal, formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; -import { seasonTitle } from "../lib/plexDetail"; +import { episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail"; import { navItemAttrs } from "../lib/listNav"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; @@ -318,7 +318,8 @@ export function PlexPosterCard({ const m = more(card, menuButtonProps, menuOpen); const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined; const add = isPlayable && onAdd ? () => onAdd(acted) : undefined; - const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" }; + // The next episode by name: its numbers, or its title when Plex has none for it. + const nextName = ep && episodeName(ep); // A show has no length: its corner says how many seasons it has instead, so the corner alone tells // a movie from a show (the film/TV glyph it replaces said the same thing a second time). const corner = @@ -361,8 +362,10 @@ export function PlexPosterCard({ - {t("plex.episodeLine", { - season: card.season_number ?? "?", - episode: card.episode_number ?? "?", - title: card.title, - })} + {episodeNumbers(card) + ? t("plex.episodeLine", { ...episodeNumbers(card)!, title: card.title }) + : card.title} +
+ ) : nextName ? ( +
+ {t("plex.nextUp", { name: nextName })}
- ) : epCode ? ( -
{t("plex.nextUp", epCode)}
) : ( (!!card.year || card.rating != null) && (
@@ -741,8 +744,8 @@ export function EpisodeCard({ ep.show_title && (
{ep.show_title} - {ep.season_number != null && ep.episode_number != null - ? ` · S${ep.season_number}·E${ep.episode_number}` + {episodeNumbers(ep) + ? ` · S${episodeNumbers(ep)!.season}·E${episodeNumbers(ep)!.episode}` : ""}
) diff --git a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx index ba75c186..242a5f90 100644 --- a/frontend/src/components/ui/KeyboardHelp.dom.test.tsx +++ b/frontend/src/components/ui/KeyboardHelp.dom.test.tsx @@ -15,6 +15,8 @@ import { type KeyScope, } from "../../lib/keymap"; import { pushLayer, removeLayer } from "../../lib/layerStack"; +import { useMediaKeys } from "../../lib/mediaKeys"; +import { useState } from "react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -179,4 +181,32 @@ describe("KeyboardHelp", () => { pressH(); expect(descriptions()).toContain("keys.episode"); }); + + // The library player's F fills the screen with a video; an audio file has no picture, so the + // sheet over an audio file does not offer it. Through the real hook, which publishes the answer. + it("offers Fullscreen over a video and not over an audio file", () => { + function Player({ tag }: { tag: "video" | "audio" }) { + const [media, setMedia] = useState(null); + useMediaKeys(media); + return tag === "video" ?
navigation can't send the X-Siftlode-Account header, so pass the active // account via ?account= (the server honours it only for an account in this browser's wallet) — // otherwise the download resolves to the session-default account and 404s for a per-tab diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 72b5e03c..a31ffd93 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -132,6 +132,8 @@ export interface PlexMarker { type: "intro" | "credits"; start_s: number; end_s: number; + /** Plex's own word that this credits block ends the item (see `lib/plexMarkers`). */ + final: boolean; } // What the player names an audio/subtitle track from (`lib/vocab` trackLabel). Plex's own title // (`label`) is in the Plex SERVER's language, so it is only the fallback for a track with no language tag. @@ -412,6 +414,7 @@ export interface PlexFacets { export interface PlexPlaySession { mode: "direct" | "hls"; url: string; + id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession) start_s: number; transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux) hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software) @@ -717,6 +720,14 @@ export const plexApi = { method: "POST", body: JSON.stringify({ position_seconds, duration_seconds, final }), }), + // 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. + plexStopSession: (id: string, sessionId: string): void => + beacon( + `/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 diff --git a/frontend/src/lib/api/plexStream.contract.test.ts b/frontend/src/lib/api/plexStream.contract.test.ts new file mode 100644 index 00000000..641ad9a9 --- /dev/null +++ b/frontend/src/lib/api/plexStream.contract.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { api } from "../api"; + +// The session stop is a keepalive beacon: nothing answers it on screen, so a URL that no longer +// matches the server's route fails silently — every transcode the player leaves runs on until the +// idle reaper. The route is read from the server's source, as the other contract tests do. +afterEach(() => vi.restoreAllMocks()); + +function serverRoute(): RegExp { + // Anchored to this file, not the working directory, so the test holds wherever vitest starts. + const source = readFileSync( + resolve(__dirname, "../../../../backend/app/routes/plex/playback.py"), + "utf8", + ); + const prefix = /APIRouter\(prefix="([^"]+)"/.exec(source)?.[1]; + const path = /@router\.post\("(\/stream\/[^"]*\/stop)"\)/.exec(source)?.[1]; + // If either is missing the route was renamed or moved — re-point this test, the rule stands. + expect(prefix, "router prefix").toBeDefined(); + expect(path, "the session stop route").toBeDefined(); + // Each path parameter becomes a group of its own NAME: two ids that swapped places still match a + // pattern of anonymous wildcards, and the server then finds no session to stop. + const pattern = (prefix! + path!).replace(/\{(\w+)\}/g, "(?<$1>[^/]+)"); + return new RegExp(`^${pattern}$`); +} + +it("sends the session stop to the route the server serves it on", () => { + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null)); + api.plexStopSession("12345", "a1b2c3"); + const url = String(fetch.mock.calls[0]![0]); + expect(url.match(serverRoute())?.groups).toEqual({ rating_key: "12345", session_id: "a1b2c3" }); + expect(fetch.mock.calls[0]![1]).toMatchObject({ method: "POST", keepalive: true }); +}); diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts index 9874126a..482d2e11 100644 --- a/frontend/src/lib/fullscreen.ts +++ b/frontend/src/lib/fullscreen.ts @@ -95,7 +95,10 @@ export function useFullscreenPortalHost(): HTMLElement | null { }, [host]); useLayoutEffect(() => { if (!host) return; - const parent = fsRoot ?? document.body; + // When the fullscreen element is the overlay's OWN content (a