Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-12 02:26:55 +02:00
commit f3d5269c40
95 changed files with 1001 additions and 888 deletions

View file

@ -1 +1 @@
0.37.0 0.38.0

View file

@ -22,6 +22,10 @@ from app.utils import valid_email
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
VERIFY_TTL = timedelta(hours=24) VERIFY_TTL = timedelta(hours=24)
RESET_TTL = timedelta(hours=1) 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) _register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300)
@ -276,6 +280,12 @@ async def callback(
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email) log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied") 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 = existing
user.google_sub = sub user.google_sub = sub
# Google verified the email and is_allowed granted access, so finish activating a # Google verified the email and is_allowed granted access, so finish activating a
@ -291,7 +301,13 @@ async def callback(
log.info("Suspended account blocked at Google login: %s", email) log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email) _notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended") return RedirectResponse(url="/?login=suspended")
user.email = email # 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.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture") user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
@ -337,7 +353,9 @@ def _store_token(db: Session, user: User, token: dict) -> None:
tok = user.token or OAuthToken(user=user) tok = user.token or OAuthToken(user=user)
if token.get("refresh_token"): if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"]) tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_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") expires_at = token.get("expires_at")
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
tok.scopes = token.get("scope") or BASE_SCOPES tok.scopes = token.get("scope") or BASE_SCOPES
@ -471,6 +489,9 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
email = (payload.get("email") or "").strip().lower() email = (payload.get("email") or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email): if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db) 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 request.session["user_id"] = demo.id
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
return {"authenticated": True} return {"authenticated": True}
@ -603,7 +624,11 @@ def password_login(
email = (payload.get("email") or "").strip().lower() email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or "" password = payload.get("password") or ""
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None or user.is_demo or not verify_password(password, user.password_hash): # 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.") raise HTTPException(status_code=401, detail="Invalid email or password.")
if user.is_suspended: if user.is_suspended:
_notify_suspended(background, user.email) _notify_suspended(background, user.email)

View file

@ -211,7 +211,7 @@ class Settings(BaseSettings):
plex_watch_reconcile_interval_min: int = 1440 plex_watch_reconcile_interval_min: int = 1440
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is # Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
# expensive; direct-serve (browser-compatible files) has no such limit. # expensive; direct-serve (browser-compatible files) has no such limit.
plex_max_transcodes: int = 1 plex_max_transcodes: int = 4
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls` # Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
# dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point # dir under download_root. On localdev the download_root is a slow Windows bind-mount, so point
# this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast # this at a fast container-local path (e.g. /var/tmp/plex-hls); prod's download_root is fast

View file

@ -79,13 +79,17 @@ def normalize_edit_spec(spec: dict | None) -> dict:
# Single TRIM (one output file; the frontend fans a "separate" split out into N of these). # Single TRIM (one output file; the frontend fans a "separate" split out into N of these).
trim = spec.get("trim") or {} trim = spec.get("trim") or {}
if trim: if trim:
start = max(0.0, float(trim.get("start_s") or 0.0)) # Guard the numeric coercion like the crop/segments branches do — a malformed value must
end_raw = trim.get("end_s") # drop the trim (→ empty spec → 400 EditError), not raise an unhandled 500.
try:
start = max(0.0, float(trim.get("start_s") or 0.0))
end_raw = trim.get("end_s")
end = float(end_raw) if end_raw is not None else None
except (TypeError, ValueError):
start, end = 0.0, None
t: dict = {"start_s": round(start, 3)} t: dict = {"start_s": round(start, 3)}
if end_raw is not None: if end is not None and end > start:
end = float(end_raw) t["end_s"] = round(end, 3)
if end > start:
t["end_s"] = round(end, 3)
# A trim with only a start (open-ended) is valid (cut to the end). # A trim with only a start (open-ended) is valid (cut to the end).
if t.get("start_s") or "end_s" in t: if t.get("start_s") or "end_s" in t:
out["trim"] = t out["trim"] = t

View file

@ -62,7 +62,12 @@ def normalize(spec: dict) -> dict:
if s["mode"] not in ("av", "v", "a"): if s["mode"] not in ("av", "v", "a"):
s["mode"] = "av" s["mode"] = "av"
if s["max_height"] is not None: if s["max_height"] is not None:
s["max_height"] = int(s["max_height"]) # A malformed max_height (non-numeric inline spec / stored profile) falls back to "best"
# rather than raising an unhandled 500 at enqueue.
try:
s["max_height"] = int(s["max_height"])
except (TypeError, ValueError):
s["max_height"] = None
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"): for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
s[b] = bool(s[b]) s[b] = bool(s[b])
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful. # Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
@ -80,14 +85,6 @@ def format_sig(spec: dict) -> str:
return hashlib.sha256(payload.encode()).hexdigest()[:24] return hashlib.sha256(payload.encode()).hexdigest()[:24]
def target_ext(spec: dict) -> str:
"""Best-effort final extension for the produced file (also used to name the staging output)."""
s = normalize(spec)
if s["mode"] == "a":
return s["audio_format"] or "m4a"
return s["container"] or "mp4"
def build_ydl_opts( def build_ydl_opts(
spec: dict, spec: dict,
outtmpl: str, outtmpl: str,

View file

@ -75,13 +75,7 @@ def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str |
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs.""" auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import service from app.downloads import service
source_url = None source_url = service.reference_url(job, asset)
if job is not None and job.job_kind != "edit":
source_url = asset.source_webpage_url or (
service.source_url(job.source_kind, job.source_ref)
if job.source_kind in ("youtube", "url")
else None
)
return { return {
"title": (job and job.display_name) or asset.title, "title": (job and job.display_name) or asset.title,
"uploader": (job and job.display_uploader) or asset.uploader, "uploader": (job and job.display_uploader) or asset.uploader,

View file

@ -32,6 +32,20 @@ def source_url(source_kind: str, source_ref: str) -> str:
return source_ref return source_ref
def reference_url(job, asset) -> str | None:
"""The clean "downloaded from" URL for a job — shared by the private Downloads page and the public
watch page so both surfaces always agree. None for edit derivatives (a clip's source is the user's
own earlier download, not a web page); else the canonical page URL yt-dlp recorded; else one derived
from source_kind+source_ref (covers queued/older rows before the worker fills it)."""
if job is None or job.job_kind == "edit":
return None
if asset is not None and asset.source_webpage_url:
return asset.source_webpage_url
if job.source_kind in ("youtube", "url"):
return source_url(job.source_kind, job.source_ref)
return None
def get_or_create_asset( def get_or_create_asset(
db: Session, source_kind: str, source_ref: str, format_sig: str db: Session, source_kind: str, source_ref: str, format_sig: str
) -> MediaAsset: ) -> MediaAsset:
@ -46,6 +60,14 @@ def get_or_create_asset(
) )
asset = db.execute(stmt).scalar_one_or_none() asset = db.execute(stmt).scalar_one_or_none()
if asset is not None: if asset is not None:
# A previously-FAILED shared asset must be re-attempted for the new job about to hold it —
# reset it to pending (+clear the error) so the worker actually re-downloads instead of
# short-circuiting the new job with the asset's stale error. (Mirrors resume_download; without
# this a fresh enqueue of a once-failed (source,format) pair is permanently poisoned, since
# errored assets carry no expires_at and GC never clears them.)
if asset.status == "error":
asset.status = "pending"
asset.error = None
return asset return asset
asset = MediaAsset( asset = MediaAsset(
source_kind=source_kind, source_kind=source_kind,

View file

@ -96,10 +96,32 @@ def edit_rel_path(user_id: int, asset_id: int, ext: str) -> str:
return f".edits/{user_id}/clip_{asset_id}.{ext}" return f".edits/{user_id}/clip_{asset_id}.{ext}"
def download_filename(display_name: str, container: str | None, path: Path) -> str:
"""The Content-Disposition filename for a served download: the cleaned display name carrying a single
correct extension the asset container, else the file's own suffix — without doubling it when the
name already ends in that extension. Shared by the private download and the public watch endpoints."""
ext = container or path.suffix.lstrip(".")
base = display_filename(display_name)
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
return f"{base}.{ext}"
def abs_path(download_root: str, rel: str) -> Path: def abs_path(download_root: str, rel: str) -> Path:
return Path(download_root) / rel return Path(download_root) / rel
def safe_abs_path(download_root: str, rel: str) -> Path | None:
"""The resolved absolute path for `rel`, but ONLY if it stays inside DOWNLOAD_ROOT and exists on
disk otherwise None. Centralizes the path-traversal + existence guard every file-serving endpoint
shares, so the containment check lives in exactly one place."""
root = Path(download_root).resolve()
path = abs_path(download_root, rel).resolve()
if root not in path.parents or not path.exists():
return None
return path
def place_file(staging_file: Path, download_root: str, rel: str) -> None: def place_file(staging_file: Path, download_root: str, rel: str) -> None:
"""Move a completed staging file into its final location under DOWNLOAD_ROOT.""" """Move a completed staging file into its final location under DOWNLOAD_ROOT."""
dest = abs_path(download_root, rel) dest = abs_path(download_root, rel)

View file

@ -232,6 +232,11 @@ class Subscription(Base, TimestampMixin):
channel: Mapped["Channel"] = relationship(back_populates="subscriptions") channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
# Video.live_status values that mean "currently a live/upcoming broadcast" — transient states
# hidden from feeds/search and revisited by the refresh job until they settle into a VOD.
LIVE_OR_UPCOMING = ("live", "upcoming")
class Video(Base, TimestampMixin): class Video(Base, TimestampMixin):
"""A YouTube video. Shared across users; per-user state lives elsewhere.""" """A YouTube video. Shared across users; per-user state lives elsewhere."""

View file

@ -217,27 +217,42 @@ class PlexClient:
return self._get("/accounts").get("Account", []) or [] return self._get("/accounts").get("Account", []) or []
def watch_history( def watch_history(
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500 self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500, max_pages: int = 40
) -> list[dict]: ) -> list[dict]:
"""The most recent watch-history rows (ratingKey / viewedAt / accountID / type), newest """The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at`
first works without Plex Pass. Filtered client-side to `account_id` and to rows at/after (epoch seconds), newest first works without Plex Pass. The efficient "what changed since T"
`min_viewed_at` (epoch seconds): the efficient "what changed since T" feed. Sorted desc, so feed, filtered client-side to `account_id`.
we stop at the first row older than the cutoff."""
mc = self._get( The history is a GLOBAL feed across all server accounts, so paginate (sorted desc) until we
"/status/sessions/history/all", reach a row at/older than the cutoff: on a busy family server the owner's recent views can sit
params={ past the first page behind other accounts' rows. `max_pages` bounds the scan (the daily full
"sort": "viewedAt:desc", reconcile is the backstop for anything beyond it)."""
"X-Plex-Container-Start": 0, out: list[dict] = []
"X-Plex-Container-Size": size, for page in range(max_pages):
}, mc = self._get(
) "/status/sessions/history/all",
out = [] params={
for r in mc.get("Metadata", []) or []: "sort": "viewedAt:desc",
if int(r.get("viewedAt") or 0) < min_viewed_at: "X-Plex-Container-Start": page * size,
"X-Plex-Container-Size": size,
},
)
rows = mc.get("Metadata", []) or []
reached_cutoff = False
for r in rows:
if int(r.get("viewedAt") or 0) < min_viewed_at:
reached_cutoff = True
break
if account_id is not None and int(r.get("accountID") or -1) != account_id:
continue
out.append(r)
if reached_cutoff or len(rows) < size:
break break
if account_id is not None and int(r.get("accountID") or -1) != account_id: else:
continue log.warning(
out.append(r) "watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail",
max_pages, min_viewed_at,
)
return out return out
def on_deck(self) -> list[dict]: def on_deck(self) -> list[dict]:

View file

@ -23,6 +23,7 @@ from pathlib import Path
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app import sysconfig
from app.config import settings from app.config import settings
from app.models import PlexItem from app.models import PlexItem
from app.plex import paths from app.plex import paths
@ -31,7 +32,6 @@ log = logging.getLogger("siftlode.plex")
_HLS_ROOT = Path(settings.plex_hls_dir) if settings.plex_hls_dir else Path(settings.download_root) / ".plex-hls" _HLS_ROOT = Path(settings.plex_hls_dir) if settings.plex_hls_dir else Path(settings.download_root) / ".plex-hls"
_SEG_SECONDS = 6 _SEG_SECONDS = 6
_MAX_SESSIONS = 4 # concurrent remux sessions (safety valve for the CPU-only host)
_SESSION_IDLE_S = 600 # reap a session with no access for this long _SESSION_IDLE_S = 600 # reap a session with no access for this long
_lock = threading.Lock() _lock = threading.Lock()
@ -168,11 +168,11 @@ def _kill(s: HlsSession) -> None:
shutil.rmtree(s.dir, ignore_errors=True) shutil.rmtree(s.dir, ignore_errors=True)
def _enforce_cap() -> None: def _enforce_cap(cap: int) -> None:
# Caller holds _lock. Drop the least-recently-accessed sessions over the cap. # Caller holds _lock. Drop the least-recently-accessed sessions over the cap.
if len(_sessions) <= _MAX_SESSIONS: if len(_sessions) <= cap:
return return
for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[: len(_sessions) - _MAX_SESSIONS]: for key, s in sorted(_sessions.items(), key=lambda kv: kv[1].last_access)[: len(_sessions) - cap]:
_kill(s) _kill(s)
_sessions.pop(key, None) _sessions.pop(key, None)
@ -211,7 +211,9 @@ def start_session(
) )
s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8") s = HlsSession(key, directory, proc, start_s, "master.m3u8" if is_multi else "index.m3u8")
_sessions[key] = s _sessions[key] = s
_enforce_cap() # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
# Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on # Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on
# ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so # ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so
# its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On # its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On

View file

@ -166,6 +166,21 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
break break
def _apply_facet_fields(row, meta: dict) -> None:
"""Set the filterable/orderable metadata columns shared by plex_items + plex_shows (identical
column names) from the cheap section listing: rating, content_rating, studio, release date,
genres/directors/cast, and the searchable people blob folded into search_vector at sync time."""
row.rating = _rating(meta)
row.content_rating = (meta.get("contentRating") or None)
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.library_id = lib_id row.library_id = lib_id
row.kind = kind row.kind = kind
@ -176,17 +191,7 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.thumb_key = meta.get("thumb") row.thumb_key = meta.get("thumb")
row.art_key = meta.get("art") or meta.get("grandparentArt") row.art_key = meta.get("art") or meta.get("grandparentArt")
row.added_at = _epoch(meta.get("addedAt")) row.added_at = _epoch(meta.get("addedAt"))
# Filterable / orderable extras — all present in the cheap section listing. _apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
row.rating = _rating(meta)
row.content_rating = (meta.get("contentRating") or None)
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre")
row.directors = _tags(meta, "Director")
row.cast_names = _tags(meta, "Role", limit=20)
# Searchable people blob (cast + directors, de-duped) → folded into search_vector at sync time.
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
row.people_text = " ".join(people) or None
mf = _media_facts(meta) mf = _media_facts(meta)
row.file_path = mf.get("file_path") row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key") row.part_key = mf.get("part_key")
@ -230,16 +235,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
sh.art_key = meta.get("art") sh.art_key = meta.get("art")
sh.child_count = meta.get("childCount") sh.child_count = meta.get("childCount")
sh.added_at = _epoch(meta.get("addedAt")) sh.added_at = _epoch(meta.get("addedAt"))
# Filterable / orderable metadata — same cheap section-listing tags as movies. _apply_facet_fields(sh, meta) # same filterable/orderable tags as movies
sh.rating = _rating(meta)
sh.content_rating = (meta.get("contentRating") or None)
sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
sh.originally_available_at = _date(meta.get("originallyAvailableAt"))
sh.genres = _tags(meta, "Genre")
sh.directors = _tags(meta, "Director")
sh.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or [])))
sh.people_text = " ".join(people) or None
stats["shows"] += 1 stats["shows"] += 1
db.flush() db.flush()
show_id = {rk: sh.id for rk, sh in shows.items()} show_id = {rk: sh.id for rk, sh in shows.items()}
@ -281,21 +277,32 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
db.flush() db.flush()
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection: def _upsert_collection(
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this ) -> PlexCollection:
collection's membership on the affected member rows.""" """Get-or-create a PlexCollection row and apply its metadata from a Plex `meta` dict. `editable`
meta = plex.metadata(rk) or {} is user-managed and preserved (never set here). Shared by the full sync + the targeted re-sync."""
col = db.query(PlexCollection).filter_by(rating_key=rk).first() or PlexCollection(rating_key=rk) col = existing or PlexCollection(rating_key=rk)
if col.id is None: if col.id is None:
db.add(col) db.add(col)
col.library_id = lib.id col.library_id = lib_id
col.title = (meta.get("title") or "").strip() or "Untitled" col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary") col.summary = meta.get("summary")
col.thumb_key = meta.get("thumb") col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount") col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1" col.smart = str(meta.get("smart")) == "1"
col.synced_at = datetime.now(timezone.utc) col.synced_at = datetime.now(timezone.utc)
return col
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection:
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this
collection's membership on the affected member rows."""
meta = plex.metadata(rk) or {}
col = _upsert_collection(
db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta
)
Model = PlexItem if lib.kind == "movie" else PlexShow Model = PlexItem if lib.kind == "movie" else PlexShow
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")} new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
current = db.query(Model).filter(Model.collection_keys.contains([rk])).all() current = db.query(Model).filter(Model.collection_keys.contains([rk])).all()
@ -333,16 +340,7 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
rk = str(meta.get("ratingKey") or "") rk = str(meta.get("ratingKey") or "")
if not rk: if not rk:
continue continue
col = existing.get(rk) or PlexCollection(rating_key=rk) _upsert_collection(db, existing.get(rk), rk, lib.id, meta)
if col.id is None:
db.add(col)
col.library_id = lib.id
col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary")
col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1" # `editable` is preserved (user-managed, not here)
col.synced_at = datetime.now(timezone.utc)
seen.add(rk) seen.add(rk)
for child in plex.collection_children(rk): for child in plex.collection_children(rk):
crk = str(child.get("ratingKey") or "") crk = str(child.get("ratingKey") or "")

View file

@ -59,15 +59,18 @@ def _plex_watch_to_state(
return None return None
def _rk_to_id(db: Session) -> dict[str, int]:
"""The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row."""
return {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)}
def _scan_plex_states( def _scan_plex_states(
db: Session, plex: PlexClient db: Session, plex: PlexClient
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]: ) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]:
"""Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at, """Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at,
prog_at)}`` for every mirrored leaf Plex holds a watch signal for, plus the scanned-leaf count. prog_at)}`` for every mirrored leaf Plex holds a watch signal for, plus the scanned-leaf count.
Shared by the one-time import and the full reconcile (both need Plex's whole current picture).""" Shared by the one-time import and the full reconcile (both need Plex's whole current picture)."""
item_id_by_rk: dict[str, int] = { item_id_by_rk = _rk_to_id(db)
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
}
wanted = _enabled_section_keys(db) wanted = _enabled_section_keys(db)
scanned = 0 scanned = 0
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {} out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
@ -165,6 +168,15 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None:
return None return None
def _state_marker(st: PlexState | None) -> datetime | None:
"""The freshest local-edit timestamp on a state row (watched_at / progress_updated_at). A push
captures it at schedule time so it can tell, when it later settles the `synced_to_plex` flag,
whether the row has been re-edited in the meantime (see `push_state_to_plex`)."""
if st is None:
return None
return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None)
def push_state_to_plex( def push_state_to_plex(
user_id: int, user_id: int,
item_id: int, item_id: int,
@ -172,6 +184,7 @@ def push_state_to_plex(
action: str, action: str,
time_ms: int = 0, time_ms: int = 0,
duration_ms: int = 0, duration_ms: int = 0,
expect_ts: datetime | None = None,
) -> None: ) -> None:
"""Best-effort Siftlode→Plex push, run in a FastAPI BackgroundTask (its OWN DB session — the """Best-effort Siftlode→Plex push, run in a FastAPI BackgroundTask (its OWN DB session — the
request's session is already closed by the time this runs). Never raises: Plex being down or slow request's session is already closed by the time this runs). Never raises: Plex being down or slow
@ -179,7 +192,10 @@ def push_state_to_plex(
pull won't bounce the change straight back; on failure the row stays dirty (synced_to_plex=False) pull won't bounce the change straight back; on failure the row stays dirty (synced_to_plex=False)
for a later retry/reconcile. for a later retry/reconcile.
`action` is one of ``watched`` (scrobble), ``unwatched`` (unscrobble), ``resume`` (timeline).""" `action` is one of ``watched`` (scrobble), ``unwatched`` (unscrobble), ``resume`` (timeline).
`expect_ts` is the row's freshest timestamp captured when this push was scheduled: if the row has
since been re-edited (a newer local change with its own pending push), we must NOT mark it clean
doing so would strand that newer value, never pushing it to Plex (lost update)."""
with SessionLocal() as db: with SessionLocal() as db:
if link_for_push(db, user_id) is None: if link_for_push(db, user_id) is None:
return return
@ -199,13 +215,24 @@ def push_state_to_plex(
) )
return return
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be # Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
# nothing to flag; that's fine (unscrobble still happened). # nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
if st is not None: if st is not None and _row_matches(st, expect_ts):
st.synced_to_plex = True st.synced_to_plex = True
db.commit() db.commit()
def _row_matches(st: PlexState, expect_ts: datetime | None) -> bool:
"""Whether a state row is still the one a push was scheduled for — true when no marker was given
(legacy/edge), the row carries no timestamp, or its freshest timestamp is within a second of the
captured marker (a genuine newer edit is always many seconds later)."""
if expect_ts is None:
return True
cur = _state_marker(st)
return cur is None or abs((cur - expect_ts).total_seconds()) < 1.0
def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None: def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None:
"""Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same """Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same
contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background
@ -271,8 +298,10 @@ def _active_sync_links(db: Session) -> list[PlexLink]:
def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int: def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
"""The owner's Plex accountID, cached on the link. The server owner is accountID 1 on their own """The owner's Plex accountID, cached on the link. On a Plex Media Server, accountID 1 is reserved
server; we confirm + grab their username from /accounts, falling back to 1 if unavailable.""" for the server owner/admin (managed + shared users get id > 1), and an owner link (`uses_admin`)
always holds the admin token so 1 is correct here, not an assumption to second-guess. We still
confirm + grab the owner's username from /accounts, falling back to 1 if that call is unavailable."""
if link.plex_account_id: if link.plex_account_id:
return link.plex_account_id return link.plex_account_id
acct_id, username = 1, None acct_id, username = 1, None
@ -335,7 +364,12 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
"""Belt-and-suspenders: push local states that never reached Plex (synced_to_plex=False) — e.g. """Belt-and-suspenders: push local states that never reached Plex (synced_to_plex=False) — e.g.
an immediate Phase B push that failed while Plex was down. `hidden` is excluded (Siftlode-only, an immediate Phase B push that failed while Plex was down. `hidden` is excluded (Siftlode-only,
never goes to Plex). Returns the count re-pushed.""" never goes to Plex). Returns the count re-pushed."""
rk_by_id = dict(db.query(PlexItem.id, PlexItem.rating_key)) # id → (rating_key, duration_s): duration comes straight from the mirrored row, so a resume
# re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's
# duration argument.
info_by_id: dict[int, tuple[str, int | None]] = {
iid: (rk, dur) for iid, rk, dur in db.query(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s)
}
dirty = ( dirty = (
db.query(PlexState) db.query(PlexState)
.filter( .filter(
@ -347,15 +381,15 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
) )
n = 0 n = 0
for st in dirty: for st in dirty:
rk = rk_by_id.get(st.item_id) info = info_by_id.get(st.item_id)
if rk is None: if info is None:
continue continue
rk, dur_s = info
try: try:
if st.status == "watched": if st.status == "watched":
plex.scrobble(rk) plex.scrobble(rk)
elif st.position_seconds and st.position_seconds >= _PROGRESS_MIN_S: elif st.position_seconds and st.position_seconds >= _PROGRESS_MIN_S:
dur = int((plex.metadata(rk) or {}).get("duration") or 0) plex.set_timeline(rk, st.position_seconds * 1000, int(dur_s or 0) * 1000)
plex.set_timeline(rk, st.position_seconds * 1000, dur)
else: else:
plex.unscrobble(rk) plex.unscrobble(rk)
st.synced_to_plex = True st.synced_to_plex = True
@ -382,7 +416,7 @@ def run_plex_watch_sync(db: Session) -> dict:
acct = _resolve_account_id(db, plex, link) acct = _resolve_account_id(db, plex, link)
since = int(link.last_watch_sync_at.timestamp()) if link.last_watch_sync_at else 0 since = int(link.last_watch_sync_at.timestamp()) if link.last_watch_sync_at else 0
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
rk_to_id = {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)} rk_to_id = _rk_to_id(db)
for row in plex.watch_history(min_viewed_at=since, account_id=acct): for row in plex.watch_history(min_viewed_at=since, account_id=acct):
iid = rk_to_id.get(str(row.get("ratingKey") or "")) iid = rk_to_id.get(str(row.get("ratingKey") or ""))

View file

@ -166,7 +166,12 @@ def set_user_role(
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.") raise HTTPException(status_code=400, detail="You can't change your own role.")
if target.role == "admin" and role == "user" and count_admins(db) <= 1: if (
target.role == "admin"
and role == "user"
and not target.is_suspended
and count_admins(db, active_only=True) <= 1
):
raise HTTPException(status_code=400, detail="Can't remove the last admin.") raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role changed = target.role != role
target.role = role target.role = role
@ -233,7 +238,7 @@ def admin_delete_user(
raise HTTPException( raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account." status_code=400, detail="Delete your own account from Settings → Account."
) )
if target.role == "admin" and count_admins(db) <= 1: if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.") raise HTTPException(status_code=400, detail="Can't delete the last admin.")
purge_user(db, target, background) purge_user(db, target, background)
return {"deleted": user_id} return {"deleted": user_id}

View file

@ -5,13 +5,14 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota, sysconfig from app import quota, sysconfig
from app.auth import current_user, has_write_scope, require_human from app.auth import current_user, has_write_scope, require_human
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag, ChannelTag,
@ -59,7 +60,7 @@ def list_channels(
func.max(Video.published_at), func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0), func.coalesce(func.sum(Video.duration_seconds), 0),
func.count(Video.id).filter(Video.is_short.is_(True)), func.count(Video.id).filter(Video.is_short.is_(True)),
func.count(Video.id).filter(Video.live_status.in_(("live", "upcoming"))), func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)),
) )
.where(Video.channel_id.in_(channel_ids)) .where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id) .group_by(Video.channel_id)
@ -100,12 +101,7 @@ def list_channels(
return [ return [
{ {
"id": ch.id, **_channel_summary(ch),
"title": ch.title,
"handle": ch.handle,
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0), "stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"), "last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0), "total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
@ -194,12 +190,7 @@ def discover_channels(
return [ return [
{ {
"id": ch.id, **_channel_summary(ch),
"title": ch.title,
"handle": ch.handle,
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"playlist_video_count": int(vid_count), "playlist_video_count": int(vid_count),
"playlist_count": int(pl_count), "playlist_count": int(pl_count),
"details_synced": ch.details_synced_at is not None, "details_synced": ch.details_synced_at is not None,
@ -208,6 +199,18 @@ def discover_channels(
] ]
def _channel_summary(ch: Channel) -> dict:
"""The channel fields common to the list and discovery projections."""
return {
"id": ch.id,
"title": ch.title,
"handle": ch.handle,
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
}
def _channel_detail_dict( def _channel_detail_dict(
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
) -> dict: ) -> dict:
@ -267,11 +270,7 @@ def channel_detail(
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
) )
).first() is not None ).first() is not None
blocked = db.execute( blocked = _is_blocked(db, user, channel_id)
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
)
).first() is not None
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
return _channel_detail_dict( return _channel_detail_dict(
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored) channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
@ -294,11 +293,7 @@ def explore_channel(
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
if channel is None: if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel") raise HTTPException(status_code=404, detail="Unknown channel")
if db.execute( if _is_blocked(db, user, channel_id):
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
)
).first() is not None:
raise HTTPException(status_code=403, detail="You've blocked this channel.") raise HTTPException(status_code=403, detail="You've blocked this channel.")
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"): if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
raise HTTPException( raise HTTPException(
@ -358,6 +353,19 @@ def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription
return sub return sub
def _is_blocked(db: Session, user: User, channel_id: str) -> bool:
"""Whether this user has blocked this channel."""
return (
db.execute(
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id,
BlockedChannel.channel_id == channel_id,
)
).first()
is not None
)
@router.patch("/{channel_id}") @router.patch("/{channel_id}")
def update_channel( def update_channel(
channel_id: str, channel_id: str,
@ -545,12 +553,7 @@ def block_channel(
un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course.""" un-kept search/explore videos are reclaimed by the discovery-cleanup job in due course."""
if db.get(Channel, channel_id) is None: if db.get(Channel, channel_id) is None:
raise HTTPException(status_code=404, detail="Unknown channel") raise HTTPException(status_code=404, detail="Unknown channel")
exists = db.execute( if not _is_blocked(db, user, channel_id):
select(BlockedChannel.id).where(
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
)
).first()
if exists is None:
db.add(BlockedChannel(user_id=user.id, channel_id=channel_id)) db.add(BlockedChannel(user_id=user.id, channel_id=channel_id))
# Stop any active exploration of it so it can be cleaned up. # Stop any active exploration of it so it can be cleaned up.
db.execute( db.execute(

View file

@ -7,7 +7,6 @@ serves finished files (range-aware, with the user's custom display name), and sh
""" """
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
@ -15,6 +14,7 @@ from fastapi.responses import FileResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig
from app.auth import admin_user, require_human from app.auth import admin_user, require_human
from app.config import settings from app.config import settings
from app.db import get_db from app.db import get_db
@ -68,20 +68,6 @@ def resolve_source(raw: str) -> tuple[str, str]:
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.")
def _reference_url(job: DownloadJob, asset: MediaAsset | None) -> str | None:
"""The clean "downloaded from" URL for the Downloads page: the canonical page URL yt-dlp
recorded, else one derived from source_kind+source_ref (covers queued/older rows before the
worker fills it). Returns None for edit derivatives a clip's source is the user's own earlier
download, not a web page, so there's no meaningful external URL to show."""
if job.job_kind == "edit":
return None
if asset is not None and asset.source_webpage_url:
return asset.source_webpage_url
if job.source_kind in ("youtube", "url"):
return service.source_url(job.source_kind, job.source_ref)
return None
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path) ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
return { return {
@ -96,7 +82,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"created_at": job.created_at.isoformat() if job.created_at else None, "created_at": job.created_at.isoformat() if job.created_at else None,
"source_kind": job.source_kind, "source_kind": job.source_kind,
"source_ref": job.source_ref, "source_ref": job.source_ref,
"source_url": _reference_url(job, asset), "source_url": service.reference_url(job, asset),
# A locally-generated poster to fall back to when the source had no thumbnail. # A locally-generated poster to fall back to when the source had no thumbnail.
"poster_url": f"/api/downloads/{job.id}/poster.jpg" "poster_url": f"/api/downloads/{job.id}/poster.jpg"
if (asset is not None and asset.poster_path) if (asset is not None and asset.poster_path)
@ -140,6 +126,13 @@ def _assets_for(db: Session, jobs: list[DownloadJob]) -> dict[int, MediaAsset]:
return {a.id: a for a in rows} return {a.id: a for a in rows}
def _serialize_job(db: Session, job: DownloadJob) -> dict:
"""Resolve a job's cache asset (if any) and serialize the pair — the resolve-then-serialize step
every single-job handler shares."""
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
return _serialize(job, asset)
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob: def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None or job.user_id != user.id: if job is None or job.user_id != user.id:
@ -273,8 +266,7 @@ def enqueue_download(
) )
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_message(e))
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/edit") @router.post("/edit")
@ -299,8 +291,7 @@ def enqueue_edit(
raise HTTPException(status_code=422, detail=_quota_message(e)) raise HTTPException(status_code=422, detail=_quota_message(e))
except service.EditError as e: except service.EditError as e:
raise HTTPException(status_code=400, detail=_edit_message(e)) raise HTTPException(status_code=400, detail=_edit_message(e))
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
def _edit_message(e: service.EditError) -> str: def _edit_message(e: service.EditError) -> str:
@ -430,8 +421,7 @@ def update_download_meta(
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS] cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
job.extra_links = cleaned or None job.extra_links = cleaned or None
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
def _set_status(db: Session, job: DownloadJob, status: str) -> None: def _set_status(db: Session, job: DownloadJob, status: str) -> None:
@ -446,8 +436,7 @@ def pause_download(
job = _own_job(db, user, job_id) job = _own_job(db, user, job_id)
if job.status in ("queued", "running"): if job.status in ("queued", "running"):
_set_status(db, job, "paused") # a running worker aborts cooperatively via the hook _set_status(db, job, "paused") # a running worker aborts cooperatively via the hook
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/{job_id}/resume") @router.post("/{job_id}/resume")
@ -467,8 +456,7 @@ def resume_download(
asset.status = "pending" asset.status = "pending"
asset.error = None asset.error = None
_set_status(db, job, "queued") _set_status(db, job, "queued")
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.post("/{job_id}/cancel") @router.post("/{job_id}/cancel")
@ -480,8 +468,7 @@ def cancel_download(
_release_asset(db, job) # release while the job still counts as holding _release_asset(db, job) # release while the job still counts as holding
job.status = "canceled" job.status = "canceled"
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize_job(db, job)
return _serialize(job, asset)
@router.delete("/{job_id}") @router.delete("/{job_id}")
@ -499,9 +486,18 @@ 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 """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, 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 and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
again). Idempotent: a job that's already left holding (canceled/error) is a no-op, so delete again). `error` counts as holding: enqueue always +1'd ref_count and the worker never
after cancel doesn't double-release.""" decrements on failure (ref_count is the route's job), so an errored job releases here on
if not job.asset_id or job.status not in ("queued", "running", "paused", "done"): delete else its +1 leaks and a later resumeready 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 return
asset = db.get(MediaAsset, job.asset_id) asset = db.get(MediaAsset, job.asset_id)
if asset is None: if asset is None:
@ -515,11 +511,6 @@ def _release_asset(db: Session, job: DownloadJob) -> None:
# --- file download (range-aware, custom display name) -------------------------------------- # --- file download (range-aware, custom display name) --------------------------------------
def _clean_basename(name: str) -> str:
# Emoji/symbol-free but keeps spaces + accents (a readable device filename).
return storage.display_filename(name)
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob: def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None: if job is None:
@ -547,16 +538,13 @@ def download_file(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise HTTPException(status_code=409, detail="This download isn't ready.")
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="File is no longer available.") raise HTTPException(status_code=404, detail="File is no longer available.")
ext = asset.container or path.suffix.lstrip(".") filename = storage.download_filename(
base = _clean_basename(job.display_name or asset.title or asset.source_ref) job.display_name or asset.title or asset.source_ref, asset.container, path
if base.lower().endswith(f".{ext.lower()}"): )
base = base[: -(len(ext) + 1)]
filename = f"{base}.{ext}"
asset.last_access_at = datetime.now(timezone.utc) asset.last_access_at = datetime.now(timezone.utc)
db.commit() db.commit()
@ -601,9 +589,8 @@ def poster_image(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.poster_path: if asset is None or not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.poster_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@ -616,9 +603,8 @@ def storyboard_image(
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.storyboard_path: if asset is None or not asset.storyboard_path:
raise HTTPException(status_code=404, detail="No filmstrip.") raise HTTPException(status_code=404, detail="No filmstrip.")
path = storage.abs_path(settings.download_root, asset.storyboard_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No filmstrip.") raise HTTPException(status_code=404, detail="No filmstrip.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@ -675,7 +661,7 @@ def unshare_download(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = _own_job(db, user, job_id) _own_job(db, user, job_id) # ownership guard (404s if not the caller's job)
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == email.strip().lower()) select(User).where(func.lower(User.email) == email.strip().lower())
).scalar_one_or_none() ).scalar_one_or_none()
@ -766,7 +752,7 @@ def list_links(
.scalars() .scalars()
.all() .all()
) )
return [linksmod.owner_view(l) for l in rows] return [linksmod.owner_view(row) for row in rows]
@router.post("/{job_id}/links") @router.post("/{job_id}/links")
@ -876,7 +862,7 @@ def admin_storage(
"ready_files": ready[0], "ready_files": ready[0],
"total_bytes": int(ready[1]), "total_bytes": int(ready[1]),
"total_assets": total_assets, "total_assets": total_assets,
"total_cap_bytes": settings.download_total_max_bytes, "total_cap_bytes": sysconfig.get_int(db, "download_total_max_bytes"),
"per_user": [ "per_user": [
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)} {"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
for uid, _ in per_user for uid, _ in per_user

View file

@ -14,6 +14,7 @@ from app import quota
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
ChannelTag, ChannelTag,
@ -34,7 +35,6 @@ router = APIRouter(prefix="/api", tags=["feed"])
# "saved" used to be a status; it's now membership in the built-in Watch later playlist. # "saved" used to be a status; it's now membership in the built-in Watch later playlist.
VALID_STATES = {"new", "watched", "hidden"} VALID_STATES = {"new", "watched", "hidden"}
HIDDEN_LIVE = ("live", "upcoming")
# Resume-position thresholds (mirror the client): positions below this are "didn't # Resume-position thresholds (mirror the client): positions below this are "didn't
# really start" and within this of the end are "basically finished" — neither is worth # really start" and within this of the end are "basically finished" — neither is worth
@ -132,7 +132,7 @@ def _filtered_query(
exclude_tag_category: str | None = None, exclude_tag_category: str | None = None,
) -> tuple[Select, object]: ) -> tuple[Select, object]:
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
Returns the column-bearing select plus the watch-status expression for sorting. Returns the column-bearing select plus the priority/relevance rank expression for sorting.
`scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions.
`scope="all"` shows every video in the shared catalog (any user's ingested channels); `scope="all"` shows every video in the shared catalog (any user's ingested channels);
@ -313,12 +313,12 @@ def _filtered_query(
type_clauses = [] type_clauses = []
if show_normal: if show_normal:
type_clauses.append( type_clauses.append(
and_(Video.is_short.is_(False), Video.live_status.notin_(HIDDEN_LIVE)) and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING))
) )
if include_shorts: if include_shorts:
type_clauses.append(Video.is_short.is_(True)) type_clauses.append(Video.is_short.is_(True))
if include_live: if include_live:
type_clauses.append(Video.live_status.in_(HIDDEN_LIVE)) type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING))
query = query.where(or_(*type_clauses) if type_clauses else false()) query = query.where(or_(*type_clauses) if type_clauses else false())
if show == "unwatched": if show == "unwatched":
@ -335,7 +335,7 @@ def _filtered_query(
else: # all else: # all
query = query.where(status_expr != "hidden") query = query.where(status_expr != "hidden")
return query, status_expr, rank_expr return query, rank_expr
def _to_tsquery_str(q: str) -> str | None: def _to_tsquery_str(q: str) -> str | None:
@ -492,7 +492,7 @@ def get_feed(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status, rank_expr = _filtered_query(db, user, **params) query, rank_expr = _filtered_query(db, user, **params)
keys = _sort_keys(sort, seed, rank_expr) keys = _sort_keys(sort, seed, rank_expr)
# Expose each key column on the row so we can build the next cursor from the last item. # Expose each key column on the row so we can build the next cursor from the last item.
@ -522,7 +522,7 @@ def get_feed_count(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status, _rank = _filtered_query(db, user, **params) query, _rank = _filtered_query(db, user, **params)
total = db.scalar(select(func.count()).select_from(query.subquery())) total = db.scalar(select(func.count()).select_from(query.subquery()))
return {"count": total or 0} return {"count": total or 0}
@ -550,7 +550,7 @@ def get_facets(
# keeps them applied, so each remaining chip narrows to channels that ALSO have all # keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden). # already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and" conjunctive = category == "topic" and params.get("tag_mode") == "and"
base, _status, _rank = _filtered_query( base, _rank = _filtered_query(
db, db,
user, user,
**{**params, "exclude_tag_category": None if conjunctive else category}, **{**params, "exclude_tag_category": None if conjunctive else category},

View file

@ -63,6 +63,10 @@ def switch_account(
raise HTTPException(status_code=404, detail="That account no longer exists.") raise HTTPException(status_code=404, detail="That account no longer exists.")
if not is_allowed(db, u.email): if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.") raise HTTPException(status_code=403, detail="That account no longer has access.")
if u.is_suspended:
# Don't make a suspended account the active one — the next request's current_user would
# see the suspension and clear the WHOLE wallet session, logging out every account here.
raise HTTPException(status_code=403, detail="That account is suspended.")
request.session["user_id"] = target request.session["user_id"] = target
return {"ok": True, "user_id": target} return {"ok": True, "user_id": target}

View file

@ -74,6 +74,23 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
return True return True
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is None:
return False
db.delete(item)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
def _summary( def _summary(
db: Session, db: Session,
pl: Playlist, pl: Playlist,
@ -239,14 +256,7 @@ def remove_watch_later(
) )
) )
if pl is not None: if pl is not None:
item = db.scalar( _remove_item(db, pl, video_id, mark_dirty=False)
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is not None:
db.delete(item)
db.commit()
return {"saved": False} return {"saved": False}
@ -452,15 +462,7 @@ def remove_item(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = _own_playlist(db, user, playlist_id)
item = db.scalar( _remove_item(db, pl, video_id, mark_dirty=True)
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if item is not None:
db.delete(item)
recompute_dirty(db, pl)
db.commit()
return {"playlist_id": pl.id, "video_id": video_id} return {"playlist_id": pl.id, "video_id": video_id}
@ -480,11 +482,20 @@ def reorder_items(
).scalars() ).scalars()
} }
pos = 0 pos = 0
seen: set[str] = set()
for vid in order: for vid in order:
it = items.get(vid) it = items.get(vid)
if it is not None: if it is not None:
it.position = pos it.position = pos
pos += 1 pos += 1
seen.add(vid)
# Any items the payload omitted (e.g. added concurrently in another tab) keep their relative
# order but get fresh trailing positions, so no two items collide on the same position.
for it in sorted(
(it for vid, it in items.items() if vid not in seen), key=lambda i: i.position
):
it.position = pos
pos += 1
recompute_dirty(db, pl) recompute_dirty(db, pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "count": pos} return {"playlist_id": pl.id, "count": pos}

View file

@ -14,6 +14,7 @@ import tempfile
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -197,12 +198,6 @@ def watch_import_now(
# --- Read: libraries / browse / show / image -------------------------------------------------- # --- Read: libraries / browse / show / image --------------------------------------------------
def _enabled() -> None:
"""Guard read endpoints when the module is off (avoids leaking a stale mirror)."""
# Read endpoints stay usable as long as a mirror exists; the toggle only gates sync + UI.
return None
@router.get("/libraries") @router.get("/libraries")
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: 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).""" """The mirrored libraries the user can browse (drives the Plex scope selector)."""
@ -392,6 +387,37 @@ def _apply_meta_filters(q, model, p: dict):
return q.filter(*conds) if conds else q return q.filter(*conds) if conds else q
@dataclass
class LibraryFilters:
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included
for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately)."""
genres: str | None = None
genre_mode: str = "any"
content_ratings: str | None = None
year_min: int | None = None
year_max: int | None = None
rating_min: float | None = None
duration_min: int | None = None
duration_max: int | None = None
added_within: str | None = None
directors: str | None = None
actors: str | None = None
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, "studios": self.studios, "collection": self.collection,
}
@router.get("/library") @router.get("/library")
def unified_library( def unified_library(
scope: str = "both", scope: str = "both",
@ -401,19 +427,7 @@ def unified_library(
show: str = "all", show: str = "all",
offset: int = 0, offset: int = 0,
limit: int = Query(default=40, ge=1, le=100), limit: int = Query(default=40, ge=1, le=100),
genres: str | None = None, f: LibraryFilters = Depends(),
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@ -422,12 +436,7 @@ def unified_library(
its episodes). On a search that also matches episodes (and shows are in scope), the matching its episodes). On a search that also matches episodes (and shows are in scope), the matching
episodes come back in a separate `episodes` list (grouped result the 'Episodes' section).""" episodes come back in a separate `episodes` list (grouped result the 'Episodes' section)."""
offset = max(0, offset) offset = max(0, offset)
p = { p = f.as_dict()
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"added_within": added_within, "directors": directors, "actors": actors,
"studios": studios, "collection": collection,
}
libs = db.query(PlexLibrary).filter_by(enabled=True).all() libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
@ -473,10 +482,10 @@ def unified_library(
else: else:
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden")) mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
mq = _apply_meta_filters(mq, PlexItem, p) mq = _apply_meta_filters(mq, PlexItem, p)
if duration_min is not None: if f.duration_min is not None:
mq = mq.filter(PlexItem.duration_s >= duration_min) mq = mq.filter(PlexItem.duration_s >= f.duration_min)
if duration_max is not None: if f.duration_max is not None:
mq = mq.filter(PlexItem.duration_s <= duration_max) mq = mq.filter(PlexItem.duration_s <= f.duration_max)
if tsq is not None: if tsq is not None:
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
selects.append(mq) selects.append(mq)
@ -582,19 +591,7 @@ def unified_library(
def facets( def facets(
scope: str = "both", scope: str = "both",
show: str = "all", show: str = "all",
genres: str | None = None, f: LibraryFilters = Depends(),
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@ -617,12 +614,7 @@ def facets(
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "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: if not movie_ids and not show_ids:
return empty return empty
p = { p = f.as_dict()
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
}
uid = user.id uid = user.id
@ -720,7 +712,7 @@ def facets(
# be absent): the sidebar renders genres solely from this list and hides the whole genre section — # be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to # chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts). # undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in _csv(genres): for g in _csv(f.genres):
genre_counts.setdefault(g, 0) genre_counts.setdefault(g, 0)
return { return {
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])], "genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
@ -1354,11 +1346,15 @@ def _vtt_ts_to_s(ts: str) -> float:
def _vtt_s_to_ts(x: float) -> str: def _vtt_s_to_ts(x: float) -> str:
x = max(0.0, x) x = max(0.0, x)
h = int(x // 3600); x -= h * 3600 h = int(x // 3600)
m = int(x // 60); x -= m * 60 x -= h * 3600
s = int(x); ms = int(round((x - s) * 1000)) m = int(x // 60)
x -= m * 60
s = int(x)
ms = int(round((x - s) * 1000))
if ms >= 1000: if ms >= 1000:
s += 1; ms -= 1000 s += 1
ms -= 1000
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
@ -1834,8 +1830,12 @@ def _push_watch(
spinning up a throwaway background session on every state change for the common no-link case.""" 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: if plex_watch.link_for_push(db, user_id) is None:
return 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( background.add_task(
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms,
plex_watch._state_marker(st),
) )

View file

@ -5,7 +5,6 @@ credential (a capability URL). They only ever expose one file's stream + minimal
the app or any account data. Password-protected links exchange the password once at `/unlock` for the app or any account data. Password-protected links exchange the password once at `/unlock` for
a short-lived signed grant that the media URL carries (a `<video src>` can't send a header). a short-lived signed grant that the media URL carries (a `<video src>` can't send a header).
""" """
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -42,9 +41,8 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never # Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree. # loads a player for a missing file — meta and file agree.
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
return job, asset return job, asset
@ -116,16 +114,11 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
if link.password_hash and not linksmod.check_grant(token, g): if link.password_hash and not linksmod.check_grant(token, g):
raise HTTPException(status_code=403, detail="This link needs a password.") raise HTTPException(status_code=403, detail="This link needs a password.")
asset = _ready_asset(db, link) asset = _ready_asset(db, link)
path = storage.abs_path(settings.download_root, asset.rel_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.rel_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
ext = asset.container or path.suffix.lstrip(".") filename = storage.download_filename(asset.title or "video", asset.container, path)
base = storage.display_filename(asset.title or "video")
if base.lower().endswith(f".{ext.lower()}"):
base = base[: -(len(ext) + 1)]
filename = f"{base}.{ext}"
disposition = "attachment" if link.allow_download else "inline" disposition = "attachment" if link.allow_download else "inline"
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing. # Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
return FileResponse(path, filename=filename, content_disposition_type=disposition) return FileResponse(path, filename=filename, content_disposition_type=disposition)
@ -141,8 +134,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
job, asset = _ready_target(db, link) job, asset = _ready_target(db, link)
if not asset.poster_path: if not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
path = storage.abs_path(settings.download_root, asset.poster_path).resolve() path = storage.safe_abs_path(settings.download_root, asset.poster_path)
root = Path(settings.download_root).resolve() if path is None:
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No poster.") raise HTTPException(status_code=404, detail="No poster.")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")

View file

@ -24,6 +24,7 @@ from app import quota, sysconfig
from app.auth import require_human from app.auth import require_human
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
Channel, Channel,
Playlist, Playlist,
@ -45,8 +46,6 @@ router = APIRouter(prefix="/api/search", tags=["search"])
# search.list costs this many quota units per page. # search.list costs this many quota units per page.
SEARCH_COST = 100 SEARCH_COST = 100
# live_status values we never surface from a live search (was_live VODs are real content).
_LIVE_HIDDEN = ("live", "upcoming")
def _classify_shorts(db: Session, videos: list[Video]) -> None: def _classify_shorts(db: Session, videos: list[Video]) -> None:
@ -165,7 +164,7 @@ def _ingest_candidates(
# 4) Confirm/deny Shorts (no quota) so none enter via search. # 4) Confirm/deny Shorts (no quota) so none enter via search.
_classify_shorts(db, videos) _classify_shorts(db, videos)
keep = {v.id for v in videos if not v.is_short and v.live_status not in _LIVE_HIDDEN} keep = {v.id for v in videos if not v.is_short and v.live_status not in LIVE_OR_UPCOMING}
return [vid for vid in ids if vid in keep] return [vid for vid in ids if vid in keep]
@ -254,8 +253,6 @@ def search_youtube(
page = yt.search_videos(term, page_token=next_cursor) page = yt.search_videos(term, page_token=next_cursor)
else: else:
page = search_scrape.search(term, continuation=next_cursor) page = search_scrape.search(term, continuation=next_cursor)
# Zero-cost, but logged so the per-user daily cap above keeps counting it.
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
except (YouTubeError, search_scrape.ScrapeError) as exc: except (YouTubeError, search_scrape.ScrapeError) as exc:
if collected: if collected:
break # keep what we already gathered break # keep what we already gathered
@ -275,6 +272,14 @@ def search_youtube(
if source == "api" or len(collected) >= limit or not next_cursor: if source == "api" or len(collected) >= limit or not next_cursor:
break break
# Count this as ONE per-user search against the daily cap. The scrape source spends no
# quota, so it never logs an event on its own; the API source already logged exactly one
# (via record_usage — it never auto-pages). Logging once here, after a search that produced
# at least one page (a total failure raises 502 above and never reaches this), keeps the cap
# counting user searches instead of internal continuation pages.
if source != "api":
quota.log_action(db, user.id, quota.QuotaAction.VIDEOS_SEARCH)
ordered = collected[:limit] ordered = collected[:limit]
if ordered: if ordered:

View file

@ -209,7 +209,7 @@ def scheduler_snapshot() -> dict:
def _rss_job() -> None: def _rss_job() -> None:
_job("rss_poll", lambda db: run_rss_poll(db)) _job("rss_poll", run_rss_poll)
def _enrich_job() -> None: def _enrich_job() -> None:

View file

@ -49,3 +49,15 @@ def decrypt(value: str | None) -> str | None:
if value is None: if value is None:
return None return None
return _require_fernet().decrypt(value.encode()).decode() return _require_fernet().decrypt(value.encode()).decode()
def decrypt_optional(value: str | None) -> str | None:
"""Like decrypt() but returns None instead of raising when the value is absent or not a valid
ciphertext (e.g. a legacy plaintext token stored before encryption) so callers fall back to
a refresh rather than erroring."""
if not value:
return None
try:
return _require_fernet().decrypt(value.encode()).decode()
except Exception:
return None

View file

@ -13,8 +13,9 @@ from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, quota from app import progress, quota
from app.auth import has_read_scope, has_write_scope from app.auth import has_read_scope
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video from app.models import Channel, Playlist, PlaylistItem, User, Video
from app.sync.runner import token_users
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
@ -370,13 +371,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
def sync_all_playlists(db: Session) -> dict: def sync_all_playlists(db: Session) -> dict:
"""Scheduler entry point: mirror playlists for every user with a read scope + token.""" """Scheduler entry point: mirror playlists for every user with a read scope + token."""
users = ( users = token_users(db)
db.execute(
select(User).join(OAuthToken).where(OAuthToken.refresh_token_enc.is_not(None))
)
.scalars()
.all()
)
total = 0 total = 0
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
if not has_read_scope(user): if not has_read_scope(user):

View file

@ -31,19 +31,23 @@ from app.youtube.rss import fetch_channel_feed
RSS_POLL_WORKERS = 16 RSS_POLL_WORKERS = 16
def get_service_user(db: Session) -> User | None: def token_users(db: Session) -> list[User]:
return ( """Every user with a stored YouTube refresh token (the read-scope service accounts), by id."""
return list(
db.execute( db.execute(
select(User) select(User)
.join(OAuthToken) .join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None)) .where(OAuthToken.refresh_token_enc.is_not(None))
.order_by(User.id) .order_by(User.id)
) ).scalars()
.scalars()
.first()
) )
def get_service_user(db: Session) -> User | None:
users = token_users(db)
return users[0] if users else None
def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int: def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> int:
if channels is None: if channels is None:
channels = db.execute(select(Channel)).scalars().all() channels = db.execute(select(Channel)).scalars().all()
@ -102,15 +106,7 @@ def run_shorts(db: Session) -> dict:
def run_subscription_resync(db: Session) -> dict: def run_subscription_resync(db: Session) -> dict:
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on """Re-import every user's subscriptions so unsubscribes and new subscriptions on
YouTube are reflected automatically.""" YouTube are reflected automatically."""
users = ( users = token_users(db)
db.execute(
select(User)
.join(OAuthToken)
.where(OAuthToken.refresh_token_enc.is_not(None))
)
.scalars()
.all()
)
for i, user in enumerate(users, 1): for i, user in enumerate(users, 1):
try: try:
import_subscriptions(db, user) import_subscriptions(db, user)

View file

@ -34,9 +34,9 @@ def _external_links(branding: dict) -> list | None:
channel = branding.get("channel", {}) channel = branding.get("channel", {})
links = channel.get("links") or [] links = channel.get("links") or []
out = [ out = [
{"title": l.get("title"), "url": l.get("url")} {"title": item.get("title"), "url": item.get("url")}
for l in links for item in links
if isinstance(l, dict) and l.get("url") if isinstance(item, dict) and item.get("url")
] ]
return out or None return out or None

View file

@ -2,6 +2,7 @@
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
and livestream classification).""" and livestream classification)."""
import re import re
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -10,7 +11,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import progress, sysconfig from app import progress, sysconfig
from app.models import Channel, Video from app.models import LIVE_OR_UPCOMING, Channel, Video
from app.titles import normalize_title from app.titles import normalize_title
from app.youtube.client import YouTubeClient, best_thumbnail from app.youtube.client import YouTubeClient, best_thumbnail
from app.youtube.rss import fetch_channel_feed from app.youtube.rss import fetch_channel_feed
@ -260,6 +261,37 @@ def apply_video_details(video: Video, item: dict) -> None:
# is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification). # is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification).
def _apply_video_batch(
db: Session,
yt: YouTubeClient,
videos: list[Video],
on_missing: Callable[[Video], None],
) -> int:
"""Fetch videos.list details for the given rows, apply them (stamping enriched_at), and
return how many were applied. Rows YouTube omits (deleted/private) are handled by the
caller-supplied `on_missing` so each caller decides how to retire them."""
if not videos:
return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
applied = 0
for video in videos:
item = items.get(video.id)
if item is None:
on_missing(video)
continue
apply_video_details(video, item)
video.enriched_at = now
applied += 1
db.commit()
return applied
def _mark_enriched(video: Video) -> None:
# Unavailable (deleted/private): stamp so we don't keep retrying.
video.enriched_at = _now()
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
limit = limit or sysconfig.get_int(db, "enrich_batch_size") limit = limit or sysconfig.get_int(db, "enrich_batch_size")
videos = ( videos = (
@ -267,22 +299,7 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
.scalars() .scalars()
.all() .all()
) )
if not videos: return _apply_video_batch(db, yt, list(videos), _mark_enriched)
return 0
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
now = _now()
enriched = 0
for video in videos:
item = items.get(video.id)
if item is None:
# Unavailable (deleted/private): stamp so we don't keep retrying.
video.enriched_at = now
continue
apply_video_details(video, item)
video.enriched_at = now
enriched += 1
db.commit()
return enriched
def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int: def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
@ -300,7 +317,7 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
select(Video) select(Video)
.where( .where(
or_( or_(
Video.live_status.in_(("live", "upcoming")), Video.live_status.in_(LIVE_OR_UPCOMING),
and_( and_(
Video.live_status == "was_live", Video.live_status == "was_live",
Video.duration_seconds.is_(None), Video.duration_seconds.is_(None),
@ -312,23 +329,13 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
.scalars() .scalars()
.all() .all()
) )
if not videos:
return 0 def _retire(video: Video) -> None:
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])} # The broadcast vanished (deleted/private). Stop treating it as live so it
now = _now() # doesn't get refreshed forever; it just becomes an ordinary (dead) row.
updated = 0 video.live_status = "none"
for video in videos:
item = items.get(video.id) return _apply_video_batch(db, yt, list(videos), _retire)
if item is None:
# The broadcast vanished (deleted/private). Stop treating it as live so it
# doesn't get refreshed forever; it just becomes an ordinary (dead) row.
video.live_status = "none"
continue
apply_video_details(video, item)
video.enriched_at = now
updated += 1
db.commit()
return updated
def run_shorts_classification(db: Session, limit: int | None = None) -> dict: def run_shorts_classification(db: Session, limit: int | None = None) -> dict:

View file

@ -26,6 +26,7 @@ from pathlib import Path
import yt_dlp import yt_dlp
from sqlalchemy import text from sqlalchemy import text
from app import sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import edit as editmod from app.downloads import edit as editmod
@ -371,6 +372,16 @@ def _extract_with_fallback(job_id: int, asset_id: int, url: str, spec: dict, sta
raise last_exc # unreachable (loop either returns or raises) raise last_exc # unreachable (loop either returns or raises)
def _download_settings() -> tuple[str, int]:
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
with SessionLocal() as db:
return (
sysconfig.get_str(db, "download_layout"),
sysconfig.get_int(db, "download_retention_days"),
)
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None: def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
staging = _staging_dir(asset_id) staging = _staging_dir(asset_id)
try: try:
@ -402,14 +413,15 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
thumbnail_url=info.get("thumbnail"), thumbnail_url=info.get("thumbnail"),
) )
rel = storage.rel_path(meta, ext, settings.download_layout) layout, retention_days = _download_settings()
rel = storage.rel_path(meta, ext, layout)
thumb = _find_thumbnail(staging, produced) thumb = _find_thumbnail(staging, produced)
storage.place_file(produced, settings.download_root, rel) storage.place_file(produced, settings.download_root, rel)
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb) nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
final = storage.abs_path(settings.download_root, rel) final = storage.abs_path(settings.download_root, rel)
size = final.stat().st_size if final.exists() else None size = final.stat().st_size if final.exists() else None
expires = datetime.now(timezone.utc) + timedelta(days=settings.download_retention_days) expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
with SessionLocal() as db: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) asset = db.get(MediaAsset, asset_id)
@ -554,7 +566,8 @@ def _process_edit(job_id: int, asset_id: int) -> None:
storage.place_file(dest_staging, settings.download_root, rel) storage.place_file(dest_staging, settings.download_root, rel)
final = storage.abs_path(settings.download_root, rel) final = storage.abs_path(settings.download_root, rel)
size = final.stat().st_size if final.exists() else None size = final.stat().st_size if final.exists() else None
expires = datetime.now(timezone.utc) + timedelta(days=settings.download_retention_days) _, retention_days = _download_settings()
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
with SessionLocal() as db: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) asset = db.get(MediaAsset, asset_id)

View file

@ -12,9 +12,8 @@ from datetime import datetime, timedelta, timezone
import httpx import httpx
from app import quota, sysconfig from app import quota, sysconfig
from app.config import settings
from app.models import User from app.models import User
from app.security import decrypt from app.security import decrypt, decrypt_optional, encrypt
log = logging.getLogger("siftlode.youtube") log = logging.getLogger("siftlode.youtube")
@ -77,8 +76,9 @@ class YouTubeClient:
if tok is None: if tok is None:
raise YouTubeError("User has no stored OAuth token") raise YouTubeError("User has no stored OAuth token")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if tok.access_token and tok.expiry and tok.expiry > now + timedelta(seconds=60): cached = decrypt_optional(tok.access_token)
return tok.access_token if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60):
return cached
refresh = decrypt(tok.refresh_token_enc) refresh = decrypt(tok.refresh_token_enc)
if not refresh: if not refresh:
raise YouTubeError("No refresh token; user must re-authenticate") raise YouTubeError("No refresh token; user must re-authenticate")
@ -95,12 +95,13 @@ class YouTubeClient:
if resp.status_code != 200: if resp.status_code != 200:
raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}") raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}")
data = resp.json() data = resp.json()
tok.access_token = data["access_token"] access = data["access_token"]
tok.access_token = encrypt(access)
tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600))) tok.expiry = now + timedelta(seconds=int(data.get("expires_in", 3600)))
self.db.add(tok) self.db.add(tok)
self.db.commit() self.db.commit()
log.info("Refreshed access token for user %s", self.user.id) log.info("Refreshed access token for user %s", self.user.id)
return tok.access_token return access
# --- core request --- # --- core request ---
def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict: def _get(self, path: str, params: dict, cost: int = 1, allow_key: bool = True) -> dict:
@ -168,9 +169,9 @@ class YouTubeClient:
if not page_token: if not page_token:
break break
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]: def _iter_playlist_items(self, playlist_id: str) -> Iterator[dict]:
"""Yield the video ids of one of the user's own playlists, in playlist order """Paginate one of the user's playlists' items (playlistItems, contentDetails part;
(OAuth, so private/unlisted playlists work).""" OAuth so private/unlisted work), yielding each raw item in playlist order."""
page_token = None page_token = None
while True: while True:
params = { params = {
@ -181,14 +182,19 @@ class YouTubeClient:
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
data = self._get("playlistItems", params, cost=1, allow_key=False) data = self._get("playlistItems", params, cost=1, allow_key=False)
for item in data.get("items", []): yield from data.get("items", [])
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield vid
page_token = data.get("nextPageToken") page_token = data.get("nextPageToken")
if not page_token: if not page_token:
break break
def iter_my_playlist_video_ids(self, playlist_id: str) -> Iterator[str]:
"""Yield the video ids of one of the user's own playlists, in playlist order
(OAuth, so private/unlisted playlists work)."""
for item in self._iter_playlist_items(playlist_id):
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield vid
def get_my_channel_id(self) -> str | None: def get_my_channel_id(self) -> str | None:
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only; """The authenticated user's own channel id (channels.list?mine=true). OAuth only;
1 quota unit. Returns None if the account has no channel.""" 1 quota unit. Returns None if the account has no channel."""
@ -265,23 +271,10 @@ class YouTubeClient:
"""Yield {item_id, video_id} for each item of one of the user's playlists, in """Yield {item_id, video_id} for each item of one of the user's playlists, in
playlist order. `item_id` is the playlistItem resource id, needed to delete or playlist order. `item_id` is the playlistItem resource id, needed to delete or
reposition the item via the write API. OAuth (private playlists work).""" reposition the item via the write API. OAuth (private playlists work)."""
page_token = None for item in self._iter_playlist_items(playlist_id):
while True: vid = item.get("contentDetails", {}).get("videoId")
params = { if vid:
"part": "contentDetails", yield {"item_id": item.get("id"), "video_id": vid}
"playlistId": playlist_id,
"maxResults": 50,
}
if page_token:
params["pageToken"] = page_token
data = self._get("playlistItems", params, cost=1, allow_key=False)
for item in data.get("items", []):
vid = item.get("contentDetails", {}).get("videoId")
if vid:
yield {"item_id": item.get("id"), "video_id": vid}
page_token = data.get("nextPageToken")
if not page_token:
break
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:

6
backend/ruff.toml Normal file
View file

@ -0,0 +1,6 @@
# Ruff config for the Siftlode backend. Keeps ruff's default rule set (E4/E7/E9 + F = the
# high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), but ignores E402:
# a handful of modules (e.g. main.py) deliberately run setup — logging config, sys.path — before
# importing route modules, so "module import not at top of file" is intentional there, not cruft.
[lint]
ignore = ["E402"]

View file

@ -831,7 +831,6 @@ export default function App() {
) : page === "plex" && meQuery.data!.plex_enabled ? ( ) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse <PlexBrowse
q={plexQ} q={plexQ}
onClearSearch={() => setPlexQ("")}
scope={plexScope} scope={plexScope}
setScope={setPlexScope} setScope={setPlexScope}
show={plexShowFilter} show={plexShowFilter}

View file

@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { formatViews } from "../lib/format"; import { formatCountOrDash } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink"; import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -48,19 +50,8 @@ export default function ChannelDiscovery({
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name }, meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
}); });
}, },
onError: (err: unknown) => { onError: (err: unknown) =>
// A 403 means the user hasn't granted the write scope — offer to connect instead of notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
// a vague failure (mirrors the subscriptions tab).
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t("channels.discovery.subscribeFailed") });
}
},
}); });
// Subscribing changes the user's real YouTube account and spends a little quota — confirm // Subscribing changes the user's real YouTube account and spends a little quota — confirm
@ -88,19 +79,7 @@ export default function ChannelDiscovery({
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} /> <ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
), ),
}, },
{ subsColumn<DiscoveredChannel>(t),
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
),
},
{ {
key: "videos", key: "videos",
header: t("channels.discovery.cols.totalVideos"), header: t("channels.discovery.cols.totalVideos"),
@ -109,9 +88,7 @@ export default function ChannelDiscovery({
sortable: true, sortable: true,
sortValue: (c) => c.video_count ?? -1, sortValue: (c) => c.video_count ?? -1,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
{c.video_count != null ? formatViews(c.video_count) : "—"}
</span>
), ),
}, },
{ {
@ -174,7 +151,7 @@ export default function ChannelDiscovery({
rows={rows} rows={rows}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined} persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top" controlsPosition="top"
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
/> />

View file

@ -6,6 +6,7 @@ import Avatar from "./Avatar";
import Feed from "./Feed"; import Feed from "./Feed";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api"; import { api, type FeedFilters, type Me } from "../lib/api";
import { channelYouTubeUrl, formatViews } from "../lib/format"; import { channelYouTubeUrl, formatViews } from "../lib/format";
@ -89,8 +90,14 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
}, },
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
// here, so no Connect button — but the user at least sees why it didn't work).
onError: (err) => notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed")),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId), mutationFn: () => api.unsubscribeChannel(channelId),

View file

@ -16,10 +16,12 @@ import {
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, type ManagedChannel, type Tag } from "../lib/api";
import { accountKey } from "../lib/storage"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -73,19 +75,6 @@ export default function Channels({
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => {
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: t("channels.notify.needYouTube"),
action: { label: t("channels.notify.connect"), onClick: onOpenWizard },
});
} else {
notify({ level: "error", message: t(fallbackKey) });
}
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@ -160,7 +149,7 @@ export default function Channels({
invalidate(); invalidate();
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
}); });
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
@ -169,7 +158,7 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
}); });
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
@ -178,7 +167,7 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
}, },
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
}); });
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
@ -190,7 +179,7 @@ export default function Channels({
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
}); });
}, },
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
}); });
// Channel-name search + tag-chip filtering, applied client-side over the status-filtered list // Channel-name search + tag-chip filtering, applied client-side over the status-filtered list
@ -286,19 +275,7 @@ export default function Channels({
sortValue: (c) => c.stored_videos, sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>, render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
}, },
{ subsColumn<ManagedChannel>(t),
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
),
},
{ {
key: "lastUpload", key: "lastUpload",
header: t("channels.cols.lastUpload"), header: t("channels.cols.lastUpload"),
@ -570,7 +547,7 @@ export default function Channels({
rows={visibleChannels} rows={visibleChannels}
columns={columns} columns={columns}
rowKey={(c) => c.id} rowKey={(c) => c.id}
persistKey={accountKey("siftlode.channelsTable") ?? undefined} persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}

View file

@ -2,9 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api"; import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, useKeyState } from "../lib/messaging"; import { partnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate"; import KeyGate from "./KeyGate";
@ -39,12 +40,15 @@ export default function ChatThread({
); );
const items = q.data?.items ?? []; const items = q.data?.items ?? [];
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
// effect (and setPlain a new object) on every render while data is loading — a render storm.
useEffect(() => { useEffect(() => {
if (isSystem || !ready) return; if (isSystem || !ready) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const out: Record<number, string> = {}; const out: Record<number, string> = {};
for (const m of items) { for (const m of q.data?.items ?? []) {
if (m.ciphertext && m.iv) { if (m.ciphertext && m.iv) {
try { try {
const pub = await partnerPub(partnerId); const pub = await partnerPub(partnerId);
@ -59,7 +63,8 @@ export default function ChatThread({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [items, ready, isSystem, partnerId, t]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening / refreshing the thread marks incoming messages read, so clear the badges. // Opening / refreshing the thread marks incoming messages read, so clear the badges.
useEffect(() => { useEffect(() => {
@ -69,9 +74,12 @@ export default function ChatThread({
} }
}, [q.dataUpdatedAt, qc]); }, [q.dataUpdatedAt, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
// which gets a fresh object on every poll's decrypt pass and would yank a scrolled-up reader
// back to the bottom even when nothing new arrived.
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" }); bottomRef.current?.scrollIntoView({ block: "end" });
}, [items.length, plain]); }, [items.length]);
const send = useMutation({ const send = useMutation({
mutationFn: async (text: string) => { mutationFn: async (text: string) => {
@ -84,6 +92,14 @@ export default function ChatThread({
qc.invalidateQueries({ queryKey: ["thread", partnerId] }); qc.invalidateQueries({ queryKey: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["conversations"] });
}, },
onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
// raised the global error dialog; 404 (recipient gone) and 429 (rate-limited) are "caller-
// handled" in api.req (no modal), so surface those here or the send fails silently.
if (e instanceof HttpError && (e.status === 404 || e.status === 429)) {
notify({ level: "error", message: e.detail || t("messages.sendFailed") });
}
},
}); });
const submit = () => { const submit = () => {

View file

@ -0,0 +1,39 @@
import { ChevronRight, SlidersHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
// The thin 46px rail shown when a filter sidebar is collapsed (both the feed Sidebar and the Plex
// PlexSidebar rendered byte-identical copies): an expand control + a filter icon carrying the
// active-filter count so you can tell filters are on without opening the panel.
export default function CollapsedFilterRail({
activeCount,
onToggleCollapse,
}: {
activeCount: number;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
return (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
}

View file

@ -7,7 +7,8 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -15,7 +16,6 @@ import { DraftSaveBar } from "./ui/DraftSaveBar";
// the server until Save. Group order is fixed where known so logically related settings (e.g. // the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together. // Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,7 +40,7 @@ export default function ConfigPanel() {
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is // One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded. // stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab("siftlode.configTab"); const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset). // Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => { useEffect(() => {
@ -110,9 +110,14 @@ export default function ConfigPanel() {
}); });
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const togglePlexLib = (key: string) => { const togglePlexLib = (key: string) => {
const next = plexLibs.includes(key) // An empty list means "all libraries" — every box renders checked. Unchecking one from that
? plexLibs.filter((k) => k !== key) // state must select all-except-this, so expand "all" to the explicit section set first;
: [...plexLibs, key]; // otherwise the toggle would ADD the key and leave it as the ONLY selected library.
const allKeys = plexResult?.sections?.map((s) => s.key) ?? [];
const current = plexLibs.length === 0 ? allKeys : plexLibs;
const next = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") })); setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
}; };

View file

@ -9,7 +9,7 @@ import { useDismiss } from "../lib/useDismiss";
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card // other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
// list built from the same column definitions. // list built from the same column definitions.
export type ColumnFilter<T> = type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string } | { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean } | { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
| { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean }; | { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };

View file

@ -1,4 +1,4 @@
import { lazy, Suspense, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -27,11 +27,11 @@ const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api"; import { api, type DownloadJob, type Me } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
import { inputCls } from "./ui/form";
const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
const GiB = 1024 ** 3; const GiB = 1024 ** 3;
const STATUS_CLS: Record<string, string> = { const STATUS_CLS: Record<string, string> = {
@ -546,7 +546,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
const [addUrl, setAddUrl] = useState(""); const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null); const [addSource, setAddSource] = useState<string | null>(null);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [renameJob, setRenameJob] = useState<DownloadJob | null>(null); const [metaJob, setMetaJob] = useState<DownloadJob | null>(null);
const [shareJob, setShareJob] = useState<DownloadJob | null>(null); const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null); const [editJob, setEditJob] = useState<DownloadJob | null>(null);
@ -556,11 +556,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done"); const library = jobs.filter((j) => j.status === "done");
const invalidate = () => { const invalidate = () => invalidateDownloads(qc);
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
};
const act = useMutation({ const act = useMutation({
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) => mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
op === "pause" ? api.pauseDownload(id) op === "pause" ? api.pauseDownload(id)
@ -586,6 +582,12 @@ export default function DownloadCenter({ me }: { me: Me }) {
{ id: "shared", label: t("downloads.tabs.shared") }, { id: "shared", label: t("downloads.tabs.shared") },
...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
]; ];
// A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin
// account) — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, me.role]);
const saveBtn = (job: DownloadJob) => ( const saveBtn = (job: DownloadJob) => (
<a <a
@ -668,7 +670,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
<Scissors className="w-4 h-4" /> <Scissors className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.editDetails")}> <IconBtn onClick={() => setMetaJob(job)} title={t("downloads.actions.editDetails")}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}> <IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
@ -732,7 +734,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
/> />
)} )}
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <MetaEditModal job={renameJob} onClose={() => setRenameJob(null)} />} {metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense> </Suspense>

View file

@ -3,14 +3,13 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal"; import Modal from "./Modal";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor")); const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav"; import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form";
const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from // Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
// the Download Center. Enqueues via the API and points the user at the Download Center. // the Download Center. Enqueues via the API and points the user at the Download Center.
@ -43,9 +42,7 @@ export default function DownloadDialog({
profile_id: chosen, profile_id: chosen,
display_name: name.trim() || undefined, display_name: name.trim() || undefined,
}); });
qc.invalidateQueries({ queryKey: ["downloads"] }); invalidateDownloads(qc);
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: t("downloads.dialog.added"),

View file

@ -184,9 +184,18 @@ export default function Feed({
setOverrides({}); setOverrides({});
setSavedOverrides({}); setSavedOverrides({});
}, [filters]); }, [filters]);
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
const pagesLenRef = useRef(0);
useEffect(() => { useEffect(() => {
setOverrides({}); const len = query.data?.pages?.length ?? 0;
setSavedOverrides({}); const appended = len > pagesLenRef.current;
pagesLenRef.current = len;
if (!appended) {
setOverrides({});
setSavedOverrides({});
}
}, [query.dataUpdatedAt]); }, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
@ -310,12 +319,21 @@ export default function Feed({
list list
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
const items: Video[] = withOverrides(loaded).filter((v) => matchesView(v.status, filters.show)); const merged: Video[] = withOverrides(loaded);
const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
// The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
// watch-state view filter is NOT applied — so marking the current video watched keeps it in the // watch-state view filter is NOT applied — so marking the current video watched keeps it in the
// sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
// goes hidden still drops out ("visible affects"). // goes hidden still drops out ("visible affects").
const playerQueue: Video[] = withOverrides(loaded).filter((v) => v.status !== "hidden"); const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
};
// --- Live YouTube search mode ------------------------------------------------------------- // --- Live YouTube search mode -------------------------------------------------------------
// A separate data source rendered in the same cards/player. No watch-state view filter (the // A separate data source rendered in the same cards/player. No watch-state view filter (the
@ -341,16 +359,11 @@ export default function Feed({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => { onClick={() => {
// The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result.
// Surface the just-searched videos in the feed you return to: switch the Source // Surface the just-searched videos in the feed you return to: switch the Source
// filter to "search" (your own search finds) and rank them by relevance. // filter to "search" (your own search finds) and rank them by relevance.
setFilters({ ...filters, librarySource: "search", sort: "relevance" }); setFilters({ ...filters, librarySource: "search", sort: "relevance" });
onExitYtSearch(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); dropFeedCaches();
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
}} }}
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
> >
@ -394,9 +407,7 @@ export default function Feed({
.catch(() => {}); .catch(() => {});
setFilters({ ...filters, librarySource: "organic", sort: "newest" }); setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch(); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); dropFeedCaches();
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
qc.removeQueries({ queryKey: ["yt-search"] }); qc.removeQueries({ queryKey: ["yt-search"] });
}} }}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
@ -541,29 +552,25 @@ export default function Feed({
</button> </button>
); );
})} })}
{( <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<> <label className="inline-flex items-center gap-1.5 text-xs text-muted">
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span>{t("feed.source.label")}</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted"> <select
<span>{t("feed.source.label")}</span> value={filters.librarySource ?? "organic"}
<select onChange={(e) =>
value={filters.librarySource ?? "organic"} setFilters({
onChange={(e) => ...filters,
setFilters({ librarySource: e.target.value as FeedFilters["librarySource"],
...filters, })
librarySource: e.target.value as FeedFilters["librarySource"], }
}) title={t("feed.source.tip")}
} className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
title={t("feed.source.tip")} >
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" <option value="organic">{t("feed.source.organic")}</option>
> <option value="all">{t("feed.source.all")}</option>
<option value="organic">{t("feed.source.organic")}</option> <option value="search">{t("feed.source.only")}</option>
<option value="all">{t("feed.source.all")}</option> </select>
<option value="search">{t("feed.source.only")}</option> </label>
</select>
</label>
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span className="text-xs text-muted whitespace-nowrap"> <span className="text-xs text-muted whitespace-nowrap">
{countQuery.data {countQuery.data

View file

@ -64,12 +64,15 @@ function ConversationList({
const items = q.data?.items ?? []; const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({}); const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
// (and setPreviews) every render while the query is loading.
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const out: Record<number, string> = {}; const out: Record<number, string> = {};
for (const c of items) { for (const c of q.data?.items ?? []) {
const m = c.last_message; const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) { if (m.kind === "user" && m.ciphertext && m.iv) {
try { try {
@ -85,7 +88,8 @@ function ConversationList({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [items, ready]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string { function previewText(c: Conversation): string {
const m = c.last_message; const m = c.last_message;

View file

@ -23,6 +23,7 @@ import {
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
@ -103,6 +104,14 @@ export default function NavSidebar({
} catch { } catch {
/* clear locally and reload regardless */ /* clear locally and reload regardless */
} }
// Forget this device's E2EE private key on sign-out: it's otherwise persisted in IndexedDB
// and auto-unlocks on the next visit, so on a shared machine the conversations would stay
// decryptable after logout. Re-entering the message passphrase is required next time.
try {
await e2ee.clearDevice(me.id);
} catch {
/* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */
}
clearActiveAccount(); clearActiveAccount();
location.reload(); location.reload();
} }

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -7,7 +7,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
@ -84,14 +84,7 @@ export default function PlayerModal({
// non-embeddable still surface the existing playback-error overlay when reached. // non-embeddable still surface the existing playback-error overlay when reached.
const [queue] = useState(() => queueProp); const [queue] = useState(() => queueProp);
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) => const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
d
? new Date(d).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})
: "";
// Track the playing item by id (not a frozen index) so it survives the queue changing // Track the playing item by id (not a frozen index) so it survives the queue changing
// under us — e.g. an item removed in another tab. The index is derived from the *live* // under us — e.g. an item removed in another tab. The index is derived from the *live*
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting // queue, so the N / M counter and prev/next neighbours stay correct without disrupting
@ -235,8 +228,14 @@ export default function PlayerModal({
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached // Auto-advance + loop are persistent per-user settings. Read the current values from the cached
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change // `me`, mirror them in local state for instant UI, and write both to the server + cache on change
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"]) const savedPrefs = useMemo(
?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode }; () =>
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
[qc]
);
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off"); const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off"); const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
const modeRef = useRef({ autoMode, loopMode }); const modeRef = useRef({ autoMode, loopMode });
@ -446,7 +445,10 @@ export default function PlayerModal({
// navigated to via description links. // navigated to via description links.
const maybeAutoWatch = (current: number, duration: number): boolean => { const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
if (current > duration - FINISH_MARGIN) { // Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
// auto-marked watched almost immediately (its threshold would otherwise go negative).
const margin = Math.min(FINISH_MARGIN, duration / 2);
if (current > duration - margin) {
autoMarkedRef.current = true; autoMarkedRef.current = true;
setWatched(true); setWatched(true);
return true; return true;
@ -646,7 +648,7 @@ export default function PlayerModal({
{hasQueue && ( {hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm"> <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button <button
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)} onClick={goPrev}
disabled={index === 0} disabled={index === 0}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
@ -657,7 +659,7 @@ export default function PlayerModal({
{index + 1} / {queue!.length} {index + 1} / {queue!.length}
</span> </span>
<button <button
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)} onClick={goNext}
disabled={index === queue!.length - 1} disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"

View file

@ -19,7 +19,6 @@ import { CSS } from "@dnd-kit/utilities";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check,
ExternalLink, ExternalLink,
GripVertical, GripVertical,
ListPlus, ListPlus,
@ -37,6 +36,7 @@ import {
import { api, type Playlist, type Video } from "../lib/api"; import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
@ -347,14 +347,13 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setReverting(true); setReverting(true);
try { try {
await api.revertPlaylist(selectedId); await api.revertPlaylist(selectedId);
refreshAll(); refreshAll();
notify({ level: "success", message: t("playlists.revertDone", { name: plName }) }); notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.revertFailed") }); notifyYouTubeActionError(e, t("playlists.revertFailed"));
} finally { } finally {
setReverting(false); setReverting(false);
} }
@ -362,12 +361,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
async function pushToYoutube() { async function pushToYoutube() {
if (selectedId == null || !detail || pushing) return; if (selectedId == null || !detail || pushing) return;
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
setPushing(true); setPushing(true);
try { try {
const plan = await api.playlistPushPlan(selectedId); const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) { if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName }) }); notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
return; return;
} }
if (!plan.affordable) { if (!plan.affordable) {
@ -406,10 +404,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
if (r.failures.length) { if (r.failures.length) {
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
} else { } else {
notify({ level: "success", message: t("playlists.pushDone", { name: plName }) }); notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
} }
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.pushFailed") }); notifyYouTubeActionError(e, t("playlists.pushFailed"));
} finally { } finally {
setPushing(false); setPushing(false);
} }
@ -423,8 +421,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: ["playlist"] });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch { } catch (e) {
notify({ level: "warning", message: t("playlists.syncFailed") }); notifyYouTubeActionError(e, t("playlists.syncFailed"));
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
@ -463,8 +461,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const next = items.filter((v) => v.id !== videoId); const next = items.filter((v) => v.id !== videoId);
order.reset(next); order.reset(next);
lastSetRef.current = idsKey(next); lastSetRef.current = idsKey(next);
await api.removeFromPlaylist(selectedId, videoId); try {
refreshAll(); await api.removeFromPlaylist(selectedId, videoId);
refreshAll();
} catch {
// The optimistic removal above didn't stick — resync from the server to restore the row.
// api.req already surfaced the error to the user via the global error dialog.
refreshAll();
}
} }
async function saveRename() { async function saveRename() {

View file

@ -26,6 +26,7 @@ import {
type PlexSeasonDetail, type PlexSeasonDetail,
type PlexUnifiedResult, type PlexUnifiedResult,
} from "../lib/api"; } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history"; import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
@ -53,7 +54,6 @@ type Sub =
type Props = { type Props = {
q: string; q: string;
onClearSearch: () => void;
scope: string; // movie | show | both (unified cross-library scope) scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void; setScope: (v: string) => void;
show: string; show: string;
@ -67,16 +67,8 @@ type Props = {
const PAGE = 40; const PAGE = 40;
function dur(n?: number | null): string {
if (!n) return "";
const h = Math.floor(n / 3600);
const m = Math.floor((n % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexBrowse({ export default function PlexBrowse({
q, q,
onClearSearch,
scope, scope,
setScope, setScope,
show, show,
@ -162,21 +154,24 @@ export default function PlexBrowse({
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0. // Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
const episodes = browseQ.data?.pages[0]?.episodes ?? []; const episodes = browseQ.data?.pages[0]?.episodes ?? [];
// Infinite scroll: auto-load the next page when the sentinel scrolls into view. // Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
const sentinel = useRef<HTMLDivElement>(null); // in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
// Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ; const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
useEffect(() => { useEffect(() => {
const el = sentinel.current; if (!sentinelEl) return;
if (!el) return;
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage(); if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
}, },
{ rootMargin: "600px" }, { rootMargin: "600px" },
); );
io.observe(el); io.observe(sentinelEl);
return () => io.disconnect(); return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);
function onCard(card: PlexCard) { function onCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
@ -211,12 +206,6 @@ export default function PlexBrowse({
await api.plexSetState(card.id, next).catch(() => {}); await api.plexSetState(card.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: ["plex-library"] });
} }
async function toggleEpisodeWatched(ep: PlexCard) {
const next = ep.status === "watched" ? "new" : "watched";
optimisticStatus(ep.id, next);
await api.plexSetState(ep.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] });
}
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed). // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
@ -348,7 +337,7 @@ export default function PlexBrowse({
ep={ep} ep={ep}
withShowTitle withShowTitle
onPlay={() => onCard(ep)} onPlay={() => onCard(ep)}
onToggleWatched={() => toggleEpisodeWatched(ep)} onToggleWatched={() => toggleWatched(ep)}
/> />
))} ))}
</div> </div>
@ -357,7 +346,7 @@ export default function PlexBrowse({
</> </>
)} )}
<div ref={sentinel} className="h-8" /> <div ref={setSentinelEl} className="h-8" />
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>} {isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
</div> </div>
); );
@ -489,7 +478,7 @@ function PlexPosterCard({
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }} style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
> >
<div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div> <div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">{card.title}</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div> <div className="text-xs text-muted">{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</div> </div>
</div> </div>
); );
@ -532,7 +521,8 @@ function PlexInfoView({
onPlay={onPlay} onPlay={onPlay}
onPlayItem={onPlayItem} onPlayItem={onPlayItem}
onFilter={onFilter} onFilter={onFilter}
onStateChange={() => q.refetch()} // No onStateChange: PlexInfo.setState already invalidates ["plex-item", id] (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/> />
</Suspense> </Suspense>
)} )}
@ -851,7 +841,7 @@ function EpisodeCard({
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>} {!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title} {ep.title}
</div> </div>
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div> <div className="text-[11px] text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</div> </div>
{onAdd && ( {onAdd && (
<button <button

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; import PlexPlaylistAdd from "./PlexPlaylistAdd";
@ -28,13 +29,6 @@ type Props = {
library?: string; library?: string;
}; };
function fmtDur(s?: number | null): string {
if (!s) return "";
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexInfo({ export default function PlexInfo({
detail, detail,
variant, variant,
@ -170,7 +164,7 @@ export default function PlexInfo({
{detail.content_rating} {detail.content_rating}
</Filterable> </Filterable>
)} )}
{detail.duration_seconds ? <span className={mutedCls}>{fmtDur(detail.duration_seconds)}</span> : null} {detail.duration_seconds ? <span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span> : null}
{detail.studio && ( {detail.studio && (
<Filterable <Filterable
className={mutedCls} className={mutedCls}

View file

@ -33,7 +33,8 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type PlexMarker } from "../lib/api"; import { api, type PlexMarker } from "../lib/api";
import { useAccountPersistedObject } from "../lib/storage"; import { formatDuration } from "../lib/format";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
@ -63,7 +64,6 @@ function subOrdForLang(subs: SubStream[], lang: string): number | null {
type PlexPlayerPrefs = { type PlexPlayerPrefs = {
volume: number; // 0..1 volume: number; // 0..1
muted: boolean; muted: boolean;
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
audioLang: string; // "" = default first audio audioLang: string; // "" = default first audio
subLang: string; // "" = subtitles off subLang: string; // "" = subtitles off
subOffset: number; // seconds, +later / -earlier (client-side cue shift) subOffset: number; // seconds, +later / -earlier (client-side cue shift)
@ -90,7 +90,6 @@ type PlexPlayerPrefs = {
const DEFAULT_PREFS: PlexPlayerPrefs = { const DEFAULT_PREFS: PlexPlayerPrefs = {
volume: 1, volume: 1,
muted: false, muted: false,
wasPlaying: true,
audioLang: "", audioLang: "",
subLang: "", subLang: "",
subOffset: 0, subOffset: 0,
@ -165,17 +164,20 @@ const HLS_SUB_LAG = 1.0;
// the queue instead of the item's own episode neighbours. // the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] }; type Props = { itemId: string; onClose: () => void; queue?: string[] };
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
function fmt(t: number): string { function fmt(t: number): string {
if (!isFinite(t) || t < 0) t = 0; return formatDuration(isFinite(t) && t >= 0 ? t : 0);
const s = Math.floor(t % 60);
const m = Math.floor((t / 60) % 60);
const h = Math.floor(t / 3600);
const pad = (n: number) => String(n).padStart(2, "0");
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
} }
export default function PlexPlayer({ itemId, onClose, queue }: Props) { export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
// through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change
// rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume
// position (losing live progress). See the detail effect's deps.
const tRef = useRef(t);
tRef.current = t;
const qc = useQueryClient(); const qc = useQueryClient();
const [id, setId] = useState(itemId); const [id, setId] = useState(itemId);
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
@ -217,7 +219,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Per-account persisted player prefs — survive F5, carry across items. // Per-account persisted player prefs — survive F5, carry across items.
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>( const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
"siftlode.plexPlayerPrefs", LS.plexPlayerPrefs,
DEFAULT_PREFS, DEFAULT_PREFS,
); );
const { volume, muted } = prefs; const { volume, muted } = prefs;
@ -288,10 +290,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// needs full transcoding (a later phase). // needs full transcoding (a later phase).
setLoadError( setLoadError(
e?.status === 501 e?.status === 501
? t("plex.player.errTranscode") ? tRef.current("plex.player.errTranscode")
: e?.status === 404 : e?.status === 404
? t("plex.player.errNotFound") ? tRef.current("plex.player.errNotFound")
: t("plex.player.errGeneric"), : tRef.current("plex.player.errGeneric"),
); );
return; return;
} }
@ -343,7 +345,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would // A fatal HLS error (e.g. the remux session died / the file became unreadable) would
// otherwise leave the spinner up forever — surface it instead. // otherwise leave the spinner up forever — surface it instead.
hls.on(Hls.Events.ERROR, (_e, data) => { hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setLoadError(t("plex.player.errGeneric")); if (data.fatal) setLoadError(tRef.current("plex.player.errGeneric"));
}); });
} else { } else {
// direct file (or Safari native HLS) // direct file (or Safari native HLS)
@ -358,7 +360,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.addEventListener("loadedmetadata", onMeta); video.addEventListener("loadedmetadata", onMeta);
} }
}, },
[id, t], [id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
); );
// Start playback when the detail arrives (resume from the saved position unless already watched). // Start playback when the detail arrives (resume from the saved position unless already watched).
@ -419,18 +421,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/* ignore */ /* ignore */
} }
}; };
const onPlay = () => { const onPlay = () => setPlaying(true);
setPlaying(true); const onPause = () => setPlaying(false);
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
};
const onPause = () => {
setPlaying(false);
patchPrefs({ wasPlaying: false });
};
// A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' — // A seek that restarts the hls.js session detaches/re-attaches the media, firing 'emptied' —
// which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the // which silently flips paused=true WITHOUT a 'pause' event, so the play/pause pair alone let the
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
// so the icon can never contradict the video. (Does not touch the wasPlaying pref.) // so the icon can never contradict the video.
const syncPlaying = () => setPlaying(!video.paused); const syncPlaying = () => setPlaying(!video.paused);
video.addEventListener("timeupdate", onTime); video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay); video.addEventListener("play", onPlay);
@ -851,6 +847,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const [skipProgress, setSkipProgress] = useState<number | null>(null); const [skipProgress, setSkipProgress] = useState<number | null>(null);
skipProgressRef.current = skipProgress; skipProgressRef.current = skipProgress;
const cancelledMarkerRef = useRef<string | null>(null); const cancelledMarkerRef = useRef<string | null>(null);
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
// the previous one (auto-skip silently dead for the rest of a binge).
useEffect(() => {
cancelledMarkerRef.current = null;
}, [id]);
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress // The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway. // alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
const skipTimerRef = useRef<number | null>(null); const skipTimerRef = useRef<number | null>(null);
@ -1222,6 +1224,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<div <div
ref={tracksRef} ref={tracksRef}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl" className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
> >
{detail.audio_streams.length > 1 && ( {detail.audio_streams.length > 1 && (

View file

@ -1,7 +1,8 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react";
import CollapsedFilterRail from "./CollapsedFilterRail";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
@ -115,34 +116,33 @@ export default function PlexSidebar({
)?.key; )?.key;
if (collapsed) { if (collapsed) {
return ( return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount}
</span>
)}
</button>
</aside>
);
} }
return ( return (
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4"> <aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
{/* Collapse control + active-filter count mirrors the feed Sidebar so the Plex filter panel
can be collapsed from the Plex page too (the collapsed rail + shared state already existed). */}
<div className="flex items-center gap-1.5 min-w-0">
<button
onClick={onToggleCollapse}
title={t("sidebar.collapsePanel")}
aria-label={t("sidebar.collapsePanel")}
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
{activeCount > 0 && (
<span
title={t("sidebar.activeCount", { count: activeCount })}
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
>
{activeCount}
</span>
)}
</div>
{/* Scope — one unified library, filtered to movies, shows, or both. */} {/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.scope")}> <Section label={t("plex.filter.scope")}>
<div className="flex gap-1"> <div className="flex gap-1">

View file

@ -5,9 +5,8 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./Modal"; import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api"; import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360]; const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
const BLANK: DownloadSpec = { const BLANK: DownloadSpec = {

View file

@ -20,7 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams, shareUrl } from "../lib/urlState"; import { filtersToParams, shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage"; import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which // Compact, order-independent signature of a filter set (reuses the share serializer, which
@ -28,14 +28,6 @@ import { useConfirm } from "./ConfirmProvider";
// saved view that matches the feed's current filters. // saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(f).toString(); const keyOf = (f: FeedFilters) => filtersToParams(f).toString();
/** The default view's filters, or null. Read synchronously on load (App.loadAccountFilters) from a
* per-account localStorage mirror the widget keeps in sync avoids a flash before the query loads.
* Per-account so one account's default view never seeds another account's feed. */
export function loadDefaultViewFilters(): FeedFilters | null {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
return key ? readJSON<FeedFilters | null>(key, null) : null;
}
function syncDefaultMirror(views: SavedView[]): void { function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount()); const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return; if (!key) return;

View file

@ -18,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -309,6 +310,7 @@ function Stat({
export default function Scheduler() { export default function Scheduler() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when // Poll faster while any job is running so live progress is smooth, then ease back when
// idle. Derived from the freshest data by react-query itself (see useLiveQuery). // idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, { const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
@ -411,7 +413,15 @@ export default function Scheduler() {
</Tooltip> </Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}> <Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<button <button
onClick={() => purgeMut.mutate()} onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending} disabled={purgeMut.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
> >

View file

@ -9,14 +9,14 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form"; import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard). // for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is // App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// how the panel reads/writes it. See App.tsx. // how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = "idle" | "saving" | "saved" | "error"; export type PrefsSaveState = SaveState;
export interface PrefsController { export interface PrefsController {
theme: ThemePrefs; theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void; setTheme: (t: ThemePrefs) => void;

View file

@ -7,10 +7,7 @@ import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls, btnCls } from "./ui/form";
const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
const EXPIRY_DAYS = [null, 1, 7, 30] as const; const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) ----------------------------------------------------- // --- share with a registered user (ACL) -----------------------------------------------------
@ -221,10 +218,10 @@ function LinkShare({ job }: { job: DownloadJob }) {
autoComplete="new-password" autoComplete="new-password"
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={() => setCreating(false)} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}> <button onClick={() => setCreating(false)} className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}>
{t("common.cancel")} {t("common.cancel")}
</button> </button>
<button onClick={() => create.mutate()} disabled={create.isPending} className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90")}> <button onClick={() => create.mutate()} disabled={create.isPending} className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90")}>
{t("downloads.share.createLink")} {t("downloads.share.createLink")}
</button> </button>
</div> </div>
@ -232,7 +229,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
) : ( ) : (
<button <button
onClick={() => setCreating(true)} onClick={() => setCreating(true)}
className={clsx(btn, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")} className={clsx(btnCls, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")}
> >
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")} <Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
</button> </button>

View file

@ -5,7 +5,6 @@ import {
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight,
Eye, Eye,
EyeOff, EyeOff,
GripVertical, GripVertical,
@ -13,7 +12,6 @@ import {
Pencil, Pencil,
RotateCcw, RotateCcw,
Share2, Share2,
SlidersHorizontal,
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -41,28 +39,11 @@ import {
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget"; import SavedViewsWidget from "./SavedViewsWidget";
import CollapsedFilterRail from "./CollapsedFilterRail";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [
"newest",
"oldest",
"views",
"duration_desc",
"duration_asc",
"title",
"subscribers",
"priority",
"shuffle",
];
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
// Fresh shuffle token for the "surprise me" sort.
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
const DATE_PRESETS: { days: number; key: string }[] = [ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" }, { days: 1, key: "24h" },
{ days: 7, key: "1week" }, { days: 7, key: "1week" },
@ -477,30 +458,7 @@ export default function Sidebar({
// Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that
// carries the active-filter count so you can tell filters are on without opening it. // carries the active-filter count so you can tell filters are on without opening it.
if (collapsed) { if (collapsed) {
return ( return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
} }
return ( return (
@ -721,19 +679,3 @@ function WidgetCard({
); );
} }
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span>
<Switch label={label} checked={checked} onChange={onChange} />
</label>
);
}

View file

@ -14,7 +14,7 @@ import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import clsx from "clsx"; import clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({ function Actions({
video, video,
@ -257,16 +257,7 @@ function VideoCard({
</> </>
)} )}
{relativeTime(video.published_at)} {relativeTime(video.published_at)}
{video.published_at && ( {video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
<>
{" · "}
{new Date(video.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})}
</>
)}
</> </>
); );
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines). // Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
@ -294,6 +285,23 @@ function VideoCard({
{video.title} {video.title}
</a> </a>
); );
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
const textBlock = (
<>
{title}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</>
);
if (view === "list") { if (view === "list") {
return ( return (
@ -304,19 +312,7 @@ function VideoCard({
)} )}
> >
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} /> <Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">{textBlock}</div>
{title}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
); );
@ -337,17 +333,7 @@ function VideoCard({
className="w-9 h-9 rounded-full shrink-0 mt-0.5" className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {textBlock}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
</div> </div>

View file

@ -6,6 +6,7 @@ import clsx from "clsx";
import Modal from "./Modal"; import Modal from "./Modal";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../lib/api"; import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { inputCls, btnCls } from "./ui/form";
// mm:ss.d timecode <-> seconds. // mm:ss.d timecode <-> seconds.
function tc(s: number): string { function tc(s: number): string {
@ -30,9 +31,6 @@ type Seg = { id: number; start: number; end: number; keep: boolean };
type Frac = { x: number; y: number; w: number; h: number }; type Frac = { x: number; y: number; w: number; h: number };
type Drag = { type: "seek" } | { type: "boundary"; bi: number }; type Drag = { type: "seek" } | { type: "boundary"; bi: number };
const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
const numCls = const numCls =
"w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40"; "w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40";
const MIN_SEG = 0.1; const MIN_SEG = 0.1;
@ -87,7 +85,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
if (!v) return; if (!v) return;
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur; const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
setDuration(d); setDuration(d);
setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s)); // Reconcile the still-pristine full-length segment to the REAL decoded duration. The initial
// segment ends at the (integer, often-rounded) metadata `srcDur`; once the media loads we know
// the true length (e.g. 213.44 vs 213). Without this the untouched last segment stays short, so
// the no-op guard (isFullSingle: end >= duration) reads false and enables a "Create" that files a
// clip of an unmodified video and drops its tail. Only touches the untouched segment (end===srcDur).
setSegments((s) => (s.length === 1 && s[0].end === srcDur ? [{ ...s[0], end: d }] : s));
}; };
const onTimeUpdate = () => { const onTimeUpdate = () => {
const v = videoRef.current; const v = videoRef.current;
@ -242,7 +245,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01; segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle); const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
const willJoin = output === "join" && kept.length > 1; const willJoin = output === "join" && kept.length > 1;
const reencode = cropOn || (willJoin ? accurate : accurate); const reencode = cropOn || accurate;
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => { const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
const v = videoRef.current; const v = videoRef.current;
@ -333,7 +336,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</button> </button>
<div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div> <div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div>
<div className="flex-1" /> <div className="flex-1" />
<button onClick={splitAtPlayhead} className={clsx(btn, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}> <button onClick={splitAtPlayhead} className={clsx(btnCls, "bg-surface hover:bg-card inline-flex items-center gap-1.5")}>
<SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")} <SplitSquareHorizontal className="w-4 h-4" /> {t("editor.splitHere")}
</button> </button>
</div> </div>
@ -363,7 +366,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</div> </div>
)} )}
{/* segment tint: dropped = dimmed + hatched */} {/* segment tint: dropped = dimmed + hatched */}
{segments.map((s, i) => ( {segments.map((s) => (
<div <div
key={s.id} key={s.id}
className={clsx( className={clsx(
@ -548,13 +551,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span> <span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={onClose} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}> <button onClick={onClose} className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}>
{t("common.cancel")} {t("common.cancel")}
</button> </button>
<button <button
onClick={() => create.mutate()} onClick={() => create.mutate()}
disabled={!valid || create.isPending} disabled={!valid || create.isPending}
className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")} className={clsx(btnCls, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
> >
{create.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Scissors className="w-4 h-4" />} {create.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Scissors className="w-4 h-4" />}
{willJoin {willJoin

View file

@ -0,0 +1,18 @@
import type { TFunction } from "i18next";
import { formatCountOrDash } from "../lib/format";
import type { Column } from "./DataTable";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
* channel row shapes. */
export function subsColumn<T extends { subscriber_count: number | null }>(t: TFunction): Column<T> {
return {
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => <span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>,
};
}

View file

@ -4,7 +4,7 @@
// with no react-query provider: the operator contact is fetched with a plain fetch below. // with no react-query provider: the operator contact is fetched with a plain fetch below.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const LAST_UPDATED = "June 14, 2026"; const LAST_UPDATED = "June 14, 2026";
// Contact shown on the legal pages: the instance operator's configured admin email, from the // Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none. // public /auth/config. One shared fetch across the page; null when the operator set none.
@ -19,7 +19,7 @@ function fetchOperatorContact(): Promise<string | null> {
return contactPromise; return contactPromise;
} }
export function useOperatorContact(): string | null { function useOperatorContact(): string | null {
const [contact, setContact] = useState<string | null>(null); const [contact, setContact] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;

View file

@ -4,6 +4,17 @@ import Tooltip from "../Tooltip";
// Shared form/settings primitives, factored out of the many panels that each re-declared // Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** The standard text/select/number input style (full-width, surface bg, accent focus ring) the
* literal string was copy-pasted into DownloadCenter/DownloadDialog/ProfileEditor/ShareDialog/
* VideoEditor. NOTE: the settings-family panels (Settings/Config/Setup/Welcome) use a DIFFERENT
* `bg-card rounded-xl focus:border-accent` style; unifying the two is a design call left to the
* glass-consistency epic, not this DRY extract. */
export const inputCls =
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
/** Pill on/off switch the bare control; compose it with your own label/row. Pass `label` for /** Pill on/off switch the bare control; compose it with your own label/row. Pass `label` for
* the accessible name (the visible row text) since the control itself has no inner text. */ * the accessible name (the visible row text) since the control itself has no inner text. */
export function Switch({ export function Switch({
@ -59,7 +70,7 @@ export function Section({
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set. /** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
* No-op styling when there's no hint, so it's safe to use unconditionally. */ * No-op styling when there's no hint, so it's safe to use unconditionally. */
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) { function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
return ( return (
<Tooltip hint={hint ?? ""}> <Tooltip hint={hint ?? ""}>
<span <span

View file

@ -10,7 +10,7 @@ export const LANGUAGES = [
] as const; ] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"]; export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
export const LANG_KEY = LS.lang; const LANG_KEY = LS.lang;
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one // Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring. // `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
@ -33,7 +33,7 @@ export function isSupported(code: string | null | undefined): code is LangCode {
// Initial language before the server preference is known: stored choice, else the browser // Initial language before the server preference is known: stored choice, else the browser
// language, else English. (After login, App adopts the server-persisted preference.) // language, else English. (After login, App adopts the server-persisted preference.)
export function detectInitialLang(): LangCode { function detectInitialLang(): LangCode {
const stored = localStorage.getItem(LANG_KEY); const stored = localStorage.getItem(LANG_KEY);
if (isSupported(stored)) return stored; if (isSupported(stored)) return stored;
const nav = (navigator.language || "en").slice(0, 2).toLowerCase(); const nav = (navigator.language || "en").slice(0, 2).toLowerCase();

View file

@ -1,6 +1,5 @@
{ {
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.", "intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
"filterPlaceholder": "Kanäle filtern…",
"syncSubscriptions": "Abos von YouTube lesen", "syncSubscriptions": "Abos von YouTube lesen",
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.", "syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
"backfillEverything": "Alles nachladen", "backfillEverything": "Alles nachladen",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Deine Tags", "yourTags": "Deine Tags",
"manage": "Tags verwalten", "manage": "Tags verwalten",
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)", "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)"
"newTag": "neuer Tag",
"createTag": "Tag erstellen"
}, },
"loading": "Kanäle werden geladen…", "loading": "Kanäle werden geladen…",
"empty": "Keine Kanäle.", "empty": "Keine Kanäle.",
@ -74,8 +71,6 @@
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)." "quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
}, },
"row": { "row": {
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen", "openOnYouTube": "Auf YouTube öffnen",
"editTags": "Tags bearbeiten", "editTags": "Tags bearbeiten",
"filterFeedByTag": "Feed nach „{{name}}“ filtern", "filterFeedByTag": "Feed nach „{{name}}“ filtern",
@ -98,8 +93,6 @@
"fullHistoryComing": "vollständiger Verlauf unterwegs", "fullHistoryComing": "vollständiger Verlauf unterwegs",
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
"getFullHistory": "vollständigen Verlauf laden",
"getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).",
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.", "hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.", "hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
"unhide": "Einblenden", "unhide": "Einblenden",

View file

@ -78,12 +78,6 @@
"copied": "Quell-Link in die Zwischenablage kopiert", "copied": "Quell-Link in die Zwischenablage kopiert",
"copyFailed": "Link konnte nicht kopiert werden" "copyFailed": "Link konnte nicht kopiert werden"
}, },
"rename": {
"title": "Download umbenennen",
"label": "Anzeigename",
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
"save": "Speichern"
},
"edit": { "edit": {
"title": "Details bearbeiten", "title": "Details bearbeiten",
"name": "Titel", "name": "Titel",

View file

@ -17,6 +17,7 @@
"encrypted": "Verschlüsselte Nachricht", "encrypted": "Verschlüsselte Nachricht",
"encryptedHint": "Ende-zu-Ende-verschlüsselt", "encryptedHint": "Ende-zu-Ende-verschlüsselt",
"cantDecrypt": "Kann nicht entschlüsselt werden", "cantDecrypt": "Kann nicht entschlüsselt werden",
"sendFailed": "Nachricht konnte nicht gesendet werden. Bitte erneut versuchen.",
"systemReadOnly": "Dies ist eine automatische Nachricht.", "systemReadOnly": "Dies ist eine automatische Nachricht.",
"setupTitle": "Sichere Nachrichten einrichten", "setupTitle": "Sichere Nachrichten einrichten",
"setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.", "setupBody": "Nachrichten sind Ende-zu-Ende-verschlüsselt. Wähle ein Passwort zum Schutz deines Schlüssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.",

View file

@ -12,7 +12,6 @@
"release": "Erscheinungsdatum" "release": "Erscheinungsdatum"
}, },
"filter": { "filter": {
"library": "Bibliothek",
"scope": "Bibliothek", "scope": "Bibliothek",
"scopeOpt": { "scopeOpt": {
"both": "Alle", "both": "Alle",
@ -67,7 +66,6 @@
"noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.", "noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.",
"loading": "Wird geladen…", "loading": "Wird geladen…",
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
"loadMore": "Mehr laden",
"watched": "Angesehen", "watched": "Angesehen",
"inProgress": "Läuft", "inProgress": "Läuft",
"play": "Abspielen", "play": "Abspielen",
@ -92,7 +90,6 @@
"markSeasonUnwatched": "Staffel als ungesehen", "markSeasonUnwatched": "Staffel als ungesehen",
"addShowCollection": "Zur Sammlung" "addShowCollection": "Zur Sammlung"
}, },
"playerSoon": "Player kommt bald — „{{title}}“",
"collEditor": { "collEditor": {
"manage": "Sammlungen", "manage": "Sammlungen",
"title": "Sammlungen — {{title}}", "title": "Sammlungen — {{title}}",
@ -218,9 +215,6 @@
"playAll": "Alle abspielen", "playAll": "Alle abspielen",
"delete": "Playlist löschen", "delete": "Playlist löschen",
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.", "empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
"up": "Nach oben",
"down": "Nach unten",
"remove": "Entfernen",
"layoutAccordion": "Akkordeon-Ansicht", "layoutAccordion": "Akkordeon-Ansicht",
"layoutTree": "Baum-Ansicht", "layoutTree": "Baum-Ansicht",
"removeShow": "Ganze Serie entfernen", "removeShow": "Ganze Serie entfernen",

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Entdeckung leeren", "purgeDiscovery": "Entdeckung leeren",
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.", "purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
"purgeConfirmTitle": "Entdeckungsinhalte löschen?",
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt" "purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
} }

View file

@ -1,6 +1,5 @@
{ {
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.", "intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
"filterPlaceholder": "Filter channels…",
"syncSubscriptions": "Read subscriptions from YouTube", "syncSubscriptions": "Read subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.", "syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything", "backfillEverything": "Backfill everything",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Your tags", "yourTags": "Your tags",
"manage": "Manage tags", "manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
"newTag": "new tag",
"createTag": "Create tag"
}, },
"loading": "Loading channels…", "loading": "Loading channels…",
"empty": "No channels.", "empty": "No channels.",
@ -74,8 +71,6 @@
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)." "quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
}, },
"row": { "row": {
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube", "openOnYouTube": "Open on YouTube",
"editTags": "Edit tags", "editTags": "Edit tags",
"filterFeedByTag": "Filter the feed by “{{name}}”", "filterFeedByTag": "Filter the feed by “{{name}}”",
@ -98,8 +93,6 @@
"fullHistoryComing": "full history coming", "fullHistoryComing": "full history coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"getFullHistory": "get full history",
"getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.", "hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.", "hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
"unhide": "Unhide", "unhide": "Unhide",

View file

@ -78,12 +78,6 @@
"copied": "Source link copied to clipboard", "copied": "Source link copied to clipboard",
"copyFailed": "Couldn't copy the link" "copyFailed": "Couldn't copy the link"
}, },
"rename": {
"title": "Rename download",
"label": "Display name",
"hint": "Only changes the name you see and download with — the stored file keeps its own name.",
"save": "Save"
},
"edit": { "edit": {
"title": "Edit details", "title": "Edit details",
"name": "Title", "name": "Title",

View file

@ -17,6 +17,7 @@
"encrypted": "Encrypted message", "encrypted": "Encrypted message",
"encryptedHint": "End-to-end encrypted", "encryptedHint": "End-to-end encrypted",
"cantDecrypt": "Can't decrypt", "cantDecrypt": "Can't decrypt",
"sendFailed": "Couldn't send your message. Try again.",
"systemReadOnly": "This is an automated message.", "systemReadOnly": "This is an automated message.",
"setupTitle": "Set up secure messaging", "setupTitle": "Set up secure messaging",
"setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.", "setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.",

View file

@ -12,7 +12,6 @@
"release": "Release date" "release": "Release date"
}, },
"filter": { "filter": {
"library": "Library",
"scope": "Library", "scope": "Library",
"scopeOpt": { "scopeOpt": {
"both": "All", "both": "All",
@ -67,7 +66,6 @@
"noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.", "noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.",
"loading": "Loading…", "loading": "Loading…",
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
"loadMore": "Load more",
"watched": "Watched", "watched": "Watched",
"inProgress": "In progress", "inProgress": "In progress",
"play": "Play", "play": "Play",
@ -92,7 +90,6 @@
"markSeasonUnwatched": "Mark season unwatched", "markSeasonUnwatched": "Mark season unwatched",
"addShowCollection": "Add to collection" "addShowCollection": "Add to collection"
}, },
"playerSoon": "Player coming soon — “{{title}}”",
"collEditor": { "collEditor": {
"manage": "Collections", "manage": "Collections",
"title": "Collections — {{title}}", "title": "Collections — {{title}}",
@ -218,9 +215,6 @@
"playAll": "Play all", "playAll": "Play all",
"delete": "Delete playlist", "delete": "Delete playlist",
"empty": "This playlist is empty. Add titles from their info page.", "empty": "This playlist is empty. Add titles from their info page.",
"up": "Move up",
"down": "Move down",
"remove": "Remove",
"layoutAccordion": "Accordion view", "layoutAccordion": "Accordion view",
"layoutTree": "Tree view", "layoutTree": "Tree view",
"removeShow": "Remove whole show", "removeShow": "Remove whole show",

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Purge discovery", "purgeDiscovery": "Purge discovery",
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Removed {{count}} discovery items" "purgedDiscovery": "Removed {{count}} discovery items"
} }

View file

@ -1,6 +1,5 @@
{ {
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.", "intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
"filterPlaceholder": "Csatornák szűrése…",
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról", "syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.", "syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
"backfillEverything": "Minden letöltése", "backfillEverything": "Minden letöltése",
@ -35,9 +34,7 @@
"tags": { "tags": {
"yourTags": "Címkéid", "yourTags": "Címkéid",
"manage": "Címkék kezelése", "manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
"newTag": "új címke",
"createTag": "Címke létrehozása"
}, },
"loading": "Csatornák betöltése…", "loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.", "empty": "Nincsenek csatornák.",
@ -74,8 +71,6 @@
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)." "quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
}, },
"row": { "row": {
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on", "openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése", "editTags": "Címkék szerkesztése",
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”", "filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
@ -98,8 +93,6 @@
"fullHistoryComing": "teljes előzmény úton", "fullHistoryComing": "teljes előzmény úton",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
"getFullHistory": "teljes előzmény lekérése",
"getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.", "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.", "hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
"unhide": "Megjelenítés", "unhide": "Megjelenítés",

View file

@ -78,12 +78,6 @@
"copied": "Forráslink a vágólapra másolva", "copied": "Forráslink a vágólapra másolva",
"copyFailed": "Nem sikerült a link másolása" "copyFailed": "Nem sikerült a link másolása"
}, },
"rename": {
"title": "Letöltés átnevezése",
"label": "Megjelenített név",
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
"save": "Mentés"
},
"edit": { "edit": {
"title": "Adatok szerkesztése", "title": "Adatok szerkesztése",
"name": "Cím", "name": "Cím",

View file

@ -17,6 +17,7 @@
"encrypted": "Titkosított üzenet", "encrypted": "Titkosított üzenet",
"encryptedHint": "Végpontig titkosítva", "encryptedHint": "Végpontig titkosítva",
"cantDecrypt": "Nem fejthető vissza", "cantDecrypt": "Nem fejthető vissza",
"sendFailed": "Nem sikerült elküldeni az üzenetet. Próbáld újra.",
"systemReadOnly": "Ez egy automatikus üzenet.", "systemReadOnly": "Ez egy automatikus üzenet.",
"setupTitle": "Biztonságos üzenetküldés beállítása", "setupTitle": "Biztonságos üzenetküldés beállítása",
"setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.", "setupBody": "Az üzenetek végpontig titkosítottak. Válassz egy jelszót a kulcsod védelméhez — sosem hagyja el az eszközödet, így senki (még az admin sem) olvashatja a beszélgetéseidet.",

View file

@ -12,7 +12,6 @@
"release": "Megjelenés dátuma" "release": "Megjelenés dátuma"
}, },
"filter": { "filter": {
"library": "Könyvtár",
"scope": "Könyvtár", "scope": "Könyvtár",
"scopeOpt": { "scopeOpt": {
"both": "Mind", "both": "Mind",
@ -67,7 +66,6 @@
"noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.", "noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.",
"loading": "Betöltés…", "loading": "Betöltés…",
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
"loadMore": "Több betöltése",
"watched": "Megnézve", "watched": "Megnézve",
"inProgress": "Folyamatban", "inProgress": "Folyamatban",
"play": "Lejátszás", "play": "Lejátszás",
@ -92,7 +90,6 @@
"markSeasonUnwatched": "Évad jelölés visszavonása", "markSeasonUnwatched": "Évad jelölés visszavonása",
"addShowCollection": "Kollekcióhoz adás" "addShowCollection": "Kollekcióhoz adás"
}, },
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
"collEditor": { "collEditor": {
"manage": "Kollekciók", "manage": "Kollekciók",
"title": "Kollekciók — {{title}}", "title": "Kollekciók — {{title}}",
@ -218,9 +215,6 @@
"playAll": "Összes lejátszása", "playAll": "Összes lejátszása",
"delete": "Lista törlése", "delete": "Lista törlése",
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.", "empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
"up": "Fel",
"down": "Le",
"remove": "Eltávolítás",
"layoutAccordion": "Harmonika nézet", "layoutAccordion": "Harmonika nézet",
"layoutTree": "Fa nézet", "layoutTree": "Fa nézet",
"removeShow": "Egész sorozat eltávolítása", "removeShow": "Egész sorozat eltávolítása",

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Felfedezés ürítése", "purgeDiscovery": "Felfedezés ürítése",
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva" "purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
} }

View file

@ -8,7 +8,7 @@ import { LS, setAccountRaw } from "./storage";
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull // WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
// it back into the main bundle and defeat the code-split. // it back into the main bundle and defeat the code-split.
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access"; const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);

View file

@ -78,7 +78,7 @@ export interface VideoDetail {
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ChannelLink { interface ChannelLink {
title: string | null; title: string | null;
url: string; url: string;
} }
@ -611,7 +611,7 @@ export interface SystemConfigData {
} }
// Admin: result of testing the Plex server connection (also drives the library-picker). // Admin: result of testing the Plex server connection (also drives the library-picker).
export interface PlexSection { interface PlexSection {
key: string; key: string;
title: string; title: string;
type: string; // "movie" | "show" type: string; // "movie" | "show"
@ -656,13 +656,6 @@ export interface PlexCard {
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // episode summary?: string | null; // episode
} }
export interface PlexBrowseResult {
kind: "movie" | "show";
total: number;
offset: number;
limit: number;
items: PlexCard[];
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that // Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section). // also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult { export interface PlexUnifiedResult {
@ -823,7 +816,7 @@ export function plexFilterCount(f: PlexFilters): number {
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are // and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
// each caller's own concern and stay out of here). // each caller's own concern and stay out of here).
export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.genres?.length) u.set("genres", f.genres.join(",")); if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all"); if (f.genreMode === "all") u.set("genre_mode", "all");
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(",")); if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
@ -903,7 +896,7 @@ export interface DownloadProfile {
sort_order: number; sort_order: number;
} }
export interface DownloadAsset { interface DownloadAsset {
id: number; id: number;
status: string; status: string;
title: string | null; title: string | null;

View file

@ -3,6 +3,7 @@
// when a message arrives) and stays open across page navigation. The dock state (which chats // when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload. // are open + their minimised state) is persisted per user so it survives a reload.
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { LS } from "./storage";
export interface DockChat { export interface DockChat {
partnerId: number; partnerId: number;
@ -20,7 +21,7 @@ let userId: number | null = null;
const subs = new Set<() => void>(); const subs = new Set<() => void>();
function storageKey(): string | null { function storageKey(): string | null {
return userId != null ? `siftlode.chatDock.${userId}` : null; return userId != null ? LS.chatDock(userId) : null;
} }
function persist(): void { function persist(): void {

View file

@ -0,0 +1,9 @@
import type { QueryClient } from "@tanstack/react-query";
// The download queue, the per-card download index, and the storage-usage meter all move together
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
}

View file

@ -46,12 +46,6 @@ export function isUnlocked(): boolean {
return !!privKey; return !!privKey;
} }
export function lock(): void {
privKey = null;
convKeys.clear();
emitUnlock();
}
// --- IndexedDB (per-device persistence of the unlocked private key) --- // --- IndexedDB (per-device persistence of the unlocked private key) ---
const DB_NAME = "siftlode-e2ee"; const DB_NAME = "siftlode-e2ee";
const STORE = "privkeys"; const STORE = "privkeys";
@ -127,7 +121,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
const wrapKey = await wrapKeyFrom(passphrase, salt); const wrapKey = await wrapKeyFrom(passphrase, salt);
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey); const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8)); const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8));
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(enc.encode("ok"))); // key_check is retained for the upload schema but MUST use its own nonce: reusing wrapIv under
// wrapKey would be GCM nonce-reuse (leaks keystream + enables GHASH forgery). It is no longer a
// verification oracle either — unlock() authenticates the passphrase via the wrapped-key GCM tag.
const checkIv = crypto.getRandomValues(new Uint8Array(12));
const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(checkIv) }, wrapKey, bsrc(enc.encode("ok")));
const spki = await subtle.exportKey("spki", kp.publicKey); const spki = await subtle.exportKey("spki", kp.publicKey);
privKey = await importPrivate(pkcs8); privKey = await importPrivate(pkcs8);
@ -149,8 +147,8 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
throw new Error("incomplete key bundle"); throw new Error("incomplete key bundle");
} }
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt)); const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
// Wrong passphrase → AES-GCM auth tag fails here. // Wrong passphrase → the AES-GCM auth tag fails on this decrypt. (No separate key_check oracle:
await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check))); // that was redundant with this tag and formerly shared wrapIv — see setup().)
const pkcs8 = await subtle.decrypt( const pkcs8 = await subtle.decrypt(
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, { name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
wrapKey, wrapKey,

View file

@ -5,7 +5,7 @@ import { createStore } from "./store";
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts // server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
// (transient, e.g. connection blips) and from silent handling. Module-level so the non-React // (transient, e.g. connection blips) and from silent handling. Module-level so the non-React
// api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one. // api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one.
export interface AppError { interface AppError {
title: string; title: string;
message: string; message: string;
} }

View file

@ -70,6 +70,15 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
} }
/** Compact human runtime ("1h 30m", "45m") from seconds used for Plex movie/episode lengths.
* Empty string for a missing/zero duration (nothing to show). */
export function formatRuntime(sec?: number | null): string {
if (!sec) return "";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw // from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
// key for any unmapped/legacy value. // key for any unmapped/legacy value.
@ -101,3 +110,15 @@ export function formatViews(n: number | null): string {
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
return String(n); return String(n);
} }
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
}
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
export function formatDate(iso: string | null | undefined, lang: string): string {
return iso
? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" })
: "";
}

View file

@ -5,7 +5,7 @@
import { getActiveAccount, type Message, type MessageUser } from "./api"; import { getActiveAccount, type Message, type MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window. // A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage { interface IncomingMessage {
message: Message; message: Message;
from?: MessageUser; from?: MessageUser;
to?: MessageUser; to?: MessageUser;

View file

@ -17,7 +17,7 @@ export async function partnerPub(partnerId: number): Promise<string> {
} }
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree. // Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
export function useUnlocked(): boolean { function useUnlocked(): boolean {
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked); return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
} }

View file

@ -6,7 +6,7 @@ import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
export interface NotifAction { interface NotifAction {
label: string; label: string;
onClick: () => void; onClick: () => void;
} }
@ -34,7 +34,7 @@ export type ChannelSubscribedMeta = {
}; };
// Admin nudge that pending access requests are waiting — carries a durable inbox link to the // Admin nudge that pending access requests are waiting — carries a durable inbox link to the
// Users page (where Access requests live). No payload; the panel provides the navigation. // Users page (where Access requests live). No payload; the panel provides the navigation.
export type AccessRequestsMeta = { type AccessRequestsMeta = {
kind: "access-requests"; kind: "access-requests";
}; };
export type NotifMeta = export type NotifMeta =
@ -223,11 +223,6 @@ export function notify(input: NotifyInput): number {
return id; return id;
} }
/** Back-compat: a simple info toast with an optional inline action (e.g. Undo). */
export function toast(message: string, action?: NotifAction): number {
return notify({ message, action });
}
/** Close the transient toast surface; the entry stays in the center's history. */ /** Close the transient toast surface; the entry stays in the center's history. */
export function dismiss(id: number): void { export function dismiss(id: number): void {
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n)); items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));

View file

@ -14,10 +14,10 @@
import { getAccountRaw, LS, setAccountRaw } from "./storage"; import { getAccountRaw, LS, setAccountRaw } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage) const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a // Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it. // different account in the same browser dismissed it.
export const ONBOARD_DISMISSED = LS.onboardingDismissed; const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: { export function shouldAutoOpenOnboarding(me: {
can_read: boolean; can_read: boolean;

View file

@ -14,6 +14,14 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.38.0",
date: "2026-07-12",
summary: "The Plex filter sidebar can now be collapsed, plus a large internal cleanup.",
fixes: [
"The Plex library no longer stops loading more titles after you open one and go back.",
],
},
{ {
version: "0.37.0", version: "0.37.0",
date: "2026-07-11", date: "2026-07-11",

View file

@ -8,15 +8,7 @@ import { LS, readAccount, writeAccount } from "./storage";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags"; export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"]; const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"];
export const WIDGET_TITLES: Record<WidgetId, string> = {
savedviews: "Saved views",
date: "Upload date",
language: "Language",
topic: "Topic",
tags: "Tags",
};
export interface SidebarLayout { export interface SidebarLayout {
order: WidgetId[]; order: WidgetId[];

View file

@ -16,7 +16,10 @@ export const LS = {
perfMode: "siftlode.perfMode", perfMode: "siftlode.perfMode",
channelFilter: "siftlode.channelFilter", channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView", channelsView: "siftlode.channelsView",
channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab", settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
statsTab: "siftlode.statsTab", statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab", adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed", navCollapsed: "siftlode.navCollapsed",
@ -29,6 +32,7 @@ export const LS = {
plexFilters: "siftlode.plexFilters", plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ", plexQ: "siftlode.plexQ",
plexPlaylistLayout: "siftlode.plexPlaylistLayout", plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
notifHistory: "siftlode.notifications", notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings", notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed", onboardingDismissed: "siftlode.onboarding.dismissed",
@ -125,29 +129,11 @@ export function writeJSON(key: string, value: unknown): void {
// --- reactive persisted string state ---------------------------------------------------- // --- reactive persisted string state ----------------------------------------------------
/** A `useState` whose string value is mirrored to localStorage, so it survives a reload (F5). /** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* Validation is deferred to the caller (clamp at render) so it can be used before an * accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* async-loaded option list is known, keeping hook order stable. Generalizes the per-component * same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
* "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter). */ * can be used before an async-loaded option list is known, keeping hook order stable. These hooks
export function usePersistedState( * run in components that only mount once signed in, so the account is known. */
key: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => localStorage.getItem(key) ?? fallback);
const set = (next: string) => {
setValue(next);
try {
localStorage.setItem(key, next);
} catch {
/* ignore */
}
};
return [value, set];
}
/** Like usePersistedState but scoped to the current account (see accountKey), so one account's
* persisted tab/view position doesn't carry over to another in the same browser. These hooks run
* in components that only mount once signed in, so the account is known. */
export function useAccountPersistedState( export function useAccountPersistedState(
base: string, base: string,
fallback = "", fallback = "",

View file

@ -1,6 +1,6 @@
import { LS, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccountMerged, writeAccount } from "./storage";
export type Mode = "dark" | "light"; type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; export type Scheme = "midnight" | "forest" | "slate" | "youtube";
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [ export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [

View file

@ -89,7 +89,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
// The single source of truth for valid page ids; `Page` and the runtime validator both // The single source of truth for valid page ids; `Page` and the runtime validator both
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in // derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback. // sync). "feed" is the default/fallback.
export const PAGES = [ const PAGES = [
"feed", "feed",
"channels", "channels",
"stats", "stats",

View file

@ -0,0 +1,25 @@
import { HttpError } from "./api";
import { notify } from "./notifications";
import i18n from "../i18n";
// A YouTube-gated action (subscribe, sync, backfill, unsubscribe) that 403s means the user hasn't
// granted the write scope — surface a "Connect" affordance instead of a vague failure. Pass
// `onConnect` to offer the connect-wizard button (callers that have no wizard handle, e.g. the
// channel page, just get the message). `fallbackMessage` is the already-translated non-403 error.
export function notifyYouTubeActionError(
err: unknown,
fallbackMessage: string,
onConnect?: () => void,
): void {
if (err instanceof HttpError && err.status === 403) {
notify({
level: "error",
message: i18n.t("channels.notify.needYouTube"),
action: onConnect
? { label: i18n.t("channels.notify.connect"), onClick: onConnect }
: undefined,
});
} else {
notify({ level: "error", message: fallbackMessage });
}
}

View file

@ -12,8 +12,8 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": true,
"noUnusedParameters": false "noUnusedParameters": true
}, },
"include": ["src"] "include": ["src"]
} }