release: 0.62.1 (r12-plex-layering)

This commit is contained in:
2026-08-12 02:01:04 +02:00
35 changed files with 3956 additions and 3101 deletions
+1 -1
View File
@@ -1 +1 @@
0.62.0
0.62.1
+15 -919
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
"""Demo-account service: reset the shared demo sandbox to a clean, seeded baseline.
Shared by the admin's manual reset endpoint (routes/admin.py) and the scheduled demo-reset job
(scheduler.py). Lives in the service layer, not routes, so the scheduler can call it without an
`app.routes -> app.scheduler` import cycle (the reason its import used to be lazy).
"""
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.models import Playlist, PlaylistItem, Subscription, User, Video, VideoState
def _seed_demo_playlists(db: Session, demo: User) -> int:
"""Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
empty — gives visitors something to play with. Best-effort: skips silently if the
catalog is too small."""
recent = (
db.execute(
select(Video.id)
.where(Video.is_short.is_(False), Video.live_status == "none")
.order_by(Video.published_at.desc().nullslast())
.limit(60)
)
.scalars()
.all()
)
if not recent:
return 0
samples = [
("Fresh picks", recent[0:10]),
("Quick watch", recent[10:18]),
("Saved for later", recent[18:26]),
]
created = 0
for pos, (name, vids) in enumerate(samples):
if not vids:
continue
pl = Playlist(user_id=demo.id, name=name, position=pos)
db.add(pl)
db.flush()
for ipos, vid in enumerate(vids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
created += 1
return created
def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
"""Seed the demo with a handful of catalog channels (the ones with the most videos) so its
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
top = db.execute(
select(Video.channel_id)
.where(Video.channel_id.is_not(None))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(n)
).scalars().all()
for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top)
def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
demo.preferences = {"language": "en"}
db.commit()
seeded = _seed_demo_playlists(db, demo)
_seed_demo_subscriptions(db, demo)
db.commit()
return seeded
+2 -2
View File
@@ -21,7 +21,7 @@ from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import formats, service
from app.models import MediaAsset
from app.worker import _uploader_url
from app.worker import build_uploader_url
log = logging.getLogger("siftlode.backfill")
@@ -73,7 +73,7 @@ def run() -> dict:
if a.uploader_url is None and _should_refetch(a):
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
info = _extract_meta(url)
uu = _uploader_url(info) if info else None
uu = build_uploader_url(info) if info else None
if uu:
a.uploader_url = uu
filled_url += 1
+29 -1
View File
@@ -12,8 +12,9 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import formats, quota
from app.downloads import formats, quota, storage
from app.models import Channel, DownloadJob, MediaAsset, Video
@@ -220,3 +221,30 @@ def enqueue_edit(
db.commit()
db.refresh(job)
return job
def release_asset(db: Session, job: DownloadJob) -> None:
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
asset anymore, delete the file + row immediately — a deleted download should free its disk,
and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
again). `error` counts as holding: enqueue always +1'd ref_count and the worker never
decrements on failure (ref_count is the route's job), so an errored job releases here on
delete — else its +1 leaks and a later resume→ready keeps the file pinned past its last holder.
Idempotent for the cancel path: cancel already released while the job was holding, then set it
`canceled` (∉ the set below), so delete-after-cancel is a no-op and never double-releases.
We DON'T delete an errored asset row here even at ref==0: another request may be concurrently
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
if not job.asset_id or job.status not in ("queued", "running", "paused", "done", "error"):
return
asset = db.get(MediaAsset, job.asset_id)
if asset is None:
return
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
if asset.ref_count == 0 and asset.status == "ready":
if asset.rel_path:
storage.delete_asset_files(settings.download_root, asset.rel_path)
db.delete(asset)
+3 -2
View File
@@ -25,13 +25,14 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import auth, state
from app import state
from app.config import settings
from app.db import SessionLocal
from app.plex import stream as plex_stream
from app.routes import (
admin,
audit as audit_routes,
auth as auth_routes,
channels,
config as config_routes,
downloads,
@@ -160,7 +161,7 @@ async def static_cache_headers(request, call_next):
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(auth_routes.router)
app.include_router(sync.router)
app.include_router(tags.router)
app.include_router(feed.router)
+13
View File
@@ -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"
+67
View File
@@ -0,0 +1,67 @@
"""Thin on-disk cache + proxy helpers for Plex poster/art images.
Posters/art/cast-photos are proxied on demand; without a cache every scroll re-hits Plex over the
LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so repeat views (and
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).
The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``).
"""
from fastapi import Response
from sqlalchemy.orm import Session
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR
_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/
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
SIZES = {"thumb": (400, 600), "art": (1280, 720)}
def key_for(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
col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
if col is not None:
return col.thumb_key
return None
def read(key: str) -> tuple[bytes, str] | None:
for ext, ct in _EXT_CT:
p = CACHE_DIR / f"{key}.{ext}"
try:
if p.exists() and p.stat().st_size > 0:
return p.read_bytes(), ct
except OSError:
pass
return None
def write(key: str, data: bytes, ct: str) -> None:
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
tmp.write_bytes(data)
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
except OSError:
pass # cache is best-effort
def response(data: bytes, ct: str) -> Response:
return Response(
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
)
+1 -1
View File
@@ -74,7 +74,7 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
# items in the same batch: SessionLocal is autoflush=False, so the ORM query above can't see a
# same-batch pending insert, and this path also races the concurrent info-page enrichment. The
# Core execute runs immediately (not deferred to the caller's commit), so a later row in the
# batch sees the row and skips it. Mirrors app/sync/videos.py:_insert_stubs.
# batch sees the row and skips it. Mirrors app/sync/videos.py:insert_stubs.
db.execute(
pg_insert(PlexPerson)
.values([{"name": nm, "photo_thumb": th} for nm, th in missing.items()])
+76
View File
@@ -0,0 +1,76 @@
"""Roll a show's episodes up into a single watch-state (new | in_progress | watched) + on-deck.
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
from app.models import PlexItem, PlexState
def show_status(total: int, watched: int, inprog: int) -> str:
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
if total and watched >= total:
return "watched"
if (watched or 0) > 0 or (inprog or 0) > 0:
return "in_progress"
return "new"
def rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
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"
]
resume = None
if inprog:
resume = inprog[-1] # continue the latest-started episode
else:
resume = next((c for c in cards if c.get("status") != "watched"), None)
return {
"status": show_status(total, watched, len(inprog)),
"resume": resume,
"first": cards[0] if cards else None,
"episode_count": total,
}
def agg_subquery(db: Session, user_id: int):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
ps = aliased(PlexState)
return (
db.query(
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1,
)
)
).label("inprog"),
)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
)
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids:
return {}
rows = agg_subquery(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
+138
View File
@@ -0,0 +1,138 @@
"""WebVTT conversion, HLS-clock time-shift, and on-disk cache for Plex subtitle streams.
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 (``cache.CACHE_DIR``).
"""
import re
import subprocess
from app.plex.cache import CACHE_DIR
from app.plex.client import PlexError
# 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",
}
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("")
)
return "WEBVTT\n\n" + body.strip() + "\n"
def _vtt_ts_to_s(ts: str) -> float:
parts = ts.split(":")
h = "0" if len(parts) == 2 else parts[0]
m, rest = parts[-2], parts[-1]
sec, _, ms = rest.partition(".")
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
def _vtt_s_to_ts(x: float) -> str:
x = max(0.0, x)
h = int(x // 3600)
x -= h * 3600
m = int(x // 60)
x -= m * 60
s = int(x)
ms = int(round((x - s) * 1000))
if ms >= 1000:
s += 1
ms -= 1000
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def shift_vtt(vtt: str, offset: float) -> str:
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
— where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue
straddling 0 is clamped to start at 0."""
if not offset:
return vtt
out_blocks: list[str] = []
for block in re.split(r"\n[ \t]*\n", vtt):
if "-->" not in block:
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
continue
lines = block.split("\n")
keep = True
for i, ln in enumerate(lines):
if "-->" not in ln:
continue
left, _, right = ln.partition("-->")
rparts = right.strip().split(None, 1)
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
try:
st = _vtt_ts_to_s(left.strip()) - offset
en = _vtt_ts_to_s(rparts[0]) - offset
except (ValueError, IndexError):
break # unparseable timing → leave the block as-is
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
)
break
if keep:
out_blocks.append("\n".join(lines))
return "\n\n".join(out_blocks)
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",
]
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"
)
return r.stdout.decode("utf-8", "replace")
def cache_read(key: str) -> str | None:
try:
p = CACHE_DIR / f"{key}.vtt"
return p.read_text(encoding="utf-8") if p.exists() else None
except OSError:
return None
def cache_write(key: str, vtt: str) -> None:
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = CACHE_DIR / f"{key}.vtt.tmp"
tmp.write_text(vtt, encoding="utf-8")
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
except OSError:
pass
+2 -73
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
@@ -18,15 +18,11 @@ from app.auth import (
purge_user,
)
from app.db import get_db
from app.demo import reset_demo_state
from app.models import (
DemoWhitelist,
Invite,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
)
from app.utils import iso, valid_email
@@ -365,73 +361,6 @@ def remove_demo_whitelist(
return {"deleted": entry_id}
def _seed_demo_playlists(db: Session, demo: User) -> int:
"""Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
empty — gives visitors something to play with. Best-effort: skips silently if the
catalog is too small."""
recent = (
db.execute(
select(Video.id)
.where(Video.is_short.is_(False), Video.live_status == "none")
.order_by(Video.published_at.desc().nullslast())
.limit(60)
)
.scalars()
.all()
)
if not recent:
return 0
samples = [
("Fresh picks", recent[0:10]),
("Quick watch", recent[10:18]),
("Saved for later", recent[18:26]),
]
created = 0
for pos, (name, vids) in enumerate(samples):
if not vids:
continue
pl = Playlist(user_id=demo.id, name=name, position=pos)
db.add(pl)
db.flush()
for ipos, vid in enumerate(vids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
created += 1
return created
def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
"""Seed the demo with a handful of catalog channels (the ones with the most videos) so its
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
top = db.execute(
select(Video.channel_id)
.where(Video.channel_id.is_not(None))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(n)
).scalars().all()
for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top)
def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
demo.preferences = {"language": "en"}
db.commit()
seeded = _seed_demo_playlists(db, demo)
_seed_demo_subscriptions(db, demo)
db.commit()
return seeded
@router.post("/demo/reset")
def reset_demo(
admin: User = Depends(admin_user), db: Session = Depends(get_db)
+945
View File
@@ -0,0 +1,945 @@
"""Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt).
Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token
helpers) stays in app/auth.py, imported below."""
import ipaddress
import secrets
from datetime import datetime, timedelta, timezone
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import email as email_mod
from app import sysconfig
from app.auth import (
_YOUTUBE_SCOPES,
BASE_SCOPES,
MAX_SESSION_ACCOUNTS,
PASSWORD_MIN_LEN,
READ_SCOPES,
WRITE_SCOPES,
current_user,
get_or_create_demo_user,
google_enabled,
is_allowed,
log,
operator_contact,
resolved_user_id,
)
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import valid_email
VERIFY_TTL = timedelta(hours=24)
RESET_TTL = timedelta(hours=1)
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
router = APIRouter(prefix="/auth", tags=["auth"])
_oauth = OAuth()
_oauth_creds: tuple[str, str] | None = None
def google_oauth(db: Session):
"""The configured authlib Google client, or None when no credentials are set (Google sign-in
is then disabled — email+password still works)."""
global _oauth, _oauth_creds
cid = sysconfig.get_str(db, "google_client_id")
csec = sysconfig.get_str(db, "google_client_secret")
if not cid or not csec:
return None
if _oauth_creds != (cid, csec):
_oauth = OAuth()
_oauth.register(
name="google",
client_id=cid,
client_secret=csec,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": BASE_SCOPES},
)
_oauth_creds = (cid, csec)
return _oauth.google
def admin_notify_emails(db: Session) -> list[str]:
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
keeps a freshly-seeded instance working before any DB admin exists."""
db_admins = db.execute(
select(User.email).where(
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
)
).scalars()
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
def is_demo_allowed(db: Session, email: str) -> bool:
"""Whether this email may enter the shared demo account (no Google sign-in)."""
if not email:
return False
return (
db.execute(
select(DemoWhitelist).where(DemoWhitelist.email == email.lower())
).scalar_one_or_none()
is not None
)
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
try:
ip = ipaddress.ip_address(s.strip())
except ValueError:
return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
def _client_ip(request: Request) -> str:
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
do NOT add that flag, or the trust check below would compare against a forgeable value."""
peer = request.client.host if request.client else "unknown"
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
if _norm_ip(peer) in trusted:
xff = request.headers.get("x-forwarded-for")
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
if parts:
return parts[-1]
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails."""
email = (email or "").lower()
if not valid_email(email):
return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not None:
# Let a previously-denied person ask again; don't disturb approved/pending rows.
if inv.status == "denied":
inv.status = "pending"
inv.requested_at = datetime.now(timezone.utc)
inv.decided_at = None
inv.decided_by = None
db.commit()
return inv
return None
inv = Invite(email=email, status="pending")
db.add(inv)
db.commit()
return inv
_DEV_OAUTH_HOSTS = frozenset(
{"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"}
)
def _oauth_redirect_uri(request: Request) -> str:
"""The redirect_uri handed to Google. Production always uses the fixed configured URL and never
trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In
local dev we honor the request's own origin when it is one of the known dev hosts, so a login
started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080."""
if settings.session_https_only: # https redirect URL == real deployment
return settings.oauth_redirect_url
host = (request.headers.get("host") or "").lower()
if host in _DEV_OAUTH_HOSTS:
return f"http://{host}/auth/callback"
return settings.oauth_redirect_url
@router.get("/login")
async def login(request: Request, db: Session = Depends(get_db)):
# access_type=offline ensures a refresh_token on first authorization. We avoid
# prompt=consent so returning users get a quick sign-in; the stored refresh token
# is kept when Google doesn't re-issue one (see the callback).
client = google_oauth(db)
if client is None:
# Google sign-in isn't configured on this instance — land back on the login page (the UI
# hides the button, so this is just a safety net for a stale/direct hit).
return RedirectResponse(url="/")
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
scope=BASE_SCOPES,
)
@router.get("/callback")
async def callback(
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
):
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/")
# Linking mode: an already-authenticated account is attaching this Google identity (a
# password account enabling YouTube or connecting SSO). The markers are set by /auth/link and
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
link_uid = request.session.pop("oauth_link_uid", None)
link_explicit = request.session.pop("oauth_link_explicit", False)
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
# The user cancelled or denied consent at Google — land back with a friendly notice instead
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
# link_uid, so the add-account path (no marker) is covered too.
log.info("Google OAuth cancelled/failed: %s", exc.error)
if request.session.get("user_id") is not None:
return RedirectResponse(url="/?oauth=cancelled")
return RedirectResponse(url="/?login=oauth_cancelled")
userinfo = token.get("userinfo")
if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google")
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower()
if link_uid is not None:
return _complete_link(
request, db, link_uid, link_explicit, sub, userinfo, token
)
if not is_allowed(db, email):
log.warning("Login denied (not approved): %s", email or "<no email>")
# A denied Google login doubles as an access request: record it for the admin and
# land the user on a friendly "request received" screen instead of a raw 403.
if email:
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied")
user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None:
# No account carries this Google identity yet. If one already exists for this email —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
if not userinfo.get("email_verified", False):
# Adopting (and activating) a pre-existing password account requires Google to
# actually attest the address — otherwise an unverified Google email that merely
# collides with a pending registration could seize that account. Defense-in-depth.
log.warning(
"Google login: unverified email, refusing to adopt account: %s",
email,
)
return RedirectResponse(url="/?access=denied")
user = existing
user.google_sub = sub
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
user.is_active = True
else:
user = User(google_sub=sub, email=email)
db.add(user)
# A suspended account can't sign in by any method. Block before establishing the session and
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended")
# Keep the email in sync with Google, but never overwrite it with one another account already
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
# Google-side email change to a value that collides with a different local account).
if user.email != email:
clash = (
db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
)
if clash is None:
user.email = email
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
user.role = "admin"
elif not user.role:
user.role = "user"
# Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets
# a Google user who later adds a password sign in (password-login gates on email_verified).
if userinfo.get("email_verified", True):
user.email_verified = True
# Default UI language from the Google-reported locale on first login (if the user hasn't
# picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {})
if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower()
prefs["language"] = loc if loc in {"en", "hu"} else "en"
user.preferences = prefs
db.flush()
_store_token(db, user, token)
db.commit()
# Multi-session: remember every account that has authenticated in this browser so the
# user can switch between them without going through Google again. Only accounts that
# actually completed OAuth here land in this list (it's the proof they may be switched to).
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
_remember_epoch(request, user)
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/")
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
include_granted_scopes=true means `scope` is the union of everything granted, so this
reflects read/write upgrades correctly."""
tok = user.token or OAuthToken(user=user)
old = set((tok.scopes or "").split())
new = set((token.get("scope") or "").split())
# A plain re-sign-in requests only BASE_SCOPES, and Google can hand back a FRESH base-only
# access+refresh token. Adopting it would DESTROY a previously-granted YouTube read/write grant —
# can_read/can_write flip to false and the feed demands a reconnect (the exact bug users hit on a
# logout→login). The OLD refresh token stays valid on Google's side, so when this exchange would
# NARROW our YouTube scopes, keep the WHOLE existing grant (refresh token, access token, scopes)
# untouched. The grant only shrinks on a full disconnect (purge_user deletes the token row); an
# externally-revoked scope surfaces later as a YouTube API 403 → reconnect, not through this write.
if (old & _YOUTUBE_SCOPES) - new:
db.add(tok)
return
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
# Encrypt the access token at rest too (it's a live ~1h bearer credential), matching the
# refresh token — a DB/backup/log leak otherwise hands out working Google API tokens.
tok.access_token = encrypt(token.get("access_token"))
expires_at = token.get("expires_at")
tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
)
# Union (never narrow): a broader-or-equal exchange may still list only the newly-added scope.
tok.scopes = " ".join(sorted(old | new)) or BASE_SCOPES
db.add(tok)
def _complete_link(
request: Request,
db: Session,
link_uid: int,
explicit: bool,
sub: str,
userinfo: dict,
token: dict,
):
"""Attach the just-authorized Google identity to the already-signed-in account that started
the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a
Google identity owned by another account, or to silently swap a different linked identity.
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
# The marker must match the live session user (guards a stale/forged marker).
if link_uid != request.session.get("user_id"):
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None or user.is_demo:
return RedirectResponse(url="/?link=error")
other = (
db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
)
if other is not None:
return RedirectResponse(url="/?link=conflict")
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
user.avatar_url = userinfo.get("picture")
_store_token(db, user, token)
db.commit()
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
return RedirectResponse(url="/?link=ok" if explicit else "/")
@router.post("/logout")
async def logout(request: Request):
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
out in one tab doesn't disturb another tab running a different account. If the account being
removed was the session default, promote the most recent remaining account (or clear the
session when none remain)."""
active, is_default = resolved_user_id(request)
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
request.session["account_ids"] = remaining
if is_default:
if remaining:
request.session["user_id"] = remaining[-1]
return JSONResponse({"ok": True, "switched": True})
request.session.clear()
return JSONResponse({"ok": True, "switched": False})
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
# is untouched. The tab drops its own override and falls back to the default on reload.
return JSONResponse({"ok": True, "switched": False})
@router.post("/request-access")
async def request_access(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Public: ask for access. Idempotent — repeat requests for the same email don't
duplicate or re-spam admins (upsert returns None for an already-pending row).
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
address made this a public enumeration oracle for the allow-list (which the rest of the module
is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google.
Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the
endpoint can't be probed for throttling either."""
email = (payload.get("email") or "").strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not _request_access_limiter.allow(_client_ip(request)):
return {"status": "pending"}
if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
return {"status": "pending"}
@router.post("/demo")
def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict:
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
exactly like a non-match (authenticated=false) so the field can't be hammered as an
enumeration oracle. On a match it sets the session and the client reloads into the app."""
if not _demo_limiter.allow(_client_ip(request)):
return {"authenticated": False}
email = (payload.get("email") or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db)
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
# can't switch to / act as a previously signed-in account via the multi-account header.
request.session.clear()
request.session["user_id"] = demo.id
_remember_epoch(request, demo)
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
return {"authenticated": True}
return {"authenticated": False}
def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session, mirroring the Google callback's multi-account
handling so password sign-in joins the same account switcher."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
_remember_epoch(request, user)
def _app_base() -> str:
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
return settings.app_base
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
(goes only into the emailed link)."""
raw = secrets.token_urlsafe(32)
db.add(
AuthToken(
user_id=user.id,
kind=kind,
token_hash=hash_token(raw),
expires_at=datetime.now(timezone.utc) + ttl,
)
)
db.commit()
return raw
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
read-check-write, so two concurrent requests can't both consume the same verify/reset token —
only the one whose UPDATE flips used_at gets a row back."""
if not raw:
return None
now = datetime.now(timezone.utc)
row = db.execute(
update(AuthToken)
.where(
AuthToken.token_hash == hash_token(raw),
AuthToken.kind == kind,
AuthToken.used_at.is_(None),
AuthToken.expires_at >= now,
)
.values(used_at=now)
.returning(AuthToken.user_id)
).first()
db.commit()
if row is None:
return None
return db.get(User, row.user_id)
@router.post("/register")
def register(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Email+password registration. Creates a pending (inactive, unverified) account, sends a
verification link, and records an access request for admin approval. Two gates before
sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
the endpoint can't be used to discover which emails are registered."""
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
# Input validation is safe to reveal (independent of whether the email exists).
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if not sysconfig.get_bool(db, "allow_registration"):
raise HTTPException(status_code=403, detail="Registration is currently closed.")
if not _register_limiter.allow(_client_ip(request)):
return {"status": "ok"} # silently throttle; uniform response
# Do the existence check + account creation OFF the response path (a background task with its own
# session). The create path hashes the password + writes several rows + schedules emails; doing it
# inline would make a NEW email respond measurably slower than an already-registered one (which
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
background.add_task(_register_account, email, password)
return {"status": "ok"}
def _register_account(email: str, password: str) -> None:
"""Background worker for /register: create the pending account (+ verification email + admin
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
with its own DB session, so the request's timing never reveals whether the email already exists."""
with SessionLocal() as db:
if (
db.execute(select(User).where(User.email == email)).scalar_one_or_none()
is not None
):
return # already registered — nothing to do (the uniform "ok" was already returned)
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
email_ok = email_mod.email_enabled()
user = User(
email=email,
password_hash=hash_password(password),
is_active=False,
email_verified=not email_ok,
)
db.add(user)
try:
db.flush()
except IntegrityError:
# A concurrent registration for the same new email won the race between the pre-check
# above and this flush (the unique-email constraint fires). Same already-registered
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate (commits)
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
admins = admin_notify_emails(db)
if admins:
email_mod.send_admin_new_request(admins, email)
@router.post("/verify")
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
on any invalid/expired/used token."""
user = _consume_token(db, payload.get("token"), "verify")
if user is None:
raise HTTPException(
status_code=400, detail="This verification link is invalid or has expired."
)
user.email_verified = True
db.commit()
return {"ok": True}
@router.post("/verify/resend")
def verify_email_resend(
payload: dict,
request: Request,
background: BackgroundTasks,
) -> dict:
"""Re-issue an email-verification link. Needed because the original link expires (VERIFY_TTL)
and re-registering an already-registered email is a silent no-op, so a user who lost the first
mail had no way back. Uniform response + timing regardless of whether the email has a pending
account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _verify_resend_limiter.allow(_client_ip(request)):
return {"status": "ok"} # silently throttle; uniform response
email = (payload.get("email") or "").strip().lower()
if valid_email(email):
background.add_task(_resend_verify_if_eligible, email)
return {"status": "ok"}
def _resend_verify_if_eligible(email: str) -> None:
"""Background worker for verify/resend: issue a fresh verify token + email the link, but ONLY for
a real account that still needs verifying. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. No-op when SMTP is disabled (accounts
are auto-verified on a no-SMTP instance, so there is nothing to resend). Token rides the URL
FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3)."""
if not email_mod.email_enabled():
return
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and not user.email_verified and not user.is_demo:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
user.email_verified = True
db.commit()
return RedirectResponse(url="/?verify=ok")
@router.post("/password-login")
def password_login(
payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
Once the password is proven correct, the owner gets a specific reason if their account is
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(
status_code=429, detail="Too many attempts. Try again shortly."
)
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
# response time is the same whether or not the email exists — no timing-based enumeration.
candidate_hash = user.password_hash if (user and not user.is_demo) else None
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
if user is None or user.is_demo or not password_ok:
raise HTTPException(status_code=401, detail="Invalid email or password.")
if user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
if not user.email_verified:
raise HTTPException(
status_code=403, detail="Please verify your email first (check your inbox)."
)
if not user.is_active:
raise HTTPException(
status_code=403, detail="Your account is awaiting admin approval."
)
_establish_session(request, user)
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
@router.post("/password-reset/request")
def password_reset_request(
payload: dict,
request: Request,
background: BackgroundTasks,
) -> dict:
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a
password account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _reset_limiter.allow(_client_ip(request)):
return {"status": "ok"}
email = (payload.get("email") or "").strip().lower()
# Do the account lookup + token issue + email OFF the response path (a background task with its
# own session), so the endpoint takes the same time for any valid email whether or not it has a
# password account — no timing-based enumeration. Any valid email schedules the same task.
if valid_email(email):
background.add_task(_send_reset_if_eligible, email)
return {"status": "ok"}
def _send_reset_if_eligible(email: str) -> None:
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
for a real (non-demo) password account. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
can't leak into proxy/access logs or a Referer (SB3)."""
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL)
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
@router.post("/password-reset/confirm")
def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
token = payload.get("token")
new_password = payload.get("password") or ""
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
user = _consume_token(db, token, "reset")
if user is None:
raise HTTPException(
status_code=400, detail="This reset link is invalid or has expired."
)
user.password_hash = hash_password(new_password)
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
# email — this rescues an account that registered but never clicked the verification link (it
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
# here: a reset proves email ownership, not that an admin approved the account.
user.email_verified = True
bump_session_epoch(
user
) # SA4: a reset (often a compromise response) kills all existing sessions.
# Burn any other outstanding reset tokens for this user.
for row in db.execute(
select(AuthToken).where(
AuthToken.user_id == user.id,
AuthToken.kind == "reset",
AuthToken.used_at.is_(None),
)
).scalars():
row.used_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
def _remember_epoch(request: Request, user: User) -> None:
"""Record this account's current `session_epoch` in the signed cookie (SA4), so `current_user`
can later reject the cookie if the account's epoch was bumped (password reset/change, or
"log out everywhere"). Per-account so one account's revocation doesn't evict the others."""
epochs = dict(request.session.get("epochs") or {})
epochs[str(user.id)] = user.session_epoch
request.session["epochs"] = epochs
def bump_session_epoch(user: User) -> None:
"""Invalidate every EXISTING signed cookie for this account by advancing its epoch (SA4). Cookies
minted before the bump carry a now-stale epoch and are rejected by `current_user`."""
user.session_epoch = (user.session_epoch or 0) + 1
@router.post("/logout-others")
def logout_others(
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""SA4: sign every OTHER session for this account out — bump the epoch so all existing cookies
are rejected, then re-stamp THIS cookie so the caller stays signed in. For "I left myself logged
in somewhere" / a suspected session compromise. The demo account (shared) is excluded."""
if user.is_demo:
raise HTTPException(
status_code=403, detail="Not available in the demo account."
)
# Commit the bump BEFORE re-stamping this cookie, so a failed commit can't strand the current
# session at a newer epoch than the DB (see set_password).
bump_session_epoch(user)
db.commit()
_remember_epoch(request, user)
log.info("Logout-others: uid=%s (epoch=%s)", user.id, user.session_epoch)
return {"ok": True}
@router.get("/link")
async def link_google(
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes — YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/?link=error")
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = True
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
scope=BASE_SCOPES,
)
@router.post("/set-password")
def set_password(
payload: dict,
request: Request,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password — the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(
status_code=403, detail="Not available in the demo account."
)
new_password = payload.get("password") or ""
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if user.password_hash:
if not verify_password(
payload.get("current_password") or "", user.password_hash
):
raise HTTPException(
status_code=403, detail="Current password is incorrect."
)
user.password_hash = hash_password(new_password)
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
bump_session_epoch(user)
db.commit()
_remember_epoch(request, user)
log.info("Password set/changed: uid=%s", user.id)
return {"ok": True}
@router.get("/upgrade")
async def upgrade(
request: Request,
access: str = "read",
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
"""Incremental consent for the onboarding wizard. `access=read` grants YouTube
read-only (enough to build the feed); `access=write` additionally grants management
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
the least-privileged read scope."""
# This is the browser-facing redirect entry point, so the shared demo account is bounced
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo:
return RedirectResponse(url="/")
client = google_oauth(db)
if client is None:
return RedirectResponse(url="/")
# Attach the grant to THIS account in the callback. Without it a password account (no
# google_sub) would get a brand-new, separate Google user created instead of being linked.
request.session["oauth_link_uid"] = user.id
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
access_type="offline",
prompt="consent",
include_granted_scopes="true",
scope=scope,
)
@router.get("/me")
async def me(user: User = Depends(current_user)) -> dict:
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"role": user.role,
}
@router.get("/config")
def auth_config(db: Session = Depends(get_db)) -> dict:
"""Public: which sign-in options this instance offers, so the login page can hide what's off
(e.g. no Google button when Google OAuth isn't configured)."""
return {
"google_enabled": google_enabled(db),
"allow_registration": sysconfig.get_bool(db, "allow_registration"),
# Contact shown on the public legal pages (the operator's admin email), if configured.
"operator_contact": operator_contact(),
}
+4 -31
View File
@@ -511,9 +511,9 @@ def redownload_download(
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
service.populate_from_catalog(db, asset)
# Now drop the old hold — `_release_asset` deletes the old file + row only if this job was its
# Now drop the old hold — `service.release_asset` deletes the old file + row only if this job was its
# last holder (a co-held asset is left intact for its other holders).
_release_asset(db, job)
service.release_asset(db, job)
asset.ref_count = (asset.ref_count or 0) + 1
job.asset_id = asset.id
@@ -538,7 +538,7 @@ def cancel_download(
) -> dict:
job = _own_job(db, user, job_id)
if job.status not in ("done", "canceled"):
_release_asset(db, job) # release while the job still counts as holding
service.release_asset(db, job) # release while the job still counts as holding
job.status = "canceled"
db.commit()
return _serialize_job(db, job)
@@ -549,39 +549,12 @@ def delete_download(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
job = _own_job(db, user, job_id)
_release_asset(db, job)
service.release_asset(db, job)
db.delete(job)
db.commit()
return {"deleted": job_id}
def _release_asset(db: Session, job: DownloadJob) -> None:
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
asset anymore, delete the file + row immediately — a deleted download should free its disk,
and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
again). `error` counts as holding: enqueue always +1'd ref_count and the worker never
decrements on failure (ref_count is the route's job), so an errored job releases here on
delete — else its +1 leaks and a later resume→ready keeps the file pinned past its last holder.
Idempotent for the cancel path: cancel already released while the job was holding, then set it
`canceled` (∉ the set below), so delete-after-cancel is a no-op and never double-releases.
We DON'T delete an errored asset row here even at ref==0: another request may be concurrently
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
if not job.asset_id or job.status not in ("queued", "running", "paused", "done", "error"):
return
asset = db.get(MediaAsset, job.asset_id)
if asset is None:
return
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
if asset.ref_count == 0 and asset.status == "ready":
if asset.rel_path:
storage.delete_asset_files(settings.download_root, asset.rel_path)
db.delete(asset)
# --- file download (range-aware, custom display name) --------------------------------------
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
+4 -4
View File
@@ -91,7 +91,7 @@ def feed_columns(user: User, state) -> list:
]
def _serialize(row) -> dict:
def serialize(row) -> dict:
return {
"id": row.id,
"title": row.title,
@@ -258,7 +258,7 @@ def _filtered_query(
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
rank_expr = None
if q:
ts_str = _to_tsquery_str(q)
ts_str = to_tsquery_str(q)
if ts_str:
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
query = query.where(
@@ -341,7 +341,7 @@ def _filtered_query(
return query, rank_expr
def _to_tsquery_str(q: str) -> str | None:
def to_tsquery_str(q: str) -> str | None:
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
@@ -512,7 +512,7 @@ def get_feed(
has_more = len(rows) > limit
page = rows[:limit]
return {
"items": [_serialize(r) for r in page],
"items": [serialize(r) for r in page],
"next_cursor": _encode_cursor(keys, page[-1]) if (has_more and page) else None,
"has_more": has_more,
"limit": limit,
+2 -2
View File
@@ -10,7 +10,7 @@ from app import quota
from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope
from app.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.routes.feed import _serialize, feed_columns
from app.routes.feed import serialize, feed_columns
from app.sync.playlists import (
plan_push,
push_playlist,
@@ -400,7 +400,7 @@ def get_playlist(
"source": pl.source,
"yt_playlist_id": pl.yt_playlist_id,
"dirty": pl.dirty,
"items": [_serialize(r) for r in rows],
"items": [serialize(r) for r in rows],
}
+18
View File
@@ -0,0 +1,18 @@
"""Plex route package — assembles the admin/browse/playback sub-routers.
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
pure-Siftlode included) — this 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.
Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app
wiring expects (`plex_routes.router`), so main.py is unchanged.
"""
from fastapi import APIRouter
from app.routes.plex import admin, browse, playback
router = APIRouter()
router.include_router(admin.router)
router.include_router(browse.router)
router.include_router(playback.router)
+19
View File
@@ -0,0 +1,19 @@
"""Shared helpers, serializers and constants for the plex route package."""
import logging
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models import (
PlexItem,
)
log = logging.getLogger("siftlode.plex")
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
+165
View File
@@ -0,0 +1,165 @@
"""Plex admin: connection test, catalog sync, and owner watch-link config."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app import sysconfig
from app.auth import current_user
from app.db import get_db
from app.models import (
PlexItem,
PlexLink,
User,
)
from app.plex import paths as plex_paths
from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.routes.admin import admin_user
from app.utils import iso
router = APIRouter(prefix="/api/plex", tags=["plex"])
@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
movie/show sections (for the config library-picker). Also probes ONE real media file through
the local mount, so a missing bind-mount / wrong path-map is caught here rather than only
surfacing as a dead player later."""
try:
with PlexClient(db) as plex:
info = plex.server_info()
sections = plex.sections()
media_check = _media_check(db, 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": [
{
"key": str(s.get("key")),
"title": s.get("title"),
"type": s.get("type"),
"count": s.get("count"),
}
for s in sections
if s.get("type") in ("movie", "show")
],
"media_check": media_check,
}
def _sample_plex_path(
db: Session, plex: PlexClient, sections: list[dict]
) -> str | None:
"""A real Plex-side media file path to validate the mount + path-map against. Prefers the
already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled
section straight from Plex."""
row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first()
if row and row[0]:
return row[0]
wanted = plex_sync._enabled_section_keys(db)
for s in sections:
if s.get("type") != "movie":
continue
key = str(s.get("key"))
if wanted is not None and key not in wanted:
continue
items, _ = plex.section_items(key, 1, 0, 1)
if items:
fp = plex_sync._media_facts(items[0]).get("file_path")
if fp:
return fp
return None
def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
"""Resolve a sample Plex file path to its local mount path and confirm it's readable, so the
admin sees at Test time whether the physical media is actually reachable (bind-mount +
path-map correct). ``checked=False`` when no sample is available (empty library)."""
plex_path = _sample_plex_path(db, plex, sections)
if not plex_path:
return {"checked": False}
local_path = plex_paths.map_path(db, plex_path)
ok = plex_paths.local_media_path(db, plex_path) is not None
return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path}
@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)
def _link_dict(link: PlexLink | None) -> dict:
return {
"linked": link is not None,
"sync_enabled": bool(link and link.sync_enabled),
"uses_admin": bool(link and link.uses_admin),
"initial_import_done": bool(link and link.initial_import_done),
"last_watch_sync_at": iso(link.last_watch_sync_at) if link else None,
}
@router.get("/watch/link")
def watch_link_status(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""This user's Plex watch-sync link status (drives the Settings toggle)."""
link = db.query(PlexLink).filter_by(user_id=user.id).first()
return _link_dict(link)
class WatchLinkIn(BaseModel):
enabled: bool = False
@router.post("/watch/link")
def watch_link_set(
body: WatchLinkIn,
user: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin: turn two-way Plex watch-sync on/off for THIS (owner) account. The link uses the server
admin token, so no separate Plex login. Enabling it for the first time runs the one-time
"Plex is master" import immediately so the owner's existing Plex history shows up in Siftlode."""
if not sysconfig.get_bool(db, "plex_enabled"):
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
enabled = body.enabled
link = db.query(PlexLink).filter_by(user_id=user.id).first()
if link is None:
link = PlexLink(
user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)
)
db.add(link)
link.sync_enabled = enabled
db.commit()
result: dict = {}
if enabled and not link.initial_import_done:
result = plex_watch.import_owner_watch_state(db, link)
return {**_link_dict(link), "import": result or None}
@router.post("/watch/import")
def watch_import_now(
user: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the
intersection; Siftlode-only states are preserved. Idempotent."""
link = db.query(PlexLink).filter_by(user_id=user.id).first()
if link is None or not link.sync_enabled:
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.")
result = plex_watch.import_owner_watch_state(db, link)
return {**_link_dict(link), "import": result}
File diff suppressed because it is too large Load Diff
+532
View File
@@ -0,0 +1,532 @@
"""Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes."""
import hashlib
import os
import tempfile
from datetime import datetime, timezone
import httpx
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Request,
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel
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 (
PlexItem,
PlexSeason,
PlexShow,
PlexState,
User,
)
from app.plex import imgcache, subtitles
from app.plex import paths as plex_paths
from app.plex import people as plex_people
from app.plex import stream as plex_stream
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
router = APIRouter(prefix="/api/plex", tags=["plex"])
def _require_session(request: Request) -> int:
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
low-sensitivity; a valid session is enough."""
uid, _ = resolved_user_id(request)
if not uid:
raise HTTPException(status_code=401, detail="Not authenticated")
return uid
@router.get("/image/{rating_key}")
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
thin on-disk cache serves repeat views without re-hitting Plex.
⚠️ This endpoint is called in BURSTS (a collections page fires 100+ concurrent poster requests),
so it must NOT hold a DB connection during the slow Plex fetch — doing so exhausted the pool
(QueuePool timeout, broken images on prod). Warm path (cache hit) touches NO DB; cold path opens a
brief session only to resolve the image key + read the Plex config, then RELEASES it before the
fetch. Auth is the signed-session check (no DB user-load)."""
_require_session(request)
v = "art" if variant == "art" else "thumb"
w, h = imgcache.SIZES[v]
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
# the small variant. The width tag also means changing a target size just re-caches, never serves a
# stale differently-sized file (old full-size files under the un-tagged key are simply orphaned).
cache_key = f"item_{rating_key}_{v}_{w}"
hit = imgcache.read(cache_key)
if hit:
return imgcache.response(*hit)
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
try:
with SessionLocal() as db:
key = imgcache.key_for(db, str(rating_key), v)
if not key:
raise HTTPException(status_code=404, detail="No image")
plex = PlexClient(
db
) # reads base URL + token now; the fetch below needs no DB
except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e))
try:
with plex:
data, ct = plex.image_bytes(key, w, h)
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
imgcache.write(cache_key, data, ct)
return imgcache.response(data, ct)
@router.get("/subtitle/{rating_key}/{sub_ord}")
def subtitle(
rating_key: str, sub_ord: int, request: Request, offset: float = 0.0
) -> Response:
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached."""
_require_session(request)
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
cache_key = f"sub_{rating_key}_{sub_ord}"
hit = subtitles.cache_read(cache_key)
if hit is not None:
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
try:
with SessionLocal() as db:
it = item_or_404(db, rating_key)
with PlexClient(db) as plex:
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[
0
].get("Part") or [{}]
subs = [
s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3
]
if sub_ord < 0 or sub_ord >= len(subs):
raise HTTPException(status_code=404, detail="No such subtitle")
s = subs[sub_ord]
codec = (s.get("codec") or s.get("format") or "").lower()
if codec in subtitles.BITMAP_SUBS:
raise HTTPException(
status_code=415,
detail="Image-based subtitle can't be shown as text",
)
if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
raw = plex.raw_get(s["key"])
if codec in ("srt", "subrip", ""):
vtt = subtitles.srt_to_vtt(raw.decode("utf-8", "replace"))
else:
fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub"))
try:
os.write(fd, raw)
os.close(fd)
vtt = subtitles.ffmpeg_to_vtt(tmp, None)
finally:
os.unlink(tmp)
elif (
s.get("index") is not None
): # embedded → extract from the local file
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"
)
emb_ord = sum(
1 for x in subs[:sub_ord] if x.get("index") is not None
)
vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord)
else:
raise HTTPException(
status_code=404, detail="Subtitle not fetchable"
)
except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e))
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
subtitles.cache_write(cache_key, vtt)
return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt")
_PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
@router.get("/person-image")
def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = imgcache.read(cache_key)
if hit:
return imgcache.response(*hit)
try:
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
r = c.get(u)
r.raise_for_status()
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Image fetch failed")
ct = r.headers.get("content-type", "image/jpeg")
imgcache.write(cache_key, r.content, ct)
return imgcache.response(r.content, ct)
def _push_watch(
background: BackgroundTasks,
db: Session,
user_id: int,
item_id: int,
rating_key: str,
action: str,
time_ms: int = 0,
duration_ms: int = 0,
) -> None:
"""Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has
push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids
spinning up a throwaway background session on every state change for the common no-link case."""
if plex_watch.link_for_push(db, user_id) is None:
return
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
background.add_task(
plex_watch.push_state_to_plex,
user_id,
item_id,
rating_key,
action,
time_ms,
duration_ms,
plex_watch._state_marker(st),
)
class ItemProgressIn(BaseModel):
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
position_seconds: float = 0
duration_seconds: float = 0
final: bool = False
@router.post("/item/{rating_key}/progress")
def item_progress(
rating_key: str,
body: ItemProgressIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Checkpoint the resume position while playing (near the end → mark watched + clear it).
`final` (sent on the pause/pagehide/unmount flush, not the periodic 10s checkpoint) is what
debounces the Plex resume push: we mirror the settled position to Plex only on a final flush, so
a single watch doesn't spray `/:/timeline` calls at the server every 10 seconds."""
it = item_or_404(db, rating_key)
position = max(0, int(body.position_seconds))
duration = int(body.duration_seconds)
final = body.final
now = datetime.now(timezone.utc)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
was_watched = st is not None and st.status == "watched"
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
st.synced_to_plex = False
db.commit()
# Immediate on the watched transition; skip a repeat scrobble if it was already watched
# (a later checkpoint of the same finished item would otherwise bump Plex's viewCount again).
if not was_watched:
_push_watch(background, db, user.id, it.id, rating_key, "watched")
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
if final:
st.synced_to_plex = False
db.commit()
if final:
_push_watch(
background,
db,
user.id,
it.id,
rating_key,
"resume",
position * 1000,
duration * 1000,
)
return {"position_seconds": position}
class ItemStateIn(BaseModel):
status: str = ""
@router.post("/item/{rating_key}/state")
def item_state(
rating_key: str,
body: ItemStateIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Set the per-user watch status (new|watched|hidden). watched→scrobble / →new→unscrobble push
to a linked Plex account immediately; `hidden` is a Siftlode-only concept with no Plex analogue,
so it never touches Plex."""
it = item_or_404(db, rating_key)
status = body.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()
prev_status = st.status if st is not None else "new"
if status == "new":
if st is not None:
db.delete(st)
db.commit()
if prev_status != "new": # something → unwatched: mirror the un-mark to Plex
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
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
st.synced_to_plex = False
db.commit()
if status == "watched" and prev_status != "watched":
_push_watch(background, db, user.id, it.id, rating_key, "watched")
return {"status": status}
def _bulk_state(
background: BackgroundTasks,
db: Session,
user_id: int,
eps: list[PlexItem],
watched: bool,
) -> int:
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
now = datetime.now(timezone.utc)
existing = {
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]),
)
}
pushable = plex_watch.link_for_push(db, user_id) is not None
to_push: list[tuple[int, str, str]] = []
for e in eps:
st = existing.get(e.id)
prev = st.status if st is not None else "new"
if watched:
if st is None:
st = PlexState(user_id=user_id, item_id=e.id)
db.add(st)
st.status = "watched"
st.watched_at = now
st.position_seconds = 0
st.synced_to_plex = False
if prev != "watched":
to_push.append((e.id, e.rating_key, "watched"))
else: # → new: drop any state (but keep the user's private "hidden" marks)
if st is not None and prev != "hidden":
db.delete(st)
if prev != "new":
to_push.append((e.id, e.rating_key, "unwatched"))
db.commit()
if pushable and to_push:
# One coalesced background task for the whole batch (reuses a single session + Plex client),
# not one task per episode — a 200-ep show mark is 1 task, not 200.
background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
return len(to_push)
class WatchedIn(BaseModel):
watched: bool = False
@router.post("/show/{rating_key}/state")
def show_state(
rating_key: str,
body: WatchedIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
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")
watched = body.watched
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/season/{rating_key}/state")
def season_state(
rating_key: str,
body: WatchedIn,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season")
watched = body.watched
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/stream/{rating_key}/session")
def stream_session(
rating_key: str,
start: float = 0.0,
audio: int | None = None,
aoff: float = 0.0,
multi: bool = False,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3. Selecting a
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart."""
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)."
)
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
# the client can switch audio without a restart — even for otherwise direct-playable files.
if it.playable == "direct" and audio is None and not aoff and not multi:
return {
"mode": "direct",
"url": f"/api/plex/stream/{it.rating_key}/file",
"start_s": 0.0,
}
if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
return {
"mode": "hls",
"start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
}
@router.get("/stream/{rating_key}/file")
def stream_file(
rating_key: str,
download: bool = False,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> FileResponse:
"""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."""
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")
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}")
return FileResponse(str(src)) # Starlette honours the Range header (206)
_HLS_CT = {
".m3u8": "application/vnd.apple.mpegurl",
".ts": "video/mp2t",
".vtt": "text/vtt",
}
@router.get("/stream/{rating_key}/hls/{filename}")
def stream_hls_file(
rating_key: str,
filename: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> Response:
"""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")
s = plex_stream.current_session(str(rating_key))
if s is None:
# No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a
# multi-audio item, index.m3u8 for a single-audio remux item.
it = item_or_404(db, rating_key)
if filename == "master.m3u8":
s = plex_stream.start_session(db, it, 0.0, multi=True)
elif filename == "index.m3u8" and it.playable == "remux":
s = plex_stream.start_session(db, it, 0.0)
else:
raise HTTPException(status_code=404, detail="No active playback session")
if s is None:
raise HTTPException(status_code=404, detail="Media file not found on disk")
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")
return Response(
content=path.read_bytes(),
media_type=_HLS_CT[ext],
headers={"Cache-Control": "no-store"},
)
+4 -4
View File
@@ -36,9 +36,9 @@ from app.models import (
Video,
VideoState,
)
from app.routes.feed import _serialize, feed_columns
from app.routes.feed import serialize, feed_columns
from app.sync.subscriptions import apply_channel_details
from app.sync.videos import _insert_stubs, apply_video_details, parse_dt
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
from app.youtube import search_scrape
from app.youtube.client import YouTubeClient, YouTubeError
from app.youtube.shorts import make_client, probe_is_short
@@ -102,7 +102,7 @@ def _serialize_ids(db: Session, user: User, ids: list[str]) -> list[dict]:
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(Video.id.in_(ids))
).all()
by_id = {r.id: _serialize(r) for r in rows}
by_id = {r.id: serialize(r) for r in rows}
return [by_id[i] for i in ids if i in by_id]
@@ -136,7 +136,7 @@ def _ingest_candidates(
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
# NOTHING), so an organically-synced video stays organic.
_insert_stubs(
insert_stubs(
db,
[
{
+3 -5
View File
@@ -13,8 +13,9 @@ from app import audit, progress, quota
from app.audit import AuditAction
from app.config import settings
from app.db import SessionLocal
from app.demo import reset_demo_state
from app.downloads.gc import run_download_gc
from app.models import SchedulerSetting
from app.models import SchedulerSetting, User
from app.plex.sync import sync as run_plex_sync
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
from app.notifications import create_notification
@@ -310,14 +311,11 @@ def _maintenance_job() -> None:
def _demo_reset_job() -> None:
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo
# account. Lazy import avoids a routes<->scheduler import cycle.
# Auto-clean the shared demo sandbox. No-op (and never creates one) if there's no demo account.
def work(db):
from app.models import User
demo = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
if demo is None:
return {"skipped": "no demo account"}
from app.routes.admin import reset_demo_state
return {"playlists_seeded": reset_demo_state(db, demo)}
_job("demo_reset", work)
+3 -3
View File
@@ -23,7 +23,7 @@ from app.models import (
Video,
VideoState,
)
from app.sync.videos import _insert_stubs, _stub_from_playlist_item, apply_video_details
from app.sync.videos import insert_stubs, stub_from_playlist_item, apply_video_details
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient
@@ -71,11 +71,11 @@ def explore_ingest_page(
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
stubs: list[dict] = []
for item in data.get("items", []):
stub = _stub_from_playlist_item(item, channel.id)
stub = stub_from_playlist_item(item, channel.id)
if stub is not None:
stub["via_explore"] = True
stubs.append(stub)
inserted = _insert_stubs(db, stubs)
inserted = insert_stubs(db, stubs)
self_enriched = _enrich_new(db, yt, [s["id"] for s in stubs])
log.info(
"explore ingest channel=%s ingested=%s enriched=%s", channel.id, inserted, self_enriched
+7 -7
View File
@@ -44,7 +44,7 @@ def parse_dt(value: str | None) -> datetime | None:
return None
def _insert_stubs(db: Session, rows: list[dict]) -> int:
def insert_stubs(db: Session, rows: list[dict]) -> int:
"""Idempotently insert video stubs; returns the number of newly inserted rows.
Uses RETURNING so the count is exact (ON CONFLICT DO NOTHING leaves rowcount
@@ -76,7 +76,7 @@ def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
"""Insert new video stubs from an already-fetched feed and stamp the channel's last RSS
time. DB-only — must run on the session's own thread; the network fetch
(fetch_channel_feed) is kept separate so callers like run_rss_poll can parallelise it."""
inserted = _insert_stubs(db, entries)
inserted = insert_stubs(db, entries)
channel.last_rss_at = _now()
db.add(channel)
db.commit()
@@ -84,7 +84,7 @@ def apply_rss_feed(db: Session, channel: Channel, entries: list[dict]) -> int:
# --- Backfill (uploads playlist; costs quota) ---
def _stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
content = item.get("contentDetails", {})
snippet = item.get("snippet", {})
video_id = content.get("videoId")
@@ -120,7 +120,7 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
stopped_mid_page = False
for item in data.get("items", []):
stub = _stub_from_playlist_item(item, channel.id)
stub = stub_from_playlist_item(item, channel.id)
if stub is None:
continue
if stub["published_at"] and stub["published_at"] < cutoff:
@@ -147,7 +147,7 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
channel.recent_synced_at = _now()
channel.backfill_cursor = next_cursor
channel.backfill_done = done
inserted = _insert_stubs(db, collected)
inserted = insert_stubs(db, collected)
db.add(channel)
db.commit()
return inserted
@@ -166,11 +166,11 @@ def backfill_channel_deep(
rows = [
s
for s in (
_stub_from_playlist_item(it, channel.id) for it in data.get("items", [])
stub_from_playlist_item(it, channel.id) for it in data.get("items", [])
)
if s is not None
]
total += _insert_stubs(db, rows)
total += insert_stubs(db, rows)
page_token = data.get("nextPageToken")
channel.backfill_cursor = page_token
if not page_token:
+3 -3
View File
@@ -262,7 +262,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
db.commit()
def _uploader_url(info: dict) -> str | None:
def build_uploader_url(info: dict) -> str | None:
"""The channel/uploader page URL for the auto-clickable channel line. YouTube gives a
`channel_url`/`uploader_url` directly; Facebook gives neither but exposes a numeric
`uploader_id` (the page id) that resolves at facebook.com/<id>. Returns None for sources with
@@ -290,7 +290,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
# the final title with its own source_ref fallback.
asset.title = normalize_title(info.get("title")) or info.get("id")
asset.uploader = info.get("uploader") or info.get("channel")
asset.uploader_url = _uploader_url(info)
asset.uploader_url = build_uploader_url(info)
asset.thumbnail_url = info.get("thumbnail")
asset.duration_s = int(info["duration"]) if info.get("duration") else None
asset.upload_date = _parse_upload_date(info.get("upload_date"))
@@ -638,7 +638,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
asset.title = meta.title
asset.uploader = meta.uploader
asset.uploader_url = _uploader_url(info)
asset.uploader_url = build_uploader_url(info)
asset.upload_date = meta.upload_date
asset.thumbnail_url = meta.thumbnail_url
# Record a self-hosted poster for every download: it's the reliable link-preview
+1 -1
View File
@@ -4,7 +4,7 @@ redirect the code); in local dev it honors a small allowlist of dev origins so a
Vite server (:5173) returns to :5173."""
from types import SimpleNamespace
from app import auth
from app.routes import auth
def _req(host):
+21 -314
View File
@@ -2,31 +2,17 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
Ban,
Check,
Eye,
EyeOff,
History,
LayoutGrid,
Pencil,
RefreshCw,
Table,
UserMinus,
X,
} from "lucide-react";
import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { formatEta } from "../lib/format";
import { buildChannelColumns } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper";
import DataTable, { type Column } from "./DataTable";
import DataTable from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
@@ -36,8 +22,6 @@ import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
@@ -284,159 +268,23 @@ export default function Channels() {
.sort((a, b) => b - a)
.map((p) => ({ value: String(p), label: String(p) }));
const columns: Column<ManagedChannel>[] = [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "56px",
sortable: true,
hideInCard: true,
sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => (
<PriorityStepper
priority={c.priority}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
// Without this a single long name blows the column ~289→429px and overflows the centered table.
render: (c) => (
<div className="max-w-[18rem]">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onView(c)}
/>
</div>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
},
subsColumn<ManagedChannel>(t),
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
nowrap: true,
cardLabel: false,
filter: {
kind: "select",
options: [
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<ChannelTagsControl
userTags={userTags}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
];
const columns = buildChannelColumns({
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
onReset,
onUnsub,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
});
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
@@ -762,144 +610,3 @@ function Stat({ label, value, hint }: { label: string; value: string | number; h
</Tooltip>
);
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`inline-flex items-center gap-1 text-3xs px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return (
<SyncBadge
ok
label={t("channels.row.fullySynced")}
hint={t("channels.row.fullySyncedHint")}
/>
);
}
return (
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={
c.recent_synced
? t("channels.row.recentSyncedHint")
: t("channels.row.recentNotSyncedHint")
}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryQueued")}
hint={t("channels.row.queuedRequestedHint")}
/>
) : c.deep_in_queue ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryComing")}
hint={t("channels.row.queuedByOtherHint")}
/>
) : (
<SyncBadge
ok={false}
label={t("channels.row.full")}
hint={t("channels.row.fullNotFetchedHint")}
/>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
+234 -191
View File
@@ -6,6 +6,7 @@ import {
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
@@ -985,6 +986,79 @@ function EpisodeCard({
);
}
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
* `children`. Poster is slightly larger on the show page (default) than the season page. */
function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = "w-32 sm:w-44",
customizeMenu,
children,
}: {
thumb: string;
posterTitle: string;
posterDisabled: boolean;
showPlayOverlay: boolean;
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
children: ReactNode;
}) {
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<button
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
title={posterTitle}
>
<img
src={thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{showPlayOverlay && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
* the show and season heroes; the labels differ, so the caller passes both. */
function WatchedToggle({
watched,
busy,
onToggle,
watchedLabel,
unwatchedLabel,
}: {
watched: boolean;
busy?: boolean;
onToggle: () => void;
watchedLabel: string;
unwatchedLabel: string;
}) {
return (
<ActionBtn
onClick={onToggle}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={watched ? watchedLabel : unwatchedLabel}
/>
);
}
function PlexShowView({
showId,
onBack,
@@ -1050,8 +1124,13 @@ function PlexShowView({
) : (
<>
{/* Hero */}
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">
<DetailHero
thumb={show.thumb}
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!show.resume}
showPlayOverlay={!!show.resume}
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
@@ -1062,129 +1141,108 @@ function PlexShowView({
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
/>
</div>
<button
onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
disabled={!show.resume}
className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
title={show.status === "new" ? t("plex.play") : t("plex.resume")}
>
<img
src={show.thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{show.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
· {show.content_rating}
</Filterable>
)}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{show.imdb_rating != null && (
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</button>
)}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<Filterable
key={g}
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">
{show.summary}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={
watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")
}
>
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{show.year != null && (
<Filterable
onClick={
onFilter
? () => onFilter({ yearMin: show.year, yearMax: show.year })
: undefined
}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
>
{show.year}
</Filterable>
)}
{show.content_rating && (
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
· {show.content_rating}
</Filterable>
)}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{show.imdb_rating != null && (
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</button>
)}
</div>
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{show.genres.map((g) => (
<Filterable
key={g}
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
<ActionBtn
primary
onClick={() => onPlay(show.resume!.id, allKeys)}
icon={Play}
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(show.resume)}
/>
)}
{show.first && show.status !== "new" && (
<ActionBtn
onClick={() => onPlay(show.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markShowUnwatched")}
unwatchedLabel={t("plex.series.markShowWatched")}
/>
{allKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
}
icon={ListPlus}
label={t("plex.playlist.addShow")}
/>
)}
{isAdmin && showLib && (
<ActionBtn
onClick={() => setCollOpen(true)}
icon={Layers}
label={t("plex.series.addShowCollection")}
/>
)}
</div>
</DetailHero>
{/* Seasons — in a glassy panel like the movie info-page strips. */}
<section
@@ -1296,8 +1354,14 @@ function PlexSeasonView({
<LoadingState label={t("plex.loading")} />
) : (
<>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">
<DetailHero
thumb={se.thumb}
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
posterDisabled={!se.resume}
showPlayOverlay={!!se.resume}
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
posterSizeClass="w-28 sm:w-40"
customizeMenu={
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
@@ -1305,78 +1369,57 @@ function PlexSeasonView({
onToggleCast={toggleCast}
hasCast={false}
/>
</div>
}
>
<button
onClick={() => se.resume && onPlay(se.resume.id, allKeys)}
disabled={!se.resume}
className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
title={se.status === "new" ? t("plex.play") : t("plex.resume")}
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
<img
src={se.thumb}
alt=""
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
/>
{se.resume && (
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
{d.show.title}
</button>
<div className="min-w-0 flex-1">
<button
onClick={onBack}
className="text-sm text-muted hover:text-accent transition text-left"
>
{d.show.title}
</button>
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
primary
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
<p className="text-sm text-muted mt-1">
{t("plex.series.episodeCount", { count: se.episode_count })}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{se.resume && (
<ActionBtn
onClick={() => markAll(!watched)}
disabled={busy}
icon={watched ? CheckCheck : Check}
label={
watched
? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched")
}
primary
onClick={() => onPlay(se.resume!.id, allKeys)}
icon={Play}
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
sub={epLabel(se.resume)}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
)}
{se.first && se.status !== "new" && (
<ActionBtn
onClick={() => onPlay(se.first!.id, allKeys)}
icon={RotateCcw}
label={t("plex.series.playFromStart")}
/>
)}
<WatchedToggle
watched={watched}
busy={busy}
onToggle={() => markAll(!watched)}
watchedLabel={t("plex.series.markSeasonUnwatched")}
unwatchedLabel={t("plex.series.markSeasonWatched")}
/>
{seasonKeys.length > 0 && (
<ActionBtn
onClick={() =>
setAddTarget({
kind: "group",
ratingKeys: seasonKeys,
title: `${d.show.title} — ${se.title}`,
})
}
icon={ListPlus}
label={t("plex.playlist.addSeason")}
/>
)}
</div>
</div>
</DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => (
+92 -579
View File
@@ -1,14 +1,4 @@
import {
Fragment,
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
type WheelEvent as ReactWheelEvent,
} from "react";
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -34,14 +24,29 @@ import {
VolumeX,
X,
} from "lucide-react";
import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import { api, HttpError } from "../lib/api";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "../lib/fullscreen";
audioOrdForLang,
clamp01,
clockParts,
DEFAULT_PREFS,
fmt,
HLS_SUB_LAG,
subOrdForLang,
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
import {
useAutoSkip,
useControlsVisibility,
useFullscreen,
usePlayerVolume,
usePlexProgress,
usePlexTracks,
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
@@ -51,126 +56,6 @@ import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
// ordinal means different languages in different files, so we re-resolve the saved language to this
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
type AudioStream = { ord: number; language?: string | null; default: boolean };
type SubStream = { ord: number; language?: string | null; text: boolean };
function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
const i = streams.findIndex((s) => (s.language ?? "") === lang);
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
// the restored language silently fall back to track 0.)
return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists
}
function subOrdForLang(subs: SubStream[], lang: string): number | null {
if (!lang) return null; // "" = subtitles off
const m = subs.find((s) => s.text && (s.language ?? "") === lang);
return m ? m.ord : null; // preferred language not available here → off
}
// Per-account player preferences, persisted so they survive F5 and carry across items.
type PlexPlayerPrefs = {
volume: number; // 0..1
muted: boolean;
audioLang: string; // "" = default first audio
subLang: string; // "" = subtitles off
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
barAutoHide: boolean; // auto-hide the control bar during playback
seekStep: number; // plain ←/→ jump (s)
fineSeekStep: number; // Ctrl+←/→ jump (s)
subSize: number; // subtitle font size (%)
subColor: string; // subtitle text color (hex)
subBg: string; // subtitle background ("transparent" or hex)
subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
subShadow: number; // subtitle text-shadow blur px (0 = off)
subShadowColor: string; // subtitle text-shadow color (hex)
clockShow: boolean; // top-right wall clock
clock24h: boolean; // 24h (European) vs 12h am/pm
clockSize: number; // clock font size (%)
clockColor: string; // clock color (hex)
clockDate: boolean; // show the date alongside the time
clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd vs YYYY-MON-DD Ddd
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
};
const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
muted: false,
audioLang: "",
subLang: "",
subOffset: 0,
audioOffset: 0,
barAutoHide: true,
seekStep: 10,
fineSeekStep: 1,
subSize: 100,
subColor: "#ffffff",
subBg: "transparent",
subPos: 88,
subShadow: 0,
subShadowColor: "#000000",
clockShow: true,
clock24h: true,
clockSize: 100,
clockColor: "#ffffff",
clockDate: false,
clockDateStyle: "hu",
autoSkipIntro: false,
autoSkipCredits: false,
autoSkipDelay: 5,
};
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
// snow-white frame. Empty string when the slider is 0 (shadow off).
function subShadowCss(px: number, color: string): string {
if (px <= 0) return "";
const o = px;
const b = Math.max(1, Math.round(px / 2));
const dirs: [number, number][] = [
[o, 0],
[-o, 0],
[0, o],
[0, -o],
[o, o],
[o, -o],
[-o, o],
[-o, -o],
];
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
}
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday).
function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } {
const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: !p.clock24h,
});
if (!p.clockDate) return { time, date: null };
const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB";
const dd = String(now.getDate()).padStart(2, "0");
const yyyy = now.getFullYear();
const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
const date =
p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
return { time, date };
}
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
const HLS_SUB_LAG = 1.0;
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
@@ -181,12 +66,6 @@ const HLS_SUB_LAG = 1.0;
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
function fmt(t: number): string {
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
}
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const { t } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
@@ -213,22 +92,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
const multiAudioRef = useRef(false);
multiAudioRef.current = (detail?.audio_streams.length ?? 0) > 1;
// Auto-skip countdown state, via refs so the keyboard handler (defined earlier than the skip
// machinery) can read/cancel it without a declaration-order dependency.
const skipProgressRef = useRef<number | null>(null);
const cancelAutoSkipRef = useRef<() => void>(() => {});
const [playing, setPlaying] = useState(false);
const [abs, setAbs] = useState(0); // absolute current time
// Live mirror of `abs` for stable callbacks + the progress checkpoint (seek/skip/ended write it too).
const absRef = useRef(0);
absRef.current = abs;
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const [fs, setFs] = useState(false);
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
// this and re-fetches when a seek-restart changes it.
@@ -249,12 +122,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
fineSeekStepRef.current = prefs.fineSeekStep;
const aoffRef = useRef(prefs.audioOffset);
aoffRef.current = prefs.audioOffset;
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [subOffApplied, setSubOffApplied] = useState(prefs.subOffset);
useEffect(() => {
const tmr = window.setTimeout(() => setSubOffApplied(prefs.subOffset), 250);
return () => window.clearTimeout(tmr);
}, [prefs.subOffset]);
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle" | "clock">(
@@ -266,8 +133,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
audioRef.current = audioOrd;
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
// Auto-hiding control bar (reveal on mouse move / any key; hide after 3s while playing).
const { uiVisible, wake } = useControlsVisibility({ barAutoHideRef, menuOpenRef, videoRef });
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
@@ -290,11 +158,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const iv = window.setInterval(() => setNow(new Date()), 15000);
return () => window.clearInterval(iv);
}, []);
const now = useWallClock();
durationRef.current = duration;
@@ -395,6 +259,37 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
);
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
const {
audioOrd,
setAudioOrd,
subOrd,
setSubOrd,
changeAudio,
changeSubtitle,
cycleAudio,
cycleSubtitle,
changeAudioOffset,
textSubs,
selectedSub,
subTrackOffset,
} = usePlexTracks({
id,
detail,
sessionK,
subOffset: prefs.subOffset,
subPos: prefs.subPos,
loadSession,
patchPrefs,
videoRef,
hlsRef,
multiAudioRef,
audioRef,
aoffRef,
absRef,
});
// Start playback when the detail arrives (resume from the saved position unless already watched).
useEffect(() => {
if (!detail) return;
@@ -474,53 +369,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.removeEventListener("canplay", syncPlaying);
video.removeEventListener("seeked", syncPlaying);
};
}, [id, patchPrefs]);
}, [id]);
// Periodic + on-unmount progress checkpoint.
const absRef = useRef(0);
absRef.current = abs;
useEffect(() => {
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
.catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
return () => {
window.clearInterval(iv);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
// watch — so the resume landed at the old spot (the progress GET could also race the POST).
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
}
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
};
}, [id, qc]);
// Periodic + on-unmount progress checkpoint (interval flush + pause/pagehide + reopen cache patch).
usePlexProgress({ id, qc, videoRef, absRef, durationRef });
// --- seeking (native within the loaded region, else restart the session) ---------------------
const seekTo = useCallback(
@@ -549,53 +401,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else v.pause();
}, []);
const applyVolume = useCallback((vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = clamp01(vol);
v.muted = mute;
}, []);
// Set volume/mute on the <video> AND persist (survives F5).
const setVol = useCallback(
(vol: number, mute: boolean) => {
applyVolume(vol, mute);
patchPrefs({ volume: clamp01(vol), muted: mute });
},
[applyVolume, patchPrefs],
);
const nudgeVolume = useCallback(
(d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0),
[prefs.volume, setVol],
);
const toggleMute = useCallback(
() => setVol(prefs.volume, !prefs.muted),
[prefs.volume, prefs.muted, setVol],
);
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect(() => {
if (ready) applyVolume(prefs.volume, prefs.muted);
}, [ready, applyVolume, prefs.volume, prefs.muted]);
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
const onWheel = useCallback(
(e: ReactWheelEvent) => {
if (infoOpenRef.current || helpOpenRef.current) return;
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
},
[nudgeVolume],
);
// Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling,
// so reading `document.fullscreenElement` alone made this player believe it is never fullscreen
// — the button did nothing, `fs` never flipped, and Escape (below) read "not fullscreen" as
// permission to close the whole player instead of just leaving fullscreen.
const toggleFs = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(el);
}, []);
useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
videoRef,
volume,
muted,
ready,
patchPrefs,
infoOpenRef,
helpOpenRef,
});
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {
@@ -622,6 +437,21 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
: null
: detail?.next_id;
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
// earlier keyboard/back handlers that cancel a running countdown.
const { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip } = useAutoSkip({
id,
abs,
markers: detail?.markers,
autoSkipIntro: prefs.autoSkipIntro,
autoSkipCredits: prefs.autoSkipCredits,
autoSkipDelay: prefs.autoSkipDelay,
nextId,
go,
seekTo,
videoRef,
});
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
const a = document.createElement("a");
@@ -632,80 +462,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
a.remove();
}, [id]);
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so loadSession sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
setAudioOrd(ord);
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
patchPrefs({
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
try {
hlsRef.current.audioTrack = ord ?? 0;
} catch {
/* ignore */
}
} else {
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
}
},
[loadSession, detail, patchPrefs],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs],
);
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback(() => {
const streams = detail?.audio_streams ?? [];
if (streams.length < 2) return;
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
changeAudio(next.ord === 0 ? null : next.ord);
}, [detail, changeAudio]);
const cycleSubtitle = useCallback(() => {
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
const i = order.findIndex((o) => o === subOrd);
changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
}, [detail, subOrd, changeSubtitle]);
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
// dragging doesn't respawn ffmpeg on every step.
const aoffTimer = useRef<number | null>(null);
const changeAudioOffset = useCallback(
(v: number) => {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
},
[patchPrefs, loadSession],
);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
const settingsTabs = [
{ key: "sync" as const, label: t("plex.player.sync") },
{ key: "playback" as const, label: t("plex.player.playback") },
@@ -715,47 +471,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{ key: "clock" as const, label: t("plex.player.clock") },
];
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
}, [subOrd, id, subTrackOffset]);
// Position subtitle cues per the user's vertical setting (prefs.subPos). The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = prefs.subPos;
} catch {
/* not a positionable cue */
}
}
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
placeAll();
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
};
}, [id, prefs.subPos]);
// Auto-advance to the next episode when one finishes.
useEffect(() => {
@@ -775,17 +490,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose]);
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
}, 3000);
}, []);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
@@ -809,7 +513,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
return;
}
handleBack();
}, [handleBack]);
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
@@ -920,87 +624,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
onClose,
wake,
handleBack,
]);
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useRef(activeMarker);
activeMarkerRef.current = activeMarker;
// Auto-skip intro/credits: entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
// marker end, credits → next item (binge). Esc/Backspace during the countdown cancels it (and must
// NOT navigate back). 0s delay = skip immediately.
const [skipProgress, setSkipProgress] = useState<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(null);
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
// the previous one (auto-skip silently dead for the rest of a binge).
useEffect(() => {
cancelledMarkerRef.current = null;
}, [id]);
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
const skipTimerRef = useRef<number | null>(null);
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (m.type === "credits" && nextId) {
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo],
);
const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current;
if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
if (skipTimerRef.current) {
window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it
skipTimerRef.current = null;
}
setSkipProgress(null);
}, []);
cancelAutoSkipRef.current = cancelAutoSkip;
useEffect(() => {
const m = activeMarker;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? prefs.autoSkipIntro : prefs.autoSkipCredits;
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (prefs.autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.min(1, elapsed / prefs.autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
skipTimerRef.current = null;
doSkip(m);
}
}, 100);
skipTimerRef.current = iv;
return () => {
window.clearInterval(iv);
skipTimerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeMarker?.type,
activeMarker?.start_s,
prefs.autoSkipIntro,
prefs.autoSkipCredits,
prefs.autoSkipDelay,
doSkip,
skipProgressRef,
cancelAutoSkipRef,
]);
// Seekbar hover: a timestamp tooltip that follows the cursor. Store the position as a FRACTION
@@ -1775,115 +1400,3 @@ function BackClose({ onBack }: { onBack: () => void }) {
useBackToClose(onBack);
return null;
}
// --- settings-panel building blocks ------------------------------------------------------------
function PanelHead({ children }: { children: ReactNode }) {
return (
<div className="px-1 pt-2 pb-1 text-2xs uppercase tracking-wide text-white/50">{children}</div>
);
}
function Slider({
label,
value,
min,
max,
step,
suffix,
onChange,
onReset,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (v: number) => void;
onReset?: () => void;
}) {
return (
<div className="px-1 py-1">
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
<span>{label}</span>
<span className="flex items-center gap-1.5">
<span className="tabular-nums text-white/60">
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix ?? ""}
</span>
{onReset && (
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
<RotateCcw className="h-3 w-3" />
</button>
)}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
);
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
</label>
);
}
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl({
label,
onClick,
disabled,
align = "center",
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
align?: "start" | "center" | "end";
children: ReactNode;
}) {
const pos =
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<div className="relative flex group/ctrl">
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
>
{children}
</button>
<span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
>
{label}
</span>
</div>
);
}
+345 -1
View File
@@ -1,6 +1,15 @@
import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { formatCountOrDash } from "../lib/format";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./DataTable";
import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl } from "./ChannelTags";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
@@ -18,3 +27,338 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
),
};
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`inline-flex items-center gap-1 text-3xs px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return (
<SyncBadge
ok
label={t("channels.row.fullySynced")}
hint={t("channels.row.fullySyncedHint")}
/>
);
}
return (
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={
c.recent_synced
? t("channels.row.recentSyncedHint")
: t("channels.row.recentNotSyncedHint")
}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryQueued")}
hint={t("channels.row.queuedRequestedHint")}
/>
) : c.deep_in_queue ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryComing")}
hint={t("channels.row.queuedByOtherHint")}
/>
) : (
<SyncBadge
ok={false}
label={t("channels.row.full")}
hint={t("channels.row.fullNotFetchedHint")}
/>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
* column block lives next to its cells instead of inside the page component. */
export interface ChannelColumnDeps {
t: TFunction;
isAdmin: boolean;
canWrite: boolean;
prioOptions: { value: string; label: string }[];
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
bumpPriority: (id: string, delta: number) => void;
setPriority: (id: string, value: number) => void;
onHide: (c: ManagedChannel) => void;
onDeep: (c: ManagedChannel) => void;
onReset: (c: ManagedChannel) => void;
onUnsub: (c: ManagedChannel) => void;
onView: (c: ManagedChannel) => void;
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
createAndAttachTag: (channelId: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
}
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const {
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide,
onDeep,
onReset,
onUnsub,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
} = deps;
return [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "56px",
sortable: true,
hideInCard: true,
sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => (
<PriorityStepper
priority={c.priority}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => onHide(c)}
onDeep={() => onDeep(c)}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
// Without this a single long name blows the column ~289→429px and overflows the centered table.
render: (c) => (
<div className="max-w-[18rem]">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onView(c)}
/>
</div>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
},
subsColumn<ManagedChannel>(t),
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
nowrap: true,
cardLabel: false,
filter: {
kind: "select",
options: [
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<ChannelTagsControl
userTags={userTags}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
];
}
@@ -0,0 +1,114 @@
import { type ReactNode } from "react";
import { RotateCcw } from "lucide-react";
// --- settings-panel building blocks ------------------------------------------------------------
export function PanelHead({ children }: { children: ReactNode }) {
return (
<div className="px-1 pt-2 pb-1 text-2xs uppercase tracking-wide text-white/50">{children}</div>
);
}
export function Slider({
label,
value,
min,
max,
step,
suffix,
onChange,
onReset,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (v: number) => void;
onReset?: () => void;
}) {
return (
<div className="px-1 py-1">
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
<span>{label}</span>
<span className="flex items-center gap-1.5">
<span className="tabular-nums text-white/60">
{Number.isInteger(value) ? value : value.toFixed(1)}
{suffix ?? ""}
</span>
{onReset && (
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
<RotateCcw className="h-3 w-3" />
</button>
)}
</span>
</div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-accent"
/>
</div>
);
}
export function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
<span className="text-[0.75rem] text-white/80">{label}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
</label>
);
}
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
export function Ctrl({
label,
onClick,
disabled,
align = "center",
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
align?: "start" | "center" | "end";
children: ReactNode;
}) {
const pos =
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
<div className="relative flex group/ctrl">
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
>
{children}
</button>
<span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
>
{label}
</span>
</div>
);
}
+490
View File
@@ -0,0 +1,490 @@
import {
useCallback,
useEffect,
useRef,
useState,
type MutableRefObject,
type RefObject,
type WheelEvent as ReactWheelEvent,
} from "react";
import type { QueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
export function useWallClock(): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const iv = window.setInterval(() => setNow(new Date()), 15000);
return () => window.clearInterval(iv);
}, []);
return now;
}
// Fullscreen toggle for `el` (the player wrapper) + a reactive `fs` flag.
// Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling, so
// reading `document.fullscreenElement` alone made this player believe it is never fullscreen — the
// button did nothing, `fs` never flipped, and Escape read "not fullscreen" as permission to close the
// whole player instead of just leaving fullscreen.
export function useFullscreen(wrapRef: RefObject<HTMLElement | null>): {
fs: boolean;
toggleFs: () => void;
} {
const [fs, setFs] = useState(false);
const toggleFs = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(el);
}, [wrapRef]);
useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []);
return { fs, toggleFs };
}
// Auto-hiding control bar: `uiVisible` drives the chrome opacity; `wake` reveals it and (re)arms the
// 3s hide timer. Reads the caller's live refs so the timer callback sees fresh state without a
// closure rebuild: barAutoHide (per-user "never auto-hide" setting), whether a menu is open (keep the
// bar up so it can't vanish mid-choice), and the <video> paused state (a paused video keeps the bar).
export function useControlsVisibility(opts: {
barAutoHideRef: RefObject<boolean>;
menuOpenRef: RefObject<boolean>;
videoRef: RefObject<HTMLVideoElement | null>;
}): { uiVisible: boolean; wake: () => void } {
const { barAutoHideRef, menuOpenRef, videoRef } = opts;
const [uiVisible, setUiVisible] = useState(true);
const hideTimer = useRef<number | null>(null);
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
hideTimer.current = window.setTimeout(() => {
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
}, 3000);
}, [barAutoHideRef, menuOpenRef, videoRef]);
return { uiVisible, wake };
}
// Periodic + on-unmount progress checkpoint. Reads live position/duration through refs (owned by the
// component, since seeking/skip/ended all mutate absRef too) so the interval and unload beacon see the
// current spot without rebuilding the effect.
export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>;
durationRef: MutableRefObject<number>;
}): void {
const { id, qc, videoRef, absRef, durationRef } = opts;
useEffect(() => {
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
.catch(() => {});
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// Save on page unload too (F5/close/navigate): React effect cleanup does NOT run on a full reload,
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
return () => {
window.clearInterval(iv);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
flush(true);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this
// watch — so the resume landed at the old spot (the progress GET could also race the POST).
// Patch the cached detail here so a reopen resumes from where we left off. (F5 was always fine:
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only on
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
// Reading the LATEST abs/duration at cleanup is the whole point of the checkpoint (the resume
// point when leaving), not a stale snapshot — the "ref value may have changed" hint is a false
// positive here. Scoped disable so the deps array below is still checked.
/* eslint-disable react-hooks/exhaustive-deps */
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
}
/* eslint-enable react-hooks/exhaustive-deps */
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
};
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
}, [id, qc, absRef, durationRef, videoRef]);
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
// default volume after a load/seek-restart). NOTE: this is the Plex-side model; the YouTube player has
// its own 0-100 / no-mute-flag arithmetic in playerVolume.ts. Sharing the tested pure core across both
// is a separate R12 consolidation sprint — this hook is the seam it will rework internally.
export function usePlayerVolume(opts: {
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
muted: boolean;
ready: boolean;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
infoOpenRef: RefObject<boolean>;
helpOpenRef: RefObject<boolean>;
}): {
setVol: (vol: number, mute: boolean) => void;
nudgeVolume: (d: number) => void;
toggleMute: () => void;
onWheel: (e: ReactWheelEvent) => void;
} {
const { videoRef, volume, muted, ready, patchPrefs, infoOpenRef, helpOpenRef } = opts;
const applyVolume = useCallback(
(vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = clamp01(vol);
v.muted = mute;
},
[videoRef],
);
// Set volume/mute on the <video> AND persist (survives F5).
const setVol = useCallback(
(vol: number, mute: boolean) => {
applyVolume(vol, mute);
patchPrefs({ volume: clamp01(vol), muted: mute });
},
[applyVolume, patchPrefs],
);
const nudgeVolume = useCallback(
(d: number) => setVol(clamp01(volume + d), clamp01(volume + d) === 0),
[volume, setVol],
);
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, muted, setVol]);
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
useEffect(() => {
if (ready) applyVolume(volume, muted);
}, [ready, applyVolume, volume, muted]);
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
const onWheel = useCallback(
(e: ReactWheelEvent) => {
if (infoOpenRef.current || helpOpenRef.current) return;
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
},
[nudgeVolume, infoOpenRef, helpOpenRef],
);
return { setVol, nudgeVolume, toggleMute, onWheel };
}
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — intro → jump to the
// marker end, credits → next item (binge). The returned refs (skipProgressRef / cancelAutoSkipRef)
// are the forward-declaration bridge the earlier-defined keyboard/back handlers read to cancel a
// running countdown (Esc/Backspace during the countdown cancels it and must NOT navigate back).
export function useAutoSkip(opts: {
id: string;
abs: number;
markers: PlexMarker[] | undefined;
autoSkipIntro: boolean;
autoSkipCredits: boolean;
autoSkipDelay: number;
nextId: string | null | undefined;
go: (id: string | null | undefined) => void;
seekTo: (t: number) => void;
videoRef: RefObject<HTMLVideoElement | null>;
}): {
activeMarker: PlexMarker | undefined;
skipProgress: number | null;
skipProgressRef: MutableRefObject<number | null>;
cancelAutoSkipRef: MutableRefObject<() => void>;
doSkip: (m: PlexMarker) => void;
} {
const {
id,
abs,
markers,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
nextId,
go,
seekTo,
videoRef,
} = opts;
const skipProgressRef = useRef<number | null>(null);
const cancelAutoSkipRef = useRef<() => void>(() => {});
const activeMarker: PlexMarker | undefined = markers?.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
);
const activeMarkerRef = useRef(activeMarker);
activeMarkerRef.current = activeMarker;
const [skipProgress, setSkipProgress] = useState<number | null>(null);
skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(null);
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
// the previous one (auto-skip silently dead for the rest of a binge).
useEffect(() => {
cancelledMarkerRef.current = null;
}, [id]);
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
const skipTimerRef = useRef<number | null>(null);
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (m.type === "credits" && nextId) {
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo],
);
const cancelAutoSkip = useCallback(() => {
const m = activeMarkerRef.current;
if (m) cancelledMarkerRef.current = `${m.type}-${m.start_s}`; // don't re-arm this same marker
if (skipTimerRef.current) {
window.clearInterval(skipTimerRef.current); // actually stop the countdown, not just blank it
skipTimerRef.current = null;
}
setSkipProgress(null);
}, []);
cancelAutoSkipRef.current = cancelAutoSkip;
useEffect(() => {
const m = activeMarker;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits;
if (!enabled || cancelledMarkerRef.current === key) return setSkipProgress(null);
if (autoSkipDelay <= 0) return doSkip(m); // immediate
let elapsed = 0;
setSkipProgress(0);
const iv = window.setInterval(() => {
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
// deliberately-paused video. It resumes from where it stopped when playback resumes.
if (videoRef.current?.paused) return;
elapsed += 0.1;
const p = Math.min(1, elapsed / autoSkipDelay);
setSkipProgress(p);
if (p >= 1) {
window.clearInterval(iv);
skipTimerRef.current = null;
doSkip(m);
}
}, 100);
skipTimerRef.current = iv;
return () => {
window.clearInterval(iv);
skipTimerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeMarker?.type,
activeMarker?.start_s,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
doSkip,
]);
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
}
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state and the
// audio/subtitle change/cycle handlers; audio switches re-map server-side via loadSession (single
// stream) or client-side via hls.js (multi-rendition), while subtitle switches just flip the single
// mounted <track>. The shared media refs (video/hls/audio/aoff/abs/multiAudio) are owned by the
// component and passed in so this hook and the media session read/write the same substrate.
export function usePlexTracks(opts: {
id: string;
detail: PlexItemDetail | undefined;
sessionK: number;
subOffset: number;
subPos: number;
loadSession: (startAt: number, resumePlay?: boolean) => Promise<void>;
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
videoRef: RefObject<HTMLVideoElement | null>;
hlsRef: RefObject<Hls | null>;
multiAudioRef: RefObject<boolean>;
audioRef: MutableRefObject<number | null>;
aoffRef: MutableRefObject<number>;
absRef: MutableRefObject<number>;
}): {
audioOrd: number | null;
setAudioOrd: (ord: number | null) => void;
subOrd: number | null;
setSubOrd: (ord: number | null) => void;
changeAudio: (ord: number | null) => void;
changeSubtitle: (ord: number | null) => void;
cycleAudio: () => void;
cycleSubtitle: () => void;
changeAudioOffset: (v: number) => void;
textSubs: PlexItemDetail["subtitle_streams"];
selectedSub: PlexItemDetail["subtitle_streams"][number] | null;
subTrackOffset: number;
} {
const { id, detail, sessionK, subOffset, subPos, loadSession, patchPrefs } = opts;
const { videoRef, hlsRef, multiAudioRef, audioRef, aoffRef, absRef } = opts;
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
const [audioOrd, setAudioOrd] = useState<number | null>(null);
const [subOrd, setSubOrd] = useState<number | null>(null);
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
const [subOffApplied, setSubOffApplied] = useState(subOffset);
useEffect(() => {
const tmr = window.setTimeout(() => setSubOffApplied(subOffset), 250);
return () => window.clearTimeout(tmr);
}, [subOffset]);
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
// position (like a seek). The ref is updated synchronously so loadSession sees it.
const changeAudio = useCallback(
(ord: number | null) => {
audioRef.current = ord;
setAudioOrd(ord);
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
patchPrefs({
audioLang:
ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? ""),
});
if (multiAudioRef.current && hlsRef.current) {
// Multi-rendition: switch the audio track CLIENT-SIDE — instant, same timeline, no restart.
try {
hlsRef.current.audioTrack = ord ?? 0;
} catch {
/* ignore */
}
} else {
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
}
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
const changeSubtitle = useCallback(
(ord: number | null) => {
setSubOrd(ord);
patchPrefs({
subLang:
ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? ""),
});
},
[detail, patchPrefs],
);
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
const cycleAudio = useCallback(() => {
const streams = detail?.audio_streams ?? [];
if (streams.length < 2) return;
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
const next = streams[(idx + 1) % streams.length]!; // length >= 2 (guarded), so in-bounds
changeAudio(next.ord === 0 ? null : next.ord);
}, [detail, changeAudio, audioRef]);
const cycleSubtitle = useCallback(() => {
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
const i = order.findIndex((o) => o === subOrd);
changeSubtitle(order[(i + 1) % order.length]!); // order always has [null, ...] → non-empty
}, [detail, subOrd, changeSubtitle]);
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
// dragging doesn't respawn ffmpeg on every step.
const aoffTimer = useRef<number | null>(null);
const changeAudioOffset = useCallback(
(v: number) => {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
},
[patchPrefs, loadSession, aoffRef, absRef],
);
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
// must be shifted by; the user offset nudges on top.
const subTrackOffset = sessionK - subOffApplied;
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
}, [subOrd, id, subTrackOffset, videoRef]);
// Position subtitle cues per the user's vertical setting (subPos). The video fills the viewport
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const placeAll = () => {
for (const tr of Array.from(video.textTracks)) {
for (const c of Array.from(tr.cues || [])) {
try {
(c as VTTCue).snapToLines = false;
(c as VTTCue).line = subPos;
} catch {
/* not a positionable cue */
}
}
}
};
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
for (const tr of Array.from(video.textTracks)) bind(tr);
const onAddTrack = (e: TrackEvent) => {
if (e.track) {
bind(e.track as TextTrack);
placeAll();
}
};
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
placeAll();
return () => {
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
};
}, [id, subPos, videoRef]);
return {
audioOrd,
setAudioOrd,
subOrd,
setSubOrd,
changeAudio,
changeSubtitle,
cycleAudio,
cycleSubtitle,
changeAudioOffset,
textSubs,
selectedSub,
subTrackOffset,
};
}
+128
View File
@@ -0,0 +1,128 @@
import { formatDuration } from "./format";
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
// ordinal means different languages in different files, so we re-resolve the saved language to this
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
export type AudioStream = { ord: number; language?: string | null; default: boolean };
export type SubStream = { ord: number; language?: string | null; text: boolean };
export function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
const i = streams.findIndex((s) => (s.language ?? "") === lang);
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
// the restored language silently fall back to track 0.)
return i <= 0 ? null : streams[i]!.ord; // i > 0 here, so streams[i] exists
}
export function subOrdForLang(subs: SubStream[], lang: string): number | null {
if (!lang) return null; // "" = subtitles off
const m = subs.find((s) => s.text && (s.language ?? "") === lang);
return m ? m.ord : null; // preferred language not available here → off
}
// Per-account player preferences, persisted so they survive F5 and carry across items.
export type PlexPlayerPrefs = {
volume: number; // 0..1
muted: boolean;
audioLang: string; // "" = default first audio
subLang: string; // "" = subtitles off
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
barAutoHide: boolean; // auto-hide the control bar during playback
seekStep: number; // plain ←/→ jump (s)
fineSeekStep: number; // Ctrl+←/→ jump (s)
subSize: number; // subtitle font size (%)
subColor: string; // subtitle text color (hex)
subBg: string; // subtitle background ("transparent" or hex)
subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
subShadow: number; // subtitle text-shadow blur px (0 = off)
subShadowColor: string; // subtitle text-shadow color (hex)
clockShow: boolean; // top-right wall clock
clock24h: boolean; // 24h (European) vs 12h am/pm
clockSize: number; // clock font size (%)
clockColor: string; // clock color (hex)
clockDate: boolean; // show the date alongside the time
clockDateStyle: "en" | "hu"; // DD-MON-YYYY Ddd vs YYYY-MON-DD Ddd
autoSkipIntro: boolean; // auto-skip the intro marker after a countdown
autoSkipCredits: boolean; // auto-skip credits → jump to the next item (binge)
autoSkipDelay: number; // countdown before an auto-skip fires (s, 0 = immediate)
};
export const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1,
muted: false,
audioLang: "",
subLang: "",
subOffset: 0,
audioOffset: 0,
barAutoHide: true,
seekStep: 10,
fineSeekStep: 1,
subSize: 100,
subColor: "#ffffff",
subBg: "transparent",
subPos: 88,
subShadow: 0,
subShadowColor: "#000000",
clockShow: true,
clock24h: true,
clockSize: 100,
clockColor: "#ffffff",
clockDate: false,
clockDateStyle: "hu",
autoSkipIntro: false,
autoSkipCredits: false,
autoSkipDelay: 5,
};
export const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
// A punchy subtitle shadow. The old single 0-offset blur washed out to nothing on bright scenes even
// at the max slider value; instead lay down eight offset copies (a solid dark OUTLINE around the
// glyphs) plus a soft glow, all scaled by the slider — legible over anything, e.g. yellow text on a
// snow-white frame. Empty string when the slider is 0 (shadow off).
export function subShadowCss(px: number, color: string): string {
if (px <= 0) return "";
const o = px;
const b = Math.max(1, Math.round(px / 2));
const dirs: [number, number][] = [
[o, 0],
[-o, 0],
[0, o],
[0, -o],
[o, o],
[o, -o],
[-o, o],
[-o, -o],
];
const outline = dirs.map(([x, y]) => `${x}px ${y}px ${b}px ${color}`).join(",");
return `text-shadow:${outline},0 0 ${o * 2}px ${color};`;
}
// Top-right wall clock text: time (24h European or 12h am/pm) + optional date in one of two fixed
// styles — EN "08-JUL-2026 Wed" or HU "2026-JÚL-08 Sze" (uppercase short month, short weekday).
export function clockParts(now: Date, p: PlexPlayerPrefs): { time: string; date: string | null } {
const time = now.toLocaleTimeString(p.clock24h ? "en-GB" : "en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: !p.clock24h,
});
if (!p.clockDate) return { time, date: null };
const loc = p.clockDateStyle === "hu" ? "hu-HU" : "en-GB";
const dd = String(now.getDate()).padStart(2, "0");
const yyyy = now.getFullYear();
const mon = now.toLocaleString(loc, { month: "short" }).replace(/\./g, "").toUpperCase();
const dow = now.toLocaleString(loc, { weekday: "short" }).replace(/\./g, "");
const date =
p.clockDateStyle === "hu" ? `${yyyy}-${mon}-${dd} ${dow}` : `${dd}-${mon}-${yyyy} ${dow}`;
return { time, date };
}
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
export const HLS_SUB_LAG = 1.0;
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
export function fmt(t: number): string {
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
}
+7
View File
@@ -14,6 +14,13 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.62.1",
date: "2026-08-12",
chores: [
"Internal refactor of the Plex player: split the large player component into focused, self-contained hooks. No change to how the player looks or behaves.",
],
},
{
version: "0.62.0",
date: "2026-08-11",