feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
"""Plex integration API.
|
|
|
|
|
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
|
|
|
|
|
pure-Siftlode included) — the module is an access layer to the Plex library WITHOUT requiring a
|
|
|
|
|
plex.tv account, and playback is a local file. Admin endpoints test the connection and run the
|
|
|
|
|
catalog sync.
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
"""
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
import logging
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
from datetime import datetime, timezone
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
from fastapi.responses import FileResponse
|
2026-07-05 03:29:20 +02:00
|
|
|
from sqlalchemy import and_, func, or_
|
|
|
|
|
from sqlalchemy.orm import Session, aliased
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
from app import sysconfig
|
|
|
|
|
from app.auth import current_user
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
from app.db import get_db
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
from app.plex import paths as plex_paths
|
|
|
|
|
from app.plex import stream as plex_stream
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
from app.plex import sync as plex_sync
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
|
|
|
|
from app.routes.admin import admin_user
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
from app.routes.feed import _to_tsquery_str
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger("siftlode.plex")
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|
|
|
|
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
_TS_CONFIG = "public.unaccent_simple"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Admin: connectivity + sync ---------------------------------------------------------------
|
|
|
|
|
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
|
|
|
|
|
@router.post("/test")
|
|
|
|
|
def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
"""Admin: verify the configured Plex server URL + token, returning the server name and the
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
movie/show sections (for the config library-picker)."""
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
try:
|
|
|
|
|
with PlexClient(db) as plex:
|
|
|
|
|
info = plex.server_info()
|
|
|
|
|
sections = plex.sections()
|
|
|
|
|
except PlexNotConfigured as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
except PlexError as e:
|
|
|
|
|
raise HTTPException(status_code=422, detail=f"Plex connection failed: {e}")
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
|
|
|
|
"version": info.get("version"),
|
|
|
|
|
"sections": [
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
{"key": str(s.get("key")), "title": s.get("title"), "type": s.get("type"), "count": s.get("count")}
|
feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.
- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
(playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
+ plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
+markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
|
|
|
for s in sections
|
|
|
|
|
if s.get("type") in ("movie", "show")
|
|
|
|
|
],
|
|
|
|
|
}
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sync")
|
|
|
|
|
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
|
|
|
|
while on a large library); a background/scheduled sync also runs periodically."""
|
|
|
|
|
return plex_sync.sync(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Read: libraries / browse / show / image --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _enabled() -> None:
|
|
|
|
|
"""Guard read endpoints when the module is off (avoids leaking a stale mirror)."""
|
|
|
|
|
# Read endpoints stay usable as long as a mirror exists; the toggle only gates sync + UI.
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/libraries")
|
|
|
|
|
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
|
|
|
"""The mirrored libraries the user can browse (drives the Plex scope selector)."""
|
|
|
|
|
libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all()
|
|
|
|
|
out = []
|
|
|
|
|
for lib in libs:
|
|
|
|
|
if lib.kind == "movie":
|
|
|
|
|
count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar()
|
|
|
|
|
else:
|
|
|
|
|
count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar()
|
|
|
|
|
out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0})
|
|
|
|
|
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": it.rating_key,
|
|
|
|
|
"type": "movie",
|
|
|
|
|
"title": it.title,
|
|
|
|
|
"year": it.year,
|
|
|
|
|
"duration_seconds": it.duration_s,
|
|
|
|
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
|
|
|
|
"playable": it.playable,
|
|
|
|
|
"status": st.status if st else "new",
|
|
|
|
|
"position_seconds": st.position_seconds if st else 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _show_card(sh: PlexShow) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": sh.rating_key,
|
|
|
|
|
"type": "show",
|
|
|
|
|
"title": sh.title,
|
|
|
|
|
"year": sh.year,
|
|
|
|
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
|
|
|
|
"season_count": sh.child_count,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": e.rating_key,
|
|
|
|
|
"type": "episode",
|
|
|
|
|
"title": e.title,
|
|
|
|
|
"summary": e.summary,
|
|
|
|
|
"season_number": e.season_number,
|
|
|
|
|
"episode_number": e.episode_number,
|
|
|
|
|
"duration_seconds": e.duration_s,
|
|
|
|
|
"thumb": f"/api/plex/image/{e.rating_key}",
|
|
|
|
|
"playable": e.playable,
|
|
|
|
|
"status": st.status if st else "new",
|
|
|
|
|
"position_seconds": st.position_seconds if st else 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/browse")
|
|
|
|
|
def browse(
|
|
|
|
|
library: str,
|
|
|
|
|
q: str | None = None,
|
|
|
|
|
sort: str = "added",
|
2026-07-05 03:29:20 +02:00
|
|
|
show: str = "all",
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
offset: int = 0,
|
|
|
|
|
limit: int = Query(default=40, ge=1, le=100),
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
2026-07-05 03:29:20 +02:00
|
|
|
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user
|
|
|
|
|
watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie
|
|
|
|
|
libraries return playable movie cards; show libraries return show cards (drill in via /show)."""
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
|
|
|
|
if lib is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
|
|
|
|
offset = max(0, offset)
|
|
|
|
|
model = PlexItem if lib.kind == "movie" else PlexShow
|
|
|
|
|
|
|
|
|
|
query = db.query(model)
|
|
|
|
|
if lib.kind == "movie":
|
|
|
|
|
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
2026-07-05 03:29:20 +02:00
|
|
|
# Per-user watch-state filter (movies only; a show isn't a single watch unit).
|
|
|
|
|
st = aliased(PlexState)
|
|
|
|
|
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
|
|
|
|
if show == "watched":
|
|
|
|
|
query = query.filter(st.status == "watched")
|
|
|
|
|
elif show == "in_progress":
|
|
|
|
|
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
|
|
|
|
elif show == "unwatched":
|
|
|
|
|
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
|
|
|
|
else: # all — hide only the explicitly hidden
|
|
|
|
|
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
|
|
|
else:
|
|
|
|
|
query = query.filter(PlexShow.library_id == lib.id)
|
|
|
|
|
|
|
|
|
|
ts = _to_tsquery_str(q) if q else None
|
|
|
|
|
tsq = None
|
|
|
|
|
if ts:
|
|
|
|
|
tsq = func.to_tsquery(_TS_CONFIG, ts)
|
|
|
|
|
query = query.filter(model.search_vector.op("@@")(tsq))
|
|
|
|
|
|
|
|
|
|
total = query.count()
|
|
|
|
|
|
|
|
|
|
if tsq is not None:
|
|
|
|
|
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
|
|
|
|
elif sort == "title":
|
|
|
|
|
order = [func.lower(model.title).asc()]
|
|
|
|
|
else: # newest first
|
|
|
|
|
order = [model.added_at.desc().nullslast()]
|
|
|
|
|
order.append(model.id.desc())
|
|
|
|
|
|
|
|
|
|
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
|
|
|
|
|
|
|
|
|
if lib.kind == "movie":
|
|
|
|
|
by_item = {r.id: r for r in rows}
|
|
|
|
|
states = {
|
|
|
|
|
s.item_id: s
|
|
|
|
|
for s in db.query(PlexState).filter(
|
|
|
|
|
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0])
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
|
|
|
|
else:
|
|
|
|
|
items = [_show_card(r) for r in rows]
|
|
|
|
|
|
|
|
|
|
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/show/{rating_key}")
|
|
|
|
|
def show_detail(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
|
|
|
|
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
|
|
|
|
|
if sh is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
|
|
|
|
seasons = (
|
|
|
|
|
db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all()
|
|
|
|
|
)
|
|
|
|
|
eps = (
|
|
|
|
|
db.query(PlexItem)
|
|
|
|
|
.filter_by(show_id=sh.id, kind="episode")
|
|
|
|
|
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
|
|
|
|
.all()
|
|
|
|
|
)
|
|
|
|
|
states = {
|
|
|
|
|
s.item_id: s
|
|
|
|
|
for s in db.query(PlexState).filter(
|
|
|
|
|
PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0])
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
by_season: dict[int | None, list] = {}
|
|
|
|
|
for e in eps:
|
|
|
|
|
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id)))
|
|
|
|
|
return {
|
|
|
|
|
"show": {
|
|
|
|
|
"id": sh.rating_key,
|
|
|
|
|
"title": sh.title,
|
|
|
|
|
"summary": sh.summary,
|
|
|
|
|
"year": sh.year,
|
|
|
|
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
|
|
|
|
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
|
|
|
|
},
|
|
|
|
|
"seasons": [
|
|
|
|
|
{
|
|
|
|
|
"id": se.rating_key,
|
|
|
|
|
"season_number": se.season_number,
|
|
|
|
|
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
|
|
|
|
|
"thumb": f"/api/plex/image/{se.rating_key}",
|
|
|
|
|
"episodes": by_season.get(se.id, []),
|
|
|
|
|
}
|
|
|
|
|
for se in seasons
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
|
|
|
|
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
|
|
|
|
are proxyable — this is not an open image proxy."""
|
|
|
|
|
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
|
|
|
|
if it is not None:
|
|
|
|
|
return it.art_key if variant == "art" else it.thumb_key
|
|
|
|
|
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
|
|
|
|
if sh is not None:
|
|
|
|
|
return sh.art_key if variant == "art" else sh.thumb_key
|
|
|
|
|
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
|
|
|
|
if se is not None:
|
|
|
|
|
return se.thumb_key
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/image/{rating_key}")
|
|
|
|
|
def image(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
variant: str = "thumb",
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> Response:
|
|
|
|
|
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication)."""
|
|
|
|
|
key = _image_key(db, str(rating_key), "art" if variant == "art" else "thumb")
|
|
|
|
|
if not key:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No image")
|
|
|
|
|
try:
|
|
|
|
|
with PlexClient(db) as plex:
|
|
|
|
|
data, ct = plex.image_bytes(key)
|
|
|
|
|
except PlexNotConfigured as e:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
except PlexError as e:
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
|
|
|
|
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _item_or_404(db: Session, rating_key: str) -> PlexItem:
|
|
|
|
|
it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first()
|
|
|
|
|
if it is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
|
|
|
|
return it
|
|
|
|
|
|
|
|
|
|
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
# Progress thresholds (mirror the YouTube player): ignore the first few seconds, and treat the
|
|
|
|
|
# last stretch as "finished" (mark watched, clear the resume point).
|
|
|
|
|
_PROGRESS_MIN_S = 5
|
|
|
|
|
_FINISH_MARGIN_S = 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
|
|
|
|
if it.kind != "episode" or it.show_id is None:
|
|
|
|
|
return None, None
|
|
|
|
|
keys = [
|
|
|
|
|
r[0]
|
|
|
|
|
for r in db.query(PlexItem.rating_key)
|
|
|
|
|
.filter_by(show_id=it.show_id, kind="episode")
|
|
|
|
|
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
|
|
|
|
.all()
|
|
|
|
|
]
|
|
|
|
|
try:
|
|
|
|
|
i = keys.index(it.rating_key)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None, None
|
|
|
|
|
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/item/{rating_key}")
|
|
|
|
|
def item_detail(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
|
|
|
|
prev/next, and the per-user resume position + watch status."""
|
|
|
|
|
it = _item_or_404(db, rating_key)
|
|
|
|
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
|
|
|
|
|
|
|
|
|
cast: list[str] = []
|
|
|
|
|
markers: list[dict] = []
|
2026-07-05 06:08:44 +02:00
|
|
|
audio_streams: list[dict] = []
|
|
|
|
|
subtitle_streams: list[dict] = []
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
try:
|
|
|
|
|
with PlexClient(db) as plex:
|
|
|
|
|
meta = plex.metadata(it.rating_key, markers=True) or {}
|
|
|
|
|
cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
|
|
|
|
|
for m in meta.get("Marker") or []:
|
|
|
|
|
if m.get("type") in ("intro", "credits"):
|
|
|
|
|
markers.append(
|
|
|
|
|
{
|
|
|
|
|
"type": m.get("type"),
|
|
|
|
|
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
|
|
|
|
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-07-05 06:08:44 +02:00
|
|
|
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
|
|
|
|
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
|
|
|
|
|
for s in part.get("Stream") or []:
|
|
|
|
|
if s.get("streamType") == 2:
|
|
|
|
|
audio_streams.append(
|
|
|
|
|
{
|
|
|
|
|
"ord": len(audio_streams),
|
|
|
|
|
"label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}",
|
|
|
|
|
"language": s.get("languageTag") or s.get("language"),
|
|
|
|
|
"default": bool(s.get("selected") or s.get("default")),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
elif s.get("streamType") == 3:
|
|
|
|
|
subtitle_streams.append(
|
|
|
|
|
{
|
|
|
|
|
"ord": len(subtitle_streams),
|
|
|
|
|
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
|
|
|
|
|
"language": s.get("languageTag") or s.get("language"),
|
|
|
|
|
}
|
|
|
|
|
)
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
except (PlexError, PlexNotConfigured):
|
|
|
|
|
pass # metadata extras are best-effort; playback works without them
|
|
|
|
|
|
|
|
|
|
prev_id, next_id = _episode_nav(db, it)
|
|
|
|
|
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
|
|
|
|
return {
|
|
|
|
|
"id": it.rating_key,
|
|
|
|
|
"kind": it.kind,
|
|
|
|
|
"title": it.title,
|
|
|
|
|
"summary": it.summary,
|
|
|
|
|
"year": it.year,
|
|
|
|
|
"duration_seconds": it.duration_s,
|
|
|
|
|
"playable": it.playable,
|
|
|
|
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
|
|
|
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
|
|
|
|
"cast": cast,
|
|
|
|
|
"markers": markers,
|
2026-07-05 06:08:44 +02:00
|
|
|
"audio_streams": audio_streams,
|
|
|
|
|
"subtitle_streams": subtitle_streams,
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
"status": st.status if st else "new",
|
|
|
|
|
"position_seconds": st.position_seconds if st else 0,
|
|
|
|
|
"show_title": show.title if show else None,
|
|
|
|
|
"season_number": it.season_number,
|
|
|
|
|
"episode_number": it.episode_number,
|
|
|
|
|
"prev_id": prev_id,
|
|
|
|
|
"next_id": next_id,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/item/{rating_key}/progress")
|
|
|
|
|
def item_progress(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Checkpoint the resume position while playing (near the end → mark watched + clear it)."""
|
|
|
|
|
it = _item_or_404(db, rating_key)
|
|
|
|
|
try:
|
|
|
|
|
position = max(0, int(payload.get("position_seconds") or 0))
|
|
|
|
|
duration = int(payload.get("duration_seconds") or 0)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
|
|
|
|
|
|
|
|
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
|
|
|
|
if near_end:
|
|
|
|
|
if st is None:
|
|
|
|
|
st = PlexState(user_id=user.id, item_id=it.id)
|
|
|
|
|
db.add(st)
|
|
|
|
|
st.status = "watched"
|
|
|
|
|
st.watched_at = now
|
|
|
|
|
st.position_seconds = 0
|
|
|
|
|
st.progress_updated_at = now
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"status": "watched", "position_seconds": 0}
|
|
|
|
|
|
|
|
|
|
if position < _PROGRESS_MIN_S:
|
|
|
|
|
if st is not None and st.status == "new":
|
|
|
|
|
db.delete(st)
|
|
|
|
|
elif st is not None:
|
|
|
|
|
st.position_seconds = 0
|
|
|
|
|
st.progress_updated_at = now
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"position_seconds": 0}
|
|
|
|
|
|
|
|
|
|
if st is None:
|
|
|
|
|
st = PlexState(user_id=user.id, item_id=it.id)
|
|
|
|
|
db.add(st)
|
|
|
|
|
st.position_seconds = position
|
|
|
|
|
st.progress_updated_at = now
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"position_seconds": position}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/item/{rating_key}/state")
|
|
|
|
|
def item_state(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
payload: dict,
|
|
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Set the per-user watch status (new|watched|hidden)."""
|
|
|
|
|
it = _item_or_404(db, rating_key)
|
|
|
|
|
status = payload.get("status")
|
|
|
|
|
if status not in ("new", "watched", "hidden"):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Invalid status")
|
|
|
|
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
|
|
|
|
if status == "new":
|
|
|
|
|
if st is not None:
|
|
|
|
|
db.delete(st)
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"status": "new"}
|
|
|
|
|
if st is None:
|
|
|
|
|
st = PlexState(user_id=user.id, item_id=it.id)
|
|
|
|
|
db.add(st)
|
|
|
|
|
st.status = status
|
|
|
|
|
if status == "watched":
|
|
|
|
|
st.watched_at = datetime.now(timezone.utc)
|
|
|
|
|
st.position_seconds = 0
|
|
|
|
|
db.commit()
|
|
|
|
|
return {"status": status}
|
|
|
|
|
|
|
|
|
|
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
@router.post("/stream/{rating_key}/session")
|
|
|
|
|
def stream_session(
|
|
|
|
|
rating_key: str,
|
|
|
|
|
start: float = 0.0,
|
2026-07-05 06:08:44 +02:00
|
|
|
audio: int | None = None,
|
|
|
|
|
subtitle: int | None = None,
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
2026-07-05 06:08:44 +02:00
|
|
|
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
|
|
|
|
|
non-default audio/subtitle track (`audio`/`subtitle` = stream ordinals) forces the HLS path so
|
|
|
|
|
the chosen tracks can be muxed in."""
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
|
|
|
|
it = _item_or_404(db, rating_key)
|
|
|
|
|
if it.playable == "transcode":
|
|
|
|
|
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
2026-07-05 06:08:44 +02:00
|
|
|
select = audio is not None or subtitle is not None
|
|
|
|
|
if it.playable == "direct" and not select:
|
|
|
|
|
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
2026-07-05 06:08:44 +02:00
|
|
|
s = plex_stream.start_session(db, it, start, audio, subtitle)
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
if s is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
2026-07-05 06:08:44 +02:00
|
|
|
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/stream/{rating_key}/file")
|
|
|
|
|
def stream_file(
|
|
|
|
|
rating_key: str,
|
2026-07-05 05:26:16 +02:00
|
|
|
download: bool = False,
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> FileResponse:
|
2026-07-05 05:26:16 +02:00
|
|
|
"""Serve the raw local media file with HTTP range support. Used both for direct-play (inline)
|
|
|
|
|
and for the player's "download original" button (`?download=1` → attachment). The download is
|
|
|
|
|
the untouched physical file — no re-encode/repackage."""
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
it = _item_or_404(db, rating_key)
|
|
|
|
|
src = plex_paths.local_media_path(db, it.file_path)
|
|
|
|
|
if src is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
2026-07-05 05:26:16 +02:00
|
|
|
if download:
|
|
|
|
|
# A clean, safe download name from the title + the real file extension.
|
|
|
|
|
ext = src.suffix or (f".{it.container}" if it.container else "")
|
|
|
|
|
base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video"
|
|
|
|
|
return FileResponse(str(src), filename=f"{base}{ext}")
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
|
|
|
|
|
|
|
|
|
|
2026-07-05 06:08:44 +02:00
|
|
|
_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/stream/{rating_key}/hls/{filename}")
|
|
|
|
|
def stream_hls_file(
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
rating_key: str,
|
2026-07-05 06:08:44 +02:00
|
|
|
filename: str,
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
user: User = Depends(current_user),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> Response:
|
2026-07-05 06:08:44 +02:00
|
|
|
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
|
|
|
|
|
playlist references its segments/subtitles by relative name, all resolving back here."""
|
|
|
|
|
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
|
|
|
|
|
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
|
|
|
|
|
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Not found")
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
s = plex_stream.current_session(str(rating_key))
|
|
|
|
|
if s is None:
|
2026-07-05 06:08:44 +02:00
|
|
|
# No session yet: only the entry playlist for a remux item may auto-start one at 0.
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
it = _item_or_404(db, rating_key)
|
2026-07-05 06:08:44 +02:00
|
|
|
if filename != "index.m3u8" or it.playable != "remux":
|
|
|
|
|
raise HTTPException(status_code=404, detail="No active playback session")
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
s = plex_stream.start_session(db, it, 0.0)
|
|
|
|
|
if s is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
2026-07-05 06:08:44 +02:00
|
|
|
path = s.dir / filename
|
|
|
|
|
timeout = 30 if ext == ".m3u8" else 20
|
|
|
|
|
if not plex_stream.wait_for(path, timeout):
|
|
|
|
|
raise HTTPException(status_code=404, detail="Not available yet")
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
return Response(
|
2026-07-05 06:08:44 +02:00
|
|
|
content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}
|
feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
|
|
|
)
|