release: 0.62.1 (r12-plex-layering)
This commit is contained in:
+15
-919
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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()])
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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"},
|
||||
)
|
||||
@@ -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,
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user