Merge: promote dev to prod
This commit is contained in:
commit
f3d5269c40
95 changed files with 1001 additions and 888 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.37.0
|
||||
0.38.0
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ from app.utils import valid_email
|
|||
PASSWORD_MIN_LEN = 10
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
# A throwaway argon2 hash to verify against when the email is unknown / has no password, so a
|
||||
# login attempt pays the same KDF cost either way and its timing can't reveal whether the account
|
||||
# exists (anti-enumeration). Computed once at startup; it never matches a real password.
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
|
|
@ -276,6 +280,12 @@ async def callback(
|
|||
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
||||
log.warning("Google login email collision (different sub): %s", email)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
if not userinfo.get("email_verified", False):
|
||||
# Adopting (and activating) a pre-existing password account requires Google to
|
||||
# actually attest the address — otherwise an unverified Google email that merely
|
||||
# collides with a pending registration could seize that account. Defense-in-depth.
|
||||
log.warning("Google login: unverified email, refusing to adopt account: %s", email)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
user = existing
|
||||
user.google_sub = sub
|
||||
# Google verified the email and is_allowed granted access, so finish activating a
|
||||
|
|
@ -291,7 +301,13 @@ async def callback(
|
|||
log.info("Suspended account blocked at Google login: %s", email)
|
||||
_notify_suspended(background, user.email)
|
||||
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.avatar_url = userinfo.get("picture")
|
||||
# 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)
|
||||
if token.get("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")
|
||||
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
|
||||
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()
|
||||
if valid_email(email) and is_demo_allowed(db, email):
|
||||
demo = get_or_create_demo_user(db)
|
||||
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
|
||||
# can't switch to / act as a previously signed-in account via the multi-account header.
|
||||
request.session.clear()
|
||||
request.session["user_id"] = demo.id
|
||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||
return {"authenticated": True}
|
||||
|
|
@ -603,7 +624,11 @@ def password_login(
|
|||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
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.")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user.email)
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ class Settings(BaseSettings):
|
|||
plex_watch_reconcile_interval_min: int = 1440
|
||||
# Max concurrent transcodes for the P3 fallback. Low by default — CPU-only transcode is
|
||||
# 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`
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
trim = spec.get("trim") or {}
|
||||
if trim:
|
||||
start = max(0.0, float(trim.get("start_s") or 0.0))
|
||||
end_raw = trim.get("end_s")
|
||||
# Guard the numeric coercion like the crop/segments branches do — a malformed value must
|
||||
# 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)}
|
||||
if end_raw is not None:
|
||||
end = float(end_raw)
|
||||
if end > start:
|
||||
t["end_s"] = round(end, 3)
|
||||
if end is not None and end > start:
|
||||
t["end_s"] = round(end, 3)
|
||||
# A trim with only a start (open-ended) is valid (cut to the end).
|
||||
if t.get("start_s") or "end_s" in t:
|
||||
out["trim"] = t
|
||||
|
|
|
|||
|
|
@ -62,7 +62,12 @@ def normalize(spec: dict) -> dict:
|
|||
if s["mode"] not in ("av", "v", "a"):
|
||||
s["mode"] = "av"
|
||||
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"):
|
||||
s[b] = bool(s[b])
|
||||
# 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]
|
||||
|
||||
|
||||
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(
|
||||
spec: dict,
|
||||
outtmpl: str,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
from app.downloads import service
|
||||
|
||||
source_url = None
|
||||
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
|
||||
)
|
||||
source_url = service.reference_url(job, asset)
|
||||
return {
|
||||
"title": (job and job.display_name) or asset.title,
|
||||
"uploader": (job and job.display_uploader) or asset.uploader,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,20 @@ def source_url(source_kind: str, source_ref: str) -> str:
|
|||
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(
|
||||
db: Session, source_kind: str, source_ref: str, format_sig: str
|
||||
) -> MediaAsset:
|
||||
|
|
@ -46,6 +60,14 @@ def get_or_create_asset(
|
|||
)
|
||||
asset = db.execute(stmt).scalar_one_or_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
|
||||
asset = MediaAsset(
|
||||
source_kind=source_kind,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
"""Move a completed staging file into its final location under DOWNLOAD_ROOT."""
|
||||
dest = abs_path(download_root, rel)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,11 @@ class Subscription(Base, TimestampMixin):
|
|||
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):
|
||||
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
||||
|
||||
|
|
|
|||
|
|
@ -217,27 +217,42 @@ class PlexClient:
|
|||
return self._get("/accounts").get("Account", []) or []
|
||||
|
||||
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]:
|
||||
"""The most recent watch-history rows (ratingKey / viewedAt / accountID / type), newest
|
||||
first — works without Plex Pass. Filtered client-side to `account_id` and to rows at/after
|
||||
`min_viewed_at` (epoch seconds): the efficient "what changed since T" feed. Sorted desc, so
|
||||
we stop at the first row older than the cutoff."""
|
||||
mc = self._get(
|
||||
"/status/sessions/history/all",
|
||||
params={
|
||||
"sort": "viewedAt:desc",
|
||||
"X-Plex-Container-Start": 0,
|
||||
"X-Plex-Container-Size": size,
|
||||
},
|
||||
)
|
||||
out = []
|
||||
for r in mc.get("Metadata", []) or []:
|
||||
if int(r.get("viewedAt") or 0) < min_viewed_at:
|
||||
"""The watch-history rows (ratingKey / viewedAt / accountID / type) newer than `min_viewed_at`
|
||||
(epoch seconds), newest first — works without Plex Pass. The efficient "what changed since T"
|
||||
feed, filtered client-side to `account_id`.
|
||||
|
||||
The history is a GLOBAL feed across all server accounts, so paginate (sorted desc) until we
|
||||
reach a row at/older than the cutoff: on a busy family server the owner's recent views can sit
|
||||
past the first page behind other accounts' rows. `max_pages` bounds the scan (the daily full
|
||||
reconcile is the backstop for anything beyond it)."""
|
||||
out: list[dict] = []
|
||||
for page in range(max_pages):
|
||||
mc = self._get(
|
||||
"/status/sessions/history/all",
|
||||
params={
|
||||
"sort": "viewedAt:desc",
|
||||
"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
|
||||
if account_id is not None and int(r.get("accountID") or -1) != account_id:
|
||||
continue
|
||||
out.append(r)
|
||||
else:
|
||||
log.warning(
|
||||
"watch_history hit the %d-page scan cap (since=%s); reconcile will catch the tail",
|
||||
max_pages, min_viewed_at,
|
||||
)
|
||||
return out
|
||||
|
||||
def on_deck(self) -> list[dict]:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from pathlib import Path
|
|||
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.models import PlexItem
|
||||
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"
|
||||
_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
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
|
@ -168,11 +168,11 @@ def _kill(s: HlsSession) -> None:
|
|||
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.
|
||||
if len(_sessions) <= _MAX_SESSIONS:
|
||||
if len(_sessions) <= cap:
|
||||
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)
|
||||
_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")
|
||||
_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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -166,6 +166,21 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
|
|||
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:
|
||||
row.library_id = lib_id
|
||||
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.art_key = meta.get("art") or meta.get("grandparentArt")
|
||||
row.added_at = _epoch(meta.get("addedAt"))
|
||||
# Filterable / orderable extras — all present in the cheap section listing.
|
||||
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
|
||||
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
||||
mf = _media_facts(meta)
|
||||
row.file_path = mf.get("file_path")
|
||||
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.child_count = meta.get("childCount")
|
||||
sh.added_at = _epoch(meta.get("addedAt"))
|
||||
# Filterable / orderable metadata — same cheap section-listing 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
|
||||
_apply_facet_fields(sh, meta) # same filterable/orderable tags as movies
|
||||
stats["shows"] += 1
|
||||
db.flush()
|
||||
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()
|
||||
|
||||
|
||||
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 = db.query(PlexCollection).filter_by(rating_key=rk).first() or PlexCollection(rating_key=rk)
|
||||
def _upsert_collection(
|
||||
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
|
||||
) -> PlexCollection:
|
||||
"""Get-or-create a PlexCollection row and apply its metadata from a Plex `meta` dict. `editable`
|
||||
is user-managed and preserved (never set here). Shared by the full sync + the targeted re-sync."""
|
||||
col = existing or PlexCollection(rating_key=rk)
|
||||
if col.id is None:
|
||||
db.add(col)
|
||||
col.library_id = lib.id
|
||||
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"
|
||||
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
|
||||
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()
|
||||
|
|
@ -333,16 +340,7 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
|
|||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
col = existing.get(rk) or PlexCollection(rating_key=rk)
|
||||
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)
|
||||
_upsert_collection(db, existing.get(rk), rk, lib.id, meta)
|
||||
seen.add(rk)
|
||||
for child in plex.collection_children(rk):
|
||||
crk = str(child.get("ratingKey") or "")
|
||||
|
|
|
|||
|
|
@ -59,15 +59,18 @@ def _plex_watch_to_state(
|
|||
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(
|
||||
db: Session, plex: PlexClient
|
||||
) -> 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,
|
||||
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)."""
|
||||
item_id_by_rk: dict[str, int] = {
|
||||
rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)
|
||||
}
|
||||
item_id_by_rk = _rk_to_id(db)
|
||||
wanted = _enabled_section_keys(db)
|
||||
scanned = 0
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
user_id: int,
|
||||
item_id: int,
|
||||
|
|
@ -172,6 +184,7 @@ def push_state_to_plex(
|
|||
action: str,
|
||||
time_ms: int = 0,
|
||||
duration_ms: int = 0,
|
||||
expect_ts: datetime | None = None,
|
||||
) -> None:
|
||||
"""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
|
||||
|
|
@ -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)
|
||||
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:
|
||||
if link_for_push(db, user_id) is None:
|
||||
return
|
||||
|
|
@ -199,13 +215,24 @@ def push_state_to_plex(
|
|||
)
|
||||
return
|
||||
# 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()
|
||||
if st is not None:
|
||||
if st is not None and _row_matches(st, expect_ts):
|
||||
st.synced_to_plex = True
|
||||
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:
|
||||
"""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
|
||||
|
|
@ -271,8 +298,10 @@ def _active_sync_links(db: Session) -> list[PlexLink]:
|
|||
|
||||
|
||||
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
|
||||
server; we confirm + grab their username from /accounts, falling back to 1 if unavailable."""
|
||||
"""The owner's Plex accountID, cached on the link. On a Plex Media Server, accountID 1 is reserved
|
||||
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:
|
||||
return link.plex_account_id
|
||||
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.
|
||||
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."""
|
||||
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 = (
|
||||
db.query(PlexState)
|
||||
.filter(
|
||||
|
|
@ -347,15 +381,15 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
|||
)
|
||||
n = 0
|
||||
for st in dirty:
|
||||
rk = rk_by_id.get(st.item_id)
|
||||
if rk is None:
|
||||
info = info_by_id.get(st.item_id)
|
||||
if info is None:
|
||||
continue
|
||||
rk, dur_s = info
|
||||
try:
|
||||
if st.status == "watched":
|
||||
plex.scrobble(rk)
|
||||
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, dur)
|
||||
plex.set_timeline(rk, st.position_seconds * 1000, int(dur_s or 0) * 1000)
|
||||
else:
|
||||
plex.unscrobble(rk)
|
||||
st.synced_to_plex = True
|
||||
|
|
@ -382,7 +416,7 @@ def run_plex_watch_sync(db: Session) -> dict:
|
|||
acct = _resolve_account_id(db, plex, link)
|
||||
since = int(link.last_watch_sync_at.timestamp()) if link.last_watch_sync_at else 0
|
||||
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):
|
||||
iid = rk_to_id.get(str(row.get("ratingKey") or ""))
|
||||
|
|
|
|||
|
|
@ -166,7 +166,12 @@ def set_user_role(
|
|||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
||||
if target.id == admin.id:
|
||||
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.")
|
||||
changed = target.role != role
|
||||
target.role = role
|
||||
|
|
@ -233,7 +238,7 @@ def admin_delete_user(
|
|||
raise HTTPException(
|
||||
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.")
|
||||
purge_user(db, target, background)
|
||||
return {"deleted": user_id}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import logging
|
|||
from datetime import datetime, timezone
|
||||
|
||||
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 app import quota, sysconfig
|
||||
from app.auth import current_user, has_write_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
LIVE_OR_UPCOMING,
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
ChannelTag,
|
||||
|
|
@ -59,7 +60,7 @@ def list_channels(
|
|||
func.max(Video.published_at),
|
||||
func.coalesce(func.sum(Video.duration_seconds), 0),
|
||||
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))
|
||||
.group_by(Video.channel_id)
|
||||
|
|
@ -100,12 +101,7 @@ def list_channels(
|
|||
|
||||
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,
|
||||
**_channel_summary(ch),
|
||||
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
||||
"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),
|
||||
|
|
@ -194,12 +190,7 @@ def discover_channels(
|
|||
|
||||
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,
|
||||
**_channel_summary(ch),
|
||||
"playlist_video_count": int(vid_count),
|
||||
"playlist_count": int(pl_count),
|
||||
"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(
|
||||
channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int
|
||||
) -> dict:
|
||||
|
|
@ -267,11 +270,7 @@ def channel_detail(
|
|||
ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id
|
||||
)
|
||||
).first() is not None
|
||||
blocked = db.execute(
|
||||
select(BlockedChannel.id).where(
|
||||
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||
)
|
||||
).first() is not None
|
||||
blocked = _is_blocked(db, user, channel_id)
|
||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
||||
return _channel_detail_dict(
|
||||
channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored)
|
||||
|
|
@ -294,11 +293,7 @@ def explore_channel(
|
|||
channel = db.get(Channel, channel_id)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
if db.execute(
|
||||
select(BlockedChannel.id).where(
|
||||
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||
)
|
||||
).first() is not None:
|
||||
if _is_blocked(db, user, channel_id):
|
||||
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
||||
raise HTTPException(
|
||||
|
|
@ -358,6 +353,19 @@ def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription
|
|||
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}")
|
||||
def update_channel(
|
||||
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."""
|
||||
if db.get(Channel, channel_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||
exists = db.execute(
|
||||
select(BlockedChannel.id).where(
|
||||
BlockedChannel.user_id == user.id, BlockedChannel.channel_id == channel_id
|
||||
)
|
||||
).first()
|
||||
if exists is None:
|
||||
if not _is_blocked(db, user, 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.
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ serves finished files (range-aware, with the user's custom display name), and sh
|
|||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -15,6 +14,7 @@ from fastapi.responses import FileResponse
|
|||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.auth import admin_user, require_human
|
||||
from app.config import settings
|
||||
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.")
|
||||
|
||||
|
||||
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:
|
||||
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
||||
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,
|
||||
"source_kind": job.source_kind,
|
||||
"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.
|
||||
"poster_url": f"/api/downloads/{job.id}/poster.jpg"
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None or job.user_id != user.id:
|
||||
|
|
@ -273,8 +266,7 @@ def enqueue_download(
|
|||
)
|
||||
except quota.QuotaExceeded as 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, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
|
|
@ -299,8 +291,7 @@ def enqueue_edit(
|
|||
raise HTTPException(status_code=422, detail=_quota_message(e))
|
||||
except service.EditError as 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, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
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]
|
||||
job.extra_links = cleaned or None
|
||||
db.commit()
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
return _serialize(job, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
def _set_status(db: Session, job: DownloadJob, status: str) -> None:
|
||||
|
|
@ -446,8 +436,7 @@ def pause_download(
|
|||
job = _own_job(db, user, job_id)
|
||||
if job.status in ("queued", "running"):
|
||||
_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, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/resume")
|
||||
|
|
@ -467,8 +456,7 @@ def resume_download(
|
|||
asset.status = "pending"
|
||||
asset.error = None
|
||||
_set_status(db, job, "queued")
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
return _serialize(job, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel")
|
||||
|
|
@ -480,8 +468,7 @@ def cancel_download(
|
|||
_release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = "canceled"
|
||||
db.commit()
|
||||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
return _serialize(job, asset)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@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
|
||||
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||
and the shared cache only needs to span *overlapping* holders (a later re-add just downloads
|
||||
again). Idempotent: a job that's already left holding (canceled/error) is a no-op, so delete
|
||||
after cancel doesn't double-release."""
|
||||
if not job.asset_id or job.status not in ("queued", "running", "paused", "done"):
|
||||
again). `error` counts as holding: enqueue always +1'd ref_count and the worker never
|
||||
decrements on failure (ref_count is the route's job), so an errored job releases here on
|
||||
delete — else its +1 leaks and a later resume→ready keeps the file pinned past its last holder.
|
||||
Idempotent for the cancel path: cancel already released while the job was holding, then set it
|
||||
`canceled` (∉ the set below), so delete-after-cancel is a no-op and never double-releases.
|
||||
|
||||
We DON'T delete an errored asset row here even at ref==0: another request may be concurrently
|
||||
re-enqueuing the same (source,format) — get_or_create_asset would reset that row to `pending` and
|
||||
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
||||
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
||||
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
|
||||
if not job.asset_id or job.status not in ("queued", "running", "paused", "done", "error"):
|
||||
return
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset is None:
|
||||
|
|
@ -515,11 +511,6 @@ def _release_asset(db: Session, job: DownloadJob) -> None:
|
|||
|
||||
# --- 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:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
|
|
@ -547,16 +538,13 @@ def download_file(
|
|||
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:
|
||||
raise HTTPException(status_code=409, detail="This download isn't ready.")
|
||||
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="File is no longer available.")
|
||||
|
||||
ext = asset.container or path.suffix.lstrip(".")
|
||||
base = _clean_basename(job.display_name or asset.title or asset.source_ref)
|
||||
if base.lower().endswith(f".{ext.lower()}"):
|
||||
base = base[: -(len(ext) + 1)]
|
||||
filename = f"{base}.{ext}"
|
||||
filename = storage.download_filename(
|
||||
job.display_name or asset.title or asset.source_ref, asset.container, path
|
||||
)
|
||||
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
|
@ -601,9 +589,8 @@ def poster_image(
|
|||
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
|
||||
if asset is None or not asset.poster_path:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
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
|
||||
if asset is None or not asset.storyboard_path:
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
path = storage.abs_path(settings.download_root, asset.storyboard_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No filmstrip.")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
|
@ -675,7 +661,7 @@ def unshare_download(
|
|||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> 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(
|
||||
select(User).where(func.lower(User.email) == email.strip().lower())
|
||||
).scalar_one_or_none()
|
||||
|
|
@ -766,7 +752,7 @@ def list_links(
|
|||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [linksmod.owner_view(l) for l in rows]
|
||||
return [linksmod.owner_view(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/{job_id}/links")
|
||||
|
|
@ -876,7 +862,7 @@ def admin_storage(
|
|||
"ready_files": ready[0],
|
||||
"total_bytes": int(ready[1]),
|
||||
"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": [
|
||||
{"user_id": uid, "email": emails.get(uid), "footprint_bytes": quota.footprint(db, uid)}
|
||||
for uid, _ in per_user
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from app import quota
|
|||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
LIVE_OR_UPCOMING,
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
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.
|
||||
VALID_STATES = {"new", "watched", "hidden"}
|
||||
HIDDEN_LIVE = ("live", "upcoming")
|
||||
|
||||
# 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
|
||||
|
|
@ -132,7 +132,7 @@ def _filtered_query(
|
|||
exclude_tag_category: str | None = None,
|
||||
) -> tuple[Select, object]:
|
||||
"""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="all"` shows every video in the shared catalog (any user's ingested channels);
|
||||
|
|
@ -313,12 +313,12 @@ def _filtered_query(
|
|||
type_clauses = []
|
||||
if show_normal:
|
||||
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:
|
||||
type_clauses.append(Video.is_short.is_(True))
|
||||
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())
|
||||
|
||||
if show == "unwatched":
|
||||
|
|
@ -335,7 +335,7 @@ def _filtered_query(
|
|||
else: # all
|
||||
query = query.where(status_expr != "hidden")
|
||||
|
||||
return query, status_expr, rank_expr
|
||||
return query, rank_expr
|
||||
|
||||
|
||||
def _to_tsquery_str(q: str) -> str | None:
|
||||
|
|
@ -492,7 +492,7 @@ def get_feed(
|
|||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> 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)
|
||||
|
||||
# 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),
|
||||
db: Session = Depends(get_db),
|
||||
) -> 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()))
|
||||
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
|
||||
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||
base, _status, _rank = _filtered_query(
|
||||
base, _rank = _filtered_query(
|
||||
db,
|
||||
user,
|
||||
**{**params, "exclude_tag_category": None if conjunctive else category},
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ def switch_account(
|
|||
raise HTTPException(status_code=404, detail="That account no longer exists.")
|
||||
if not is_allowed(db, u.email):
|
||||
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
|
||||
return {"ok": True, "user_id": target}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
|||
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(
|
||||
db: Session,
|
||||
pl: Playlist,
|
||||
|
|
@ -239,14 +256,7 @@ def remove_watch_later(
|
|||
)
|
||||
)
|
||||
if pl is not None:
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if item is not None:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
_remove_item(db, pl, video_id, mark_dirty=False)
|
||||
return {"saved": False}
|
||||
|
||||
|
||||
|
|
@ -452,15 +462,7 @@ def remove_item(
|
|||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist(db, user, playlist_id)
|
||||
item = db.scalar(
|
||||
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()
|
||||
_remove_item(db, pl, video_id, mark_dirty=True)
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
||||
|
|
@ -480,11 +482,20 @@ def reorder_items(
|
|||
).scalars()
|
||||
}
|
||||
pos = 0
|
||||
seen: set[str] = set()
|
||||
for vid in order:
|
||||
it = items.get(vid)
|
||||
if it is not None:
|
||||
it.position = pos
|
||||
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)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import tempfile
|
|||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
|
@ -197,12 +198,6 @@ def watch_import_now(
|
|||
# --- 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")
|
||||
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)."""
|
||||
|
|
@ -392,6 +387,37 @@ def _apply_meta_filters(q, model, p: dict):
|
|||
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")
|
||||
def unified_library(
|
||||
scope: str = "both",
|
||||
|
|
@ -401,19 +427,7 @@ def unified_library(
|
|||
show: str = "all",
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=40, ge=1, le=100),
|
||||
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,
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
|
|
@ -422,12 +436,7 @@ def unified_library(
|
|||
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)."""
|
||||
offset = max(0, offset)
|
||||
p = {
|
||||
"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,
|
||||
}
|
||||
p = f.as_dict()
|
||||
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
|
||||
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"]
|
||||
|
|
@ -473,10 +482,10 @@ def unified_library(
|
|||
else:
|
||||
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||
mq = _apply_meta_filters(mq, PlexItem, p)
|
||||
if duration_min is not None:
|
||||
mq = mq.filter(PlexItem.duration_s >= duration_min)
|
||||
if duration_max is not None:
|
||||
mq = mq.filter(PlexItem.duration_s <= duration_max)
|
||||
if f.duration_min is not None:
|
||||
mq = mq.filter(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
mq = mq.filter(PlexItem.duration_s <= f.duration_max)
|
||||
if tsq is not None:
|
||||
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
|
||||
selects.append(mq)
|
||||
|
|
@ -582,19 +591,7 @@ def unified_library(
|
|||
def facets(
|
||||
scope: str = "both",
|
||||
show: str = "all",
|
||||
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,
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> 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 []
|
||||
if not movie_ids and not show_ids:
|
||||
return empty
|
||||
p = {
|
||||
"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,
|
||||
}
|
||||
p = f.as_dict()
|
||||
|
||||
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 —
|
||||
# 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).
|
||||
for g in _csv(genres):
|
||||
for g in _csv(f.genres):
|
||||
genre_counts.setdefault(g, 0)
|
||||
return {
|
||||
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
|
||||
|
|
@ -1354,11 +1346,15 @@ def _vtt_ts_to_s(ts: str) -> float:
|
|||
|
||||
def _vtt_s_to_ts(x: float) -> str:
|
||||
x = max(0.0, x)
|
||||
h = int(x // 3600); x -= h * 3600
|
||||
m = int(x // 60); x -= m * 60
|
||||
s = int(x); ms = int(round((x - s) * 1000))
|
||||
h = int(x // 3600)
|
||||
x -= h * 3600
|
||||
m = int(x // 60)
|
||||
x -= m * 60
|
||||
s = int(x)
|
||||
ms = int(round((x - s) * 1000))
|
||||
if ms >= 1000:
|
||||
s += 1; ms -= 1000
|
||||
s += 1
|
||||
ms -= 1000
|
||||
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."""
|
||||
if plex_watch.link_for_push(db, user_id) is None:
|
||||
return
|
||||
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
||||
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
|
||||
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
||||
background.add_task(
|
||||
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms
|
||||
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms,
|
||||
plex_watch._state_marker(st),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.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.")
|
||||
# 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.
|
||||
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
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):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
asset = _ready_asset(db, link)
|
||||
path = storage.abs_path(settings.download_root, asset.rel_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="This video is no longer available.")
|
||||
|
||||
ext = asset.container or path.suffix.lstrip(".")
|
||||
base = storage.display_filename(asset.title or "video")
|
||||
if base.lower().endswith(f".{ext.lower()}"):
|
||||
base = base[: -(len(ext) + 1)]
|
||||
filename = f"{base}.{ext}"
|
||||
filename = storage.download_filename(asset.title or "video", asset.container, path)
|
||||
disposition = "attachment" if link.allow_download else "inline"
|
||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||
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)
|
||||
if not asset.poster_path:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
|
||||
root = Path(settings.download_root).resolve()
|
||||
if root not in path.parents or not path.exists():
|
||||
path = storage.safe_abs_path(settings.download_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="No poster.")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from app import quota, sysconfig
|
|||
from app.auth import require_human
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
LIVE_OR_UPCOMING,
|
||||
BlockedChannel,
|
||||
Channel,
|
||||
Playlist,
|
||||
|
|
@ -45,8 +46,6 @@ router = APIRouter(prefix="/api/search", tags=["search"])
|
|||
|
||||
# search.list costs this many quota units per page.
|
||||
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:
|
||||
|
|
@ -165,7 +164,7 @@ def _ingest_candidates(
|
|||
# 4) Confirm/deny Shorts (no quota) so none enter via search.
|
||||
_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]
|
||||
|
||||
|
||||
|
|
@ -254,8 +253,6 @@ def search_youtube(
|
|||
page = yt.search_videos(term, page_token=next_cursor)
|
||||
else:
|
||||
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:
|
||||
if collected:
|
||||
break # keep what we already gathered
|
||||
|
|
@ -275,6 +272,14 @@ def search_youtube(
|
|||
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||
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]
|
||||
|
||||
if ordered:
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ def scheduler_snapshot() -> dict:
|
|||
|
||||
|
||||
def _rss_job() -> None:
|
||||
_job("rss_poll", lambda db: run_rss_poll(db))
|
||||
_job("rss_poll", run_rss_poll)
|
||||
|
||||
|
||||
def _enrich_job() -> None:
|
||||
|
|
|
|||
|
|
@ -49,3 +49,15 @@ def decrypt(value: str | None) -> str | None:
|
|||
if value is None:
|
||||
return None
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ from sqlalchemy import delete, select
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import progress, quota
|
||||
from app.auth import has_read_scope, has_write_scope
|
||||
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
||||
from app.auth import has_read_scope
|
||||
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.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:
|
||||
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
|
||||
users = (
|
||||
db.execute(
|
||||
select(User).join(OAuthToken).where(OAuthToken.refresh_token_enc.is_not(None))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
users = token_users(db)
|
||||
total = 0
|
||||
for i, user in enumerate(users, 1):
|
||||
if not has_read_scope(user):
|
||||
|
|
|
|||
|
|
@ -31,19 +31,23 @@ from app.youtube.rss import fetch_channel_feed
|
|||
RSS_POLL_WORKERS = 16
|
||||
|
||||
|
||||
def get_service_user(db: Session) -> User | None:
|
||||
return (
|
||||
def token_users(db: Session) -> list[User]:
|
||||
"""Every user with a stored YouTube refresh token (the read-scope service accounts), by id."""
|
||||
return list(
|
||||
db.execute(
|
||||
select(User)
|
||||
.join(OAuthToken)
|
||||
.where(OAuthToken.refresh_token_enc.is_not(None))
|
||||
.order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
).scalars()
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
if channels is None:
|
||||
channels = db.execute(select(Channel)).scalars().all()
|
||||
|
|
@ -102,15 +106,7 @@ def run_shorts(db: Session) -> dict:
|
|||
def run_subscription_resync(db: Session) -> dict:
|
||||
"""Re-import every user's subscriptions so unsubscribes and new subscriptions on
|
||||
YouTube are reflected automatically."""
|
||||
users = (
|
||||
db.execute(
|
||||
select(User)
|
||||
.join(OAuthToken)
|
||||
.where(OAuthToken.refresh_token_enc.is_not(None))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
users = token_users(db)
|
||||
for i, user in enumerate(users, 1):
|
||||
try:
|
||||
import_subscriptions(db, user)
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ def _external_links(branding: dict) -> list | None:
|
|||
channel = branding.get("channel", {})
|
||||
links = channel.get("links") or []
|
||||
out = [
|
||||
{"title": l.get("title"), "url": l.get("url")}
|
||||
for l in links
|
||||
if isinstance(l, dict) and l.get("url")
|
||||
{"title": item.get("title"), "url": item.get("url")}
|
||||
for item in links
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
return out or None
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
||||
and livestream classification)."""
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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 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.youtube.client import YouTubeClient, best_thumbnail
|
||||
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).
|
||||
|
||||
|
||||
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:
|
||||
limit = limit or sysconfig.get_int(db, "enrich_batch_size")
|
||||
videos = (
|
||||
|
|
@ -267,22 +299,7 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
|
|||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not videos:
|
||||
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
|
||||
return _apply_video_batch(db, yt, list(videos), _mark_enriched)
|
||||
|
||||
|
||||
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)
|
||||
.where(
|
||||
or_(
|
||||
Video.live_status.in_(("live", "upcoming")),
|
||||
Video.live_status.in_(LIVE_OR_UPCOMING),
|
||||
and_(
|
||||
Video.live_status == "was_live",
|
||||
Video.duration_seconds.is_(None),
|
||||
|
|
@ -312,23 +329,13 @@ def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> in
|
|||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not videos:
|
||||
return 0
|
||||
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
|
||||
now = _now()
|
||||
updated = 0
|
||||
for video in videos:
|
||||
item = items.get(video.id)
|
||||
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 _retire(video: Video) -> 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"
|
||||
|
||||
return _apply_video_batch(db, yt, list(videos), _retire)
|
||||
|
||||
|
||||
def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from pathlib import Path
|
|||
import yt_dlp
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
staging = _staging_dir(asset_id)
|
||||
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"),
|
||||
)
|
||||
|
||||
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)
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, thumb)
|
||||
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
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:
|
||||
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)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
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:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ from datetime import datetime, timedelta, timezone
|
|||
import httpx
|
||||
|
||||
from app import quota, sysconfig
|
||||
from app.config import settings
|
||||
from app.models import User
|
||||
from app.security import decrypt
|
||||
from app.security import decrypt, decrypt_optional, encrypt
|
||||
|
||||
log = logging.getLogger("siftlode.youtube")
|
||||
|
||||
|
|
@ -77,8 +76,9 @@ class YouTubeClient:
|
|||
if tok is None:
|
||||
raise YouTubeError("User has no stored OAuth token")
|
||||
now = datetime.now(timezone.utc)
|
||||
if tok.access_token and tok.expiry and tok.expiry > now + timedelta(seconds=60):
|
||||
return tok.access_token
|
||||
cached = decrypt_optional(tok.access_token)
|
||||
if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60):
|
||||
return cached
|
||||
refresh = decrypt(tok.refresh_token_enc)
|
||||
if not refresh:
|
||||
raise YouTubeError("No refresh token; user must re-authenticate")
|
||||
|
|
@ -95,12 +95,13 @@ class YouTubeClient:
|
|||
if resp.status_code != 200:
|
||||
raise YouTubeError(f"Token refresh failed: {resp.status_code} {resp.text[:200]}")
|
||||
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)))
|
||||
self.db.add(tok)
|
||||
self.db.commit()
|
||||
log.info("Refreshed access token for user %s", self.user.id)
|
||||
return tok.access_token
|
||||
return access
|
||||
|
||||
# --- core request ---
|
||||
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:
|
||||
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)."""
|
||||
def _iter_playlist_items(self, playlist_id: str) -> Iterator[dict]:
|
||||
"""Paginate one of the user's playlists' items (playlistItems, contentDetails part;
|
||||
OAuth so private/unlisted work), yielding each raw item in playlist order."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
|
|
@ -181,14 +182,19 @@ class YouTubeClient:
|
|||
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 vid
|
||||
yield from data.get("items", [])
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
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:
|
||||
"""The authenticated user's own channel id (channels.list?mine=true). OAuth only;
|
||||
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
|
||||
playlist order. `item_id` is the playlistItem resource id, needed to delete or
|
||||
reposition the item via the write API. OAuth (private playlists work)."""
|
||||
page_token = None
|
||||
while True:
|
||||
params = {
|
||||
"part": "contentDetails",
|
||||
"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
|
||||
for item in self._iter_playlist_items(playlist_id):
|
||||
vid = item.get("contentDetails", {}).get("videoId")
|
||||
if vid:
|
||||
yield {"item_id": item.get("id"), "video_id": vid}
|
||||
|
||||
# --- 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:
|
||||
|
|
|
|||
6
backend/ruff.toml
Normal file
6
backend/ruff.toml
Normal 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"]
|
||||
|
|
@ -831,7 +831,6 @@ export default function App() {
|
|||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse
|
||||
q={plexQ}
|
||||
onClearSearch={() => setPlexQ("")}
|
||||
scope={plexScope}
|
||||
setScope={setPlexScope}
|
||||
show={plexShowFilter}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey } from "../lib/storage";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { formatCountOrDash } from "../lib/format";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import Tooltip from "./Tooltip";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
|
@ -48,19 +50,8 @@ export default function ChannelDiscovery({
|
|||
meta: { kind: "channel-subscribed", channelId: c.id, channelName: name },
|
||||
});
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// A 403 means the user hasn't granted the write scope — offer to connect instead of
|
||||
// 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") });
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard),
|
||||
});
|
||||
|
||||
// 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} />
|
||||
),
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
subsColumn<DiscoveredChannel>(t),
|
||||
{
|
||||
key: "videos",
|
||||
header: t("channels.discovery.cols.totalVideos"),
|
||||
|
|
@ -109,9 +88,7 @@ export default function ChannelDiscovery({
|
|||
sortable: true,
|
||||
sortValue: (c) => c.video_count ?? -1,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">
|
||||
{c.video_count != null ? formatViews(c.video_count) : "—"}
|
||||
</span>
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -174,7 +151,7 @@ export default function ChannelDiscovery({
|
|||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey("siftlode.channelDiscoveryTable") ?? undefined}
|
||||
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Avatar from "./Avatar";
|
|||
import Feed from "./Feed";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
|
||||
|
|
@ -89,8 +90,14 @@ export default function ChannelPage({
|
|||
qc.invalidateQueries({ queryKey: ["channel", channelId] });
|
||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||
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 ?? "" }) });
|
||||
},
|
||||
// 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({
|
||||
mutationFn: () => api.unsubscribeChannel(channelId),
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ import {
|
|||
UserMinus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { accountKey } from "../lib/storage";
|
||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
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 Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
|
@ -73,19 +75,6 @@ export default function Channels({
|
|||
const qc = useQueryClient();
|
||||
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 tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||
|
|
@ -160,7 +149,7 @@ export default function Channels({
|
|||
invalidate();
|
||||
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({
|
||||
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
|
||||
|
|
@ -169,7 +158,7 @@ export default function Channels({
|
|||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||
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({
|
||||
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
|
||||
|
|
@ -178,7 +167,7 @@ export default function Channels({
|
|||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||
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({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
|
|
@ -190,7 +179,7 @@ export default function Channels({
|
|||
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
|
||||
|
|
@ -286,19 +275,7 @@ export default function Channels({
|
|||
sortValue: (c) => c.stored_videos,
|
||||
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
subsColumn<ManagedChannel>(t),
|
||||
{
|
||||
key: "lastUpload",
|
||||
header: t("channels.cols.lastUpload"),
|
||||
|
|
@ -570,7 +547,7 @@ export default function Channels({
|
|||
rows={visibleChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey("siftlode.channelsTable") ?? undefined}
|
||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||
controlsPosition="top"
|
||||
controlsLeading={statusChips}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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 { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
|
|
@ -39,12 +40,15 @@ export default function ChatThread({
|
|||
);
|
||||
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(() => {
|
||||
if (isSystem || !ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const m of items) {
|
||||
for (const m of q.data?.items ?? []) {
|
||||
if (m.ciphertext && m.iv) {
|
||||
try {
|
||||
const pub = await partnerPub(partnerId);
|
||||
|
|
@ -59,7 +63,8 @@ export default function ChatThread({
|
|||
return () => {
|
||||
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.
|
||||
useEffect(() => {
|
||||
|
|
@ -69,9 +74,12 @@ export default function ChatThread({
|
|||
}
|
||||
}, [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(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [items.length, plain]);
|
||||
}, [items.length]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async (text: string) => {
|
||||
|
|
@ -84,6 +92,14 @@ export default function ChatThread({
|
|||
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
|
||||
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 = () => {
|
||||
|
|
|
|||
39
frontend/src/components/CollapsedFilterRail.tsx
Normal file
39
frontend/src/components/CollapsedFilterRail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ import { notify } from "../lib/notifications";
|
|||
import Tooltip from "./Tooltip";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
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
|
||||
// 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.
|
||||
// Email/SMTP) stay together.
|
||||
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -40,7 +40,7 @@ export default function ConfigPanel() {
|
|||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||
// 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.
|
||||
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).
|
||||
useEffect(() => {
|
||||
|
|
@ -110,9 +110,14 @@ export default function ConfigPanel() {
|
|||
});
|
||||
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const togglePlexLib = (key: string) => {
|
||||
const next = plexLibs.includes(key)
|
||||
? plexLibs.filter((k) => k !== key)
|
||||
: [...plexLibs, key];
|
||||
// An empty list means "all libraries" — every box renders checked. Unchecking one from that
|
||||
// state must select all-except-this, so expand "all" to the explicit section set first;
|
||||
// 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(",") }));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// list built from the same column definitions.
|
||||
|
||||
export type ColumnFilter<T> =
|
||||
type ColumnFilter<T> =
|
||||
| { kind: "text"; get: (row: T) => string }
|
||||
| { 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 };
|
||||
|
|
|
|||
|
|
@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -27,11 +27,11 @@ const ShareDialog = lazy(() => import("./ShareDialog"));
|
|||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api, type DownloadJob, type Me } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
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 STATUS_CLS: Record<string, string> = {
|
||||
|
|
@ -546,7 +546,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
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 [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 library = jobs.filter((j) => j.status === "done");
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||
};
|
||||
const invalidate = () => invalidateDownloads(qc);
|
||||
const act = useMutation({
|
||||
mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
|
||||
op === "pause" ? api.pauseDownload(id)
|
||||
|
|
@ -586,6 +582,12 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
{ id: "shared", label: t("downloads.tabs.shared") },
|
||||
...(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) => (
|
||||
<a
|
||||
|
|
@ -668,7 +670,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
|
|||
<Scissors className="w-4 h-4" />
|
||||
</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" />
|
||||
</IconBtn>
|
||||
<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)} />}
|
||||
{renameJob && <MetaEditModal job={renameJob} onClose={() => setRenameJob(null)} />}
|
||||
{metaJob && <MetaEditModal job={metaJob} onClose={() => setMetaJob(null)} />}
|
||||
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
|
||||
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { useTranslation } from "react-i18next";
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./Modal";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
|
||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
|
||||
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";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
// 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.
|
||||
|
|
@ -43,9 +42,7 @@ export default function DownloadDialog({
|
|||
profile_id: chosen,
|
||||
display_name: name.trim() || undefined,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
qc.invalidateQueries({ queryKey: ["download-index"] });
|
||||
qc.invalidateQueries({ queryKey: ["download-usage"] });
|
||||
invalidateDownloads(qc);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.dialog.added"),
|
||||
|
|
|
|||
|
|
@ -184,9 +184,18 @@ export default function Feed({
|
|||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [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(() => {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
const len = query.data?.pages?.length ?? 0;
|
||||
const appended = len > pagesLenRef.current;
|
||||
pagesLenRef.current = len;
|
||||
if (!appended) {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
const countQuery = useQuery({
|
||||
|
|
@ -310,12 +319,21 @@ export default function Feed({
|
|||
list
|
||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[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
|
||||
// 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
|
||||
// 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 -------------------------------------------------------------
|
||||
// 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">
|
||||
<button
|
||||
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
|
||||
// filter to "search" (your own search finds) and rank them by relevance.
|
||||
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
||||
onExitYtSearch();
|
||||
qc.removeQueries({ queryKey: ["feed"] });
|
||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||
qc.removeQueries({ queryKey: ["facets"] });
|
||||
dropFeedCaches();
|
||||
}}
|
||||
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(() => {});
|
||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||
onExitYtSearch();
|
||||
qc.removeQueries({ queryKey: ["feed"] });
|
||||
qc.removeQueries({ queryKey: ["feed-count"] });
|
||||
qc.removeQueries({ queryKey: ["facets"] });
|
||||
dropFeedCaches();
|
||||
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"
|
||||
|
|
@ -541,29 +552,25 @@ export default function Feed({
|
|||
</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>{t("feed.source.label")}</span>
|
||||
<select
|
||||
value={filters.librarySource ?? "organic"}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...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"
|
||||
>
|
||||
<option value="organic">{t("feed.source.organic")}</option>
|
||||
<option value="all">{t("feed.source.all")}</option>
|
||||
<option value="search">{t("feed.source.only")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<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>{t("feed.source.label")}</span>
|
||||
<select
|
||||
value={filters.librarySource ?? "organic"}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...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"
|
||||
>
|
||||
<option value="organic">{t("feed.source.organic")}</option>
|
||||
<option value="all">{t("feed.source.all")}</option>
|
||||
<option value="search">{t("feed.source.only")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<span className="text-xs text-muted whitespace-nowrap">
|
||||
{countQuery.data
|
||||
|
|
|
|||
|
|
@ -64,12 +64,15 @@ function ConversationList({
|
|||
const items = q.data?.items ?? [];
|
||||
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(() => {
|
||||
if (!ready) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
for (const c of items) {
|
||||
for (const c of q.data?.items ?? []) {
|
||||
const m = c.last_message;
|
||||
if (m.kind === "user" && m.ciphertext && m.iv) {
|
||||
try {
|
||||
|
|
@ -85,7 +88,8 @@ function ConversationList({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [items, ready]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q.dataUpdatedAt, ready]);
|
||||
|
||||
function previewText(c: Conversation): string {
|
||||
const m = c.last_message;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
|
|
@ -103,6 +104,14 @@ export default function NavSidebar({
|
|||
} catch {
|
||||
/* 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();
|
||||
location.reload();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -7,7 +7,7 @@ import Avatar from "./Avatar";
|
|||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
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 { useBackToClose } from "../lib/history";
|
||||
|
||||
|
|
@ -84,14 +84,7 @@ export default function PlayerModal({
|
|||
// non-embeddable still surface the existing playback-error overlay when reached.
|
||||
const [queue] = useState(() => queueProp);
|
||||
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
|
||||
const fullDate = (d: string | null | undefined) =>
|
||||
d
|
||||
? new Date(d).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
|
||||
// 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*
|
||||
// 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
|
||||
// `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.
|
||||
const savedPrefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])
|
||||
?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode };
|
||||
const savedPrefs = useMemo(
|
||||
() =>
|
||||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||||
playerAutoAdvance?: AutoMode;
|
||||
playerLoop?: LoopMode;
|
||||
},
|
||||
[qc]
|
||||
);
|
||||
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
||||
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
||||
const modeRef = useRef({ autoMode, loopMode });
|
||||
|
|
@ -446,7 +445,10 @@ export default function PlayerModal({
|
|||
// navigated to via description links.
|
||||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||||
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;
|
||||
setWatched(true);
|
||||
return true;
|
||||
|
|
@ -646,7 +648,7 @@ export default function PlayerModal({
|
|||
{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">
|
||||
<button
|
||||
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)}
|
||||
onClick={goPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
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}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)}
|
||||
onClick={goNext}
|
||||
disabled={index === queue!.length - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ExternalLink,
|
||||
GripVertical,
|
||||
ListPlus,
|
||||
|
|
@ -37,6 +36,7 @@ import {
|
|||
import { api, type Playlist, type Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
|
@ -347,14 +347,13 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
|
||||
setReverting(true);
|
||||
try {
|
||||
await api.revertPlaylist(selectedId);
|
||||
refreshAll();
|
||||
notify({ level: "success", message: t("playlists.revertDone", { name: plName }) });
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("playlists.revertFailed") });
|
||||
notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.revertFailed"));
|
||||
} finally {
|
||||
setReverting(false);
|
||||
}
|
||||
|
|
@ -362,12 +361,11 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
|
||||
async function pushToYoutube() {
|
||||
if (selectedId == null || !detail || pushing) return;
|
||||
const plName = detail.kind === "watch_later" ? t("playlists.watchLater") : detail.name;
|
||||
setPushing(true);
|
||||
try {
|
||||
const plan = await api.playlistPushPlan(selectedId);
|
||||
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;
|
||||
}
|
||||
if (!plan.affordable) {
|
||||
|
|
@ -406,10 +404,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
if (r.failures.length) {
|
||||
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
|
||||
} else {
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName }) });
|
||||
notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
|
||||
}
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("playlists.pushFailed") });
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.pushFailed"));
|
||||
} finally {
|
||||
setPushing(false);
|
||||
}
|
||||
|
|
@ -423,8 +421,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
|
||||
} catch {
|
||||
notify({ level: "warning", message: t("playlists.syncFailed") });
|
||||
} catch (e) {
|
||||
notifyYouTubeActionError(e, t("playlists.syncFailed"));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
|
|
@ -463,8 +461,14 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
const next = items.filter((v) => v.id !== videoId);
|
||||
order.reset(next);
|
||||
lastSetRef.current = idsKey(next);
|
||||
await api.removeFromPlaylist(selectedId, videoId);
|
||||
refreshAll();
|
||||
try {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
type PlexSeasonDetail,
|
||||
type PlexUnifiedResult,
|
||||
} from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||
|
|
@ -53,7 +54,6 @@ type Sub =
|
|||
|
||||
type Props = {
|
||||
q: string;
|
||||
onClearSearch: () => void;
|
||||
scope: string; // movie | show | both (unified cross-library scope)
|
||||
setScope: (v: string) => void;
|
||||
show: string;
|
||||
|
|
@ -67,16 +67,8 @@ type Props = {
|
|||
|
||||
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({
|
||||
q,
|
||||
onClearSearch,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
|
|
@ -162,21 +154,24 @@ export default function PlexBrowse({
|
|||
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
|
||||
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
|
||||
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
// Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
|
||||
// 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;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el) return;
|
||||
if (!sentinelEl) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
||||
},
|
||||
{ rootMargin: "600px" },
|
||||
);
|
||||
io.observe(el);
|
||||
io.observe(sentinelEl);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function onCard(card: PlexCard) {
|
||||
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(() => {});
|
||||
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.
|
||||
// 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).
|
||||
|
|
@ -348,7 +337,7 @@ export default function PlexBrowse({
|
|||
ep={ep}
|
||||
withShowTitle
|
||||
onPlay={() => onCard(ep)}
|
||||
onToggleWatched={() => toggleEpisodeWatched(ep)}
|
||||
onToggleWatched={() => toggleWatched(ep)}
|
||||
/>
|
||||
))}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -489,7 +478,7 @@ function PlexPosterCard({
|
|||
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-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>
|
||||
);
|
||||
|
|
@ -532,7 +521,8 @@ function PlexInfoView({
|
|||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
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>
|
||||
)}
|
||||
|
|
@ -851,7 +841,7 @@ function EpisodeCard({
|
|||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</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>
|
||||
{onAdd && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
|
|
@ -28,13 +29,6 @@ type Props = {
|
|||
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({
|
||||
detail,
|
||||
variant,
|
||||
|
|
@ -170,7 +164,7 @@ export default function PlexInfo({
|
|||
{detail.content_rating}
|
||||
</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 && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
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 { useBackToClose } from "../lib/history";
|
||||
|
||||
|
|
@ -63,7 +64,6 @@ function subOrdForLang(subs: SubStream[], lang: string): number | null {
|
|||
type PlexPlayerPrefs = {
|
||||
volume: number; // 0..1
|
||||
muted: boolean;
|
||||
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
|
||||
audioLang: string; // "" = default first audio
|
||||
subLang: string; // "" = subtitles off
|
||||
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
|
||||
|
|
@ -90,7 +90,6 @@ type PlexPlayerPrefs = {
|
|||
const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||
volume: 1,
|
||||
muted: false,
|
||||
wasPlaying: true,
|
||||
audioLang: "",
|
||||
subLang: "",
|
||||
subOffset: 0,
|
||||
|
|
@ -165,17 +164,20 @@ const HLS_SUB_LAG = 1.0;
|
|||
// the queue instead of the item's own episode neighbours.
|
||||
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||
|
||||
// Seekbar clock — same H:MM:SS / M:SS shape as the shared formatDuration, but the live media clock
|
||||
// can be NaN/negative before metadata loads, so clamp to 0 first (formatDuration never sees null here).
|
||||
function fmt(t: number): string {
|
||||
if (!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)}`;
|
||||
return formatDuration(isFinite(t) && t >= 0 ? t : 0);
|
||||
}
|
||||
|
||||
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const { t } = useTranslation();
|
||||
// `t` changes identity on a language switch. loadSession only uses it for error strings, so read it
|
||||
// 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 [id, setId] = useState(itemId);
|
||||
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.
|
||||
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
|
||||
"siftlode.plexPlayerPrefs",
|
||||
LS.plexPlayerPrefs,
|
||||
DEFAULT_PREFS,
|
||||
);
|
||||
const { volume, muted } = prefs;
|
||||
|
|
@ -288,10 +290,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
// needs full transcoding (a later phase).
|
||||
setLoadError(
|
||||
e?.status === 501
|
||||
? t("plex.player.errTranscode")
|
||||
? tRef.current("plex.player.errTranscode")
|
||||
: e?.status === 404
|
||||
? t("plex.player.errNotFound")
|
||||
: t("plex.player.errGeneric"),
|
||||
? tRef.current("plex.player.errNotFound")
|
||||
: tRef.current("plex.player.errGeneric"),
|
||||
);
|
||||
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
|
||||
// otherwise leave the spinner up forever — surface it instead.
|
||||
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 {
|
||||
// direct file (or Safari native HLS)
|
||||
|
|
@ -358,7 +360,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
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).
|
||||
|
|
@ -419,18 +421,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
/* ignore */
|
||||
}
|
||||
};
|
||||
const onPlay = () => {
|
||||
setPlaying(true);
|
||||
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
|
||||
};
|
||||
const onPause = () => {
|
||||
setPlaying(false);
|
||||
patchPrefs({ wasPlaying: false });
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => setPlaying(false);
|
||||
// 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
|
||||
// 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);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
video.addEventListener("play", onPlay);
|
||||
|
|
@ -851,6 +847,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
const [skipProgress, setSkipProgress] = useState<number | null>(null);
|
||||
skipProgressRef.current = skipProgress;
|
||||
const cancelledMarkerRef = useRef<string | null>(null);
|
||||
// Forget a cancelled-skip when the item changes: the ref holds a `${type}-${start_s}` key, and a
|
||||
// marker at the same offset on the NEXT episode would otherwise be suppressed by a cancel made on
|
||||
// the previous one (auto-skip silently dead for the rest of a binge).
|
||||
useEffect(() => {
|
||||
cancelledMarkerRef.current = null;
|
||||
}, [id]);
|
||||
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
|
||||
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
|
||||
const skipTimerRef = useRef<number | null>(null);
|
||||
|
|
@ -1222,6 +1224,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||
<div
|
||||
ref={tracksRef}
|
||||
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"
|
||||
>
|
||||
{detail.audio_streams.length > 1 && (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
|
|
@ -115,34 +116,33 @@ export default function PlexSidebar({
|
|||
)?.key;
|
||||
|
||||
if (collapsed) {
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
|
||||
}
|
||||
|
||||
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">
|
||||
{/* 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. */}
|
||||
<Section label={t("plex.filter.scope")}>
|
||||
<div className="flex gap-1">
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
|
|||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
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 BLANK: DownloadSpec = {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
||||
import { filtersToParams, shareUrl } from "../lib/urlState";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
|
@ -309,6 +310,7 @@ function Stat({
|
|||
export default function Scheduler() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// 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).
|
||||
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
|
||||
|
|
@ -411,7 +413,15 @@ export default function Scheduler() {
|
|||
</Tooltip>
|
||||
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
|
||||
<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}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import Avatar from "./Avatar";
|
|||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Section, SettingRow, Switch } from "./ui/form";
|
||||
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
export type PrefsSaveState = "idle" | "saving" | "saved" | "error";
|
||||
export type PrefsSaveState = SaveState;
|
||||
export interface PrefsController {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import Modal from "./Modal";
|
|||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||
|
||||
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";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
|
|
@ -221,10 +218,10 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||
autoComplete="new-password"
|
||||
/>
|
||||
<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")}
|
||||
</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")}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -232,7 +229,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
|||
) : (
|
||||
<button
|
||||
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")}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
|
|
@ -13,7 +12,6 @@ import {
|
|||
Pencil,
|
||||
RotateCcw,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -41,28 +39,11 @@ import {
|
|||
import { shareUrl } from "../lib/urlState";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { Switch } from "./ui/form";
|
||||
import TagManager from "./TagManager";
|
||||
import SavedViewsWidget from "./SavedViewsWidget";
|
||||
import CollapsedFilterRail from "./CollapsedFilterRail";
|
||||
|
||||
// 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 }[] = [
|
||||
{ days: 1, key: "24h" },
|
||||
{ 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
|
||||
// carries the active-filter count so you can tell filters are on without opening it.
|
||||
if (collapsed) {
|
||||
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>
|
||||
);
|
||||
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import AddToPlaylist from "./AddToPlaylist";
|
|||
import DownloadButton from "./DownloadButton";
|
||||
import clsx from "clsx";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
|
||||
function Actions({
|
||||
video,
|
||||
|
|
@ -257,16 +257,7 @@ function VideoCard({
|
|||
</>
|
||||
)}
|
||||
{relativeTime(video.published_at)}
|
||||
{video.published_at && (
|
||||
<>
|
||||
{" · "}
|
||||
{new Date(video.published_at).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
||||
</>
|
||||
);
|
||||
// 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}
|
||||
</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") {
|
||||
return (
|
||||
|
|
@ -304,19 +312,7 @@ function VideoCard({
|
|||
)}
|
||||
>
|
||||
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{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>
|
||||
<div className="min-w-0 flex-1">{textBlock}</div>
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -337,17 +333,7 @@ function VideoCard({
|
|||
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{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>
|
||||
{textBlock}
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import clsx from "clsx";
|
|||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
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 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 =
|
||||
"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;
|
||||
|
|
@ -87,7 +85,12 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||
if (!v) return;
|
||||
const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
|
||||
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 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;
|
||||
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
|
||||
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 v = videoRef.current;
|
||||
|
|
@ -333,7 +336,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||
</button>
|
||||
<div className="text-xs text-muted tabular-nums">{tc(current)} / {tc(duration)}</div>
|
||||
<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")}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -363,7 +366,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||
</div>
|
||||
)}
|
||||
{/* segment tint: dropped = dimmed + hatched */}
|
||||
{segments.map((s, i) => (
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={clsx(
|
||||
|
|
@ -548,13 +551,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
|||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-muted">{reencode ? t("editor.willReencode") : t("editor.willCopy")}</span>
|
||||
<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")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => create.mutate()}
|
||||
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" />}
|
||||
{willJoin
|
||||
|
|
|
|||
18
frontend/src/components/channelColumns.tsx
Normal file
18
frontend/src/components/channelColumns.tsx
Normal 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>,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// with no react-query provider: the operator contact is fetched with a plain fetch below.
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function useOperatorContact(): string | null {
|
||||
function useOperatorContact(): string | null {
|
||||
const [contact, setContact] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ import Tooltip from "../Tooltip";
|
|||
// 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.
|
||||
|
||||
/** 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
|
||||
* the accessible name (the visible row text) since the control itself has no inner text. */
|
||||
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.
|
||||
* 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 (
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const LANGUAGES = [
|
|||
] as const;
|
||||
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
||||
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
|
||||
// `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
|
||||
// language, else English. (After login, App adopts the server-persisted preference.)
|
||||
export function detectInitialLang(): LangCode {
|
||||
function detectInitialLang(): LangCode {
|
||||
const stored = localStorage.getItem(LANG_KEY);
|
||||
if (isSupported(stored)) return stored;
|
||||
const nav = (navigator.language || "en").slice(0, 2).toLowerCase();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"filterPlaceholder": "Kanäle filtern…",
|
||||
"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.",
|
||||
"backfillEverything": "Alles nachladen",
|
||||
|
|
@ -35,9 +34,7 @@
|
|||
"tags": {
|
||||
"yourTags": "Deine Tags",
|
||||
"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.)",
|
||||
"newTag": "neuer Tag",
|
||||
"createTag": "Tag erstellen"
|
||||
"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.)"
|
||||
},
|
||||
"loading": "Kanäle werden geladen…",
|
||||
"empty": "Keine Kanäle.",
|
||||
|
|
@ -74,8 +71,6 @@
|
|||
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
|
||||
},
|
||||
"row": {
|
||||
"stored": "{{formatted}} gespeichert",
|
||||
"subs": "{{formatted}} Abonnenten",
|
||||
"openOnYouTube": "Auf YouTube öffnen",
|
||||
"editTags": "Tags bearbeiten",
|
||||
"filterFeedByTag": "Feed nach „{{name}}“ filtern",
|
||||
|
|
@ -98,8 +93,6 @@
|
|||
"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.",
|
||||
"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.",
|
||||
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
|
||||
"unhide": "Einblenden",
|
||||
|
|
|
|||
|
|
@ -78,12 +78,6 @@
|
|||
"copied": "Quell-Link in die Zwischenablage kopiert",
|
||||
"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": {
|
||||
"title": "Details bearbeiten",
|
||||
"name": "Titel",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"encrypted": "Verschlüsselte Nachricht",
|
||||
"encryptedHint": "Ende-zu-Ende-verschlüsselt",
|
||||
"cantDecrypt": "Kann nicht entschlüsselt werden",
|
||||
"sendFailed": "Nachricht konnte nicht gesendet werden. Bitte erneut versuchen.",
|
||||
"systemReadOnly": "Dies ist eine automatische Nachricht.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"release": "Erscheinungsdatum"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Bibliothek",
|
||||
"scope": "Bibliothek",
|
||||
"scopeOpt": {
|
||||
"both": "Alle",
|
||||
|
|
@ -67,7 +66,6 @@
|
|||
"noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.",
|
||||
"loading": "Wird geladen…",
|
||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||
"loadMore": "Mehr laden",
|
||||
"watched": "Angesehen",
|
||||
"inProgress": "Läuft",
|
||||
"play": "Abspielen",
|
||||
|
|
@ -92,7 +90,6 @@
|
|||
"markSeasonUnwatched": "Staffel als ungesehen",
|
||||
"addShowCollection": "Zur Sammlung"
|
||||
},
|
||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||
"collEditor": {
|
||||
"manage": "Sammlungen",
|
||||
"title": "Sammlungen — {{title}}",
|
||||
|
|
@ -218,9 +215,6 @@
|
|||
"playAll": "Alle abspielen",
|
||||
"delete": "Playlist löschen",
|
||||
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
|
||||
"up": "Nach oben",
|
||||
"down": "Nach unten",
|
||||
"remove": "Entfernen",
|
||||
"layoutAccordion": "Akkordeon-Ansicht",
|
||||
"layoutTree": "Baum-Ansicht",
|
||||
"removeShow": "Ganze Serie entfernen",
|
||||
|
|
|
|||
|
|
@ -103,5 +103,6 @@
|
|||
},
|
||||
"purgeDiscovery": "Entdeckung leeren",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"filterPlaceholder": "Filter channels…",
|
||||
"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.",
|
||||
"backfillEverything": "Backfill everything",
|
||||
|
|
@ -35,9 +34,7 @@
|
|||
"tags": {
|
||||
"yourTags": "Your 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.)",
|
||||
"newTag": "new tag",
|
||||
"createTag": "Create tag"
|
||||
"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.)"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
|
|
@ -74,8 +71,6 @@
|
|||
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
|
||||
},
|
||||
"row": {
|
||||
"stored": "{{formatted}} stored",
|
||||
"subs": "{{formatted}} subs",
|
||||
"openOnYouTube": "Open on YouTube",
|
||||
"editTags": "Edit tags",
|
||||
"filterFeedByTag": "Filter the feed by “{{name}}”",
|
||||
|
|
@ -98,8 +93,6 @@
|
|||
"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.",
|
||||
"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.",
|
||||
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||
"unhide": "Unhide",
|
||||
|
|
|
|||
|
|
@ -78,12 +78,6 @@
|
|||
"copied": "Source link copied to clipboard",
|
||||
"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": {
|
||||
"title": "Edit details",
|
||||
"name": "Title",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"encrypted": "Encrypted message",
|
||||
"encryptedHint": "End-to-end encrypted",
|
||||
"cantDecrypt": "Can't decrypt",
|
||||
"sendFailed": "Couldn't send your message. Try again.",
|
||||
"systemReadOnly": "This is an automated message.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"release": "Release date"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Library",
|
||||
"scope": "Library",
|
||||
"scopeOpt": {
|
||||
"both": "All",
|
||||
|
|
@ -67,7 +66,6 @@
|
|||
"noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.",
|
||||
"loading": "Loading…",
|
||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||
"loadMore": "Load more",
|
||||
"watched": "Watched",
|
||||
"inProgress": "In progress",
|
||||
"play": "Play",
|
||||
|
|
@ -92,7 +90,6 @@
|
|||
"markSeasonUnwatched": "Mark season unwatched",
|
||||
"addShowCollection": "Add to collection"
|
||||
},
|
||||
"playerSoon": "Player coming soon — “{{title}}”",
|
||||
"collEditor": {
|
||||
"manage": "Collections",
|
||||
"title": "Collections — {{title}}",
|
||||
|
|
@ -218,9 +215,6 @@
|
|||
"playAll": "Play all",
|
||||
"delete": "Delete playlist",
|
||||
"empty": "This playlist is empty. Add titles from their info page.",
|
||||
"up": "Move up",
|
||||
"down": "Move down",
|
||||
"remove": "Remove",
|
||||
"layoutAccordion": "Accordion view",
|
||||
"layoutTree": "Tree view",
|
||||
"removeShow": "Remove whole show",
|
||||
|
|
|
|||
|
|
@ -103,5 +103,6 @@
|
|||
},
|
||||
"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.",
|
||||
"purgeConfirmTitle": "Purge discovery content?",
|
||||
"purgedDiscovery": "Removed {{count}} discovery items"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"filterPlaceholder": "Csatornák szűrése…",
|
||||
"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.",
|
||||
"backfillEverything": "Minden letöltése",
|
||||
|
|
@ -35,9 +34,7 @@
|
|||
"tags": {
|
||||
"yourTags": "Címkéid",
|
||||
"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.)",
|
||||
"newTag": "új címke",
|
||||
"createTag": "Címke létrehozása"
|
||||
"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.)"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"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)."
|
||||
},
|
||||
"row": {
|
||||
"stored": "{{formatted}} tárolt",
|
||||
"subs": "{{formatted}} feliratkozó",
|
||||
"openOnYouTube": "Megnyitás a YouTube-on",
|
||||
"editTags": "Címkék szerkesztése",
|
||||
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
|
||||
|
|
@ -98,8 +93,6 @@
|
|||
"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.",
|
||||
"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.",
|
||||
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||
"unhide": "Megjelenítés",
|
||||
|
|
|
|||
|
|
@ -78,12 +78,6 @@
|
|||
"copied": "Forráslink a vágólapra másolva",
|
||||
"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": {
|
||||
"title": "Adatok szerkesztése",
|
||||
"name": "Cím",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"encrypted": "Titkosított üzenet",
|
||||
"encryptedHint": "Végpontig titkosítva",
|
||||
"cantDecrypt": "Nem fejthető vissza",
|
||||
"sendFailed": "Nem sikerült elküldeni az üzenetet. Próbáld újra.",
|
||||
"systemReadOnly": "Ez egy automatikus üzenet.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
"release": "Megjelenés dátuma"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Könyvtár",
|
||||
"scope": "Könyvtár",
|
||||
"scopeOpt": {
|
||||
"both": "Mind",
|
||||
|
|
@ -67,7 +66,6 @@
|
|||
"noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.",
|
||||
"loading": "Betöltés…",
|
||||
"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",
|
||||
"inProgress": "Folyamatban",
|
||||
"play": "Lejátszás",
|
||||
|
|
@ -92,7 +90,6 @@
|
|||
"markSeasonUnwatched": "Évad jelölés visszavonása",
|
||||
"addShowCollection": "Kollekcióhoz adás"
|
||||
},
|
||||
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
||||
"collEditor": {
|
||||
"manage": "Kollekciók",
|
||||
"title": "Kollekciók — {{title}}",
|
||||
|
|
@ -218,9 +215,6 @@
|
|||
"playAll": "Összes lejátszása",
|
||||
"delete": "Lista törlése",
|
||||
"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",
|
||||
"layoutTree": "Fa nézet",
|
||||
"removeShow": "Egész sorozat eltávolítása",
|
||||
|
|
|
|||
|
|
@ -103,5 +103,6 @@
|
|||
},
|
||||
"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.",
|
||||
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
|
||||
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { LS, setAccountRaw } from "./storage";
|
|||
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
|
||||
// it back into the main bundle and defeat the code-split.
|
||||
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 {
|
||||
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export interface VideoDetail {
|
|||
duration_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface ChannelLink {
|
||||
interface ChannelLink {
|
||||
title: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
|
@ -611,7 +611,7 @@ export interface SystemConfigData {
|
|||
}
|
||||
|
||||
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
||||
export interface PlexSection {
|
||||
interface PlexSection {
|
||||
key: string;
|
||||
title: string;
|
||||
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)
|
||||
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
|
||||
// also matches episodes (the grouped "Episodes" section).
|
||||
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
|
||||
// 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).
|
||||
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.genreMode === "all") u.set("genre_mode", "all");
|
||||
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
||||
|
|
@ -903,7 +896,7 @@ export interface DownloadProfile {
|
|||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface DownloadAsset {
|
||||
interface DownloadAsset {
|
||||
id: number;
|
||||
status: string;
|
||||
title: string | null;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 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.
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { LS } from "./storage";
|
||||
|
||||
export interface DockChat {
|
||||
partnerId: number;
|
||||
|
|
@ -20,7 +21,7 @@ let userId: number | null = null;
|
|||
const subs = new Set<() => void>();
|
||||
|
||||
function storageKey(): string | null {
|
||||
return userId != null ? `siftlode.chatDock.${userId}` : null;
|
||||
return userId != null ? LS.chatDock(userId) : null;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
|
|
|
|||
9
frontend/src/lib/downloads.ts
Normal file
9
frontend/src/lib/downloads.ts
Normal 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"] });
|
||||
}
|
||||
|
|
@ -46,12 +46,6 @@ export function isUnlocked(): boolean {
|
|||
return !!privKey;
|
||||
}
|
||||
|
||||
export function lock(): void {
|
||||
privKey = null;
|
||||
convKeys.clear();
|
||||
emitUnlock();
|
||||
}
|
||||
|
||||
// --- IndexedDB (per-device persistence of the unlocked private key) ---
|
||||
const DB_NAME = "siftlode-e2ee";
|
||||
const STORE = "privkeys";
|
||||
|
|
@ -127,7 +121,11 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
|
|||
const wrapKey = await wrapKeyFrom(passphrase, salt);
|
||||
const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey);
|
||||
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);
|
||||
|
||||
privKey = await importPrivate(pkcs8);
|
||||
|
|
@ -149,8 +147,8 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
|
|||
throw new Error("incomplete key bundle");
|
||||
}
|
||||
const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt));
|
||||
// Wrong passphrase → AES-GCM auth tag fails here.
|
||||
await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check)));
|
||||
// Wrong passphrase → the AES-GCM auth tag fails on this decrypt. (No separate key_check oracle:
|
||||
// that was redundant with this tag and formerly shared wrapIv — see setup().)
|
||||
const pkcs8 = await subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) },
|
||||
wrapKey,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createStore } from "./store";
|
|||
// 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
|
||||
// api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one.
|
||||
export interface AppError {
|
||||
interface AppError {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,15 @@ export function formatDuration(sec: number | null): string {
|
|||
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
|
||||
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
||||
// 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`;
|
||||
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" })
|
||||
: "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { getActiveAccount, type Message, type MessageUser } from "./api";
|
||||
|
||||
// A pushed message plus both parties, so the dock can open/flash the right window.
|
||||
export interface IncomingMessage {
|
||||
interface IncomingMessage {
|
||||
message: Message;
|
||||
from?: MessageUser;
|
||||
to?: MessageUser;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
export function useUnlocked(): boolean {
|
||||
function useUnlocked(): boolean {
|
||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
|
|||
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
export interface NotifAction {
|
||||
interface NotifAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ export type ChannelSubscribedMeta = {
|
|||
};
|
||||
// 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.
|
||||
export type AccessRequestsMeta = {
|
||||
type AccessRequestsMeta = {
|
||||
kind: "access-requests";
|
||||
};
|
||||
export type NotifMeta =
|
||||
|
|
@ -223,11 +223,6 @@ export function notify(input: NotifyInput): number {
|
|||
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. */
|
||||
export function dismiss(id: number): void {
|
||||
items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n));
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@
|
|||
|
||||
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
|
||||
// different account in the same browser dismissed it.
|
||||
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||
const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: {
|
||||
can_read: boolean;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ export interface 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",
|
||||
date: "2026-07-11",
|
||||
|
|
|
|||
|
|
@ -8,15 +8,7 @@ import { LS, readAccount, writeAccount } from "./storage";
|
|||
|
||||
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
|
||||
|
||||
export 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",
|
||||
};
|
||||
const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"];
|
||||
|
||||
export interface SidebarLayout {
|
||||
order: WidgetId[];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ export const LS = {
|
|||
perfMode: "siftlode.perfMode",
|
||||
channelFilter: "siftlode.channelFilter",
|
||||
channelsView: "siftlode.channelsView",
|
||||
channelsTable: "siftlode.channelsTable",
|
||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
configTab: "siftlode.configTab",
|
||||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
navCollapsed: "siftlode.navCollapsed",
|
||||
|
|
@ -29,6 +32,7 @@ export const LS = {
|
|||
plexFilters: "siftlode.plexFilters",
|
||||
plexQ: "siftlode.plexQ",
|
||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||||
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
|
|
@ -125,29 +129,11 @@ export function writeJSON(key: string, value: unknown): void {
|
|||
|
||||
// --- reactive persisted string state ----------------------------------------------------
|
||||
|
||||
/** A `useState` whose string value is mirrored to localStorage, so it survives a reload (F5).
|
||||
* Validation is deferred to the caller (clamp at render) so it can be used before an
|
||||
* async-loaded option list is known, keeping hook order stable. Generalizes the per-component
|
||||
* "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter…). */
|
||||
export function usePersistedState(
|
||||
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. */
|
||||
/** A `useState` whose string value is mirrored to localStorage, 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. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
|
||||
* can be used before an async-loaded option list is known, keeping hook order stable. These hooks
|
||||
* run in components that only mount once signed in, so the account is known. */
|
||||
export function useAccountPersistedState(
|
||||
base: string,
|
||||
fallback = "",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LS, readAccountMerged, writeAccount } from "./storage";
|
||||
|
||||
export type Mode = "dark" | "light";
|
||||
type Mode = "dark" | "light";
|
||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
||||
|
||||
export const SCHEMES: { id: Scheme; name: string; swatch: string }[] = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
|
||||
// sync). "feed" is the default/fallback.
|
||||
export const PAGES = [
|
||||
const PAGES = [
|
||||
"feed",
|
||||
"channels",
|
||||
"stats",
|
||||
|
|
|
|||
25
frontend/src/lib/youtubeErrors.ts
Normal file
25
frontend/src/lib/youtubeErrors.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@
|
|||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue