diff --git a/VERSION b/VERSION index 4d74f323..e2050de8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.62.0 +0.62.1 diff --git a/backend/app/auth.py b/backend/app/auth.py index 1723e25c..27f234a3 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,42 +1,19 @@ -import ipaddress import logging -import secrets -from datetime import datetime, timedelta, timezone import httpx -from authlib.integrations.starlette_client import OAuth, OAuthError -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request -from fastapi.responses import JSONResponse, RedirectResponse -from starlette.requests import HTTPConnection -from sqlalchemy import delete, func, select, update -from sqlalchemy.exc import IntegrityError +from fastapi import BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session +from starlette.requests import HTTPConnection from app import email as email_mod from app import sysconfig 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 decrypt, encrypt, hash_password, hash_token, verify_password -from app.utils import valid_email +from app.db import get_db +from app.models import Invite, User +from app.security import decrypt -# Email+password auth tuning. PASSWORD_MIN_LEN = 10 -VERIFY_TTL = timedelta(hours=24) -RESET_TTL = timedelta(hours=1) -# A throwaway argon2 hash to verify against when the email is unknown / has no password, so a -# login attempt pays the same KDF cost either way and its timing can't reveal whether the account -# exists (anti-enumeration). Computed once at startup; it never matches a real password. -_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) -# At most one "your account is suspended" email per address per hour, so a suspended user who -# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them. -_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600) def operator_contact() -> str | None: @@ -44,72 +21,22 @@ def operator_contact() -> str | None: return next(iter(sorted(settings.admin_email_set)), None) -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()) - -# The single shared demo account's stable identity. The row is created lazily on first -# demo login (see get_or_create_demo_user); these are just its sentinel keys. DEMO_GOOGLE_SUB = "__demo__" DEMO_EMAIL = "demo@siftlode.local" - -# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait, -# stingy enough that the field can't be hammered as an enumeration oracle or for DoS. -_demo_limiter = RateLimiter(max_events=5, window_seconds=60) - -# How many recently-used accounts to keep switchable in one browser session. MAX_SESSION_ACCOUNTS = 6 -# Base sign-in requests only the non-sensitive OpenID scopes (profile/email). These do -# NOT trigger Google's "unverified app" warning and do NOT expire after 7 days, so a new -# user gets a clean, familiar consent. YouTube access is granted later, one step at a -# time, by the onboarding wizard via /auth/upgrade (incremental authorization): -# - read -> youtube.readonly (read subscriptions, build the feed) -# - write -> youtube (full: unsubscribe / playlist export) -# Both YouTube scopes are "sensitive"; the wizard explains the Google warning before -# sending the user there, so the extra permission never feels like a surprise. +# OAuth scope vocabulary — the atoms + the request bundles, kept together as core. has_*_scope check +# the atoms against a user's stored grant; the *_SCOPES bundles are what the login/callback endpoints +# request (imported by app/routes/auth.py). WRITE_SCOPE = "https://www.googleapis.com/auth/youtube" READ_SCOPE = f"{WRITE_SCOPE}.readonly" BASE_SCOPES = "openid email profile" READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}" WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}" -# The YouTube grants (read ⊂ write). A sign-in must never silently drop one of these — see _store_token. _YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE}) log = logging.getLogger("siftlode.auth") -router = APIRouter(prefix="/auth", tags=["auth"]) - -# The Google OAuth client is built lazily from the CURRENT credentials (DB override via sysconfig, -# else env), so the install wizard / admin can set or change them at runtime without a restart. The -# authlib registry is rebuilt only when the creds change (cheap, first use after a change). -_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 google_enabled(db: Session) -> bool: """Whether Sign in with Google is available (both client id + secret resolve to non-empty).""" @@ -137,20 +64,6 @@ def has_write_scope(user: User) -> bool: return WRITE_SCOPE in tok.scopes.split() -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_allowed(db: Session, email: str) -> bool: """May this email sign in? An approved Invite is the source of truth; the env ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).""" @@ -163,18 +76,6 @@ def is_allowed(db: Session, email: str) -> bool: return email in settings.allowed_email_set or email in 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 get_or_create_demo_user(db: Session) -> User: """The one shared demo user, created on first use. No OAuth token / YouTube scope, so has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it.""" @@ -193,270 +94,6 @@ def get_or_create_demo_user(db: Session) -> User: return user -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 - - -# Local-dev origins allowed to drive the OAuth redirect_uri from the request Host. The Vite dev -# server (:5173) proxies /auth to the backend (:8080) preserving the Host header, and the session -# cookie is port-agnostic — so a flow started on :5173 can come back to :5173 instead of always -# landing on the fixed :8080 callback. Deriving the redirect_uri from the request host keeps the -# WHOLE round-trip on ONE origin (login, Google callback, and the session/state cookie all on the -# same host) — that consistency is load-bearing, because a session cookie set on 127.0.0.1 is NOT -# sent to localhost and vice versa, so a cross-host fallback would silently lose the OAuth state. -# EVERY host listed here must ALSO be registered as a redirect URI in the Google console, or Google -# rejects it with redirect_uri_mismatch. An unlisted host falls back to the fixed configured -# callback (fine only when that lands on the same host the flow started on). -_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 "") - # 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 revoke_google_token(token: str | None) -> None: """Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes @@ -486,7 +123,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: tok = user.token # Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing # ciphertext, so a token row with no refresh token silently failed to revoke). - google_token = (decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None + google_token = ( + (decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None + ) # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. db.execute(delete(User).where(User.id == user_id)) db.execute(delete(Invite).where(Invite.email == email)) @@ -498,404 +137,6 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: background.add_task(email_mod.send_account_deleted, email, operator_contact()) -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} - - -# A tab can act as a different signed-in account than the session default by sending this header -# (per-tab identity: two tabs in one browser, two accounts). Lowercased — Starlette header lookup -# is case-insensitive, but we compare explicitly below. ACTIVE_ACCOUNT_HEADER = "x-siftlode-account" @@ -925,21 +166,6 @@ def resolved_user_id(conn: HTTPConnection) -> tuple[int | None, bool]: return default_id, 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 - - def current_user(request: Request, db: Session = Depends(get_db)) -> User: user_id, is_default = resolved_user_id(request) if not user_id: @@ -982,31 +208,15 @@ def optional_current_user( return None -@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} - - def require_human(user: User = Depends(current_user)) -> User: """Reject the shared demo account from actions that need a real Google/YouTube identity or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit where there's no scope check to lean on.""" if user.is_demo: - raise HTTPException(status_code=403, detail="Not available in the demo account.") + raise HTTPException( + status_code=403, detail="Not available in the demo account." + ) return user @@ -1038,117 +248,3 @@ def count_admins(db: Session, *, active_only: bool = False) -> int: if active_only: q = q.where(User.is_suspended.is_(False)) return db.scalar(q) or 0 - - -@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(), - } diff --git a/backend/app/demo.py b/backend/app/demo.py new file mode 100644 index 00000000..80f2d10f --- /dev/null +++ b/backend/app/demo.py @@ -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 diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py index de99c412..3db13bf6 100644 --- a/backend/app/downloads/backfill.py +++ b/backend/app/downloads/backfill.py @@ -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 diff --git a/backend/app/downloads/service.py b/backend/app/downloads/service.py index 1e4514de..d6fe7a17 100644 --- a/backend/app/downloads/service.py +++ b/backend/app/downloads/service.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 85a6725c..00c76347 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/plex/cache.py b/backend/app/plex/cache.py new file mode 100644 index 00000000..b1aec046 --- /dev/null +++ b/backend/app/plex/cache.py @@ -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" diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py new file mode 100644 index 00000000..2dbeb2a3 --- /dev/null +++ b/backend/app/plex/imgcache.py @@ -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"} + ) diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 1142e7dd..7f5652c7 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -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()]) diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py new file mode 100644 index 00000000..fb6497ee --- /dev/null +++ b/backend/app/plex/rollup.py @@ -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} diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py new file mode 100644 index 00000000..85a7ee03 --- /dev/null +++ b/backend/app/plex/subtitles.py @@ -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 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 diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 3a1f5ad9..c4bbfa3b 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -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) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 00000000..b71f8457 --- /dev/null +++ b/backend/app/routes/auth.py @@ -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 "") + # 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(), + } diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 7c1c9655..aa10e5ce 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -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: diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 89d0367a..5591ee77 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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, diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index d61a1078..08621ea8 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -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], } diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py new file mode 100644 index 00000000..8c3cdc72 --- /dev/null +++ b/backend/app/routes/plex/__init__.py @@ -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) diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py new file mode 100644 index 00000000..ecd7f8c2 --- /dev/null +++ b/backend/app/routes/plex/_shared.py @@ -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 diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py new file mode 100644 index 00000000..5c5ddee8 --- /dev/null +++ b/backend/app/routes/plex/admin.py @@ -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} diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex/browse.py similarity index 51% rename from backend/app/routes/plex.py rename to backend/app/routes/plex/browse.py index 8c67e0e5..9d0efef1 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex/browse.py @@ -1,39 +1,23 @@ -"""Plex integration API. +"""Plex browse: libraries, facets, collections, playlists, show/item detail, people.""" -Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo + -pure-Siftlode included) — the module is an access layer to the Plex library WITHOUT requiring a -plex.tv account, and playback is a local file. Admin endpoints test the connection and run the -catalog sync. -""" -import hashlib -import logging -import os -import re -import subprocess -import tempfile import threading import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from pathlib import Path -import httpx -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response -from fastapi.responses import FileResponse +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_ from sqlalchemy.orm import Session, aliased from app import sysconfig -from app.auth import current_user, resolved_user_id -from app.config import settings -from app.db import SessionLocal, get_db +from app.auth import current_user +from app.db import get_db from app.models import ( PlexCollection, PlexItem, PlexLibrary, - PlexLink, PlexPerson, PlexPlaylist, PlexPlaylistItem, @@ -42,177 +26,48 @@ from app.models import ( PlexState, User, ) -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 rollup, subtitles 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._common import owned_or_404 from app.routes.admin import admin_user -from app.routes.feed import _to_tsquery_str -from app.utils import iso - -log = logging.getLogger("siftlode.plex") +from app.routes.feed import to_tsquery_str +from app.routes.plex._shared import item_or_404 router = APIRouter(prefix="/api/plex", tags=["plex"]) _TS_CONFIG = "public.unaccent_simple" -# --- Admin: connectivity + sync --------------------------------------------------------------- - - -@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) - - -# --- Watch-state sync (Plex ↔ Siftlode) ------------------------------------------------------- -# Phase A: the owner links their Siftlode account to the Plex admin account and does a one-time -# "Plex is master" import. Two-way push/pull lands in later phases. Admin-only for now because it -# rides the server admin token (which reads/writes the OWNER Plex account's state). - - -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( +@router.get("/libraries") +def list_libraries( 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} - - -# --- Read: libraries / browse / show / image -------------------------------------------------- - - -@router.get("/libraries") -def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """The mirrored libraries the user can browse (drives the Plex scope selector).""" - libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() + libs = ( + db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() + ) out = [] for lib in libs: if lib.kind == "movie": - count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar() + count = ( + db.query(func.count(PlexItem.id)) + .filter_by(library_id=lib.id, kind="movie") + .scalar() + ) else: - count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() - out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0}) + count = ( + db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() + ) + out.append( + { + "key": lib.plex_key, + "title": lib.title, + "kind": lib.kind, + "count": count or 0, + } + ) return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} @@ -243,66 +98,6 @@ def _show_card(sh: PlexShow, status: str = "new") -> dict: } -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 _show_agg_subq(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 _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]: - if not show_ids: - return {} - rows = _show_agg_subq(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} - - def _lib_key(db: Session, library_id: int | None) -> str | None: """The Plex section key for a library id — the info/show page passes it to the admin collection editor (in the unified cross-library view there's no single library prop otherwise).""" @@ -329,7 +124,10 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict: def _leaf_card( - it: PlexItem, st: PlexState | None, show_title: str | None = None, show_rk: str | None = None + it: PlexItem, + st: PlexState | None, + show_title: str | None = None, + show_rk: str | None = None, ) -> dict: """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" @@ -410,19 +208,28 @@ class LibraryFilters: added_within: str | None = None directors: str | None = None actors: str | None = None - actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND) + actor_mode: str = ( + "any" # how multiple actors combine: any (OR, default) | all (AND) + ) studios: str | None = None collection: str | None = None def as_dict(self) -> dict: return { - "genres": self.genres, "genre_mode": self.genre_mode, - "content_ratings": self.content_ratings, "year_min": self.year_min, - "year_max": self.year_max, "rating_min": self.rating_min, - "duration_min": self.duration_min, "duration_max": self.duration_max, - "added_within": self.added_within, "directors": self.directors, - "actors": self.actors, "actor_mode": self.actor_mode, - "studios": self.studios, "collection": self.collection, + "genres": self.genres, + "genre_mode": self.genre_mode, + "content_ratings": self.content_ratings, + "year_min": self.year_min, + "year_max": self.year_max, + "rating_min": self.rating_min, + "duration_min": self.duration_min, + "duration_max": self.duration_max, + "added_within": self.added_within, + "directors": self.directors, + "actors": self.actors, + "actor_mode": self.actor_mode, + "studios": self.studios, + "collection": self.collection, } @@ -451,7 +258,7 @@ def unified_library( want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) - ts = _to_tsquery_str(q) if q else None + ts = to_tsquery_str(q) if q else None tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None selects = [] @@ -459,7 +266,13 @@ def unified_library( st = aliased(PlexState) m_status = case( (st.status == "watched", "watched"), - (and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress"), + ( + and_( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ), + "in_progress", + ), else_="new", ) mq = ( @@ -476,7 +289,11 @@ def unified_library( cast(null(), Integer).label("season_count"), func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), - (func.ts_rank(PlexItem.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"), + ( + func.ts_rank(PlexItem.search_vector, tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), ) .outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) .filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) @@ -484,9 +301,14 @@ def unified_library( if show == "watched": mq = mq.filter(st.status == "watched") elif show == "in_progress": - mq = mq.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) + mq = mq.filter( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ) elif show == "unwatched": - mq = mq.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) + mq = mq.filter( + or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) + ) else: mq = mq.filter(or_(st.status.is_(None), st.status != "hidden")) mq = _apply_meta_filters(mq, PlexItem, p) @@ -498,7 +320,7 @@ def unified_library( mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) selects.append(mq) if want_shows: - agg = _show_agg_subq(db, user.id).subquery() + agg = rollup.agg_subquery(db, user.id).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) @@ -521,7 +343,11 @@ def unified_library( PlexShow.child_count.label("season_count"), literal(0).label("position_seconds"), s_status.label("status"), - (func.ts_rank(PlexShow.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"), + ( + func.ts_rank(PlexShow.search_vector, tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), ) .outerjoin(agg, agg.c.show_id == PlexShow.id) .filter(PlexShow.library_id.in_(show_lib_ids)) @@ -538,7 +364,14 @@ def unified_library( selects.append(sq) if not selects: - return {"scope": scope, "total": 0, "offset": offset, "limit": limit, "items": [], "episodes": []} + return { + "scope": scope, + "total": 0, + "offset": offset, + "limit": limit, + "items": [], + "episodes": [], + } union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) u = union.subquery() total = db.query(func.count()).select_from(u).scalar() or 0 @@ -560,23 +393,40 @@ def unified_library( items = [] for r in rows: if r.kind == "movie": - items.append({ - "id": r.rk, "type": "movie", "title": r.title, "year": r.year, - "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", - "playable": r.playable, "status": r.status, "position_seconds": r.position_seconds, - }) + items.append( + { + "id": r.rk, + "type": "movie", + "title": r.title, + "year": r.year, + "duration_seconds": r.duration_s, + "thumb": f"/api/plex/image/{r.rk}", + "playable": r.playable, + "status": r.status, + "position_seconds": r.position_seconds, + } + ) else: - items.append({ - "id": r.rk, "type": "show", "title": r.title, "year": r.year, - "thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status, - }) + items.append( + { + "id": r.rk, + "type": "show", + "title": r.title, + "year": r.year, + "thumb": f"/api/plex/image/{r.rk}", + "season_count": r.season_count, + "status": r.status, + } + ) episodes = [] if tsq is not None and want_shows: ep_st = aliased(PlexState) eps = ( db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) - .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)) + .outerjoin( + ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id) + ) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .filter( PlexItem.kind == "episode", @@ -592,7 +442,14 @@ def unified_library( card["show_title"] = show_title episodes.append(card) - return {"scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes} + return { + "scope": scope, + "total": total, + "offset": offset, + "limit": limit, + "items": items, + "episodes": episodes, + } @router.get("/facets") @@ -618,8 +475,14 @@ def facets( "duration_max": None, } libs = db.query(PlexLibrary).filter_by(enabled=True).all() - movie_ids = [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") else [] - show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] + movie_ids = ( + [lb.id for lb in libs if lb.kind == "movie"] + if scope in ("movie", "both") + else [] + ) + show_ids = ( + [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] + ) if not movie_ids and not show_ids: return empty p = f.as_dict() @@ -643,14 +506,19 @@ def facets( if show == "watched": query = query.filter(st.status == "watched") elif show == "in_progress": - query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) + query = query.filter( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ) elif show == "unwatched": - query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) + query = query.filter( + or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) + ) else: query = query.filter(or_(st.status.is_(None), st.status != "hidden")) else: if show in ("watched", "in_progress", "unwatched"): - agg = _show_agg_subq(db, uid).subquery() + agg = rollup.agg_subquery(db, uid).subquery() query = query.outerjoin(agg, agg.c.show_id == model.id) tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) @@ -683,36 +551,68 @@ def facets( # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. - expanded = wsq( - db.query(func.jsonb_array_elements_text(model.genres).label("g")), - model, ids, genre_pp, kind_movie, True, - ).filter(func.jsonb_typeof(model.genres) == "array").subquery() + expanded = ( + wsq( + db.query(func.jsonb_array_elements_text(model.genres).label("g")), + model, + ids, + genre_pp, + kind_movie, + True, + ) + .filter(func.jsonb_typeof(model.genres) == "array") + .subquery() + ) for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): genre_counts[g] = genre_counts.get(g, 0) + c # Content ratings — exclude its own. - crq = wsq( - db.query(model.content_rating, func.count()), - model, ids, {**p, "content_ratings": None}, kind_movie, True, - ).filter(model.content_rating.isnot(None)).group_by(model.content_rating) + crq = ( + wsq( + db.query(model.content_rating, func.count()), + model, + ids, + {**p, "content_ratings": None}, + kind_movie, + True, + ) + .filter(model.content_rating.isnot(None)) + .group_by(model.content_rating) + ) for cr, c in crq.all(): cr_counts[cr] = cr_counts.get(cr, 0) + c # Year bounds — exclude the year filter. by = wsq( db.query(func.min(model.year), func.max(model.year)), - model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True, + model, + ids, + {**p, "year_min": None, "year_max": None}, + kind_movie, + True, ).first() if by[0] is not None: years.append(by[0]) if by[1] is not None: years.append(by[1]) # Rating max — exclude the rating filter. - br = wsq(db.query(func.max(model.rating)), model, ids, {**p, "rating_min": None}, kind_movie, True).scalar() + br = wsq( + db.query(func.max(model.rating)), + model, + ids, + {**p, "rating_min": None}, + kind_movie, + True, + ).scalar() if br is not None: ratings.append(float(br)) # Duration bounds (movies) — exclude the duration filter. if kind_movie: bd = wsq( - db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False + db.query(func.min(model.duration_s), func.max(model.duration_s)), + model, + ids, + p, + kind_movie, + False, ).first() if bd[0] is not None: durs.append(bd[0]) @@ -728,8 +628,14 @@ def facets( for g in _csv(f.genres): genre_counts.setdefault(g, 0) return { - "genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])], - "content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])], + "genres": [ + {"value": g, "count": c} + for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) + ], + "content_ratings": [ + {"value": cr, "count": c} + for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) + ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, @@ -758,7 +664,9 @@ def _collection_card(c: PlexCollection) -> dict: "smart": c.smart, "source": _collection_source(c), "editable": c.editable, - "can_edit": _collection_editable(c), # whether the edit controls should be offered (admin UI) + "can_edit": _collection_editable( + c + ), # whether the edit controls should be offered (admin UI) } @@ -782,7 +690,9 @@ def collections( enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)] if not enabled_ids: return {"collections": []} - query = db.query(PlexCollection).filter(PlexCollection.library_id.in_(enabled_ids)) + query = db.query(PlexCollection).filter( + PlexCollection.library_id.in_(enabled_ids) + ) term = (q or "").strip() if term: query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) @@ -792,7 +702,6 @@ def collections( return {"collections": [_collection_card(c) for c in rows]} -# --- Collection editing (P2, admin-only — writes back to Plex; changes are shared across all clients). def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() if col is None: @@ -810,14 +719,18 @@ class CreateCollectionIn(BaseModel): @router.post("/collections") def create_collection( - body: CreateCollectionIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + body: CreateCollectionIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: """Create a manual collection (seeded with one item — Plex needs at least one). Editable by default.""" library = body.library title = body.title.strip() seed = body.item_rating_key if not title or not seed: - raise HTTPException(status_code=400, detail="title and item_rating_key are required") + raise HTTPException( + status_code=400, detail="title and item_rating_key are required" + ) lib = db.query(PlexLibrary).filter_by(plex_key=library).first() if lib is None: raise HTTPException(status_code=404, detail="Unknown library") @@ -835,7 +748,10 @@ def create_collection( @router.post("/collections/{rating_key}/items/{item_rating_key}") def collection_add_item( - rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + item_rating_key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) @@ -852,7 +768,10 @@ def collection_add_item( @router.delete("/collections/{rating_key}/items/{item_rating_key}") def collection_remove_item( - rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + item_rating_key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) @@ -873,7 +792,10 @@ class CollectionRenameIn(BaseModel): @router.patch("/collections/{rating_key}") def collection_rename( - rating_key: str, body: CollectionRenameIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + body: CollectionRenameIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) title = body.title.strip() @@ -914,7 +836,10 @@ class CollectionEditableIn(BaseModel): @router.post("/collections/{rating_key}/editable") def collection_set_editable( - rating_key: str, body: CollectionEditableIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + body: CollectionEditableIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" @@ -923,13 +848,15 @@ def collection_set_editable( raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable if want and (col.smart or _collection_source(col) != "collection"): - raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable") + raise HTTPException( + status_code=400, + detail="Smart / auto-list collections can't be made editable", + ) col.editable = want db.commit() return _collection_card(col) -# --- Playlists (Siftlode-native, per-user, ordered — no Plex account needed; Plex sync is a later phase). def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist: return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found") @@ -973,19 +900,33 @@ def playlists( rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all() group_ids = {r[0] for r in rows} out = [] - for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()): - count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + for pl in ( + db.query(PlexPlaylist) + .filter_by(user_id=user.id) + .order_by(PlexPlaylist.updated_at.desc()) + ): + count = ( + db.query(func.count(PlexPlaylistItem.id)) + .filter_by(playlist_id=pl.id) + .scalar() + or 0 + ) first = _playlist_item_query(db, pl.id).first() card = _playlist_card(pl, count, first[1].rating_key if first else None) if contains_id is not None: card["has_item"] = ( - db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() + db.query(PlexPlaylistItem.id) + .filter_by(playlist_id=pl.id, item_id=contains_id) + .first() is not None ) if group_ids is not None: card["group_in"] = ( db.query(func.count(PlexPlaylistItem.id)) - .filter(PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(group_ids or [0])) + .filter( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id.in_(group_ids or [0]), + ) .scalar() or 0 ) @@ -1003,7 +944,9 @@ class CreatePlexPlaylistIn(BaseModel): @router.post("/playlists") def create_playlist( - body: CreatePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db) + body: CreatePlexPlaylistIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: title = body.title.strip() if not title: @@ -1024,19 +967,24 @@ def create_playlist( @router.get("/playlists/{pid}") -def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def playlist_detail( + pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: pl = _own_playlist_or_404(db, pid, user) rows = _playlist_item_query(db, pid).all() items = [it for (_pi, it) in rows] states = { s.item_id: s for s in db.query(PlexState).filter( - PlexState.user_id == user.id, PlexState.item_id.in_([it.id for it in items] or [0]) + PlexState.user_id == user.id, + PlexState.item_id.in_([it.id for it in items] or [0]), ) } shows = { sh.id: sh - for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])) + for sh in db.query(PlexShow).filter( + PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) + ) } cards = [ _leaf_card( @@ -1056,7 +1004,10 @@ class RenamePlexPlaylistIn(BaseModel): @router.patch("/playlists/{pid}") def rename_playlist( - pid: int, body: RenamePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: RenamePlexPlaylistIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) title = body.title.strip() @@ -1064,13 +1015,18 @@ def rename_playlist( raise HTTPException(status_code=400, detail="title is required") pl.title = title db.commit() - count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + count = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() + or 0 + ) first = _playlist_item_query(db, pl.id).first() return _playlist_card(pl, count, first[1].rating_key if first else None) @router.delete("/playlists/{pid}") -def delete_playlist(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def delete_playlist( + pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: pl = _own_playlist_or_404(db, pid, user) db.delete(pl) db.commit() @@ -1083,23 +1039,39 @@ class PlexItemRefIn(BaseModel): @router.post("/playlists/{pid}/items") def playlist_add_item( - pid: int, body: PlexItemRefIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemRefIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) it = db.query(PlexItem).filter_by(rating_key=body.item_rating_key).first() if it is None: raise HTTPException(status_code=404, detail="Unknown item") - exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() + exists = ( + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() + ) if exists is None: - maxpos = db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar() - db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1)) + maxpos = ( + db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) + .filter_by(playlist_id=pid) + .scalar() + ) + db.add( + PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1) + ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} @router.delete("/playlists/{pid}/items/{item_rating_key}") -def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def playlist_remove_item( + pid: int, + item_rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: pl = _own_playlist_or_404(db, pid, user) it = db.query(PlexItem).filter_by(rating_key=str(item_rating_key)).first() if it is not None: @@ -1118,7 +1090,10 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: order = [str(k) for k in rks if str(k)] if not order: return [] - by_rk = {it.rating_key: it for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order))} + by_rk = { + it.rating_key: it + for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order)) + } seen: set[str] = set() out: list[PlexItem] = [] for rk in order: @@ -1131,14 +1106,23 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: @router.post("/playlists/{pid}/items/bulk") def playlist_add_bulk( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Append many items at once (a whole season/show). Already-present items are skipped; the input order is preserved for the newly-added ones. Returns how many were added + the new total.""" pl = _own_playlist_or_404(db, pid, user) items = _items_by_rks(db, body.item_rating_keys) - existing = {row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid)} - pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar()) + existing = { + row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid) + } + pos = int( + db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) + .filter_by(playlist_id=pid) + .scalar() + ) added = 0 for it in items: if it.id in existing: @@ -1150,13 +1134,19 @@ def playlist_add_bulk( if added: pl.updated_at = datetime.now(timezone.utc) db.commit() - total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0 + total = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + or 0 + ) return {"added": added, "item_count": total} @router.post("/playlists/{pid}/items/remove-bulk") def playlist_remove_bulk( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Remove many items at once (a whole season/show, or a multi-select). Returns how many were removed + the new total.""" @@ -1166,38 +1156,50 @@ def playlist_remove_bulk( if ids: removed = ( db.query(PlexPlaylistItem) - .filter(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)) + .filter( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) + ) .delete(synchronize_session=False) ) if removed: pl.updated_at = datetime.now(timezone.utc) db.commit() - total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0 + total = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + or 0 + ) return {"removed": removed, "item_count": total} @router.put("/playlists/{pid}/order") def reorder_playlist( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Set the playlist order from an explicit list of item rating_keys (0-based positions).""" pl = _own_playlist_or_404(db, pid, user) order = body.item_rating_keys - id_by_rk = {it.rating_key: it.id for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""]))} + id_by_rk = { + it.rating_key: it.id + for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""])) + } for pos, rk in enumerate(order): iid = id_by_rk.get(rk) if iid is not None: - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update({"position": pos}) + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update( + {"position": pos} + ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} -# Live-Plex enrichment for a show (cast/IMDb + related) is user-independent raw payload, so it's cached -# per rating_key for a short TTL and shared across users; per-user shaping happens in the caller. Bounds -# staleness to the TTL while turning repeat opens of the same show into zero network calls. _SHOW_ENRICH_TTL_S = 300 + _show_enrich_cache: dict[str, tuple[float, dict, list]] = {} + _show_enrich_lock = threading.Lock() @@ -1214,7 +1216,9 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: with PlexClient(db) as plex: with ThreadPoolExecutor(max_workers=2) as ex: meta_f = ex.submit(plex.metadata, rating_key) - related_f = ex.submit(plex.related, rating_key) # swallows its own errors → [] + related_f = ex.submit( + plex.related, rating_key + ) # swallows its own errors → [] meta = meta_f.result() or {} related_raw = related_f.result() except (PlexError, PlexNotConfigured): @@ -1225,7 +1229,11 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: # have related items — the payload worth caching — still get cached. if related_raw: with _show_enrich_lock: - _show_enrich_cache[rating_key] = (now + _SHOW_ENRICH_TTL_S, meta, related_raw) + _show_enrich_cache[rating_key] = ( + now + _SHOW_ENRICH_TTL_S, + meta, + related_raw, + ) return meta, related_raw @@ -1240,27 +1248,36 @@ def show_detail( if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") seasons = ( - db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all() + db.query(PlexSeason) + .filter_by(show_id=sh.id) + .order_by(PlexSeason.season_number.asc().nullsfirst()) + .all() ) eps = ( db.query(PlexItem) .filter_by(show_id=sh.id, kind="episode") - .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) .all() ) states = { s.item_id: s for s in db.query(PlexState).filter( - PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0]) + PlexState.user_id == user.id, + PlexState.item_id.in_([e.id for e in eps] or [0]), ) } by_season: dict[int | None, list] = {} - all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup + all_cards: list[ + dict + ] = [] # every episode, in season/episode order → show-level rollup for e in eps: card = _episode_card(e, states.get(e.id)) by_season.setdefault(e.season_id, []).append(card) all_cards.append(card) - show_roll = _rollup(all_cards) + show_roll = rollup.rollup(all_cards) # Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live # Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping @@ -1312,10 +1329,7 @@ def show_detail( "episode_count": show_roll["episode_count"], "collection_keys": sh.collection_keys or [], }, - "seasons": [ - _season_block(se, by_season.get(se.id, [])) - for se in seasons - ], + "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "related": related, } @@ -1323,11 +1337,12 @@ def show_detail( def _season_block(se: PlexSeason, cards: list[dict]) -> dict: """A season's card for the show page (with its aggregate rollup) plus its episodes (used by the season subpage, read from the same cached show-detail payload).""" - roll = _rollup(cards) + roll = rollup.rollup(cards) return { "id": se.rating_key, "season_number": se.season_number, - "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), + "title": se.title + or (f"Season {se.season_number}" if se.season_number else "Season"), "thumb": f"/api/plex/image/{se.rating_key}", "episode_count": roll["episode_count"], "status": roll["status"], @@ -1337,7 +1352,9 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict: } -def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]: +def _related_show_cards( + db: Session, user_id: int, related: list[dict], exclude: int +) -> list[dict]: """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable), excluding the current show. Order preserved; capped.""" rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] @@ -1345,9 +1362,11 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: return [] shows = { s.rating_key: s - for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude) + for s in db.query(PlexShow).filter( + PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude + ) } - statuses = _show_state_map(db, user_id, [s.id for s in shows.values()]) + statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) out: list[dict] = [] seen: set[str] = set() for rk in rks: @@ -1361,280 +1380,6 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: return out -def _image_key(db: Session, rating_key: str, variant: str) -> str | None: - """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys - are proxyable — this is not an open image proxy.""" - it = db.query(PlexItem).filter_by(rating_key=rating_key).first() - if it is not None: - return it.art_key if variant == "art" else it.thumb_key - sh = db.query(PlexShow).filter_by(rating_key=rating_key).first() - if sh is not None: - return sh.art_key if variant == "art" else sh.thumb_key - se = db.query(PlexSeason).filter_by(rating_key=rating_key).first() - if se is not None: - return se.thumb_key - col = db.query(PlexCollection).filter_by(rating_key=rating_key).first() - if col is not None: - return col.thumb_key - return None - - -# --- Thin on-disk image cache ----------------------------------------------------------------- -# 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). -_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache" -_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] -_CT_EXT = {ct: ext for ext, ct in _EXT_CT} -# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/ -# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB. -_IMG_SIZES = {"thumb": (400, 600), "art": (1280, 720)} - -# 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 _sub_cache_read(key: str) -> str | None: - try: - p = _IMG_CACHE / f"{key}.vtt" - return p.read_text(encoding="utf-8") if p.exists() else None - except OSError: - return None - - -def _sub_cache_write(key: str, vtt: str) -> None: - try: - _IMG_CACHE.mkdir(parents=True, exist_ok=True) - tmp = _IMG_CACHE / f"{key}.vtt.tmp" - tmp.write_text(vtt, encoding="utf-8") - tmp.replace(_IMG_CACHE / f"{key}.vtt") # atomic publish - except OSError: - pass - - -def _img_cache_read(key: str) -> tuple[bytes, str] | None: - for ext, ct in _EXT_CT: - p = _IMG_CACHE / f"{key}.{ext}" - try: - if p.exists() and p.stat().st_size > 0: - return p.read_bytes(), ct - except OSError: - pass - return None - - -def _img_cache_write(key: str, data: bytes, ct: str) -> None: - ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg") - try: - _IMG_CACHE.mkdir(parents=True, exist_ok=True) - tmp = _IMG_CACHE / f"{key}.{ext}.tmp" - tmp.write_bytes(data) - tmp.replace(_IMG_CACHE / f"{key}.{ext}") # atomic publish - except OSError: - pass # cache is best-effort - - -def _img_response(data: bytes, ct: str) -> Response: - return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}) - - -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 = _IMG_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 = _img_cache_read(cache_key) - if hit: - return _img_response(*hit) - # Cold miss: short-lived session for the key + Plex client config, released before the slow fetch. - try: - with SessionLocal() as db: - key = _image_key(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}") - _img_cache_write(cache_key, data, ct) - return _img_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 — 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 = _sub_cache_read(cache_key) - if hit is not None: - return Response(_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 _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 = _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 = _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 = _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}") - _sub_cache_write(cache_key, vtt) - return Response(_shift_vtt(vtt, offset), media_type="text/vtt") - - -# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ---------------------------- - - -def _item_or_404(db: Session, rating_key: str) -> PlexItem: - it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first() - if it is None: - raise HTTPException(status_code=404, detail="Unknown Plex item") - return it - - -# Progress thresholds (mirror the YouTube player): ignore the first few seconds, and treat the -# last stretch as "finished" (mark watched, clear the resume point). -_PROGRESS_MIN_S = 5 -_FINISH_MARGIN_S = 30 - - def _rich_meta(meta: dict) -> dict: """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any @@ -1650,7 +1395,9 @@ def _rich_meta(meta: dict) -> dict: break if imdb_rating is None: try: - imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1) + imdb_rating = round( + float(meta.get("audienceRating") or meta.get("rating")), 1 + ) except (TypeError, ValueError): imdb_rating = None # IMDb id (tt…) from the Guid list → a clickable link. @@ -1666,7 +1413,9 @@ def _rich_meta(meta: dict) -> dict: "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, "content_rating": meta.get("contentRating"), "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], - "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4], + "directors": [ + d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") + ][:4], "studio": meta.get("studio"), "tagline": meta.get("tagline"), "cast": plex_people.cast_from_meta(meta), @@ -1692,7 +1441,10 @@ def people_cards( show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) - photos = {p.name: p.photo_thumb for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted))} + photos = { + p.name: p.photo_thumb + for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted)) + } out = [] for nm in wanted: count = 0 @@ -1710,7 +1462,10 @@ def people_cards( if want_shows: count += ( db.query(func.count(PlexShow.id)) - .filter(PlexShow.library_id.in_(show_lib_ids), PlexShow.cast_names.contains([nm])) + .filter( + PlexShow.library_id.in_(show_lib_ids), + PlexShow.cast_names.contains([nm]), + ) .scalar() or 0 ) @@ -1718,32 +1473,6 @@ def people_cards( return {"people": out} -@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 = _img_cache_read(cache_key) - if hit: - return _img_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") - _img_cache_write(cache_key, r.content, ct) - return _img_response(r.content, ct) - - -# Known external list providers that seed Plex collections (via plextraktsync et al). A collection's -# "source" buckets the info-page strips so the user can show/hide each type independently (e.g. hide -# the noisy 'IMDb Popular'/'TMDb Trending' auto-lists but keep genuine franchise collections). Derived -# from the already-synced title/smart — no schema change, no re-sync. Unknown providers stay "collection". _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") @@ -1762,9 +1491,14 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: keys = it.collection_keys or [] if not keys or it.kind != "movie": return [] - cols = {c.rating_key: c for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys))} + cols = { + c.rating_key: c + for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys)) + } # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. - ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title)) + ordered = sorted( + (c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title) + ) out = [] for col in ordered: rk = col.rating_key @@ -1804,14 +1538,19 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: r[0] for r in db.query(PlexItem.rating_key) .filter_by(show_id=it.show_id, kind="episode") - .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) .all() ] try: i = keys.index(it.rating_key) except ValueError: return None, None - return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None) + return (keys[i - 1] if i > 0 else None), ( + keys[i + 1] if i < len(keys) - 1 else None + ) @router.get("/item/{rating_key}") @@ -1822,7 +1561,7 @@ def item_detail( ) -> dict: """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode prev/next, and the per-user resume position + watch status.""" - it = _item_or_404(db, rating_key) + it = item_or_404(db, rating_key) st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() rich: dict = {} @@ -1849,7 +1588,9 @@ def item_detail( audio_streams.append( { "ord": len(audio_streams), - "label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}", + "label": s.get("displayTitle") + or s.get("language") + or f"Audio {len(audio_streams) + 1}", "language": s.get("languageTag") or s.get("language"), "default": bool(s.get("selected") or s.get("default")), } @@ -1859,12 +1600,14 @@ def item_detail( subtitle_streams.append( { "ord": len(subtitle_streams), - "label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}", + "label": s.get("displayTitle") + or s.get("language") + or f"Subtitle {len(subtitle_streams) + 1}", "language": s.get("languageTag") or s.get("language"), "codec": codec, # Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub) # can't be turned into text, so the player hides them (burn-in is a later phase). - "text": codec not in _BITMAP_SUBS, + "text": codec not in subtitles.BITMAP_SUBS, } ) except Exception: @@ -1916,312 +1659,3 @@ def item_detail( "next_id": next_id, "collections": _collection_strips(db, it, user.id), } - - -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) - - -# Shared by the show- and season-level "mark all episodes watched/unwatched" endpoints. -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"} - ) diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py new file mode 100644 index 00000000..cbb7d6e2 --- /dev/null +++ b/backend/app/routes/plex/playback.py @@ -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 — 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"}, + ) diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 7f4809a4..8c860ac3 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -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, [ { diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 2bf5a660..1723cfc3 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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) diff --git a/backend/app/sync/explore.py b/backend/app/sync/explore.py index 9faf8007..b9c8ee93 100644 --- a/backend/app/sync/explore.py +++ b/backend/app/sync/explore.py @@ -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 diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index 2a8cba93..3b2f605f 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -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: diff --git a/backend/app/worker.py b/backend/app/worker.py index 846f7d7b..c2949104 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -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/. 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 diff --git a/backend/tests/test_oauth_redirect.py b/backend/tests/test_oauth_redirect.py index eb418cea..3b143c2a 100644 --- a/backend/tests/test_oauth_redirect.py +++ b/backend/tests/test_oauth_redirect.py @@ -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): diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index fe388946..6105c7c9 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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[] = [ - { - 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) => ( - 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) => ( - 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) => ( -
- onView(c)} - /> -
- ), - }, - { - key: "about", - header: t("channels.cols.about"), - hideInCard: true, - render: (c) => , - }, - { - key: "stored", - header: t("channels.cols.stored"), - align: "right", - nowrap: true, - sortable: true, - sortValue: (c) => c.stored_videos, - render: (c) => ( - {c.stored_videos.toLocaleString()} - ), - }, - subsColumn(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) => {relativeTime(c.last_video_at) || "—"}, - }, - { - key: "length", - header: t("channels.cols.length"), - align: "right", - nowrap: true, - sortable: true, - sortValue: (c) => c.total_duration_seconds, - render: (c) => ( - - {formatTotalHours(c.total_duration_seconds)} - - ), - }, - { - key: "types", - header: t("channels.cols.types"), - nowrap: true, - render: (c) => ( - - - {c.count_normal} / {c.count_short} / {c.count_live} - - - ), - }, - { - 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) => , - }, - { - 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) => ( - 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 ); } - -function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) { - return ( - - - {ok && } - {label} - - - ); -} - -// 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 ( - - ); - } - return ( -
- - {c.backfill_done ? ( - - ) : c.deep_requested ? ( - - ) : c.deep_in_queue ? ( - - ) : ( - - )} -
- ); -} - -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 ( -
- - - - - - - {canWrite && ( - - - - )} -
- ); -} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6aedf49d..cca253f1 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 ( +
+
{customizeMenu}
+ +
{children}
+
+ ); +} + +/** 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 ( + + ); +} + function PlexShowView({ showId, onBack, @@ -1050,8 +1124,13 @@ function PlexShowView({ ) : ( <> {/* Hero */} -
-
+ show.resume && onPlay(show.resume.id, allKeys)} + customizeMenu={ 0} /> -
- -
-

{show.title}

-
- {show.year != null && ( - onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } - > - {show.year} - - )} - {show.content_rating && ( - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - · {show.content_rating} - - )} - {show.season_count != null && ( - · {t("plex.seasons", { count: show.season_count })} - )} - {show.imdb_rating != null && ( - - )} -
- {show.genres.length > 0 && ( -
- {show.genres.map((g) => ( - onFilter({ genres: [g] }) : undefined} - > - {g} - - ))} -
- )} - {show.summary && ( -

- {show.summary} -

- )} -
- {show.resume && ( - 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" && ( - onPlay(show.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - disabled={busy} - icon={watched ? CheckCheck : Check} - label={ - watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched") + } + > +

{show.title}

+
+ {show.year != null && ( + onFilter({ yearMin: show.year, yearMax: show.year }) + : undefined } - /> - {allKeys.length > 0 && ( - - setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) - } - icon={ListPlus} - label={t("plex.playlist.addShow")} - /> - )} - {isAdmin && showLib && ( - setCollOpen(true)} - icon={Layers} - label={t("plex.series.addShowCollection")} - /> - )} -
+ > + {show.year} + + )} + {show.content_rating && ( + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + · {show.content_rating} + + )} + {show.season_count != null && ( + · {t("plex.seasons", { count: show.season_count })} + )} + {show.imdb_rating != null && ( + + )}
-
+ {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + 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" && ( + onPlay(show.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markShowUnwatched")} + unwatchedLabel={t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) + } + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && showLib && ( + setCollOpen(true)} + icon={Layers} + label={t("plex.series.addShowCollection")} + /> + )} +
+ {/* Seasons — in a glassy panel like the movie info-page strips. */}
) : ( <> -
-
+ se.resume && onPlay(se.resume.id, allKeys)} + posterSizeClass="w-28 sm:w-40" + customizeMenu={ -
+ } + > -
- -

{se.title}

-

- {t("plex.series.episodeCount", { count: se.episode_count })} -

-
- {se.resume && ( - 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" && ( - onPlay(se.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} +

{se.title}

+

+ {t("plex.series.episodeCount", { count: se.episode_count })} +

+
+ {se.resume && ( 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 && ( - - setAddTarget({ - kind: "group", - ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, - }) - } - icon={ListPlus} - label={t("plex.playlist.addSeason")} - /> - )} -
+ )} + {se.first && se.status !== "new" && ( + onPlay(se.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markSeasonUnwatched")} + unwatchedLabel={t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ + kind: "group", + ratingKeys: seasonKeys, + title: `${d.show.title} — ${se.title}`, + }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )}
-
+
{se.episodes.map((ep) => ( diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index a9d6e464..437bad27 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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