refactor(plex): address /code-review on the R12 S1 split
Cleanup only, no behaviour change (ruff clean + pytest 193): - imports hoisted above `router =` and grouped/sorted (isort) across the split files; ruff-format pass restores the 2-blank-line PEP8 spacing the generator collapsed to 1. - the shared on-disk cache root moves to a neutral app/plex/cache.py, so subtitles.py no longer imports it from imgcache.py (drops the cross-import). - the former plex.py module docstring's access-model note (read endpoints open to any authed user; admin endpoints test/sync) is restored in the package __init__.py.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
"""Shared on-disk cache root for proxied Plex assets.
|
||||
|
||||
Both poster/art images (imgcache.py) and converted subtitle VTTs (subtitles.py) are cached here, so
|
||||
the directory belongs to neither module — it lives in this neutral home to avoid a cross-import.
|
||||
On-demand + bounded by the library size; no eviction (a few hundred MB at most for a big library —
|
||||
acceptable, matching the download disk trade-offs).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
||||
@@ -5,18 +5,15 @@ LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so r
|
||||
other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a
|
||||
few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
|
||||
|
||||
`CACHE_DIR` is the shared Plex on-disk cache root; converted subtitle VTTs live here too
|
||||
(see ``subtitles.py``).
|
||||
The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||
from app.plex.cache import CACHE_DIR
|
||||
|
||||
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
||||
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
|
||||
@@ -65,4 +62,6 @@ def write(key: str, data: bytes, ct: str) -> None:
|
||||
|
||||
|
||||
def response(data: bytes, ct: str) -> Response:
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||
return Response(
|
||||
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
|
||||
"""
|
||||
|
||||
from sqlalchemy import and_, case, func, or_
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
@@ -23,7 +24,11 @@ def rollup(cards: list[dict]) -> dict:
|
||||
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
|
||||
total = len(cards)
|
||||
watched = sum(1 for c in cards if c.get("status") == "watched")
|
||||
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
|
||||
inprog = [
|
||||
c
|
||||
for c in cards
|
||||
if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"
|
||||
]
|
||||
resume = None
|
||||
if inprog:
|
||||
resume = inprog[-1] # continue the latest-started episode
|
||||
@@ -49,7 +54,10 @@ def agg_subquery(db: Session, user_id: int):
|
||||
func.count(
|
||||
case(
|
||||
(
|
||||
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
|
||||
and_(
|
||||
ps.position_seconds > 0,
|
||||
or_(ps.status.is_(None), ps.status != "watched"),
|
||||
),
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,22 +2,33 @@
|
||||
|
||||
Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them;
|
||||
bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared
|
||||
Plex on-disk cache root (``imgcache.CACHE_DIR``).
|
||||
Plex on-disk cache root (``cache.CACHE_DIR``).
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from app.plex.cache import CACHE_DIR
|
||||
from app.plex.client import PlexError
|
||||
from app.plex.imgcache import CACHE_DIR
|
||||
|
||||
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
||||
BITMAP_SUBS = {"pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "vobsub", "dvbsub", "dvb_subtitle"}
|
||||
BITMAP_SUBS = {
|
||||
"pgs",
|
||||
"hdmv_pgs_subtitle",
|
||||
"dvd_subtitle",
|
||||
"dvdsub",
|
||||
"vobsub",
|
||||
"dvbsub",
|
||||
"dvb_subtitle",
|
||||
}
|
||||
|
||||
|
||||
def srt_to_vtt(text: str) -> str:
|
||||
"""Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index
|
||||
lines are valid in WebVTT, so they're left as-is."""
|
||||
body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip(""))
|
||||
body = re.sub(
|
||||
r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("")
|
||||
)
|
||||
return "WEBVTT\n\n" + body.strip() + "\n"
|
||||
|
||||
|
||||
@@ -75,7 +86,9 @@ def shift_vtt(vtt: str, offset: float) -> str:
|
||||
if en <= 0:
|
||||
keep = False # whole cue is before the session start → drop the block
|
||||
else:
|
||||
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
||||
lines[i] = (
|
||||
f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
||||
)
|
||||
break
|
||||
if keep:
|
||||
out_blocks.append("\n".join(lines))
|
||||
@@ -85,11 +98,25 @@ def shift_vtt(vtt: str, offset: float) -> str:
|
||||
def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
||||
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
|
||||
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
|
||||
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", src,
|
||||
"-map", f"0:s:{sub_ord if sub_ord is not None else 0}", "-f", "webvtt", "pipe:1"]
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
src,
|
||||
"-map",
|
||||
f"0:s:{sub_ord if sub_ord is not None else 0}",
|
||||
"-f",
|
||||
"webvtt",
|
||||
"pipe:1",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True)
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed")
|
||||
raise PlexError(
|
||||
(r.stderr or b"").decode("utf-8", "replace")[:200]
|
||||
or "ffmpeg subtitle convert failed"
|
||||
)
|
||||
return r.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user