Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00bf9a39f7 | ||
|
|
640a8efeed | ||
|
|
23ac884b42 | ||
|
|
1579a81395 | ||
|
|
4fe6f38f1b | ||
|
|
94a608053a | ||
|
|
aea99cc71a | ||
|
|
180ab56379 | ||
|
|
0b592345e6 | ||
|
|
4800df4208 | ||
|
|
8bfc01684f | ||
|
|
48aa1a25f5 | ||
|
|
c82cf25bcf | ||
|
|
634e9bb17c | ||
|
|
ce2954cf2b | ||
|
|
2229b01a91 | ||
|
|
b17b47f54e | ||
|
|
1e2b3c5d29 | ||
|
|
d065902404 | ||
|
|
a300977ed4 | ||
|
|
ddb7818d38 | ||
|
|
48787af11c | ||
|
|
b733ee84fa | ||
|
|
96413c3d5e | ||
|
|
d9677e0e3a | ||
|
|
2b804300d5 | ||
|
|
3f047bbad1 | ||
|
|
8a97c79d37 | ||
|
|
d37b6ef07f | ||
|
|
88c5d5c5cb | ||
|
|
cff8bed62b | ||
|
|
bc79720e81 | ||
|
|
b02a9e37e8 | ||
|
|
b2013a079a | ||
|
|
0fab0ce779 | ||
|
|
4ace486618 | ||
|
|
3334a8e895 | ||
|
|
e2362e1ed1 | ||
|
|
7d89bc8b8e | ||
|
|
f0a0214d97 | ||
|
|
e824fe0ae9 | ||
|
|
49011940d6 | ||
|
|
52b6535dc6 | ||
|
|
9bf7da2cbe | ||
|
|
3301976375 |
@@ -0,0 +1,47 @@
|
||||
"""Fold the maintenance re-validation batch from app_state into sysconfig (B-core-11)
|
||||
|
||||
The admin-tunable `maintenance_revalidate_batch` lived in its own `app_state` column with a
|
||||
hand-rolled override-or-default reader in `app.state`, duplicating the sysconfig SPECS pattern. It's
|
||||
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
|
||||
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0070_fold_maintenance_batch"
|
||||
down_revision: Union[str, None] = "0069_job_index_hygiene"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Preserve a customized override: copy it into the sysconfig store before the column goes.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO system_config (key, value, encrypted)
|
||||
SELECT 'maintenance_revalidate_batch', maintenance_revalidate_batch::text, false
|
||||
FROM app_state
|
||||
WHERE id = 1 AND maintenance_revalidate_batch IS NOT NULL
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
op.drop_column("app_state", "maintenance_revalidate_batch")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"app_state",
|
||||
sa.Column("maintenance_revalidate_batch", sa.Integer(), nullable=True),
|
||||
)
|
||||
# Carry the value back onto the row, then drop the sysconfig override (symmetric restore).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE app_state SET maintenance_revalidate_batch =
|
||||
(SELECT value::int FROM system_config WHERE key = 'maintenance_revalidate_batch')
|
||||
WHERE id = 1
|
||||
AND EXISTS (SELECT 1 FROM system_config WHERE key = 'maintenance_revalidate_batch')
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM system_config WHERE key = 'maintenance_revalidate_batch'")
|
||||
@@ -0,0 +1,75 @@
|
||||
# Backend conventions
|
||||
|
||||
The rules below are already followed across `backend/app` — they lived only in a few module
|
||||
docstrings (`demo.py`, `userscope.py`, `routes/_common.py`) until this file collected them (E3 S3,
|
||||
B-core-20). When you add code, match these so the next reader doesn't have to guess "which pattern do
|
||||
I copy".
|
||||
|
||||
## Module layering
|
||||
|
||||
Three layers, by *who* runs the code — not by feature:
|
||||
|
||||
1. **`routes/…` — request-only.** A route module holds HTTP concerns: auth dependencies, request/
|
||||
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
|
||||
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
|
||||
|
||||
2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the
|
||||
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
|
||||
route, the worker, and the scheduler share one definition. Examples from this codebase:
|
||||
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
|
||||
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
|
||||
|
||||
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
|
||||
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, … are single-file primitives many
|
||||
layers import. They depend on nothing above them (a core module must never import a `routes/…`
|
||||
module).
|
||||
|
||||
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
|
||||
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
|
||||
a layer — into the relevant domain package, or a flat core module if it's cross-cutting. Don't
|
||||
pre-abstract for a single caller; don't leave shared logic duplicated once there are two.
|
||||
|
||||
## Transaction ownership
|
||||
|
||||
This follows CLAUDE.md's canonical rule verbatim — **"the handler/job-wrapper commits; helpers flush
|
||||
(only `quota` owns its session)"** — so read it that way, not as an exception to it. The commit belongs
|
||||
to the **operation owner**; anything called *inside* another transaction only stages its change
|
||||
(`db.add` / `flush`) and lets the owner commit, so the whole operation is one transaction.
|
||||
`audit.record()` is the reference: it adds the audit row and the caller commits it together with the
|
||||
mutation it records, so an audit entry can never exist without its change (or vice-versa).
|
||||
|
||||
- The **operation owner** is usually the route handler. A `service.*` operation the handler delegates a
|
||||
whole transition to (e.g. `service.cancel`) *is* that owner and commits its one transition — it isn't
|
||||
a mid-operation helper. A helper that owns its OWN short-lived session (the worker's `jobdb` writes,
|
||||
the same carve-out CLAUDE.md grants `quota`) commits that session. Everything else flushes.
|
||||
- `SessionLocal` is `expire_on_commit=False`: after a raw `UPDATE` + commit, an ORM object you already
|
||||
loaded is **stale**. Read the fresh value from the row (RETURNING, or a re-`SELECT`), not the ORM
|
||||
attribute — see `service.decr_ref_count`.
|
||||
|
||||
## Shared counters and status: do it atomically at the DB
|
||||
|
||||
- **Never read-modify-write a shared counter in Python** (`x.count = x.count + 1`). Concurrent sessions
|
||||
lose an update. Use a DB-atomic statement — `col = col + 1`, or `greatest(col - 1, 0)` with
|
||||
`RETURNING` so the caller branches on the post-value under the row lock (`service.incr_ref_count` /
|
||||
`decr_ref_count`).
|
||||
- **Download job status is a `JobStatus` StrEnum** (`downloads/status.py`), not bare strings. The
|
||||
`HOLDING` set defines which states hold a `MediaAsset` (so release accounting stays correct);
|
||||
`STOPPED_BY_ROUTE` is the route-initiated stop set. A worker child's terminal write goes through
|
||||
`service.finalize_if_running` (an atomic `UPDATE … WHERE status='running'`) so a finishing child can't
|
||||
resurrect a job a route just stopped.
|
||||
|
||||
## Config: one home per kind
|
||||
|
||||
- **`app.config.settings`** — boot/env config (DB URL, download root, worker concurrency): read once at
|
||||
startup, not user-tunable at runtime.
|
||||
- **`sysconfig.SPECS`** — the runtime admin knobs (DB-override-or-default). A new admin-tunable value
|
||||
goes in `SPECS`; don't invent a fourth store. (`state.py`'s `maintenance_batch` predates this and is
|
||||
slated to fold into `sysconfig` — do not add more like it.)
|
||||
|
||||
## The gate
|
||||
|
||||
Everything below must be green before a change is done (run via `siftlode check`):
|
||||
`ruff` (backend) · `tsc` ×3 + `eslint` + `prettier` + `knip` (frontend) · `vitest` · `pytest`. The
|
||||
pytest lane runs in `backend/docker-compose.test.yml` — an ephemeral Postgres + the backend tree
|
||||
bind-mounted — so the DB-backed integration tests (the `db` fixture) actually run; `SIFTLODE_TEST_
|
||||
REQUIRE_DB=1` there turns a would-be silent skip into a hard failure. See `docs/development.md`.
|
||||
@@ -57,7 +57,7 @@ class Settings(BaseSettings):
|
||||
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
|
||||
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
|
||||
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
|
||||
# deploy. Comma-separated. See auth._client_ip + the deploy docs.
|
||||
# deploy. Comma-separated. See routes.auth._client_ip + the deploy docs.
|
||||
trusted_proxy_ips: str = ""
|
||||
|
||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ class Base(DeclarativeBase):
|
||||
|
||||
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
||||
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
||||
# (see routes/plex.py), so this headroom is only for the many brief sessions, not long ones.
|
||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
|
||||
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||
|
||||
def _set_job(job_id: int, **fields) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
return
|
||||
for k, v in fields.items():
|
||||
setattr(job, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _job_status(job_id: int) -> str | None:
|
||||
with SessionLocal() as db:
|
||||
return db.execute(
|
||||
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _touch_job(job_id: int) -> None:
|
||||
"""Bump a running job's `updated_at` without changing any value — a stall-watchdog heartbeat for
|
||||
phases that legitimately write no progress. Extraction writes nothing, and a multi-client-set
|
||||
fallback re-writes identical values (progress 0 / phase 'downloading'), so ORM `onupdate` would
|
||||
NOT fire and two slow extraction attempts could otherwise cross the stall timeout and be killed
|
||||
mid-extraction though they'd have succeeded."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("UPDATE download_jobs SET updated_at=now() WHERE id=:id AND status='running'"),
|
||||
{"id": job_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _requeue_if_running(job_id: int, phase: str) -> None:
|
||||
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
||||
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
||||
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
||||
requeue a no-op unless we still own a running job."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
||||
"WHERE id=:id AND status='running'"
|
||||
),
|
||||
{"id": job_id, "phase": phase},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _reset_asset_pending(asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset and asset.status == "downloading":
|
||||
asset.status = "pending"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
||||
|
||||
|
||||
def _edit_staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, formats, service, storage
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
_reset_asset_pending,
|
||||
_set_job,
|
||||
_staging_dir,
|
||||
_touch_job,
|
||||
)
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
log = logging.getLogger("siftlode.worker")
|
||||
|
||||
|
||||
class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
# opens a session and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so unthrottled they cost
|
||||
# ~10^5 queries on a long job. ONE interval, ONE implementation, used by both paths — three
|
||||
# hand-rolled copies is how the ffmpeg side ended up unthrottled while the download side was fine.
|
||||
_DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
# stream as a ready asset.
|
||||
_YTDLP_TEMP_RE = re.compile(
|
||||
r"\.part(-Frag\d+)?$" # in-flight download / fragment
|
||||
r"|\.ytdl$" # resume state
|
||||
r"|\.temp\.[^.]+$" # merge/postprocess temp
|
||||
r"|\.f\d+\.[^.]+$", # per-format stream kept before the merge
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _notify_download_done(
|
||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
||||
) -> None:
|
||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
if user_id is None:
|
||||
return
|
||||
try:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||
data={"title": title, "kind": kind},
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("failed to write download-done notification for job %s", job_id)
|
||||
|
||||
|
||||
def _parse_upload_date(raw) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
|
||||
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
|
||||
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset is None or asset.title:
|
||||
return
|
||||
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
|
||||
# (e.g. a title that was only a social engagement prefix); the completion path later sets
|
||||
# the final title with its own source_ref fallback.
|
||||
asset.title = normalize_title(info.get("title")) or info.get("id")
|
||||
asset.uploader = info.get("uploader") or info.get("channel")
|
||||
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
|
||||
asset.thumbnail_url = info.get("thumbnail")
|
||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _make_progress_hook(job_id: int, asset_id: int):
|
||||
state = {"meta_done": False}
|
||||
cancelled = _cancel_poll(job_id) # same policy as the ffmpeg path
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S)
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||
if cancelled():
|
||||
raise _Aborted
|
||||
if not state["meta_done"] and d.get("info_dict"):
|
||||
state["meta_done"] = True
|
||||
try:
|
||||
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||
pass
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
if not allow_write():
|
||||
return
|
||||
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||
# Label the phase so the user understands the bar resetting between streams instead of
|
||||
# seeing a mysterious 95%→5% jump.
|
||||
info = d.get("info_dict") or {}
|
||||
vcodec = info.get("vcodec") or "none"
|
||||
acodec = info.get("acodec") or "none"
|
||||
if vcodec != "none" and acodec == "none":
|
||||
phase = "video"
|
||||
elif acodec != "none" and vcodec == "none":
|
||||
phase = "audio"
|
||||
else:
|
||||
phase = "media"
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
done = d.get("downloaded_bytes") or 0
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
_set_job(
|
||||
job_id,
|
||||
progress=min(pct, 99),
|
||||
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _rate_limiter(interval_s: float, *, now=time.monotonic):
|
||||
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
|
||||
`now` is injectable so tests drive a clock instead of patching the global one."""
|
||||
# `None`, not 0.0: "never ran" has to be a VALUE, not a falsy timestamp. A truthiness test here
|
||||
# reads a legitimate `now() == 0.0` as "never ran" and lets a second call straight through —
|
||||
# unreachable with real `time.monotonic()`, but `now` exists to be injected, and a test clock
|
||||
# starting at 0 is the obvious choice. That would let a throttle test encode the wrong
|
||||
# behaviour and still pass.
|
||||
state: dict[str, float | None] = {"at": None}
|
||||
|
||||
def should_run() -> bool:
|
||||
t = now()
|
||||
last = state["at"]
|
||||
if last is not None and t - last < interval_s:
|
||||
return False
|
||||
state["at"] = t
|
||||
return True
|
||||
|
||||
return should_run
|
||||
|
||||
|
||||
def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, now=time.monotonic):
|
||||
"""Cooperative "has this job been paused/cancelled?" poll, rate-limited and sticky: once it
|
||||
says yes it never queries again. Shared by the yt-dlp hook and run_ffmpeg so cancel latency is
|
||||
one policy, not one per call site."""
|
||||
allow = _rate_limiter(interval_s, now=now)
|
||||
state = {"cancelled": False}
|
||||
|
||||
def cancelled() -> bool:
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
||||
"""`on_progress` for run_ffmpeg: persist a progress row write at most once per interval and only
|
||||
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
|
||||
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
|
||||
# Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the
|
||||
# round-1 concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat —
|
||||
# so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that
|
||||
# same interval, keeping active downloads at ~1 write/interval, not two.
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||
state = {"pct": -1}
|
||||
last_touch: dict = {"t": None} # monotonic time of the last DB write/heartbeat
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
if whole != state["pct"]:
|
||||
if allow_write():
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
last_touch["t"] = now()
|
||||
return
|
||||
# Same integer percent, but ffmpeg IS still working — a slow re-encode (AV1/VP9 -> H.264 or a
|
||||
# crop on the CPU-only host) can sit on one percent for minutes. Heartbeat updated_at so the
|
||||
# stall watchdog doesn't kill a live job (B-sub-4) — but only if no write/beat already touched
|
||||
# the row this interval, so a progressing download doesn't pay a redundant write.
|
||||
t = now()
|
||||
if last_touch["t"] is None or t - last_touch["t"] >= _DB_WRITE_INTERVAL_S:
|
||||
_touch_job(job_id)
|
||||
last_touch["t"] = t
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _pp_phase(pp: str) -> str:
|
||||
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||
pulse — this makes the long merge/finalize legible instead of a silent 'Processing'."""
|
||||
if "Merg" in pp:
|
||||
return "merging"
|
||||
if "ExtractAudio" in pp:
|
||||
return "audio_extract"
|
||||
if "Thumbnail" in pp:
|
||||
return "thumbnail"
|
||||
if "SponsorBlock" in pp or "ModifyChapters" in pp:
|
||||
return "sponsorblock"
|
||||
if "Metadata" in pp:
|
||||
return "metadata"
|
||||
return "processing"
|
||||
|
||||
|
||||
def _make_pp_hook(job_id: int):
|
||||
"""Postprocessor hook: surface the current ffmpeg step so the bar shows a concrete label
|
||||
(Merging / Embedding thumbnail / …) instead of freezing at 100% after the streams finish."""
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
if d.get("status") in ("started", "processing"):
|
||||
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||
for p in staging.iterdir():
|
||||
if p != media and p.suffix.lower() in _THUMB_SUFFIXES:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _produced_file(info: dict, staging: Path) -> Path:
|
||||
"""The media file yt-dlp actually wrote.
|
||||
|
||||
`requested_downloads[0].filepath` is the authoritative answer on YouTube, but an arbitrary-URL
|
||||
extractor may not fill it in at all — and `Path("")` is `Path(".")`, which *passes* `.exists()`,
|
||||
so a missing value used to sail past the fallback and blow up two steps later (`with_name` on an
|
||||
empty name: the BBC-news failure). Anything that isn't a real file counts as absent; then fall
|
||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
req = (info.get("requested_downloads") or [{}])[0]
|
||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||
if fp:
|
||||
produced = Path(str(fp))
|
||||
if produced.is_file():
|
||||
return produced
|
||||
cands = [
|
||||
p
|
||||
for p in staging.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix.lower() not in _THUMB_SUFFIXES
|
||||
and not _YTDLP_TEMP_RE.search(p.name)
|
||||
]
|
||||
if not cands:
|
||||
raise RuntimeError("yt-dlp produced no output file")
|
||||
return max(cands, key=lambda p: p.stat().st_size)
|
||||
|
||||
|
||||
def _media_info(info: dict) -> dict:
|
||||
"""Unwrap a playlist/multi_video extraction to the entry that carries the download.
|
||||
|
||||
A single non-YouTube page (a news article's video, a multi-rendition embed) can extract as a
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on an entry. Pick the
|
||||
FIRST entry that actually downloaded something: on a page with a skipped teaser in front of the
|
||||
real video, entries[0] carries no file, and taking it blindly would label the produced file
|
||||
with the wrong id/title/duration."""
|
||||
entries = [e for e in (info.get("entries") or []) if e]
|
||||
if not entries or info.get("requested_downloads"):
|
||||
return info
|
||||
chosen = next((e for e in entries if e.get("requested_downloads")), entries[0])
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
for i, clients in enumerate(client_sets):
|
||||
opts = formats.build_ydl_opts(
|
||||
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=True)
|
||||
except yt_dlp.utils.DownloadError as exc:
|
||||
last_exc = exc
|
||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
||||
for p in list(staging.iterdir()): # drop partials before the retry
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_set_job(job_id, progress=0, phase="downloading")
|
||||
continue
|
||||
raise
|
||||
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 _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
|
||||
if spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
||||
if not needs:
|
||||
return produced
|
||||
|
||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
|
||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||
# re-encode still shows real progress instead of a frozen 0%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
editmod.run_ffmpeg(
|
||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
if reenc_v:
|
||||
info["vcodec"] = "h264"
|
||||
if reenc_a:
|
||||
info["acodec"] = "aac"
|
||||
return dest
|
||||
|
||||
|
||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||
staging = _staging_dir(asset_id)
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
|
||||
produced = _produced_file(info, staging)
|
||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||
ext = produced.suffix.lstrip(".").lower()
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(info.get("title")) or source_ref,
|
||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||
duration_s=int(info["duration"]) if info.get("duration") else None,
|
||||
description=info.get("description"),
|
||||
thumbnail_url=info.get("thumbnail"),
|
||||
)
|
||||
|
||||
layout, retention_days = _download_settings()
|
||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||
thumb = _find_thumbnail(staging, produced)
|
||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
||||
|
||||
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=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.duration_s = meta.duration_s
|
||||
asset.width = int(info["width"]) if info.get("width") else None
|
||||
asset.height = int(info["height"]) if info.get("height") else None
|
||||
asset.container = ext
|
||||
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
asset.upload_date = meta.upload_date
|
||||
asset.thumbnail_url = meta.thumbnail_url
|
||||
# Record a self-hosted poster for every download: it's the reliable link-preview
|
||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
asset.nfo_written = nfo_ok
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||
"eta_s": None}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
fields["display_name"] = meta.title[:255]
|
||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||
log.info("job %s done: %s", job_id, rel)
|
||||
|
||||
except (_Aborted, editmod.EditAborted):
|
||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("job %s failed: %s", job_id, msg)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running (see the download error path).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("edit job %s failed: %s", job_id, msg)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
src = db.get(MediaAsset, job.source_asset_id) if job and job.source_asset_id else None
|
||||
edit_spec = dict(job.edit_spec or {}) if job else {}
|
||||
user_id = job.user_id if job else None
|
||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||
src_rel = src.rel_path if src else None
|
||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
return
|
||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
||||
if not src_path.exists():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
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
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||
owner_id = job.user_id if job else None
|
||||
dl_title = asset.title if asset else None
|
||||
# Atomic terminal write (see the download path): don't resurrect a route-stopped job.
|
||||
finalized = service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||
)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||
log.info("edit job %s done: %s", job_id, rel)
|
||||
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
@@ -38,8 +38,8 @@ def main() -> None:
|
||||
return
|
||||
|
||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||
# real work, and to keep the import graph obvious: runner is a thin entry over worker's helpers.
|
||||
from app.worker import _download, _process_edit
|
||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||
from app.downloads.pipeline import _download, _process_edit
|
||||
|
||||
if job_kind == "edit":
|
||||
_process_edit(job_id, asset_id)
|
||||
|
||||
@@ -112,6 +112,34 @@ def next_queue_pos(db: Session) -> int:
|
||||
return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1
|
||||
|
||||
|
||||
def incr_ref_count(db: Session, asset_id: int) -> None:
|
||||
"""Atomically +1 an asset's hold count AT THE DB. A Python read-modify-write
|
||||
(`asset.ref_count = asset.ref_count + 1`) loses an update when two enqueues of the same
|
||||
(source,format) run in parallel — both read the same value and write the same +1 — leaving the
|
||||
count one short, so the first release can free a file another job still holds (B-sub-5)."""
|
||||
db.execute(
|
||||
update(MediaAsset)
|
||||
.where(MediaAsset.id == asset_id)
|
||||
.values(ref_count=MediaAsset.ref_count + 1)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
|
||||
def decr_ref_count(db: Session, asset_id: int) -> int:
|
||||
"""Atomically -1 an asset's hold count (clamped at 0) and RETURN the new value. The caller decides
|
||||
whether to free the asset from the returned count while still holding the row's write lock, so two
|
||||
concurrent releases can't both observe the same pre-decrement value and double-free — Postgres
|
||||
serialises the two UPDATEs on the row lock (B-sub-5). synchronize_session=False: the ORM copy is
|
||||
intentionally left stale; callers read the returned value, not `asset.ref_count`."""
|
||||
return db.execute(
|
||||
update(MediaAsset)
|
||||
.where(MediaAsset.id == asset_id)
|
||||
.values(ref_count=func.greatest(MediaAsset.ref_count - 1, 0))
|
||||
.returning(MediaAsset.ref_count)
|
||||
.execution_options(synchronize_session=False)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def enqueue(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -143,7 +171,7 @@ def enqueue(
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
# Cache hit: the file already exists — no worker round-trip needed.
|
||||
if asset.status == "ready":
|
||||
@@ -212,7 +240,7 @@ def enqueue_edit(
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||
job.status = "done"
|
||||
@@ -273,8 +301,83 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset is None:
|
||||
return
|
||||
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
|
||||
if asset.ref_count == 0 and asset.status == "ready":
|
||||
new_count = decr_ref_count(db, asset.id)
|
||||
if new_count == 0 and asset.status == "ready":
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
|
||||
|
||||
# --- Job state transitions ---------------------------------------------------------------------
|
||||
# The single home for the pause/resume/cancel/delete/redownload transitions + their asset
|
||||
# bookkeeping (release, failed-asset reset, fresh-asset rebuild), so the routes are thin callers and
|
||||
# the worker/GC could share the same definitions (B-sub-8). Each commits its own transition; the
|
||||
# route keeps only auth + HTTP mapping + serialization.
|
||||
|
||||
|
||||
def pause(db: Session, job: DownloadJob) -> None:
|
||||
"""Pause a queued/running job (a running worker aborts cooperatively via the cancel poll); a no-op
|
||||
in any other state."""
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
|
||||
job.status = JobStatus.PAUSED
|
||||
db.commit()
|
||||
|
||||
|
||||
def resume(db: Session, job: DownloadJob) -> None:
|
||||
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
||||
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
||||
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset)."""
|
||||
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
||||
job.progress = 0
|
||||
job.error = None
|
||||
job.error_code = None
|
||||
if job.asset_id:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
||||
asset.status = "pending"
|
||||
asset.error = None
|
||||
job.status = JobStatus.QUEUED
|
||||
db.commit()
|
||||
|
||||
|
||||
def cancel(db: Session, job: DownloadJob) -> None:
|
||||
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
||||
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||
release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = JobStatus.CANCELED
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete(db: Session, job: DownloadJob) -> None:
|
||||
"""Delete a job, releasing its asset hold first (which frees the file iff this was the last holder)."""
|
||||
release_asset(db, job)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
|
||||
|
||||
def redownload(db: Session, job: DownloadJob) -> None:
|
||||
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
||||
the job id + current asset id, so it never reuses the dedup pool) and requeue the SAME job row —
|
||||
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
||||
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
||||
has already rejected edit clips + still-in-progress jobs (HTTP 409)."""
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
||||
asset = get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
||||
populate_from_catalog(db, asset)
|
||||
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
||||
release_asset(db, job)
|
||||
incr_ref_count(db, asset.id)
|
||||
job.asset_id = asset.id
|
||||
job.status = JobStatus.QUEUED
|
||||
job.progress = 0
|
||||
job.phase = None
|
||||
job.error = None
|
||||
job.speed_bps = None
|
||||
job.eta_s = None
|
||||
# Re-derive the display name from the freshly cleaned title on completion (else an older, un-cleaned
|
||||
# name would survive the rebuild).
|
||||
job.display_name = None
|
||||
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||
db.commit()
|
||||
|
||||
@@ -547,9 +547,6 @@ class AppState(Base):
|
||||
sync_paused: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
|
||||
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
|
||||
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
|
||||
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
|
||||
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
|
||||
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, func, or_, type_coerce
|
||||
from sqlalchemy import and_, case, func, or_, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
@@ -189,3 +189,62 @@ class LibraryFilters:
|
||||
|
||||
def like_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
||||
#
|
||||
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
||||
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
|
||||
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
|
||||
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
|
||||
# home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_
|
||||
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
|
||||
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
|
||||
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
|
||||
|
||||
|
||||
def movie_status_case(st):
|
||||
"""The per-movie status label (new | in_progress | watched) from its PlexState alias `st`."""
|
||||
return case(
|
||||
(st.status == "watched", "watched"),
|
||||
(
|
||||
and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")),
|
||||
"in_progress",
|
||||
),
|
||||
else_="new",
|
||||
)
|
||||
|
||||
|
||||
def show_status_case(tot, wat, inp):
|
||||
"""The per-show status label rolled up from its episodes' totals (total/watched/in-progress)."""
|
||||
return case(
|
||||
(and_(tot > 0, wat >= tot), "watched"),
|
||||
(or_(wat > 0, inp > 0), "in_progress"),
|
||||
else_="new",
|
||||
)
|
||||
|
||||
|
||||
def apply_movie_state_filter(query, st, show: str):
|
||||
"""Restrict a movie query to the `show` scope via its PlexState alias `st`. The default (`all`)
|
||||
excludes hidden movies — the exclusion that must match between grid and facets."""
|
||||
if show == "watched":
|
||||
return query.where(st.status == "watched")
|
||||
if show == "in_progress":
|
||||
return query.where(
|
||||
st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")
|
||||
)
|
||||
if show == "unwatched":
|
||||
return query.where(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||
return query.where(or_(st.status.is_(None), st.status != "hidden"))
|
||||
|
||||
|
||||
def apply_show_state_filter(query, tot, wat, inp, show: str):
|
||||
"""Restrict a show query to the `show` scope via its rollup totals. `all` is a no-op (shows have
|
||||
no hidden concept); the caller only joins the rollup for the three non-`all` scopes."""
|
||||
if show == "watched":
|
||||
return query.where(tot > 0, wat >= tot)
|
||||
if show == "in_progress":
|
||||
return query.where(or_(wat > 0, inp > 0), wat < tot)
|
||||
if show == "unwatched":
|
||||
return query.where(wat == 0, inp == 0)
|
||||
return query
|
||||
|
||||
@@ -5,9 +5,14 @@ Playback is from the local physical file, so this only mirrors METADATA + the ph
|
||||
(no ffprobe needed) to classify each item's browser playability. Movies + episodes become playable
|
||||
LEAF rows (plex_items); shows/seasons are mirrored for the drill-down + episode ordering.
|
||||
|
||||
The sync is a full reconcile of the enabled sections (upsert by rating_key). Pruning of items that
|
||||
disappeared from Plex is intentionally left for later (a follow-up can diff rating_keys per library).
|
||||
Cast + intro/credit markers are fetched lazily on the item-detail/player path (P2), not here.
|
||||
The sync is a full reconcile of the enabled sections (upsert by rating_key) AND prunes rows for
|
||||
titles that disappeared from Plex: each section diffs the rating_keys it just listed against the ones
|
||||
stored, and deletes the stale ones (their watch-state / playlist links cascade). The prune is guarded
|
||||
on a NON-empty listing — a section that returns zero items is left untouched, so a transient Plex
|
||||
glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental
|
||||
mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the
|
||||
dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the
|
||||
item-detail/player path (P2), not here.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
@@ -150,7 +155,7 @@ def sync(db: Session) -> dict:
|
||||
return {"skipped": "disabled"}
|
||||
stats = {
|
||||
"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0,
|
||||
"collections": 0, "overlaid": 0,
|
||||
"collections": 0, "overlaid": 0, "pruned": 0,
|
||||
}
|
||||
wanted = _enabled_section_keys(db)
|
||||
overlay_map = _overlay_map(db) # {huKey: enKey}
|
||||
@@ -279,8 +284,14 @@ def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary
|
||||
return lib
|
||||
|
||||
|
||||
def _paginate(plex: PlexClient, key: str, item_type: int):
|
||||
def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | None = None):
|
||||
"""Yield a section's items page by page. If `progress` is given, set `progress["complete"]` True
|
||||
ONLY when the walk reached the server's reported `total` — a truncated fetch (an empty/short page
|
||||
mid-sequence, e.g. a transient Plex hiccup, before `total` is reached) leaves it False so the
|
||||
caller can skip pruning. Pruning on a partial listing would delete rows the listing merely failed
|
||||
to mention (the metadata is re-syncable, but that still cascades watch-state)."""
|
||||
start = 0
|
||||
total = None
|
||||
while True:
|
||||
items, total = plex.section_items(key, item_type, start, _PAGE)
|
||||
if not items:
|
||||
@@ -292,6 +303,10 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
|
||||
break
|
||||
if len(items) < _PAGE:
|
||||
break
|
||||
if progress is not None:
|
||||
# A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows
|
||||
# (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative.
|
||||
progress["complete"] = bool(total) and start >= total
|
||||
|
||||
|
||||
# The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this
|
||||
@@ -351,6 +366,24 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
row.playable = classify_playable(mf.get("codec_video"), mf.get("codec_audio"), mf.get("container"))
|
||||
|
||||
|
||||
def _prune_stale(
|
||||
db: Session, existing: dict, seen: set[str], stats: dict, *, complete: bool
|
||||
) -> None:
|
||||
"""Delete stored rows whose rating_key is no longer in the freshly-listed `seen` set — the titles
|
||||
that disappeared from Plex (their watch-state / playlist links cascade via the FKs). GUARDED on a
|
||||
COMPLETE, non-empty listing: a truncated fetch (`complete` False — a mid-sequence empty/short page
|
||||
before the server's total) or a zero-item listing prunes NOTHING, so a transient Plex hiccup can
|
||||
never wipe a library over rows it merely failed to list. Callers pass the rating_key -> row map
|
||||
they already built for the upsert."""
|
||||
if not complete or not seen:
|
||||
return
|
||||
for rk, row in existing.items():
|
||||
if rk not in seen:
|
||||
db.delete(row)
|
||||
stats["pruned"] = stats.get("pruned", 0) + 1
|
||||
db.flush()
|
||||
|
||||
|
||||
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
existing = {
|
||||
r.rating_key: r
|
||||
@@ -358,7 +391,9 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
||||
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
|
||||
seen: set[str] = set()
|
||||
progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE, progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
@@ -366,8 +401,10 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
_apply_item(row, lib.id, "movie", meta)
|
||||
seen.add(rk)
|
||||
stats["movies"] += 1
|
||||
db.flush()
|
||||
_prune_stale(db, existing, seen, stats, complete=progress.get("complete", False))
|
||||
|
||||
|
||||
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
@@ -378,10 +415,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexShow).where(PlexShow.library_id == lib.id)
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
|
||||
shows_seen: set[str] = set()
|
||||
shows_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW, shows_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
shows_seen.add(rk)
|
||||
sh = shows.get(rk) or PlexShow(rating_key=rk)
|
||||
if sh.id is None:
|
||||
db.add(sh)
|
||||
@@ -407,11 +447,14 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id)
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
|
||||
seasons_seen: set[str] = set()
|
||||
seasons_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON, seasons_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
||||
if not rk or sid is None:
|
||||
continue
|
||||
seasons_seen.add(rk)
|
||||
se = seasons.get(rk) or PlexSeason(rating_key=rk)
|
||||
if se.id is None:
|
||||
db.add(se)
|
||||
@@ -431,10 +474,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode")
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
|
||||
episodes_seen: set[str] = set()
|
||||
episodes_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE, episodes_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
episodes_seen.add(rk)
|
||||
row = episodes.get(rk) or PlexItem(rating_key=rk)
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
@@ -446,6 +492,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
stats["episodes"] += 1
|
||||
db.flush()
|
||||
|
||||
# Prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly, before its
|
||||
# parent's cascade would reach it — avoids deleting the same ORM object twice. Each level is
|
||||
# independently guarded on a non-empty listing by _prune_stale.
|
||||
_prune_stale(db, episodes, episodes_seen, stats, complete=episodes_progress.get("complete", False))
|
||||
_prune_stale(db, seasons, seasons_seen, stats, complete=seasons_progress.get("complete", False))
|
||||
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
|
||||
|
||||
|
||||
def _sync_overlay(
|
||||
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
|
||||
|
||||
+53
-33
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -187,6 +188,36 @@ def _oauth_redirect_uri(request: Request) -> str:
|
||||
return settings.oauth_redirect_url
|
||||
|
||||
|
||||
|
||||
# --- Request bodies (typed contracts; B-core-6) ---------------------------------------------
|
||||
# Fields are `str | None` (not required) so the handlers keep their exact prior behaviour — a
|
||||
# missing/null field reads as None and the existing `(... or "")` coercions still apply — while
|
||||
# FastAPI now validates the body is a JSON object and publishes the schema.
|
||||
|
||||
|
||||
class EmailIn(BaseModel):
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class TokenPasswordIn(BaseModel):
|
||||
token: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class SetPasswordIn(BaseModel):
|
||||
password: str | None = None
|
||||
current_password: str | None = None
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
@@ -448,7 +479,7 @@ async def logout(request: Request):
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -462,7 +493,7 @@ async def request_access(
|
||||
is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google.
|
||||
Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the
|
||||
endpoint can't be probed for throttling either."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not _request_access_limiter.allow(_client_ip(request)):
|
||||
@@ -477,7 +508,7 @@ async def request_access(
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
|
||||
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
|
||||
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
|
||||
@@ -485,7 +516,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||
if not _demo_limiter.allow(_client_ip(request)):
|
||||
return {"authenticated": False}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.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
|
||||
@@ -556,7 +587,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
payload: dict,
|
||||
body: EmailPasswordIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -565,8 +596,8 @@ def register(
|
||||
verification link, and records an access request for admin approval. Two gates before
|
||||
sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
|
||||
the endpoint can't be used to discover which emails are registered."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
# Input validation is safe to reveal (independent of whether the email exists).
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
@@ -628,10 +659,10 @@ def _register_account(email: str, password: str) -> None:
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||
on any invalid/expired/used token."""
|
||||
user = _consume_token(db, payload.get("token"), "verify")
|
||||
user = _consume_token(db, body.token, "verify")
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This verification link is invalid or has expired."
|
||||
@@ -643,7 +674,7 @@ def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
|
||||
@router.post("/verify/resend")
|
||||
def verify_email_resend(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> dict:
|
||||
@@ -653,7 +684,7 @@ def verify_email_resend(
|
||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _verify_resend_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email):
|
||||
background.add_task(_resend_verify_if_eligible, email)
|
||||
return {"status": "ok"}
|
||||
@@ -688,7 +719,7 @@ def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/password-login")
|
||||
def password_login(
|
||||
payload: dict,
|
||||
body: EmailPasswordIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -700,8 +731,8 @@ def password_login(
|
||||
raise HTTPException(
|
||||
status_code=429, detail="Too many attempts. Try again shortly."
|
||||
)
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
||||
# response time is the same whether or not the email exists — no timing-based enumeration.
|
||||
@@ -731,7 +762,7 @@ def password_login(
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
def password_reset_request(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> dict:
|
||||
@@ -739,7 +770,7 @@ def password_reset_request(
|
||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _reset_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||
@@ -761,10 +792,10 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
|
||||
token = payload.get("token")
|
||||
new_password = payload.get("password") or ""
|
||||
token = body.token
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -872,7 +903,7 @@ async def link_google(
|
||||
|
||||
@router.post("/set-password")
|
||||
def set_password(
|
||||
payload: dict,
|
||||
body: SetPasswordIn,
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -884,7 +915,7 @@ def set_password(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not available in the demo account."
|
||||
)
|
||||
new_password = payload.get("password") or ""
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -892,7 +923,7 @@ def set_password(
|
||||
)
|
||||
if user.password_hash:
|
||||
if not verify_password(
|
||||
payload.get("current_password") or "", user.password_hash
|
||||
body.current_password or "", user.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Current password is incorrect."
|
||||
@@ -941,17 +972,6 @@ async def upgrade(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(current_user)) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def auth_config(db: Session = Depends(get_db)) -> dict:
|
||||
"""Public: which sign-in options this instance offers, so the login page can hide what's off
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Channel manager: the user's subscriptions with their personal overrides (priority,
|
||||
hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes
|
||||
(unsubscribe) live behind the optional write scope and are added in a later phase."""
|
||||
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
||||
(subscribe / unsubscribe) that require the optional write scope."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import formats, quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads import quota, service, storage
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -454,18 +453,12 @@ def update_download_meta(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
def _set_status(db: Session, job: DownloadJob, status: str) -> None:
|
||||
job.status = status
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{job_id}/pause")
|
||||
def pause_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
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
|
||||
service.pause(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -474,19 +467,7 @@ def resume_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status in ("paused", "error"):
|
||||
job.progress = 0
|
||||
job.error = None
|
||||
job.error_code = None
|
||||
# If the shared asset itself failed, reset it to pending so the worker actually
|
||||
# re-downloads — otherwise the requeued job would instantly re-inherit the asset's
|
||||
# stale error (the worker short-circuits a job whose asset is already errored).
|
||||
if job.asset_id:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset and asset.status == "error":
|
||||
asset.status = "pending"
|
||||
asset.error = None
|
||||
_set_status(db, job, "queued")
|
||||
service.resume(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -510,33 +491,7 @@ def redownload_download(
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
|
||||
# Resolve the fresh private asset FIRST (before touching any file). Salting with the current
|
||||
# asset id makes it distinct from both the dedup pool and this job's current asset, so the
|
||||
# insert never collides — no IntegrityError/rollback can strand us after a file was deleted.
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
||||
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
||||
service.populate_from_catalog(db, asset)
|
||||
|
||||
# Now drop the old hold — `service.release_asset` deletes the old file + row only if this job was its
|
||||
# last holder (a co-held asset is left intact for its other holders).
|
||||
service.release_asset(db, job)
|
||||
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
job.asset_id = asset.id
|
||||
job.status = "queued"
|
||||
job.progress = 0
|
||||
job.phase = None
|
||||
job.error = None
|
||||
job.speed_bps = None
|
||||
job.eta_s = None
|
||||
# Re-derive the display name (card title + Content-Disposition filename) from the freshly
|
||||
# cleaned title — otherwise a name auto-set from an older, un-cleaned title (e.g. still carrying
|
||||
# a "1.6M views · … |" social prefix) would survive the rebuild. The worker fills it on completion.
|
||||
job.display_name = None
|
||||
job.queue_pos = service.next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||
db.commit()
|
||||
service.redownload(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -545,10 +500,7 @@ def cancel_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||
service.release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = JobStatus.CANCELED
|
||||
db.commit()
|
||||
service.cancel(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -557,9 +509,7 @@ def delete_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
service.release_asset(db, job)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
service.delete(db, job)
|
||||
return {"deleted": job_id}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Local playlists: per-user named, ordered collections of videos from the shared
|
||||
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
|
||||
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
|
||||
"""Playlists: per-user named, ordered collections of videos from the shared catalog. Local
|
||||
CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-way sync —
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
|
||||
@@ -10,9 +10,21 @@ wiring expects (`plex_routes.router`), so main.py is unchanged.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.routes.plex import admin, browse, playback
|
||||
from app.routes.plex import (
|
||||
admin,
|
||||
browse,
|
||||
collections,
|
||||
detail,
|
||||
people,
|
||||
playback,
|
||||
playlists,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(admin.router)
|
||||
router.include_router(browse.router)
|
||||
router.include_router(collections.router)
|
||||
router.include_router(detail.router)
|
||||
router.include_router(people.router)
|
||||
router.include_router(playback.router)
|
||||
router.include_router(playlists.router)
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
)
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
@@ -18,3 +20,22 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||
return it
|
||||
|
||||
|
||||
def _norm_lang(lang: str | None) -> str:
|
||||
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
|
||||
return "hu" if lang == "hu" else "en"
|
||||
|
||||
|
||||
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5)."""
|
||||
libs = db.execute(
|
||||
select(PlexLibrary).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
).scalars().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"]
|
||||
return movie_lib_ids, show_lib_ids
|
||||
|
||||
+17
-1241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexLibrary,
|
||||
User,
|
||||
)
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import like_escape
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
||||
|
||||
|
||||
def _collection_source(col: PlexCollection) -> str:
|
||||
first = (col.title or "").strip().split(" ", 1)[0].lower()
|
||||
if first in _STRIP_PROVIDERS:
|
||||
return first
|
||||
if col.smart:
|
||||
return "smart"
|
||||
return "collection"
|
||||
|
||||
|
||||
def _collection_editable(c: PlexCollection) -> bool:
|
||||
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||
return bool(c.editable) and not c.smart and _collection_source(c) == "collection"
|
||||
|
||||
|
||||
def _collection_card(c: PlexCollection) -> dict:
|
||||
return {
|
||||
"id": c.rating_key,
|
||||
"title": c.title,
|
||||
"summary": c.summary,
|
||||
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||
"child_count": c.child_count,
|
||||
"smart": c.smart,
|
||||
"source": _collection_source(c),
|
||||
"editable": c.editable,
|
||||
"can_edit": _collection_editable(
|
||||
c
|
||||
), # whether the edit controls should be offered (admin UI)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
def collections(
|
||||
library: str | None = None,
|
||||
q: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
|
||||
`library` plex_key → that one library's collections (used by the collection editor). WITHOUT one →
|
||||
the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
|
||||
library selected)."""
|
||||
if library is not None:
|
||||
lib = db.execute(
|
||||
select(PlexLibrary).where(PlexLibrary.plex_key == str(library))
|
||||
).scalars().first()
|
||||
if lib is None:
|
||||
return {"collections": []}
|
||||
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
|
||||
else:
|
||||
enabled_ids = list(
|
||||
db.execute(
|
||||
select(PlexLibrary.id).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
if not enabled_ids:
|
||||
return {"collections": []}
|
||||
query = select(PlexCollection).where(
|
||||
PlexCollection.library_id.in_(enabled_ids)
|
||||
)
|
||||
term = (q or "").strip()
|
||||
if term:
|
||||
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
|
||||
rows = db.execute(
|
||||
query.order_by(
|
||||
PlexCollection.child_count.desc().nullslast(),
|
||||
func.lower(PlexCollection.title),
|
||||
)
|
||||
).scalars().all()
|
||||
return {"collections": [_collection_card(c) for c in rows]}
|
||||
|
||||
|
||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
if not _collection_editable(col):
|
||||
raise HTTPException(status_code=403, detail="This collection is read-only")
|
||||
return col
|
||||
|
||||
|
||||
class CreateCollectionIn(BaseModel):
|
||||
library: str = ""
|
||||
title: str = ""
|
||||
item_rating_key: str = ""
|
||||
|
||||
|
||||
@router.post("/collections")
|
||||
def create_collection(
|
||||
body: CreateCollectionIn,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Create a manual collection (seeded with one item — Plex needs at least one). Editable by default."""
|
||||
library = body.library
|
||||
title = body.title.strip()
|
||||
seed = body.item_rating_key
|
||||
if not title or not seed:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="title and item_rating_key are required"
|
||||
)
|
||||
lib = db.execute(
|
||||
select(PlexLibrary).where(PlexLibrary.plex_key == library)
|
||||
).scalars().first()
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown library")
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
new = plex.create_collection(lib.plex_key, title, seed)
|
||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
||||
col.editable = True
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@router.post("/collections/{rating_key}/items/{item_rating_key}")
|
||||
def collection_add_item(
|
||||
rating_key: str,
|
||||
item_rating_key: str,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.add_collection_item(rating_key, str(item_rating_key))
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@router.delete("/collections/{rating_key}/items/{item_rating_key}")
|
||||
def collection_remove_item(
|
||||
rating_key: str,
|
||||
item_rating_key: str,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.remove_collection_item(rating_key, str(item_rating_key))
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
class CollectionRenameIn(BaseModel):
|
||||
title: str = ""
|
||||
|
||||
|
||||
@router.patch("/collections/{rating_key}")
|
||||
def collection_rename(
|
||||
rating_key: str,
|
||||
body: CollectionRenameIn,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.rename_collection(lib.plex_key, rating_key, title)
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@router.delete("/collections/{rating_key}")
|
||||
def collection_delete(
|
||||
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.delete_collection(rating_key)
|
||||
plex_sync.delete_collection_local(db, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
return {"deleted": rating_key}
|
||||
|
||||
|
||||
class CollectionEditableIn(BaseModel):
|
||||
editable: bool = False
|
||||
|
||||
|
||||
@router.post("/collections/{rating_key}/editable")
|
||||
def collection_set_editable(
|
||||
rating_key: str,
|
||||
body: CollectionEditableIn,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
|
||||
(IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
want = body.editable
|
||||
if want and (col.smart or _collection_source(col) != "collection"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Smart / auto-list collections can't be made editable",
|
||||
)
|
||||
col.editable = want
|
||||
db.commit()
|
||||
return _collection_card(col)
|
||||
@@ -0,0 +1,438 @@
|
||||
"""Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav)."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import detail as plex_detail
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import rollup, subtitles
|
||||
from app.plex.cards import episode_card, movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes.plex._shared import _norm_lang, item_or_404
|
||||
from app.routes.plex.collections import _collection_source
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
||||
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||
if library_id is None:
|
||||
return None
|
||||
lib = db.get(PlexLibrary, library_id)
|
||||
return lib.plex_key if lib else None
|
||||
|
||||
|
||||
_SHOW_ENRICH_TTL_S = 300
|
||||
|
||||
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
|
||||
|
||||
_show_enrich_lock = threading.Lock()
|
||||
|
||||
|
||||
def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
|
||||
"""(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when
|
||||
warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns
|
||||
({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open)."""
|
||||
now = time.monotonic()
|
||||
with _show_enrich_lock:
|
||||
hit = _show_enrich_cache.get(rating_key)
|
||||
if hit is not None and hit[0] > now:
|
||||
return hit[1], hit[2]
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
meta_f = ex.submit(plex.metadata, rating_key)
|
||||
related_f = ex.submit(
|
||||
plex.related, rating_key
|
||||
) # swallows its own errors → []
|
||||
meta = meta_f.result() or {}
|
||||
related_raw = related_f.result()
|
||||
except (PlexError, PlexNotConfigured):
|
||||
return {}, []
|
||||
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
|
||||
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
|
||||
# even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO
|
||||
# have related items — the payload worth caching — still get cached.
|
||||
if related_raw:
|
||||
with _show_enrich_lock:
|
||||
_show_enrich_cache[rating_key] = (
|
||||
now + _SHOW_ENRICH_TTL_S,
|
||||
meta,
|
||||
related_raw,
|
||||
)
|
||||
return meta, related_raw
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}")
|
||||
def show_detail(
|
||||
rating_key: str,
|
||||
lang: str = "en",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
||||
hu = _norm_lang(lang) == "hu"
|
||||
sh = by_rating_key(db, PlexShow, rating_key)
|
||||
if sh is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||
seasons = db.execute(
|
||||
select(PlexSeason)
|
||||
.where(PlexSeason.show_id == sh.id)
|
||||
.order_by(PlexSeason.season_number.asc().nullsfirst())
|
||||
).scalars().all()
|
||||
eps = db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).scalars().all()
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
by_season: dict[int | None, list] = {}
|
||||
all_cards: list[
|
||||
dict
|
||||
] = [] # every episode, in season/episode order → show-level rollup
|
||||
for e in eps:
|
||||
card = episode_card(e, states.get(e.id))
|
||||
by_season.setdefault(e.season_id, []).append(card)
|
||||
all_cards.append(card)
|
||||
show_roll = rollup.rollup(all_cards)
|
||||
|
||||
# Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one
|
||||
# remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries
|
||||
# the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and
|
||||
# the lazy fill; the row-served detail below still renders (outage-resilient).
|
||||
related: list[dict] = []
|
||||
try:
|
||||
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id)
|
||||
if sh.people_enriched_at is None and meta:
|
||||
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
|
||||
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
|
||||
sh.people_enriched_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
# Best-effort only — the local episode list is already assembled, so any live-Plex failure
|
||||
# (not configured, HTTP/JSON shape) must degrade, not 500.
|
||||
pass
|
||||
|
||||
imdb_id = sh.imdb_id
|
||||
return {
|
||||
"show": {
|
||||
"id": sh.rating_key,
|
||||
"title": (sh.title_hu or sh.title) if hu else sh.title,
|
||||
"summary": (sh.summary_hu or sh.summary) if hu else sh.summary,
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||
"library": _lib_key(db, sh.library_id),
|
||||
"content_rating": sh.content_rating,
|
||||
"rating": sh.rating,
|
||||
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
|
||||
"studio": sh.studio,
|
||||
"season_count": sh.child_count,
|
||||
"imdb_rating": sh.imdb_rating,
|
||||
"imdb_id": imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||
"cast": sh.full_cast or [],
|
||||
# Show-level rollup for the hero action buttons.
|
||||
"status": show_roll["status"],
|
||||
"resume": show_roll["resume"],
|
||||
"first": show_roll["first"],
|
||||
"episode_count": show_roll["episode_count"],
|
||||
"collection_keys": sh.collection_keys or [],
|
||||
},
|
||||
"seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons],
|
||||
"related": related,
|
||||
}
|
||||
|
||||
|
||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||
season subpage, read from the same cached show-detail payload)."""
|
||||
roll = rollup.rollup(cards)
|
||||
return {
|
||||
"id": se.rating_key,
|
||||
"season_number": se.season_number,
|
||||
"title": se.title
|
||||
or (f"Season {se.season_number}" if se.season_number else "Season"),
|
||||
"thumb": f"/api/plex/image/{se.rating_key}",
|
||||
"episode_count": roll["episode_count"],
|
||||
"status": roll["status"],
|
||||
"resume": roll["resume"],
|
||||
"first": roll["first"],
|
||||
"episodes": cards,
|
||||
}
|
||||
|
||||
|
||||
def _relatedshow_cards(
|
||||
db: Session, user_id: int, related: list[dict], exclude: int
|
||||
) -> list[dict]:
|
||||
"""Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
|
||||
excluding the current show. Order preserved; capped."""
|
||||
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
|
||||
if not rks:
|
||||
return []
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
select(PlexShow).where(
|
||||
PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for rk in rks:
|
||||
sh = shows.get(rk)
|
||||
if sh is None or rk in seen:
|
||||
continue
|
||||
seen.add(rk)
|
||||
out.append(show_card(sh, statuses.get(sh.id, "new")))
|
||||
if len(out) >= 20:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
|
||||
"""The collections this movie belongs to, each with its member titles (playable cards) — the
|
||||
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
|
||||
keys = it.collection_keys or []
|
||||
if not keys or it.kind != "movie":
|
||||
return []
|
||||
cols = {
|
||||
c.rating_key: c
|
||||
for c in db.execute(
|
||||
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
|
||||
).scalars()
|
||||
}
|
||||
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
|
||||
ordered = sorted(
|
||||
(c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title)
|
||||
)
|
||||
out = []
|
||||
for col in ordered:
|
||||
rk = col.rating_key
|
||||
members = db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.library_id == it.library_id,
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.collection_keys.contains([rk]),
|
||||
)
|
||||
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
|
||||
.limit(60)
|
||||
).scalars().all()
|
||||
ids = [m.id for m in members]
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
out.append(
|
||||
{
|
||||
"id": rk,
|
||||
"title": col.title,
|
||||
"source": _collection_source(col),
|
||||
"items": [movie_card(m, states.get(m.id)) for m in members],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||
if it.kind != "episode" or it.show_id is None:
|
||||
return None, None
|
||||
keys = list(
|
||||
db.execute(
|
||||
select(PlexItem.rating_key)
|
||||
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
|
||||
.order_by(
|
||||
PlexItem.season_number.asc().nullsfirst(),
|
||||
PlexItem.episode_number.asc().nullsfirst(),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
try:
|
||||
i = keys.index(it.rating_key)
|
||||
except ValueError:
|
||||
return None, None
|
||||
return (keys[i - 1] if i > 0 else None), (
|
||||
keys[i + 1] if i < len(keys) - 1 else None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/item/{rating_key}")
|
||||
def item_detail(
|
||||
rating_key: str,
|
||||
lang: str = "en",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
||||
prev/next, and the per-user resume position + watch status."""
|
||||
lang = _norm_lang(lang)
|
||||
it = item_or_404(db, rating_key)
|
||||
st = db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id, PlexState.item_id == it.id
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
|
||||
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
|
||||
# parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay).
|
||||
meta: dict = {}
|
||||
markers: list[dict] = []
|
||||
audio_streams: list[dict] = []
|
||||
subtitle_streams: list[dict] = []
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||
for m in meta.get("Marker") or []:
|
||||
if m.get("type") in ("intro", "credits"):
|
||||
markers.append(
|
||||
{
|
||||
"type": m.get("type"),
|
||||
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
||||
}
|
||||
)
|
||||
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
|
||||
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
|
||||
for s in part.get("Stream") or []:
|
||||
if s.get("streamType") == 2:
|
||||
audio_streams.append(
|
||||
{
|
||||
"ord": len(audio_streams),
|
||||
"label": s.get("displayTitle")
|
||||
or s.get("language")
|
||||
or f"Audio {len(audio_streams) + 1}",
|
||||
"language": s.get("languageTag") or s.get("language"),
|
||||
"default": bool(s.get("selected") or s.get("default")),
|
||||
}
|
||||
)
|
||||
elif s.get("streamType") == 3:
|
||||
codec = (s.get("codec") or s.get("format") or "").lower()
|
||||
subtitle_streams.append(
|
||||
{
|
||||
"ord": len(subtitle_streams),
|
||||
"label": s.get("displayTitle")
|
||||
or s.get("language")
|
||||
or f"Subtitle {len(subtitle_streams) + 1}",
|
||||
"language": s.get("languageTag") or s.get("language"),
|
||||
"codec": codec,
|
||||
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
|
||||
# can't be turned into text, so the player hides them (burn-in is a later phase).
|
||||
"text": codec not in subtitles.BITMAP_SUBS,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
||||
|
||||
# Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync
|
||||
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
|
||||
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
|
||||
if it.kind == "movie" and it.people_enriched_at is None and meta:
|
||||
cast = plex_people.cast_from_meta(meta)
|
||||
if cast:
|
||||
plex_people.enrich_row_people(db, it, cast)
|
||||
plex_detail.apply_rich_detail(it, meta)
|
||||
it.people_enriched_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
hu = lang == "hu"
|
||||
# Movies serve rich detail from the DB row; episodes from the live meta just fetched.
|
||||
if it.kind == "movie":
|
||||
_imdb_id = it.imdb_id
|
||||
rich = {
|
||||
"cast": it.full_cast or [],
|
||||
"imdb_rating": it.imdb_rating,
|
||||
"imdb_id": _imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None,
|
||||
"content_rating": it.content_rating,
|
||||
"genres": (it.genres_hu or it.genres if hu else it.genres) or [],
|
||||
"directors": it.directors or [],
|
||||
"studio": it.studio,
|
||||
"tagline": it.tagline,
|
||||
}
|
||||
else:
|
||||
rich = plex_detail.rich_detail(meta)
|
||||
|
||||
prev_id, next_id = _episode_nav(db, it)
|
||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"kind": it.kind,
|
||||
"title": (it.title_hu or it.title) if hu else it.title,
|
||||
"summary": (it.summary_hu or it.summary) if hu else it.summary,
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"playable": it.playable,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||
"library": _lib_key(db, it.library_id),
|
||||
"cast": rich.get("cast", []),
|
||||
"imdb_rating": rich.get("imdb_rating"),
|
||||
"imdb_id": rich.get("imdb_id"),
|
||||
"imdb_url": rich.get("imdb_url"),
|
||||
# Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the
|
||||
# IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too.
|
||||
"rating": it.rating,
|
||||
"content_rating": rich.get("content_rating"),
|
||||
"genres": rich.get("genres", []),
|
||||
"directors": rich.get("directors", []),
|
||||
"studio": rich.get("studio"),
|
||||
"tagline": rich.get("tagline"),
|
||||
"markers": markers,
|
||||
"audio_streams": audio_streams,
|
||||
"subtitle_streams": subtitle_streams,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
"show_title": (
|
||||
(show.title_hu or show.title if hu else show.title) if show else None
|
||||
),
|
||||
"season_number": it.season_number,
|
||||
"episode_number": it.episode_number,
|
||||
"prev_id": prev_id,
|
||||
"next_id": next_id,
|
||||
"collections": _collection_strips(db, it, user.id),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexPerson,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.query import split_csv
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
@router.get("/people/cards")
|
||||
def people_cards(
|
||||
names: str,
|
||||
scope: str = "both",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Cards for the actor filter-chips currently applied: each name + its cached headshot + how many
|
||||
movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated
|
||||
as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names.
|
||||
A name with no cached photo yet still returns a card (the UI shows a placeholder)."""
|
||||
wanted = split_csv(names)[:16]
|
||||
if not wanted:
|
||||
return {"people": []}
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||
photos = {
|
||||
p.name: p.photo_thumb
|
||||
for p in db.execute(
|
||||
select(PlexPerson).where(PlexPerson.name.in_(wanted))
|
||||
).scalars()
|
||||
}
|
||||
out = []
|
||||
for nm in wanted:
|
||||
count = 0
|
||||
if want_movies:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexItem.id)).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
PlexItem.cast_names.contains([nm]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
if want_shows:
|
||||
count += (
|
||||
db.scalar(
|
||||
select(func.count(PlexShow.id)).where(
|
||||
PlexShow.library_id.in_(show_lib_ids),
|
||||
PlexShow.cast_names.contains([nm]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
out.append({"name": nm, "photo": photos.get(nm), "count": count})
|
||||
return {"people": out}
|
||||
|
||||
|
||||
# Cap each filmography section — a prolific person shouldn't return an unbounded list the client then
|
||||
# mounts a card per row for (the grid paginates at PAGE; this page renders all at once). Ample for a
|
||||
# personal library; the most-recent titles win via the year-desc order.
|
||||
_PERSON_SECTION_MAX = 200
|
||||
|
||||
|
||||
@router.get("/person/{name}")
|
||||
def person_detail(
|
||||
name: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled
|
||||
libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter
|
||||
link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct
|
||||
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
|
||||
simply returns empty sections."""
|
||||
photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name))
|
||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||
|
||||
movies: list[dict] = []
|
||||
if movie_lib_ids:
|
||||
items = db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_lib_ids),
|
||||
or_(
|
||||
PlexItem.cast_names.contains([name]),
|
||||
PlexItem.directors.contains([name]),
|
||||
),
|
||||
)
|
||||
.order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title))
|
||||
.limit(_PERSON_SECTION_MAX)
|
||||
).scalars().all()
|
||||
item_ids = [it.id for it in items]
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.item_id.in_(item_ids or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
movies = [movie_card(it, states.get(it.id)) for it in items]
|
||||
|
||||
shows: list[dict] = []
|
||||
if show_lib_ids:
|
||||
show_rows = db.execute(
|
||||
select(PlexShow)
|
||||
.where(
|
||||
PlexShow.library_id.in_(show_lib_ids),
|
||||
or_(
|
||||
PlexShow.cast_names.contains([name]),
|
||||
PlexShow.directors.contains([name]),
|
||||
),
|
||||
)
|
||||
.order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title))
|
||||
.limit(_PERSON_SECTION_MAX)
|
||||
).scalars().all()
|
||||
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
||||
shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows]
|
||||
|
||||
return {"name": name, "photo": photo, "movies": movies, "shows": shows}
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Plex playlists: the current user's personal, orderable playlists of playable items."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex.cards import leaf_card
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.routes._common import owned_or_404
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
|
||||
def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
|
||||
return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found")
|
||||
|
||||
|
||||
def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict:
|
||||
return {
|
||||
"id": pl.id,
|
||||
"title": pl.title,
|
||||
"item_count": count,
|
||||
"thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None,
|
||||
}
|
||||
|
||||
|
||||
def _playlist_item_query(pid: int):
|
||||
return (
|
||||
select(PlexPlaylistItem, PlexItem)
|
||||
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/playlists")
|
||||
def playlists(
|
||||
contains: str | None = None,
|
||||
contains_group: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The current user's playlists (newest-touched first), each with an item count + a cover thumb.
|
||||
`contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog).
|
||||
`contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the
|
||||
group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
|
||||
contains_id = None
|
||||
if contains:
|
||||
contains_id = db.scalar(
|
||||
select(PlexItem.id).where(PlexItem.rating_key == str(contains))
|
||||
)
|
||||
group_ids: set[int] | None = None
|
||||
if contains_group is not None:
|
||||
rks = [s for s in (contains_group or "").split(",") if s]
|
||||
group_ids = set(
|
||||
db.execute(
|
||||
select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""]))
|
||||
).scalars()
|
||||
)
|
||||
out = []
|
||||
for pl in db.execute(
|
||||
select(PlexPlaylist)
|
||||
.where(PlexPlaylist.user_id == user.id)
|
||||
.order_by(PlexPlaylist.updated_at.desc())
|
||||
).scalars():
|
||||
count = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
first = db.execute(_playlist_item_query(pl.id)).first()
|
||||
card = _playlist_card(pl, count, first[1].rating_key if first else None)
|
||||
if contains_id is not None:
|
||||
card["has_item"] = (
|
||||
db.execute(
|
||||
select(PlexPlaylistItem.id).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id,
|
||||
PlexPlaylistItem.item_id == contains_id,
|
||||
)
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
if group_ids is not None:
|
||||
card["group_in"] = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id,
|
||||
PlexPlaylistItem.item_id.in_(group_ids or [0]),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
out.append(card)
|
||||
resp: dict = {"playlists": out}
|
||||
if group_ids is not None:
|
||||
resp["group_size"] = len(group_ids)
|
||||
return resp
|
||||
|
||||
|
||||
class CreatePlexPlaylistIn(BaseModel):
|
||||
title: str = ""
|
||||
item_rating_key: str = ""
|
||||
|
||||
|
||||
@router.post("/playlists")
|
||||
def create_playlist(
|
||||
body: CreatePlexPlaylistIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
pl = PlexPlaylist(user_id=user.id, title=title)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
seed = body.item_rating_key
|
||||
count = 0
|
||||
thumb_rk = None
|
||||
if seed:
|
||||
it = by_rating_key(db, PlexItem, seed)
|
||||
if it is not None:
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0))
|
||||
count, thumb_rk = 1, it.rating_key
|
||||
db.commit()
|
||||
return _playlist_card(pl, count, thumb_rk)
|
||||
|
||||
|
||||
@router.get("/playlists/{pid}")
|
||||
def playlist_detail(
|
||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
rows = db.execute(_playlist_item_query(pid)).all()
|
||||
items = [it for (_pi, it) in rows]
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.item_id.in_([it.id for it in items] or [0]),
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
shows = {
|
||||
sh.id: sh
|
||||
for sh in db.execute(
|
||||
select(PlexShow).where(
|
||||
PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
cards = [
|
||||
leaf_card(
|
||||
it,
|
||||
states.get(it.id),
|
||||
shows[it.show_id].title if it.show_id in shows else None,
|
||||
shows[it.show_id].rating_key if it.show_id in shows else None,
|
||||
)
|
||||
for it in items
|
||||
]
|
||||
return {"id": pl.id, "title": pl.title, "items": cards}
|
||||
|
||||
|
||||
class RenamePlexPlaylistIn(BaseModel):
|
||||
title: str = ""
|
||||
|
||||
|
||||
@router.patch("/playlists/{pid}")
|
||||
def rename_playlist(
|
||||
pid: int,
|
||||
body: RenamePlexPlaylistIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
pl.title = title
|
||||
db.commit()
|
||||
count = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pl.id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
first = db.execute(_playlist_item_query(pl.id)).first()
|
||||
return _playlist_card(pl, count, first[1].rating_key if first else None)
|
||||
|
||||
|
||||
@router.delete("/playlists/{pid}")
|
||||
def delete_playlist(
|
||||
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
db.delete(pl)
|
||||
db.commit()
|
||||
return {"deleted": pid}
|
||||
|
||||
|
||||
class PlexItemRefIn(BaseModel):
|
||||
item_rating_key: str = ""
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items")
|
||||
def playlist_add_item(
|
||||
pid: int,
|
||||
body: PlexItemRefIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown item")
|
||||
exists = db.execute(
|
||||
select(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
).scalars().first()
|
||||
if exists is None:
|
||||
maxpos = db.scalar(
|
||||
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1)
|
||||
)
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/playlists/{pid}/items/{item_rating_key}")
|
||||
def playlist_remove_item(
|
||||
pid: int,
|
||||
item_rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||
if it is not None:
|
||||
db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
||||
)
|
||||
)
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class PlexItemKeysIn(BaseModel):
|
||||
item_rating_keys: list[str] = []
|
||||
|
||||
|
||||
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
||||
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
|
||||
order = [str(k) for k in rks if str(k)]
|
||||
if not order:
|
||||
return []
|
||||
by_rk = {
|
||||
it.rating_key: it
|
||||
for it in db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key.in_(order))
|
||||
).scalars()
|
||||
}
|
||||
seen: set[str] = set()
|
||||
out: list[PlexItem] = []
|
||||
for rk in order:
|
||||
it = by_rk.get(rk)
|
||||
if it is not None and rk not in seen:
|
||||
seen.add(rk)
|
||||
out.append(it)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/bulk")
|
||||
def playlist_add_bulk(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid)
|
||||
).scalars()
|
||||
)
|
||||
pos = int(
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
)
|
||||
added = 0
|
||||
for it in items:
|
||||
if it.id in existing:
|
||||
continue
|
||||
pos += 1
|
||||
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos))
|
||||
existing.add(it.id)
|
||||
added += 1
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"added": added, "item_count": total}
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/remove-bulk")
|
||||
def playlist_remove_bulk(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||
removed + the new total."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||
removed = 0
|
||||
if ids:
|
||||
removed = db.execute(
|
||||
delete(PlexPlaylistItem).where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)
|
||||
),
|
||||
execution_options={"synchronize_session": False},
|
||||
).rowcount
|
||||
if removed:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(
|
||||
PlexPlaylistItem.playlist_id == pid
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"removed": removed, "item_count": total}
|
||||
|
||||
|
||||
@router.put("/playlists/{pid}/order")
|
||||
def reorder_playlist(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
||||
pl = _own_playlist_or_404(db, pid, user)
|
||||
order = body.item_rating_keys
|
||||
id_by_rk = {
|
||||
it.rating_key: it.id
|
||||
for it in db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
||||
).scalars()
|
||||
}
|
||||
for pos, rk in enumerate(order):
|
||||
iid = id_by_rk.get(rk)
|
||||
if iid is not None:
|
||||
db.execute(
|
||||
update(PlexPlaylistItem)
|
||||
.where(
|
||||
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid
|
||||
)
|
||||
.values(position=pos)
|
||||
)
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -90,17 +90,13 @@ def set_maintenance_settings(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
||||
run). Persisted to app_state; the job reads it each run."""
|
||||
value = body.revalidate_batch
|
||||
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and "
|
||||
f"{state.MAINTENANCE_BATCH_MAX}"
|
||||
),
|
||||
)
|
||||
new = state.set_maintenance_batch(db, value)
|
||||
run). Persisted via sysconfig; the job reads it each run."""
|
||||
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||
try:
|
||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
|
||||
db.commit()
|
||||
return {"revalidate_batch": new}
|
||||
@@ -167,6 +163,7 @@ def get_scheduler(
|
||||
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
|
||||
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
|
||||
}
|
||||
maint = sysconfig.spec("maintenance_revalidate_batch")
|
||||
|
||||
return {
|
||||
**snap,
|
||||
@@ -174,9 +171,9 @@ def get_scheduler(
|
||||
"queue": queue,
|
||||
"quota": quota_info,
|
||||
"maintenance": {
|
||||
"revalidate_batch": state.get_maintenance_batch(db),
|
||||
"revalidate_batch": sysconfig.get_int(db, "maintenance_revalidate_batch"),
|
||||
"revalidate_batch_default": settings.maintenance_revalidate_batch,
|
||||
"min": state.MAINTENANCE_BATCH_MIN,
|
||||
"max": state.MAINTENANCE_BATCH_MAX,
|
||||
"min": maint.min,
|
||||
"max": maint.max,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`GET /api/setup/status` is public (the SPA uses it to decide whether to show the wizard). Every
|
||||
mutating step goes through `require_setup`: it rejects once the instance is configured AND requires
|
||||
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
|
||||
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
|
||||
X-Setup-Token header).
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
@@ -96,6 +96,7 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
saved.append(key)
|
||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||
return {"saved": saved}
|
||||
|
||||
|
||||
|
||||
@@ -7,30 +7,13 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import (
|
||||
estimate_deep_backfill,
|
||||
run_enrich,
|
||||
run_recent_backfill,
|
||||
run_rss_poll,
|
||||
)
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
def _user_channels(db: Session, user: User) -> list[Channel]:
|
||||
return (
|
||||
db.execute(
|
||||
select(Channel)
|
||||
.join(Subscription, Subscription.channel_id == Channel.id)
|
||||
.where(Subscription.user_id == user.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
@@ -48,38 +31,6 @@ def sync_subscriptions(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rss")
|
||||
def sync_rss(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
# A partial failure comes back in `failed_feeds` so a manual poll reports it instead of an
|
||||
# innocent-looking "0 new"; a TOTAL outage raises out of run_rss_poll and surfaces as a 500,
|
||||
# which is the honest answer for a poll that reached nothing.
|
||||
result = run_rss_poll(db, _user_channels(db, user))
|
||||
return {"new_videos": result["new"], "failed_feeds": result["failed"]}
|
||||
|
||||
|
||||
@router.post("/backfill")
|
||||
def sync_backfill(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
|
||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||
result["quota_used_estimate"] = m.used
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/enrich")
|
||||
def sync_enrich(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
|
||||
enriched = run_enrich(db)
|
||||
return {"enriched": enriched, "quota_used_estimate": m.used}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def sync_status(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
|
||||
@@ -4,11 +4,10 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import admin_user, current_user
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ChannelTag, Tag, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.sync.autotag import run_autotag_all
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
@@ -113,12 +112,3 @@ def delete_tag(
|
||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||
db.commit()
|
||||
return {"deleted": tag_id}
|
||||
|
||||
|
||||
@router.post("/recompute")
|
||||
def recompute(
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
only_missing: bool = False,
|
||||
) -> dict:
|
||||
return run_autotag_all(db, only_missing=only_missing)
|
||||
|
||||
+3
-23
@@ -4,16 +4,11 @@ import secrets
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import AppState
|
||||
from app.security import hash_token
|
||||
|
||||
log = logging.getLogger("siftlode.state")
|
||||
|
||||
# Bounds for the admin-set maintenance re-validation batch (videos re-checked per run).
|
||||
MAINTENANCE_BATCH_MIN = 100
|
||||
MAINTENANCE_BATCH_MAX = 100_000
|
||||
|
||||
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
|
||||
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
|
||||
_configured_cache = False
|
||||
@@ -33,10 +28,12 @@ def is_sync_paused(db: Session) -> bool:
|
||||
|
||||
|
||||
def set_sync_paused(db: Session, paused: bool) -> None:
|
||||
# Flushes, doesn't commit — the caller owns the transaction so the change lands with its audit
|
||||
# row in one transaction (B-core-10).
|
||||
row = _row(db)
|
||||
row.sync_paused = paused
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.flush()
|
||||
log.info("Background sync %s", "paused" if paused else "resumed")
|
||||
|
||||
|
||||
@@ -90,20 +87,3 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
|
||||
return False
|
||||
stored = _row(db).setup_token_hash
|
||||
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
|
||||
|
||||
|
||||
def get_maintenance_batch(db: Session) -> int:
|
||||
"""Effective maintenance re-validation batch: the admin override if set, else the
|
||||
env/config default."""
|
||||
return _row(db).maintenance_revalidate_batch or settings.maintenance_revalidate_batch
|
||||
|
||||
|
||||
def set_maintenance_batch(db: Session, value: int) -> int:
|
||||
"""Clamp and persist the maintenance batch override. Returns the applied value."""
|
||||
value = max(MAINTENANCE_BATCH_MIN, min(MAINTENANCE_BATCH_MAX, int(value)))
|
||||
row = _row(db)
|
||||
row.maintenance_revalidate_batch = value
|
||||
db.add(row)
|
||||
db.commit()
|
||||
log.info("Maintenance re-validation batch set to %s", value)
|
||||
return value
|
||||
|
||||
@@ -25,11 +25,10 @@ from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import progress, quota
|
||||
from app import progress, quota, sysconfig
|
||||
from app.config import settings
|
||||
from app.models import Playlist, PlaylistItem, Video
|
||||
from app.notifications import create_notification
|
||||
from app.state import get_maintenance_batch
|
||||
from app.sync.runner import get_service_user
|
||||
from app.sync.videos import apply_video_details, parse_dt
|
||||
from app.utils import now_utc as _now
|
||||
@@ -182,7 +181,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
|
||||
def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
||||
"""Phase 2: re-check the least-recently-checked currently-available videos and flag any
|
||||
that have become unplayable (hidden immediately; deleted later if they don't recover)."""
|
||||
batch = get_maintenance_batch(db) # admin override, else config default
|
||||
batch = sysconfig.get_int(db, "maintenance_revalidate_batch") # admin override, else config default
|
||||
flagged_new = 0
|
||||
checked = 0
|
||||
now = _now()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Read-direction YouTube playlist sync: mirror each user's own YouTube playlists into
|
||||
local `source='youtube'` playlists (kept fresh by the scheduler). One-way (YT -> local);
|
||||
the write-back direction (local edits -> YouTube) is a later phase.
|
||||
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
||||
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: push local
|
||||
edits out to YouTube (`plan_push` / `push_playlist`), behind the optional write scope.
|
||||
|
||||
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
||||
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Channel, Subscription, User
|
||||
from app.utils import normalize_description
|
||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||
|
||||
log = logging.getLogger("siftlode.sync")
|
||||
@@ -55,7 +56,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||
|
||||
channel.title = snippet.get("title") or channel.title
|
||||
channel.handle = snippet.get("customUrl")
|
||||
channel.description = snippet.get("description")
|
||||
channel.description = normalize_description(snippet.get("description"))
|
||||
channel.thumbnail_url = (
|
||||
best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url
|
||||
)
|
||||
@@ -104,7 +105,7 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||
channel = Channel(
|
||||
id=cid,
|
||||
title=sub.get("title"),
|
||||
description=sub.get("description"),
|
||||
description=normalize_description(sub.get("description")),
|
||||
thumbnail_url=sub.get("thumbnail_url"),
|
||||
)
|
||||
db.add(channel)
|
||||
|
||||
@@ -59,6 +59,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
# --- Batch sizes ---
|
||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||
# --- Maintenance (rolling re-validation of already-known videos) ---
|
||||
# Videos re-checked per maintenance run. Folded here from app_state (B-core-11) so every admin
|
||||
# knob lives in one store; read by sync/maintenance.py, tuned from the Scheduler dashboard.
|
||||
ConfigSpec("maintenance_revalidate_batch", "int", "maintenance", "maintenance_revalidate_batch", min=100, max=100_000),
|
||||
# --- Discovery cleanup (search + explore ephemeral content) ---
|
||||
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
||||
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
|
||||
@@ -96,8 +100,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
# NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the
|
||||
# incremental watch-sync scheduler job that reads it lands.
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||
@@ -182,7 +186,9 @@ def get_bool(db: Session, key: str) -> bool:
|
||||
|
||||
|
||||
def set_value(db: Session, key: str, value) -> None:
|
||||
"""Validate, coerce, and persist a DB override for `key`. Secrets are encrypted."""
|
||||
"""Validate, coerce, and stage a DB override for `key` (secrets are encrypted). Flushes but does
|
||||
NOT commit — the caller owns the transaction, so the change lands together with its audit row
|
||||
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
|
||||
s = _BY_KEY[key]
|
||||
if s.type == "int":
|
||||
v = int(value)
|
||||
@@ -208,15 +214,16 @@ def set_value(db: Session, key: str, value) -> None:
|
||||
db.add(row)
|
||||
row.value = stored
|
||||
row.encrypted = encrypted
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def reset(db: Session, key: str) -> None:
|
||||
"""Remove the DB override for `key` (fall back to the env/config default)."""
|
||||
"""Remove the DB override for `key` (fall back to the env/config default). Flushes but does NOT
|
||||
commit — the caller owns the transaction (B-core-10)."""
|
||||
row = db.get(SystemConfig, key)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def describe(db: Session) -> dict:
|
||||
|
||||
@@ -36,3 +36,18 @@ def to_tsquery_str(q: str) -> str | None:
|
||||
return None
|
||||
tokens[-1] = tokens[-1] + ":*"
|
||||
return " & ".join(tokens)
|
||||
|
||||
|
||||
def normalize_description(text: str | None) -> str | None:
|
||||
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
|
||||
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
|
||||
bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and
|
||||
right-strips each kept line; `str.splitlines()` also folds CR / CRLF and Unicode line separators
|
||||
to "\\n" (a benign line-ending normalization). None stays None; all-blank collapses to "".
|
||||
|
||||
Deliberately CHANNEL-only — video descriptions are left with their original formatting: they are
|
||||
user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block."""
|
||||
if text is None:
|
||||
return None
|
||||
kept = [line.rstrip() for line in text.splitlines() if line.strip()]
|
||||
return "\n".join(kept)
|
||||
|
||||
+25
-635
@@ -17,125 +17,40 @@ Design:
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, formats, quota, service, storage
|
||||
from app.downloads import quota, service
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_requeue_if_running,
|
||||
_reset_asset_pending,
|
||||
_set_job,
|
||||
_staging_dir,
|
||||
)
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
log = logging.getLogger("siftlode.worker")
|
||||
|
||||
|
||||
def _notify_download_done(
|
||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
||||
) -> None:
|
||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
if user_id is None:
|
||||
return
|
||||
try:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||
data={"title": title, "kind": kind},
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("failed to write download-done notification for job %s", job_id)
|
||||
|
||||
_stop = threading.Event()
|
||||
|
||||
|
||||
class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
def _handle_signal(signum, frame):
|
||||
_stop.set()
|
||||
|
||||
|
||||
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||
|
||||
def _set_job(job_id: int, **fields) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
return
|
||||
for k, v in fields.items():
|
||||
setattr(job, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _job_status(job_id: int) -> str | None:
|
||||
with SessionLocal() as db:
|
||||
return db.execute(
|
||||
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _touch_job(job_id: int) -> None:
|
||||
"""Bump a running job's `updated_at` without changing any value — a stall-watchdog heartbeat for
|
||||
phases that legitimately write no progress. Extraction writes nothing, and a multi-client-set
|
||||
fallback re-writes identical values (progress 0 / phase 'downloading'), so ORM `onupdate` would
|
||||
NOT fire and two slow extraction attempts could otherwise cross the stall timeout and be killed
|
||||
mid-extraction though they'd have succeeded."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("UPDATE download_jobs SET updated_at=now() WHERE id=:id AND status='running'"),
|
||||
{"id": job_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _requeue_if_running(job_id: int, phase: str) -> None:
|
||||
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
||||
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
||||
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
||||
requeue a no-op unless we still own a running job."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
||||
"WHERE id=:id AND status='running'"
|
||||
),
|
||||
{"id": job_id, "phase": phase},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _parse_upload_date(raw) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --- claim + recovery -----------------------------------------------------------------------
|
||||
|
||||
def _wait_for_schema(timeout: float = 300.0) -> bool:
|
||||
@@ -176,7 +91,14 @@ def _recover_orphans() -> None:
|
||||
|
||||
|
||||
def _claim_job() -> int | None:
|
||||
"""Claim the oldest queued job whose user is under their per-user concurrency limit.
|
||||
"""Claim the oldest queued job whose user is under their per-user concurrency limit AND whose
|
||||
shared asset isn't already being downloaded by another worker.
|
||||
|
||||
The asset exclusion (`NOT EXISTS … media_assets.status='downloading'`) stops the same-asset ping-
|
||||
pong (B-sub-3): without it, a second worker claims a queued job that shares an in-flight asset,
|
||||
finds it busy, requeues it, and immediately re-claims it — burning a worker slot behind one
|
||||
in-flight asset and starving everyone else. Excluded here, that job simply waits until its asset
|
||||
settles, so `_process_job` no longer needs to sleep on the busy/lost-race paths.
|
||||
|
||||
Users already AT their concurrency limit are excluded IN THE SQL, so one user flooding the
|
||||
queue can't monopolize the fetch window and starve everyone behind them. (The old fixed
|
||||
@@ -193,6 +115,10 @@ def _claim_job() -> int | None:
|
||||
"""
|
||||
SELECT id, user_id FROM download_jobs
|
||||
WHERE status='queued'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_assets a
|
||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
||||
)
|
||||
AND user_id NOT IN (
|
||||
SELECT r.user_id
|
||||
FROM download_jobs r
|
||||
@@ -463,15 +389,16 @@ def _process_job(job_id: int) -> None:
|
||||
_set_job(job_id, status="error", error=asset_error or "Download failed", phase=None)
|
||||
return
|
||||
if asset_status == "downloading":
|
||||
# Another worker owns this asset; wait and let the job be reclaimed later.
|
||||
# Another worker won this asset between our claim and here (a narrow race — the claim SQL
|
||||
# excludes downloading-asset jobs). Requeue and let it be reclaimed once the asset settles;
|
||||
# no sleep — the claim exclusion keeps it out of the window until then, so no busy-loop.
|
||||
_requeue_if_running(job_id, "waiting")
|
||||
time.sleep(2)
|
||||
return
|
||||
|
||||
# asset_status == "pending": try to win the download / edit.
|
||||
if not _claim_asset(asset_id):
|
||||
# Lost the pending->downloading race; requeue (same reasoning — the exclusion prevents a spin).
|
||||
_requeue_if_running(job_id, "waiting")
|
||||
time.sleep(2)
|
||||
return
|
||||
|
||||
# The asset is now ours ('downloading'): run the heavy work in a supervised child process.
|
||||
@@ -494,543 +421,6 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
|
||||
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
|
||||
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset is None or asset.title:
|
||||
return
|
||||
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
|
||||
# (e.g. a title that was only a social engagement prefix); the completion path later sets
|
||||
# the final title with its own source_ref fallback.
|
||||
asset.title = normalize_title(info.get("title")) or info.get("id")
|
||||
asset.uploader = info.get("uploader") or info.get("channel")
|
||||
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
|
||||
asset.thumbnail_url = info.get("thumbnail")
|
||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _make_progress_hook(job_id: int, asset_id: int):
|
||||
state = {"meta_done": False}
|
||||
cancelled = _cancel_poll(job_id) # same policy as the ffmpeg path
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S)
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||
if cancelled():
|
||||
raise _Aborted
|
||||
if not state["meta_done"] and d.get("info_dict"):
|
||||
state["meta_done"] = True
|
||||
try:
|
||||
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||
pass
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
if not allow_write():
|
||||
return
|
||||
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||
# Label the phase so the user understands the bar resetting between streams instead of
|
||||
# seeing a mysterious 95%→5% jump.
|
||||
info = d.get("info_dict") or {}
|
||||
vcodec = info.get("vcodec") or "none"
|
||||
acodec = info.get("acodec") or "none"
|
||||
if vcodec != "none" and acodec == "none":
|
||||
phase = "video"
|
||||
elif acodec != "none" and vcodec == "none":
|
||||
phase = "audio"
|
||||
else:
|
||||
phase = "media"
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
done = d.get("downloaded_bytes") or 0
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
_set_job(
|
||||
job_id,
|
||||
progress=min(pct, 99),
|
||||
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
# opens a session and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so unthrottled they cost
|
||||
# ~10^5 queries on a long job. ONE interval, ONE implementation, used by both paths — three
|
||||
# hand-rolled copies is how the ffmpeg side ended up unthrottled while the download side was fine.
|
||||
_DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
def _rate_limiter(interval_s: float, *, now=time.monotonic):
|
||||
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
|
||||
`now` is injectable so tests drive a clock instead of patching the global one."""
|
||||
# `None`, not 0.0: "never ran" has to be a VALUE, not a falsy timestamp. A truthiness test here
|
||||
# reads a legitimate `now() == 0.0` as "never ran" and lets a second call straight through —
|
||||
# unreachable with real `time.monotonic()`, but `now` exists to be injected, and a test clock
|
||||
# starting at 0 is the obvious choice. That would let a throttle test encode the wrong
|
||||
# behaviour and still pass.
|
||||
state: dict[str, float | None] = {"at": None}
|
||||
|
||||
def should_run() -> bool:
|
||||
t = now()
|
||||
last = state["at"]
|
||||
if last is not None and t - last < interval_s:
|
||||
return False
|
||||
state["at"] = t
|
||||
return True
|
||||
|
||||
return should_run
|
||||
|
||||
|
||||
def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, now=time.monotonic):
|
||||
"""Cooperative "has this job been paused/cancelled?" poll, rate-limited and sticky: once it
|
||||
says yes it never queries again. Shared by the yt-dlp hook and run_ffmpeg so cancel latency is
|
||||
one policy, not one per call site."""
|
||||
allow = _rate_limiter(interval_s, now=now)
|
||||
state = {"cancelled": False}
|
||||
|
||||
def cancelled() -> bool:
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
||||
"""`on_progress` for run_ffmpeg: persist at most one row write per interval, and only when the
|
||||
whole-percent value actually changed (the bar shows integers)."""
|
||||
allow = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||
state = {"pct": -1}
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
if whole == state["pct"] or not allow():
|
||||
return
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _pp_phase(pp: str) -> str:
|
||||
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||
pulse — this makes the long merge/finalize legible instead of a silent 'Processing'."""
|
||||
if "Merg" in pp:
|
||||
return "merging"
|
||||
if "ExtractAudio" in pp:
|
||||
return "audio_extract"
|
||||
if "Thumbnail" in pp:
|
||||
return "thumbnail"
|
||||
if "SponsorBlock" in pp or "ModifyChapters" in pp:
|
||||
return "sponsorblock"
|
||||
if "Metadata" in pp:
|
||||
return "metadata"
|
||||
return "processing"
|
||||
|
||||
|
||||
def _make_pp_hook(job_id: int):
|
||||
"""Postprocessor hook: surface the current ffmpeg step so the bar shows a concrete label
|
||||
(Merging / Embedding thumbnail / …) instead of freezing at 100% after the streams finish."""
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
if d.get("status") in ("started", "processing"):
|
||||
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
||||
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
# stream as a ready asset.
|
||||
_YTDLP_TEMP_RE = re.compile(
|
||||
r"\.part(-Frag\d+)?$" # in-flight download / fragment
|
||||
r"|\.ytdl$" # resume state
|
||||
r"|\.temp\.[^.]+$" # merge/postprocess temp
|
||||
r"|\.f\d+\.[^.]+$", # per-format stream kept before the merge
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||
for p in staging.iterdir():
|
||||
if p != media and p.suffix.lower() in _THUMB_SUFFIXES:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _produced_file(info: dict, staging: Path) -> Path:
|
||||
"""The media file yt-dlp actually wrote.
|
||||
|
||||
`requested_downloads[0].filepath` is the authoritative answer on YouTube, but an arbitrary-URL
|
||||
extractor may not fill it in at all — and `Path("")` is `Path(".")`, which *passes* `.exists()`,
|
||||
so a missing value used to sail past the fallback and blow up two steps later (`with_name` on an
|
||||
empty name: the BBC-news failure). Anything that isn't a real file counts as absent; then fall
|
||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
req = (info.get("requested_downloads") or [{}])[0]
|
||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||
if fp:
|
||||
produced = Path(str(fp))
|
||||
if produced.is_file():
|
||||
return produced
|
||||
cands = [
|
||||
p
|
||||
for p in staging.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix.lower() not in _THUMB_SUFFIXES
|
||||
and not _YTDLP_TEMP_RE.search(p.name)
|
||||
]
|
||||
if not cands:
|
||||
raise RuntimeError("yt-dlp produced no output file")
|
||||
return max(cands, key=lambda p: p.stat().st_size)
|
||||
|
||||
|
||||
def _media_info(info: dict) -> dict:
|
||||
"""Unwrap a playlist/multi_video extraction to the entry that carries the download.
|
||||
|
||||
A single non-YouTube page (a news article's video, a multi-rendition embed) can extract as a
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on an entry. Pick the
|
||||
FIRST entry that actually downloaded something: on a page with a skipped teaser in front of the
|
||||
real video, entries[0] carries no file, and taking it blindly would label the produced file
|
||||
with the wrong id/title/duration."""
|
||||
entries = [e for e in (info.get("entries") or []) if e]
|
||||
if not entries or info.get("requested_downloads"):
|
||||
return info
|
||||
chosen = next((e for e in entries if e.get("requested_downloads")), entries[0])
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
for i, clients in enumerate(client_sets):
|
||||
opts = formats.build_ydl_opts(
|
||||
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=True)
|
||||
except yt_dlp.utils.DownloadError as exc:
|
||||
last_exc = exc
|
||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
||||
for p in list(staging.iterdir()): # drop partials before the retry
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_set_job(job_id, progress=0, phase="downloading")
|
||||
continue
|
||||
raise
|
||||
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 _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
|
||||
if spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
||||
if not needs:
|
||||
return produced
|
||||
|
||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
|
||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||
# re-encode still shows real progress instead of a frozen 0%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
editmod.run_ffmpeg(
|
||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
if reenc_v:
|
||||
info["vcodec"] = "h264"
|
||||
if reenc_a:
|
||||
info["acodec"] = "aac"
|
||||
return dest
|
||||
|
||||
|
||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||
staging = _staging_dir(asset_id)
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
|
||||
produced = _produced_file(info, staging)
|
||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||
ext = produced.suffix.lstrip(".").lower()
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(info.get("title")) or source_ref,
|
||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||
duration_s=int(info["duration"]) if info.get("duration") else None,
|
||||
description=info.get("description"),
|
||||
thumbnail_url=info.get("thumbnail"),
|
||||
)
|
||||
|
||||
layout, retention_days = _download_settings()
|
||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||
thumb = _find_thumbnail(staging, produced)
|
||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
||||
|
||||
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=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.duration_s = meta.duration_s
|
||||
asset.width = int(info["width"]) if info.get("width") else None
|
||||
asset.height = int(info["height"]) if info.get("height") else None
|
||||
asset.container = ext
|
||||
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
asset.upload_date = meta.upload_date
|
||||
asset.thumbnail_url = meta.thumbnail_url
|
||||
# Record a self-hosted poster for every download: it's the reliable link-preview
|
||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
asset.nfo_written = nfo_ok
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||
"eta_s": None}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
fields["display_name"] = meta.title[:255]
|
||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||
log.info("job %s done: %s", job_id, rel)
|
||||
|
||||
except (_Aborted, editmod.EditAborted):
|
||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("job %s failed: %s", job_id, msg)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _reset_asset_pending(asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset and asset.status == "downloading":
|
||||
asset.status = "pending"
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- editor derivatives (phase 2: trim/crop via ffmpeg) -------------------------------------
|
||||
|
||||
def _edit_staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
||||
|
||||
|
||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running (see the download error path).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("edit job %s failed: %s", job_id, msg)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
src = db.get(MediaAsset, job.source_asset_id) if job and job.source_asset_id else None
|
||||
edit_spec = dict(job.edit_spec or {}) if job else {}
|
||||
user_id = job.user_id if job else None
|
||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||
src_rel = src.rel_path if src else None
|
||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
return
|
||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
||||
if not src_path.exists():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
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
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||
owner_id = job.user_id if job else None
|
||||
dl_title = asset.title if asset else None
|
||||
# Atomic terminal write (see the download path): don't resurrect a route-stopped job.
|
||||
finalized = service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||
)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||
log.info("edit job %s done: %s", job_id, rel)
|
||||
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# --- loop -----------------------------------------------------------------------------------
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""E3 S3 (B-core-10): the config/state mutators flush but do NOT self-commit, so the caller owns the
|
||||
transaction and a change lands together with its audit row. These lock that contract — a rollback
|
||||
before the caller commits discards the change (proving no hidden internal commit), while a same-session
|
||||
read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from app import state, sysconfig
|
||||
|
||||
|
||||
def test_set_value_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
default = sysconfig.get_int(db, "quota_daily_budget")
|
||||
new = default + 4321
|
||||
|
||||
sysconfig.set_value(db, "quota_daily_budget", new)
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == new # visible in-session (flushed)
|
||||
|
||||
db.rollback() # the caller never committed
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default # discarded — no hidden self-commit
|
||||
|
||||
|
||||
def test_reset_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
default = sysconfig.get_int(db, "quota_daily_budget")
|
||||
sysconfig.set_value(db, "quota_daily_budget", default + 10)
|
||||
db.commit() # a persisted override to reset
|
||||
|
||||
sysconfig.reset(db, "quota_daily_budget")
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default # reset visible in-session
|
||||
db.rollback()
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default + 10 # reset discarded until committed
|
||||
|
||||
|
||||
def test_set_sync_paused_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
assert state.is_sync_paused(db) is False
|
||||
|
||||
state.set_sync_paused(db, True)
|
||||
assert state.is_sync_paused(db) is True # visible in-session
|
||||
|
||||
db.rollback()
|
||||
assert state.is_sync_paused(db) is False # discarded — the caller owns the commit
|
||||
@@ -9,13 +9,14 @@
|
||||
Uses the `db` fixture (real Postgres) — skipped when no database is reachable. `_claim_job` runs in
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
from app.models import DownloadJob, DownloadQuota, User
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
|
||||
def _job(db, user_id, queue_pos, status="queued"):
|
||||
def _job(db, user_id, queue_pos, status="queued", asset_id=None):
|
||||
j = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset_id,
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
status=status,
|
||||
@@ -26,6 +27,15 @@ def _job(db, user_id, queue_pos, status="queued"):
|
||||
return j
|
||||
|
||||
|
||||
def _asset(db, status, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url", source_ref=ref, format_sig="s", status=status, ref_count=1
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def _quota(db, user_id, max_concurrent, unlimited=False):
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
@@ -104,6 +114,27 @@ def test_stored_zero_concurrency_is_floored_not_starving(db):
|
||||
assert db.get(DownloadJob, claimed).user_id == a.id # A's own first job runs (floored to 1)
|
||||
|
||||
|
||||
def test_job_whose_asset_is_already_downloading_is_skipped(db):
|
||||
"""B-sub-3: a queued job whose shared asset is ALREADY `downloading` (another worker owns it) must
|
||||
not be claimed — otherwise a second worker claims it, finds the asset busy, requeues, and ping-
|
||||
pongs on it, starving everyone behind. The claim SQL excludes it, so the job behind it (on a
|
||||
different, free asset) is served instead."""
|
||||
u = User(email="asset-excl@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=5) # high → the ASSET rule, not the per-user rule, does the excluding
|
||||
busy = _asset(db, status="downloading", ref="busy")
|
||||
free = _asset(db, status="pending", ref="free")
|
||||
_job(db, u.id, queue_pos=1, status="running", asset_id=busy.id) # the holder actually downloading it
|
||||
competitor = _job(db, u.id, queue_pos=2, asset_id=busy.id) # same asset — must be skipped
|
||||
behind = _job(db, u.id, queue_pos=3, asset_id=free.id) # different asset — should be served
|
||||
db.commit()
|
||||
|
||||
assert _claim_job() == behind.id # the competitor on the busy asset was skipped
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, competitor.id).status == "queued" # still queued, never claimed
|
||||
|
||||
|
||||
def test_unlimited_user_is_never_excluded(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
||||
from app.worker import _media_info, _produced_file
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""E3 S2 characterization net — MediaAsset.ref_count enqueue/release accounting (B-sub-5).
|
||||
|
||||
`ref_count` tracks how many DownloadJobs still hold a shared cache asset; when it hits zero a READY
|
||||
asset's file + row are freed. Enqueue +1's it and release_asset -1's it. Today those are Python
|
||||
read-modify-writes (`asset.ref_count = (asset.ref_count or 0) ± 1`), which lose an update under two
|
||||
concurrent enqueues/releases and can delete a file out from under a live holder. The fix makes the
|
||||
increment/decrement atomic at the DB; this net locks the OBSERVABLE single-threaded semantics so the
|
||||
conversion is provably behaviour-equivalent (concurrency itself can't be exercised in one session).
|
||||
|
||||
DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.downloads import service
|
||||
from app.models import DownloadJob, MediaAsset, User
|
||||
|
||||
|
||||
def _user(db, email="ref@example.com"):
|
||||
u = User(email=email)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
|
||||
def _ref_count(db, asset_id) -> int | None:
|
||||
# Read straight from the row — the atomic-UPDATE fix leaves the ORM copy stale on purpose.
|
||||
return db.execute(
|
||||
text("SELECT ref_count FROM media_assets WHERE id = :id"), {"id": asset_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _holding_asset(db, *, status="ready", ref_count=1, rel_path=None) -> MediaAsset:
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref=f"r{ref_count}-{status}", format_sig="sig",
|
||||
status=status, ref_count=ref_count, rel_path=rel_path,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _job(db, user_id, asset_id, status="done") -> DownloadJob:
|
||||
j = DownloadJob(
|
||||
user_id=user_id, asset_id=asset_id, source_kind="url", source_ref="r",
|
||||
status=status, queue_pos=1, profile_snapshot={},
|
||||
)
|
||||
db.add(j)
|
||||
db.flush()
|
||||
return j
|
||||
|
||||
|
||||
def test_two_enqueues_share_one_asset_and_stack_ref_count(db):
|
||||
u = _user(db)
|
||||
j1 = service.enqueue(db, u.id, "url", "http://example.com/v", {})
|
||||
j2 = service.enqueue(db, u.id, "url", "http://example.com/v", {})
|
||||
# One shared cache asset, two jobs on it, ref_count == 2.
|
||||
assert j1.asset_id == j2.asset_id
|
||||
assets = db.execute(select(MediaAsset)).scalars().all()
|
||||
assert len(assets) == 1
|
||||
assert _ref_count(db, j1.asset_id) == 2
|
||||
|
||||
|
||||
def test_release_keeps_asset_until_last_holder_then_deletes_row(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="ready", ref_count=2)
|
||||
a = _job(db, u.id, asset.id, status="done")
|
||||
b = _job(db, u.id, asset.id, status="done")
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, a)
|
||||
db.commit()
|
||||
assert _ref_count(db, asset.id) == 1
|
||||
assert db.get(MediaAsset, asset.id) is not None # still held by b
|
||||
|
||||
service.release_asset(db, b)
|
||||
db.commit()
|
||||
# Last holder gone → the ready asset's row is freed (rel_path None, so no file to unlink).
|
||||
assert db.execute(select(MediaAsset)).scalars().all() == []
|
||||
|
||||
|
||||
def test_release_is_a_noop_for_a_non_holding_job(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="ready", ref_count=1)
|
||||
canceled = _job(db, u.id, asset.id, status="canceled") # canceled ∉ HOLDING
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, canceled)
|
||||
db.commit()
|
||||
assert _ref_count(db, asset.id) == 1 # untouched — a canceled job already released while holding
|
||||
|
||||
|
||||
def test_release_never_drives_ref_count_negative(db):
|
||||
u = _user(db)
|
||||
# error-status asset (kept at zero, unlike ready) so we isolate the clamp from the delete branch.
|
||||
asset = _holding_asset(db, status="error", ref_count=0) # already at zero (defensive)
|
||||
j = _job(db, u.id, asset.id, status="error") # error ∈ HOLDING, so release runs
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, j)
|
||||
db.commit()
|
||||
# The count clamps at 0 (never negative) even when released with nothing left to release.
|
||||
assert _ref_count(db, asset.id) == 0
|
||||
assert db.get(MediaAsset, asset.id) is not None
|
||||
|
||||
|
||||
def test_errored_asset_is_not_deleted_at_zero(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="error", ref_count=1)
|
||||
j = _job(db, u.id, asset.id, status="error")
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, j)
|
||||
db.commit()
|
||||
# An errored row is cheap (no file) and may be re-enqueued → never deleted here, only ready is.
|
||||
assert _ref_count(db, asset.id) == 0
|
||||
assert db.get(MediaAsset, asset.id) is not None
|
||||
@@ -0,0 +1,86 @@
|
||||
"""E3 S2 characterization net — download job state transitions (B-sub-8).
|
||||
|
||||
pause / resume / cancel / delete / redownload currently live inline in the routes, each hand-rolling
|
||||
the asset bookkeeping (release, error-reset, fresh-asset rebuild). This pins their OBSERVABLE HTTP
|
||||
behaviour so the logic can move into `service.*` methods (with the routes as thin callers) provably
|
||||
behaviour-equivalently. DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import DownloadJob, MediaAsset, User
|
||||
|
||||
|
||||
def _seed(db, *, job_status, asset_status="ready", ref_count=1, rel_path=None):
|
||||
u = User(email=f"tr-{job_status}-{asset_status}-{ref_count}@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref=f"r-{u.id}", format_sig="s",
|
||||
status=asset_status, ref_count=ref_count, rel_path=rel_path,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=u.id, asset_id=asset.id, source_kind="url", source_ref="r",
|
||||
status=job_status, queue_pos=1, profile_snapshot={},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
return u, asset, job
|
||||
|
||||
|
||||
def _ref(db, asset_id):
|
||||
return db.execute(
|
||||
text("SELECT ref_count FROM media_assets WHERE id = :id"), {"id": asset_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def test_cancel_releases_the_asset_and_marks_canceled(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="running", asset_status="downloading", ref_count=1)
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/cancel")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "canceled"
|
||||
assert _ref(db, asset.id) == 0 # the hold was released while the job still counted as holding
|
||||
|
||||
|
||||
def test_pause_then_resume_round_trips_queued(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="queued", asset_status="pending")
|
||||
c = as_user(u)
|
||||
assert c.post(f"/api/downloads/{job.id}/pause").status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "paused"
|
||||
assert c.post(f"/api/downloads/{job.id}/resume").status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "queued"
|
||||
|
||||
|
||||
def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="error", asset_status="error")
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/resume")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "queued"
|
||||
# A failed shared asset is reset so the worker actually re-downloads instead of re-inheriting the error.
|
||||
assert db.get(MediaAsset, asset.id).status == "pending"
|
||||
|
||||
|
||||
def test_delete_releases_and_removes_the_job(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
||||
r = as_user(u).request("DELETE", f"/api/downloads/{job.id}")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id) is None
|
||||
assert db.get(MediaAsset, asset.id) is None # last holder gone → ready asset freed
|
||||
|
||||
|
||||
def test_redownload_rebuilds_into_a_fresh_asset(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
||||
old_asset_id = asset.id
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/redownload")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
j = db.get(DownloadJob, job.id)
|
||||
assert j.status == "queued"
|
||||
assert j.progress == 0
|
||||
assert j.asset_id != old_asset_id # a fresh private asset, not the old (now-released) one
|
||||
@@ -4,20 +4,20 @@ run_ffmpeg calls `on_progress`/`should_cancel` once per ffmpeg output LINE (`-pr
|
||||
emits a block of ~12 every half second), and both callbacks are database round-trips: `_set_job`
|
||||
opens a session and UPDATE+COMMITs, `_job_status` opens one and SELECTs. Unthrottled, a long
|
||||
re-encode is ~10^5 of each."""
|
||||
from app import worker
|
||||
import app.downloads.pipeline as pipeline
|
||||
|
||||
|
||||
class TestProgressWriter:
|
||||
def test_writes_the_first_value_immediately(self, monkeypatch):
|
||||
writes = []
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
worker._ffmpeg_progress_writer(7, "processing")(12.4)
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
pipeline._ffmpeg_progress_writer(7, "processing")(12.4)
|
||||
assert writes == [{"progress": 12, "phase": "processing"}]
|
||||
|
||||
def test_a_burst_of_lines_costs_one_write(self, monkeypatch):
|
||||
writes = []
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing")
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing")
|
||||
for pct in range(0, 40): # what one second of ffmpeg output looks like
|
||||
write(pct)
|
||||
assert len(writes) == 1
|
||||
@@ -25,8 +25,8 @@ class TestProgressWriter:
|
||||
def test_the_next_second_writes_again(self, monkeypatch):
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10)
|
||||
write(11) # same second → dropped
|
||||
clock["t"] += 1.5
|
||||
@@ -36,19 +36,51 @@ class TestProgressWriter:
|
||||
def test_an_unchanged_whole_percent_is_never_rewritten(self, monkeypatch):
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: None)
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10.1)
|
||||
clock["t"] += 5
|
||||
write(10.9) # still 10% — the bar shows integers
|
||||
assert [w["progress"] for w in writes] == [10]
|
||||
|
||||
def test_a_slow_same_percent_stretch_heartbeats_the_watchdog(self, monkeypatch):
|
||||
"""B-sub-4: a re-encode that sits on one integer percent for minutes must NOT be killed as
|
||||
stalled — the writer touches the row (heartbeat) each interval even though it writes no new
|
||||
progress, so the stall watchdog keeps seeing a fresh updated_at."""
|
||||
writes, touches = [], []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: touches.append(job_id))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10.1) # first real write → progress 10
|
||||
for _ in range(3): # three more intervals stuck on 10%
|
||||
clock["t"] += 1.5
|
||||
write(10.4)
|
||||
assert [w["progress"] for w in writes] == [10] # progress never rewritten
|
||||
assert touches == [7, 7, 7] # …but each interval heartbeats the watchdog
|
||||
|
||||
def test_no_redundant_heartbeat_when_a_progress_write_just_happened(self, monkeypatch):
|
||||
"""A progress write already refreshes updated_at, so an unchanged-percent call in the SAME
|
||||
interval must NOT also heartbeat — that would be a redundant DB write (~2x during an active
|
||||
download). The heartbeat only fires once no write/beat touched the row for an interval."""
|
||||
writes, touches = [], []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: touches.append(job_id))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(6.0) # progress write at t=1000 → refreshes updated_at
|
||||
clock["t"] += 0.3
|
||||
write(6.4) # same percent, SAME interval → the write already touched the row, so no heartbeat
|
||||
assert [w["progress"] for w in writes] == [6]
|
||||
assert touches == []
|
||||
|
||||
|
||||
class TestCancelPoll:
|
||||
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||
cancelled = worker._cancel_poll(7)
|
||||
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||
cancelled = pipeline._cancel_poll(7)
|
||||
for _ in range(40):
|
||||
assert cancelled() is False
|
||||
assert len(calls) == 1
|
||||
@@ -61,8 +93,8 @@ class TestCancelPoll:
|
||||
calls.append(job_id)
|
||||
return "cancelled"
|
||||
|
||||
monkeypatch.setattr(worker, "_job_status", status)
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_job_status", status)
|
||||
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is True
|
||||
clock["t"] += 60 # cancellation is sticky: no re-query, and no un-cancelling
|
||||
assert cancelled() is True
|
||||
@@ -71,8 +103,8 @@ class TestCancelPoll:
|
||||
def test_a_cancel_arriving_later_is_seen_on_the_next_tick(self, monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
state = {"status": "running"}
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: state["status"])
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: state["status"])
|
||||
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is False
|
||||
state["status"] = "paused"
|
||||
clock["t"] += 1.0 # the throttle must not delay a cancel by more than its interval
|
||||
|
||||
@@ -13,7 +13,7 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.routes.plex.browse as browse
|
||||
import app.routes.plex.detail as detail_routes
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
@@ -216,7 +216,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||
user = _user(db, "outage@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
@@ -240,7 +240,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
user = _user(db, "lazy@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["imdb_id"] == "tt0068646"
|
||||
@@ -267,7 +267,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||
user = _user(db, "ep@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/e1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
@@ -298,7 +298,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
||||
def _boom(db_, rk):
|
||||
raise RuntimeError("plex unavailable")
|
||||
|
||||
monkeypatch.setattr(browse, "_show_enrichment", _boom)
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()["show"]
|
||||
@@ -322,7 +322,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
||||
monkeypatch.setattr(browse, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""E3 S1 characterization net — /library grid ⟷ /facets equivalence across watch-state scopes.
|
||||
|
||||
The Plex god-module split (E3 S1) extracts the movie/show watch-state predicates that are currently
|
||||
HAND-DUPLICATED in two places: `unified_library` (the grid) and `facets.wsq` (the facet aggregation).
|
||||
The facets docstring promises the facets "reflect exactly the visible result set" — i.e. with NO
|
||||
metadata filter active (only the `show` state scope), a facet's genre/content-rating counts must equal
|
||||
the same counts taken over the grid's visible items. This test derives the expected counts from the
|
||||
grid's own response and asserts /facets matches, for every (scope × show) combination.
|
||||
|
||||
That locks the two duplicated branch-sets together BEFORE they are merged into one shared helper, so
|
||||
the merge is provably behaviour-equivalent (and a drift between grid and facets fails here). DB lane
|
||||
(`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
|
||||
SCOPES = ("movie", "show", "both")
|
||||
SHOW_STATES = ("all", "watched", "in_progress", "unwatched")
|
||||
|
||||
|
||||
def _dt(day: int) -> datetime:
|
||||
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _seed(db):
|
||||
"""A corpus spanning every watch state, each row carrying genres + a content rating so the facet
|
||||
aggregation has something to count. Movies get per-item state directly; a show's state is the
|
||||
rollup of its episodes' state (new / in-progress / fully watched)."""
|
||||
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([movie_lib, show_lib])
|
||||
db.flush()
|
||||
|
||||
# Movies — new / in-progress / watched / hidden, with overlapping genres.
|
||||
m_new = PlexItem(rating_key="m_new", library_id=movie_lib.id, kind="movie", title="New Movie",
|
||||
year=2001, added_at=_dt(1), genres=["Drama"], content_rating="PG")
|
||||
m_prog = PlexItem(rating_key="m_prog", library_id=movie_lib.id, kind="movie", title="Prog Movie",
|
||||
year=2002, added_at=_dt(2), genres=["Drama", "Comedy"], content_rating="PG-13")
|
||||
m_watch = PlexItem(rating_key="m_watch", library_id=movie_lib.id, kind="movie", title="Watched Movie",
|
||||
year=2003, added_at=_dt(3), genres=["Comedy"], content_rating="R")
|
||||
m_hidden = PlexItem(rating_key="m_hidden", library_id=movie_lib.id, kind="movie", title="Hidden Movie",
|
||||
year=2004, added_at=_dt(4), genres=["Drama"], content_rating="R")
|
||||
|
||||
# Shows — new / in-progress / watched (state comes from the episodes below).
|
||||
sh_new = PlexShow(rating_key="sh_new", library_id=show_lib.id, title="New Show", year=2005,
|
||||
added_at=_dt(5), child_count=1, genres=["Sci-Fi"], content_rating="TV-14")
|
||||
sh_prog = PlexShow(rating_key="sh_prog", library_id=show_lib.id, title="Prog Show", year=2006,
|
||||
added_at=_dt(6), child_count=1, genres=["Sci-Fi", "Drama"], content_rating="TV-MA")
|
||||
sh_watch = PlexShow(rating_key="sh_watch", library_id=show_lib.id, title="Watched Show", year=2007,
|
||||
added_at=_dt(7), child_count=1, genres=["Drama"], content_rating="TV-PG")
|
||||
db.add_all([m_new, m_prog, m_watch, m_hidden, sh_new, sh_prog, sh_watch])
|
||||
db.flush()
|
||||
|
||||
def _episode(rk, show, day, n):
|
||||
se = PlexSeason(rating_key=f"se_{rk}", show_id=show.id, season_number=1)
|
||||
db.add(se)
|
||||
db.flush()
|
||||
ep = PlexItem(rating_key=rk, library_id=show_lib.id, kind="episode", title=f"Ep {rk}",
|
||||
show_id=show.id, season_id=se.id, season_number=1, episode_number=n, added_at=_dt(day))
|
||||
db.add(ep)
|
||||
db.flush()
|
||||
return ep
|
||||
|
||||
_episode("e_new", sh_new, 8, 1) # no state -> show "new"
|
||||
e_prog = _episode("e_prog", sh_prog, 9, 1) # in-progress episode -> show "in_progress"
|
||||
_episode("e_prog2", sh_prog, 10, 2) # second episode, untouched (so watched < total)
|
||||
e_watch = _episode("e_watch", sh_watch, 11, 1) # watched episode -> show "watched"
|
||||
|
||||
user = User(email="plexparity@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexState(user_id=user.id, item_id=m_prog.id, position_seconds=120),
|
||||
PlexState(user_id=user.id, item_id=m_watch.id, status="watched"),
|
||||
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden"),
|
||||
PlexState(user_id=user.id, item_id=e_prog.id, position_seconds=90),
|
||||
PlexState(user_id=user.id, item_id=e_watch.id, status="watched"),
|
||||
])
|
||||
db.commit()
|
||||
# rating_key -> (table, genres, content_rating) so the test can total the grid's visible set.
|
||||
meta = {}
|
||||
for it in (m_new, m_prog, m_watch, m_hidden):
|
||||
meta[it.rating_key] = (it.genres, it.content_rating)
|
||||
for sh in (sh_new, sh_prog, sh_watch):
|
||||
meta[sh.rating_key] = (sh.genres, sh.content_rating)
|
||||
return {"user": user, "meta": meta}
|
||||
|
||||
|
||||
def _facet_counts(body):
|
||||
genres = {g["value"]: g["count"] for g in body["genres"]}
|
||||
crs = {c["value"]: c["count"] for c in body["content_ratings"]}
|
||||
return genres, crs
|
||||
|
||||
|
||||
def test_facets_mirror_the_grid_visible_set(db, as_user):
|
||||
"""For every (scope, show) the facet genre / content-rating counts equal the counts over the
|
||||
grid's visible items — the invariant the extracted shared watch-state predicate must preserve."""
|
||||
seed = _seed(db)
|
||||
meta = seed["meta"]
|
||||
client = as_user(seed["user"])
|
||||
|
||||
for scope in SCOPES:
|
||||
for show in SHOW_STATES:
|
||||
grid = client.get(f"/api/plex/library?scope={scope}&show={show}&limit=100")
|
||||
assert grid.status_code == 200, grid.text
|
||||
ids = [i["id"] for i in grid.json()["items"]]
|
||||
|
||||
exp_genres: Counter = Counter()
|
||||
exp_crs: Counter = Counter()
|
||||
for rk in ids:
|
||||
genres, cr = meta[rk]
|
||||
for g in genres or []:
|
||||
exp_genres[g] += 1
|
||||
if cr is not None:
|
||||
exp_crs[cr] += 1
|
||||
|
||||
facets = client.get(f"/api/plex/facets?scope={scope}&show={show}")
|
||||
assert facets.status_code == 200, facets.text
|
||||
got_genres, got_crs = _facet_counts(facets.json())
|
||||
|
||||
ctx = f"scope={scope} show={show} grid_ids={ids}"
|
||||
assert got_genres == dict(exp_genres), f"genre facet != grid ({ctx})"
|
||||
assert got_crs == dict(exp_crs), f"content-rating facet != grid ({ctx})"
|
||||
|
||||
|
||||
def test_hidden_movie_absent_from_grid_and_facets(db, as_user):
|
||||
"""A hidden movie must not appear in the default grid NOR inflate its facets — the single most
|
||||
important equivalence between the two duplicated 'exclude hidden' branches."""
|
||||
seed = _seed(db)
|
||||
client = as_user(seed["user"])
|
||||
|
||||
grid = client.get("/api/plex/library?scope=movie&show=all&limit=100")
|
||||
assert "m_hidden" not in [i["id"] for i in grid.json()["items"]]
|
||||
|
||||
# Only the hidden movie carries content_rating "R" among the *visible* movies? No — m_watch is
|
||||
# also "R". The hidden one must not add to the "R" count: visible R = {m_watch} = 1.
|
||||
facets = client.get("/api/plex/facets?scope=movie&show=all")
|
||||
crs = {c["value"]: c["count"] for c in facets.json()["content_ratings"]}
|
||||
assert crs.get("R") == 1, crs
|
||||
@@ -11,7 +11,7 @@ import app.plex.sync as syncmod
|
||||
from app import sysconfig
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
|
||||
from app.routes.plex.browse import _content_libs
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import (
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.routes.plex.browse import _content_libs
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
NAME = "Robert De Niro"
|
||||
|
||||
|
||||
@@ -149,7 +149,9 @@ class TestRollup:
|
||||
|
||||
|
||||
def _fake_paginate(by_type):
|
||||
def _p(_plex, _key, typ):
|
||||
def _p(_plex, _key, typ, progress=None):
|
||||
if progress is not None:
|
||||
progress["complete"] = True # the fake returns the whole list — a complete listing
|
||||
return list(by_type.get(typ, []))
|
||||
return _p
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""E3 S1 catalog-prune (B-sub-6): a sync now deletes rows for titles that disappeared from Plex,
|
||||
diffing each section's freshly-listed rating_keys against the stored ones. The prune is GUARDED on a
|
||||
non-empty listing so a transient Plex glitch (an empty page) can never wipe a whole library.
|
||||
|
||||
DB lane (`db` fixture) — skipped without Postgres. Drives `_sync_movies` / `_sync_shows` directly
|
||||
with a stub Plex (the same seam the overlay test uses), so no live server is needed.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex.sync import (
|
||||
_TYPE_EPISODE,
|
||||
_TYPE_MOVIE,
|
||||
_TYPE_SEASON,
|
||||
_TYPE_SHOW,
|
||||
_sync_movies,
|
||||
_sync_shows,
|
||||
)
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
"""Returns canned section listings per Plex item type — the current 'truth' the sync reconciles
|
||||
the stored rows against. `_paginate` is the only method the sync calls on it."""
|
||||
|
||||
def __init__(self, by_type: dict[int, list[dict]]):
|
||||
self.by_type = by_type
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
items = self.by_type.get(item_type, [])
|
||||
return items[start : start + size], len(items)
|
||||
|
||||
|
||||
class _TruncatedPlex:
|
||||
"""Simulates a truncated listing (a transient Plex hiccup): it reports a large `total` but returns
|
||||
only one short page, so pagination stops BEFORE reaching total. Prune must treat this as an
|
||||
incomplete fetch and delete nothing — the un-listed rows are present, just not mentioned."""
|
||||
|
||||
def __init__(self, items: list[dict], claimed_total: int):
|
||||
self._items = items
|
||||
self._total = claimed_total
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
if start == 0:
|
||||
return list(self._items), self._total # one short page, total says there's more
|
||||
return [], self._total
|
||||
|
||||
|
||||
def _seed_movies(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
m1 = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Kept Movie")
|
||||
m2 = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Gone Movie")
|
||||
db.add_all([m1, m2])
|
||||
user = User(email="prune@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
# A watch-state on EACH movie: the kept one's must survive, the pruned one's must cascade away.
|
||||
db.add_all([
|
||||
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
|
||||
PlexState(user_id=user.id, item_id=m2.id, status="watched"),
|
||||
])
|
||||
db.commit()
|
||||
return lib, m1.id, m2.id, user.id
|
||||
|
||||
|
||||
def test_movie_prune_deletes_disappeared_titles_and_cascades_state(db):
|
||||
lib, m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
# Plex now lists only m1 — m2 disappeared.
|
||||
stub = _StubPlex({_TYPE_MOVIE: [{"ratingKey": "m1", "title": "Kept Movie"}]})
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1"}, "the disappeared movie must be pruned"
|
||||
assert stats["pruned"] == 1
|
||||
# The kept movie's watch-state survives; the pruned movie's cascaded away.
|
||||
states = {s.item_id for s in db.execute(select(PlexState)).scalars()}
|
||||
assert states == {m1_id}
|
||||
|
||||
|
||||
def test_empty_listing_prunes_nothing(db):
|
||||
"""The safety guard: a section that lists ZERO items (a real empty library, or a transient Plex
|
||||
glitch we can't distinguish) must leave the stored rows untouched — never mass-delete."""
|
||||
lib, _m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
stub = _StubPlex({_TYPE_MOVIE: []}) # Plex returned nothing this run
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1", "m2"}, "an empty listing must not prune anything"
|
||||
assert stats["pruned"] == 0
|
||||
|
||||
|
||||
def test_truncated_listing_prunes_nothing(db):
|
||||
"""A partial fetch (Plex reports 5 items but the listing returns only 1 then stops) must NOT prune
|
||||
the 4 it failed to mention — the completeness guard, not just the empty-listing guard."""
|
||||
lib, m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
# Only m1 is listed, but total claims 5 → the walk never reaches total → incomplete → no prune.
|
||||
stub = _TruncatedPlex([{"ratingKey": "m1", "title": "Kept Movie"}], claimed_total=5)
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1", "m2"}, "a truncated listing must not prune the un-listed rows"
|
||||
assert stats["pruned"] == 0
|
||||
|
||||
|
||||
def test_show_prune_removes_stale_show_season_and_episode(db):
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# Stored: one kept show (s1 -> se1 -> e1) and one stale show (s2 -> se2 -> e2) that Plex dropped.
|
||||
s1 = PlexShow(rating_key="s1", library_id=lib.id, title="Kept Show")
|
||||
s2 = PlexShow(rating_key="s2", library_id=lib.id, title="Gone Show")
|
||||
db.add_all([s1, s2])
|
||||
db.flush()
|
||||
se1 = PlexSeason(rating_key="se1", show_id=s1.id, season_number=1)
|
||||
se2 = PlexSeason(rating_key="se2", show_id=s2.id, season_number=1)
|
||||
db.add_all([se1, se2])
|
||||
db.flush()
|
||||
e1 = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Kept Ep",
|
||||
show_id=s1.id, season_id=se1.id, season_number=1, episode_number=1)
|
||||
e2 = PlexItem(rating_key="e2", library_id=lib.id, kind="episode", title="Gone Ep",
|
||||
show_id=s2.id, season_id=se2.id, season_number=1, episode_number=1)
|
||||
db.add_all([e1, e2])
|
||||
db.commit()
|
||||
|
||||
# Plex now lists only s1's subtree.
|
||||
stub = _StubPlex({
|
||||
_TYPE_SHOW: [{"ratingKey": "s1", "title": "Kept Show"}],
|
||||
_TYPE_SEASON: [{"ratingKey": "se1", "parentRatingKey": "s1", "index": 1, "title": "Season 1"}],
|
||||
_TYPE_EPISODE: [{
|
||||
"ratingKey": "e1", "grandparentRatingKey": "s1", "parentRatingKey": "se1",
|
||||
"parentIndex": 1, "index": 1, "title": "Kept Ep",
|
||||
}],
|
||||
})
|
||||
stats = {"shows": 0, "seasons": 0, "episodes": 0, "pruned": 0}
|
||||
|
||||
_sync_shows(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
shows = {r.rating_key for r in db.execute(select(PlexShow)).scalars()}
|
||||
seasons = {r.rating_key for r in db.execute(select(PlexSeason)).scalars()}
|
||||
eps = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert shows == {"s1"}, "the stale show must be pruned"
|
||||
assert seasons == {"se1"}, "the stale season must be pruned"
|
||||
assert eps == {"e1"}, "the stale episode must be pruned"
|
||||
assert stats["pruned"] == 3 # s2 + se2 + e2
|
||||
@@ -1,7 +1,7 @@
|
||||
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from app.utils import iso
|
||||
from app.utils import iso, normalize_description
|
||||
|
||||
|
||||
def test_none_maps_to_none():
|
||||
@@ -28,3 +28,30 @@ def test_matches_datetime_isoformat_contract():
|
||||
# hand-roll `x.isoformat() if x else None`.
|
||||
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
|
||||
assert iso(dt) == dt.isoformat()
|
||||
|
||||
|
||||
def test_normalize_description_none_stays_none():
|
||||
assert normalize_description(None) is None
|
||||
|
||||
|
||||
def test_normalize_description_drops_blank_lines():
|
||||
# A YouTube contact block padded with runs of blank lines — the waste we strip before storing.
|
||||
raw = "Contact us\n\n\nEmail: a@b.com\n \nInstagram: @x\n\n\n"
|
||||
assert normalize_description(raw) == "Contact us\nEmail: a@b.com\nInstagram: @x"
|
||||
|
||||
|
||||
def test_normalize_description_right_strips_and_trims():
|
||||
assert normalize_description(" hi there \n\n bye ") == " hi there\n bye"
|
||||
|
||||
|
||||
def test_normalize_description_all_blank_collapses_to_empty():
|
||||
assert normalize_description("\n \n\t\n") == ""
|
||||
|
||||
|
||||
def test_normalize_description_single_line_unchanged():
|
||||
assert normalize_description("just one line") == "just one line"
|
||||
|
||||
|
||||
def test_normalize_description_folds_crlf_line_endings():
|
||||
# splitlines() folds CR/CRLF to \n — a documented, benign line-ending normalization.
|
||||
assert normalize_description("a\r\nb\r\n\r\nc") == "a\nb\nc"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Development
|
||||
|
||||
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in
|
||||
[`backend/app/README.md`](../backend/app/README.md).
|
||||
|
||||
## Run it locally
|
||||
|
||||
The whole stack (Postgres + API + worker) comes up from the localdev compose file:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.localdev.yml up --build
|
||||
```
|
||||
|
||||
Browse at **http://localhost:8080** (Google login works — the OAuth redirect is
|
||||
`http://localhost:8080/auth/callback`). The API runs `alembic upgrade head` on startup, so the schema
|
||||
is always current.
|
||||
|
||||
For a fast frontend edit loop, run the Vite dev server with HMR against that API:
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||
```
|
||||
|
||||
## The gate
|
||||
|
||||
A change is done when all of this is green. CI runs the same checks server-side
|
||||
(`.gitea/workflows/check.yml`), so "main is always green".
|
||||
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
# lint
|
||||
python -m ruff check --config backend/ruff.toml backend scripts
|
||||
|
||||
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
|
||||
# The backend tree is bind-mounted, so it always tests your working files.
|
||||
docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test
|
||||
docker compose -f backend/docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
The test compose sets `SIFTLODE_TEST_REQUIRE_DB=1`, which turns a would-be silent skip (no DB, or no
|
||||
ffmpeg) into a hard failure — so the gate can't print green while the DB-backed tests were quietly not
|
||||
run. Tests that need a database take the `db` fixture (`backend/tests/conftest.py`); the rest are
|
||||
pure-logic and run without one.
|
||||
|
||||
**Frontend** (from `frontend/`)
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc x3 (app / node / e2e)
|
||||
npm run lint # eslint
|
||||
npm run format:check # prettier
|
||||
npm run knip # unused exports/deps
|
||||
npm run test # vitest
|
||||
```
|
||||
|
||||
## Branching
|
||||
|
||||
Trunk-based: branch off `main`, keep the branch gate-green, and land the whole change back to `main`
|
||||
in one reviewed step. Migrations are Alembic revisions under `backend/alembic/versions`; the API
|
||||
applies them on startup, and the DB-lane rebuilds the schema via `alembic upgrade head`, so a broken
|
||||
migration chain fails the tests.
|
||||
+28
-51
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type PanelLayout,
|
||||
} from "./lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -90,37 +91,20 @@ export default function App() {
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.filterCollapsed, true)),
|
||||
);
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
||||
);
|
||||
function setFilterCollapsed(next: boolean) {
|
||||
setFilterCollapsedState(next);
|
||||
writeAccount(LS.filterCollapsed, next);
|
||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
||||
}
|
||||
function setPlaylistsCollapsed(next: boolean) {
|
||||
setPlaylistsCollapsedState(next);
|
||||
writeAccount(LS.playlistsCollapsed, next);
|
||||
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
||||
}
|
||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params) || params.has("page")) {
|
||||
// A share link's filters are held in state; they're persisted to the account's key by the
|
||||
// post-login effect (which knows the account id). Here we only clean the address bar.
|
||||
stripUrlParams();
|
||||
}
|
||||
}, []);
|
||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
@@ -152,11 +136,11 @@ export default function App() {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
useEffect(() => {
|
||||
if (meQuery.data) stripUrlParams();
|
||||
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
@@ -188,7 +172,7 @@ export default function App() {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("link");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||
@@ -197,12 +181,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
stripUrlParams();
|
||||
clearParams("oauth");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
useEffect(() => {
|
||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||
@@ -210,12 +194,13 @@ export default function App() {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
||||
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
||||
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
@@ -227,14 +212,6 @@ export default function App() {
|
||||
saveLayoutLocal(p, l);
|
||||
}
|
||||
});
|
||||
if (typeof prefs.filterCollapsed === "boolean") {
|
||||
setFilterCollapsedState(prefs.filterCollapsed);
|
||||
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
||||
}
|
||||
if (typeof prefs.playlistsCollapsed === "boolean") {
|
||||
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
|
||||
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
|
||||
}
|
||||
const lang = prefs.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
offset: 6,
|
||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||
hoverDelay: { open: 0, close: 160 },
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
@@ -51,7 +55,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-[480px] max-h-112 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
className="glass w-[480px] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
|
||||
@@ -131,7 +131,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
// Cap the positioned element, AND publish the available space as a custom property so
|
||||
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
|
||||
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
|
||||
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
|
||||
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
|
||||
// height, spilling its top off-screen (the whole point of fitHeight).
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -19,11 +16,9 @@ import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Modal from "./Modal";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
@@ -35,726 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
queued: "text-muted",
|
||||
running: "text-accent",
|
||||
paused: "text-warn",
|
||||
done: "text-success",
|
||||
error: "text-danger",
|
||||
canceled: "text-muted",
|
||||
};
|
||||
|
||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
||||
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
||||
const PHASE_KEYS = [
|
||||
"video",
|
||||
"audio",
|
||||
"editing",
|
||||
"merging",
|
||||
"audio_extract",
|
||||
"thumbnail",
|
||||
"sponsorblock",
|
||||
"metadata",
|
||||
"processing",
|
||||
];
|
||||
|
||||
function StatusPill({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumb({ job }: { job: DownloadJob }) {
|
||||
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources.
|
||||
const primary = job.asset?.thumbnail_url || job.poster_url;
|
||||
// A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a
|
||||
// broken image. On load failure fall back to our always-present self-hosted poster.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(job: DownloadJob): string {
|
||||
return job.display_name || job.asset?.title || job.source_ref;
|
||||
}
|
||||
|
||||
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
||||
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
||||
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
||||
return {
|
||||
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
||||
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||
danger ? "hover:text-danger" : "hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||
function displayUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
||||
function SourceRef({ url }: { url: string }) {
|
||||
const { t } = useTranslation();
|
||||
const copy = async () => {
|
||||
if (await copyText(url)) {
|
||||
notify({ level: "success", message: t("downloads.source.copied") });
|
||||
} else {
|
||||
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${t("downloads.source.open")} — ${url}`}
|
||||
className="truncate hover:text-accent hover:underline"
|
||||
>
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
<button
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.source.copy")}
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
||||
function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={url}
|
||||
className="text-muted hover:text-accent hover:underline"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
function DownloadRow({
|
||||
job,
|
||||
onShowError,
|
||||
children,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onShowError?: (job: DownloadJob) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const running = job.status === "running";
|
||||
return (
|
||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
{job.asset?.duration_s ? (
|
||||
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
||||
) : null}
|
||||
{job.status === "done" && job.created_at ? (
|
||||
<span
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
<button
|
||||
onClick={() => onShowError?.(job)}
|
||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||
title={t("downloads.errors.fail.title")}
|
||||
>
|
||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{job.source_url && <SourceRef url={job.source_url} />}
|
||||
{job.extra_links?.map((url) => (
|
||||
<SourceRef key={url} url={url} />
|
||||
))}
|
||||
{running && (
|
||||
<div className="mt-1.5">
|
||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rename + Share small modals ------------------------------------------------------------
|
||||
|
||||
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
||||
// All are display-only overrides — the on-disk file is never renamed.
|
||||
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
||||
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
||||
const [channelUrl, setChannelUrl] = useState(
|
||||
job.display_uploader_url || job.asset?.uploader_url || "",
|
||||
);
|
||||
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
||||
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
||||
|
||||
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
||||
const addLink = () => setLinks((ls) => [...ls, ""]);
|
||||
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateDownloadMeta(job.id, {
|
||||
display_name: name.trim(),
|
||||
display_uploader: channel.trim(),
|
||||
display_uploader_url: channelUrl.trim(),
|
||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputCls}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
||||
<input
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
||||
<input
|
||||
value={channelUrl}
|
||||
onChange={(e) => setChannelUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
||||
<div className="space-y-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
value={l}
|
||||
onChange={(e) => setLink(i, e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
<IconBtn
|
||||
onClick={() => removeLink(i)}
|
||||
title={t("downloads.edit.removeLink")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name.trim() || save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.edit.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error detail modal ---------------------------------------------------------------------
|
||||
|
||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
||||
|
||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||
function ErrorModal({
|
||||
job,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const code = job.error_code || "unknown";
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const retryable = RETRYABLE_ERRORS.includes(code);
|
||||
return (
|
||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
||||
{job.error && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
className="text-xs text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("downloads.errors.fail.detailsToggle")}
|
||||
</button>
|
||||
{showRaw && (
|
||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||
{job.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
{retryable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onRetry();
|
||||
onClose();
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||
>
|
||||
{t("downloads.actions.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Usage bar ------------------------------------------------------------------------------
|
||||
|
||||
function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
||||
const u = usage.data;
|
||||
if (!u) return null;
|
||||
const pct =
|
||||
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||
return (
|
||||
<div className="rounded-xl glass-card p-3 mb-4">
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||
<span className="text-muted">
|
||||
{formatBytes(u.footprint_bytes)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||
{u.unlimited
|
||||
? t("downloads.usage.unlimited")
|
||||
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||
</span>
|
||||
</div>
|
||||
{!u.unlimited && (
|
||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin system tab -----------------------------------------------------------------------
|
||||
|
||||
function QuotaModal({
|
||||
userId,
|
||||
email,
|
||||
onClose,
|
||||
}: {
|
||||
userId: number;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: qk.dlQuota(userId),
|
||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||
});
|
||||
const [form, setForm] = useState<{
|
||||
gb: number;
|
||||
jobs: number;
|
||||
conc: number;
|
||||
unlimited: boolean;
|
||||
} | null>(null);
|
||||
const cur = q.data;
|
||||
const state =
|
||||
form ??
|
||||
(cur
|
||||
? {
|
||||
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
||||
jobs: cur.max_jobs,
|
||||
conc: cur.max_concurrent,
|
||||
unlimited: cur.unlimited,
|
||||
}
|
||||
: null);
|
||||
const done = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||
onClose();
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.adminSetDownloadQuota(userId, {
|
||||
max_bytes: Math.round(state!.gb * GiB),
|
||||
max_jobs: state!.jobs,
|
||||
max_concurrent: state!.conc,
|
||||
unlimited: state!.unlimited,
|
||||
}),
|
||||
onSuccess: done,
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: done,
|
||||
});
|
||||
if (!state) return null;
|
||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||
return (
|
||||
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxBytes")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.gb}
|
||||
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
disabled={state.unlimited}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxJobs")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.jobs}
|
||||
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxConcurrent")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.conc}
|
||||
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.unlimited}
|
||||
onChange={(e) => upd({ unlimited: e.target.checked })}
|
||||
/>
|
||||
{t("downloads.admin.unlimited")}
|
||||
</label>
|
||||
<div className="flex justify-between pt-1">
|
||||
<button
|
||||
onClick={() => reset.mutate()}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("downloads.admin.reset")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
||||
// footprint list below only shows users who already have files).
|
||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
const base = s
|
||||
? list.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||
)
|
||||
: list;
|
||||
return base.slice(0, 8);
|
||||
}, [q, list]);
|
||||
return (
|
||||
<div className="relative w-full sm:w-72">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSystem() {
|
||||
const { t } = useTranslation();
|
||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||
const s = storage.data;
|
||||
return (
|
||||
<div>
|
||||
{s && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
{[
|
||||
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||
[
|
||||
t("downloads.admin.cap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="rounded-xl glass-card p-3">
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||
<QuotaUserPicker onPick={setQuotaFor} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{s.per_user.map((u) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||
<button
|
||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||
title={t("downloads.admin.editQuota")}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.per_user.length === 0 && (
|
||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||
<div className="space-y-1.5">
|
||||
{(jobs.data ?? []).map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||
<StatusPill job={j} />
|
||||
<span className="text-muted w-16 text-right">
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{jobs.data && jobs.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quotaFor && (
|
||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main -----------------------------------------------------------------------------------
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
||||
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
|
||||
@@ -90,8 +92,8 @@ export function useFeedFiltersActions(): FeedFiltersActions {
|
||||
|
||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
@@ -108,6 +110,14 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
if (key) writeJSON(key, next);
|
||||
}, []);
|
||||
|
||||
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
|
||||
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
|
||||
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
|
||||
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
|
||||
// other providers own.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||
|
||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
||||
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { parseFeedView, type FeedView } from "../lib/feedView";
|
||||
import { LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { LS } from "../lib/storage";
|
||||
import { usePref } from "../lib/usePref";
|
||||
|
||||
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||
//
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
|
||||
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
|
||||
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
|
||||
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||
|
||||
interface FeedViewActions {
|
||||
setView: (v: FeedView) => void;
|
||||
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
|
||||
}
|
||||
|
||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setViewState] = useState<FeedView>(() =>
|
||||
parseFeedView(readAccount(LS.feedView, null)),
|
||||
);
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
|
||||
const setView = useCallback((next: FeedView) => {
|
||||
setViewState(next);
|
||||
writeAccount(LS.feedView, next);
|
||||
api.savePrefs({ view: next }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
|
||||
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
|
||||
// "grid" / "list" values forward.
|
||||
useEffect(() => {
|
||||
const prefs = me?.preferences;
|
||||
if (!prefs) return;
|
||||
const adopted = parseFeedView(prefs.view);
|
||||
setViewState(adopted);
|
||||
writeAccount(LS.feedView, adopted);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||
localKey: LS.feedView,
|
||||
parse: parseFeedView,
|
||||
});
|
||||
|
||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
|
||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
|
||||
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
|
||||
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
|
||||
// that need neither.
|
||||
export default function FilterRail({
|
||||
kind,
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
actions,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
layout,
|
||||
setLayout,
|
||||
items,
|
||||
top,
|
||||
children,
|
||||
}: {
|
||||
kind: PanelId;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
count?: number;
|
||||
actions?: ReactNode;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
items: PanelItem[];
|
||||
top?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<SidePanel
|
||||
title={title}
|
||||
icon={icon}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout(kind))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={actions}
|
||||
>
|
||||
{top}
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
{children}
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ function ConversationList({
|
||||
onClick={() => openChat(c.partner)}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
|
||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
||||
import { useFullscreenElement } from "../lib/fullscreen";
|
||||
|
||||
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
|
||||
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
|
||||
// collapsed disclosure) so the trap never lands focus on something the user can't see.
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
|
||||
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||
@@ -27,6 +35,13 @@ export default function Modal({
|
||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||
useBackToClose(onClose);
|
||||
const fsRoot = useFullscreenElement();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
|
||||
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
|
||||
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
|
||||
// focus on <body>. useState initializer runs exactly once, on first render.
|
||||
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
|
||||
|
||||
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
|
||||
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
|
||||
@@ -39,20 +54,57 @@ export default function Modal({
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
if (!isTopmost(id)) return;
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
// Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
|
||||
// page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
|
||||
if (e.key === "Tab") {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const items = focusablesIn(panel);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (!panel.contains(active) || active === panel) {
|
||||
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
|
||||
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
|
||||
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
||||
// focus to the first control so the dialog opens focused, not stranded on the background.
|
||||
const panel = panelRef.current;
|
||||
if (panel && !panel.contains(document.activeElement)) {
|
||||
(focusablesIn(panel)[0] ?? panel).focus();
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
removeLayer(id);
|
||||
document.body.style.overflow = prev;
|
||||
// Restore focus to the trigger, if it's still in the document and focusable.
|
||||
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||
};
|
||||
}, []);
|
||||
}, [restoreTo]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -65,10 +117,17 @@ export default function Modal({
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isPage, readPage, type Page } from "../lib/urlState";
|
||||
import { hasFilterParams, isPage, readPage, urlWithParams, type Page } from "../lib/urlState";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
@@ -52,6 +52,12 @@ export interface NavigationActions {
|
||||
function loadInitialPage(): Page {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("page")) return readPage();
|
||||
// A view-specific param implies its page, so a bookmarked deep link lands on the right module even
|
||||
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
|
||||
if (params.has("v")) return "plex";
|
||||
if (params.has("pl")) return "playlists";
|
||||
if (hasFilterParams(params) || params.has("chan")) return "feed";
|
||||
// A bare URL → the account's stored landing page.
|
||||
const stored = getAccountRaw(PAGE_KEY);
|
||||
return isPage(stored) ? stored : "feed";
|
||||
}
|
||||
@@ -88,10 +94,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
|
||||
});
|
||||
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
|
||||
// detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
|
||||
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
|
||||
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
|
||||
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
|
||||
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
|
||||
const c = window.history.state?._chan as string | undefined;
|
||||
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
||||
const st = window.history.state;
|
||||
const id =
|
||||
(st?._chan as string | undefined) ??
|
||||
new URLSearchParams(window.location.search).get("chan") ??
|
||||
undefined;
|
||||
return id ? { id, name: (st?._chanName as string) ?? undefined } : null;
|
||||
});
|
||||
|
||||
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
|
||||
@@ -116,8 +128,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
|
||||
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
|
||||
// rather than being orphaned from history so Back can't close it.
|
||||
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
|
||||
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
|
||||
// address bar too (urlWithParams keeps any ?chan/filter params already there).
|
||||
window.history.replaceState(
|
||||
{ ...(window.history.state || {}), sfPage: stored },
|
||||
"",
|
||||
urlWithParams({ page: stored === "feed" ? null : stored }),
|
||||
);
|
||||
});
|
||||
|
||||
const setPage = useCallback(
|
||||
@@ -130,9 +147,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
||||
// _yt/_chan markers) so each page starts at its root.
|
||||
window.history.pushState({ sfPage: next }, "");
|
||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
|
||||
// FeedFiltersProvider) are left intact.
|
||||
window.history.pushState(
|
||||
{ sfPage: next },
|
||||
"",
|
||||
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
|
||||
);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
@@ -143,13 +166,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
||||
const url = urlWithParams({ chan: id });
|
||||
if (st._chan)
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
}, []);
|
||||
|
||||
const closeChannel = useCallback(() => {
|
||||
if (window.history.state?._chan) window.history.back();
|
||||
else setChannelView(null);
|
||||
else {
|
||||
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
|
||||
// drop the ?chan param from the address bar.
|
||||
setChannelView(null);
|
||||
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const enterYtSearch = useCallback((q: string) => {
|
||||
@@ -189,19 +219,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
document.title = label ? `${label} · ${brand}` : brand;
|
||||
}, [page, channelView, i18n.language, t]);
|
||||
|
||||
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
|
||||
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
|
||||
// later query-string strip in App.)
|
||||
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
||||
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||
// the popstate handler only needs to read history.state.
|
||||
useEffect(() => {
|
||||
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
||||
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
||||
// are always kept so a channel page survives F5.
|
||||
const st = window.history.state || {};
|
||||
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
|
||||
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
|
||||
// ?chan / ?v / ?pl / filter params the loaders already read.
|
||||
const url = urlWithParams({ page: page === "feed" ? null : page });
|
||||
if (st._yt && st._ytScrape) {
|
||||
window.history.replaceState({ ...st, sfPage: page }, "");
|
||||
window.history.replaceState({ ...st, sfPage: page }, "", url);
|
||||
} else {
|
||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
||||
window.history.replaceState({ ...rest, sfPage: page }, "", url);
|
||||
}
|
||||
function onPop(e: PopStateEvent) {
|
||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
||||
@@ -210,7 +244,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
|
||||
// restores or clears them to match the entry we landed on.
|
||||
setYtSearch((e.state?._yt as string) ?? null);
|
||||
const chan = e.state?._chan as string | undefined;
|
||||
// Fall back to the ?chan URL param (the browser restored the landed entry's URL) when the entry
|
||||
// has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would
|
||||
// wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic
|
||||
// history.back()). Mirrors the channelView initializer's URL fallback.
|
||||
const chan =
|
||||
(e.state?._chan as string | undefined) ??
|
||||
new URLSearchParams(window.location.search).get("chan") ??
|
||||
undefined;
|
||||
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||
}
|
||||
window.addEventListener("popstate", onPop);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
AUTO_MODES,
|
||||
LOOP_MODES,
|
||||
PlayerTransportControls,
|
||||
type AutoMode,
|
||||
type LoopMode,
|
||||
} from "./playerModalControls";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
@@ -15,12 +23,7 @@ import {
|
||||
Maximize,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
Repeat,
|
||||
Repeat1,
|
||||
Settings,
|
||||
Shuffle,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
@@ -31,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
import {
|
||||
channelYouTubeUrl,
|
||||
formatDate,
|
||||
@@ -40,15 +43,14 @@ import {
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import {
|
||||
exitFullscreen,
|
||||
fullscreenElement,
|
||||
onFullscreenChange,
|
||||
requestFullscreen,
|
||||
} from "../lib/fullscreen";
|
||||
import { useBackToClose, useBackToExit } from "../lib/history";
|
||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
useNativeControlsYield,
|
||||
useYtBigView,
|
||||
useYtProgress,
|
||||
useYtVolume,
|
||||
} from "../lib/playerModalHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||
@@ -57,119 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
|
||||
// the cross-origin player iframe — a browser security boundary we can't cross).
|
||||
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
const FINISH_MARGIN = 10;
|
||||
|
||||
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
|
||||
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
|
||||
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
|
||||
type AutoMode = "off" | "next" | "prev" | "random";
|
||||
type LoopMode = "off" | "one" | "all";
|
||||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
||||
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
||||
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
function PlayerTransportControls({
|
||||
variant,
|
||||
index,
|
||||
queueLength,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCycleAuto,
|
||||
onCycleLoop,
|
||||
}: {
|
||||
variant: "docked" | "modal";
|
||||
index: number;
|
||||
queueLength: number;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onCycleAuto: () => void;
|
||||
onCycleLoop: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const docked = variant === "docked";
|
||||
const navBtn = docked
|
||||
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
||||
const navLabel = docked ? "hidden sm:inline" : "";
|
||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const toggle = (on: boolean) =>
|
||||
docked
|
||||
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`
|
||||
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||
}`;
|
||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className={navBtn}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
<span className={navLabel}>{t("player.previous")}</span>
|
||||
</button>
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className={navBtn}
|
||||
>
|
||||
<span className={navLabel}>{t("player.next")}</span>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<button
|
||||
onClick={onCycleAuto}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={toggle(autoMode !== "off")}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCycleLoop}
|
||||
title={t("player.loop.hint")}
|
||||
className={toggle(loopMode !== "off")}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
||||
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
||||
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||||
@@ -185,26 +74,6 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- IFrame Player API loader (singleton) ---
|
||||
let apiPromise: Promise<YTNamespace> | null = null;
|
||||
function loadYouTubeApi(): Promise<YTNamespace> {
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise<YTNamespace>((resolve) => {
|
||||
const w = window;
|
||||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||||
const prev = w.onYouTubeIframeAPIReady;
|
||||
w.onYouTubeIframeAPIReady = () => {
|
||||
if (typeof prev === "function") prev();
|
||||
// The API-ready callback only fires once the script has installed window.YT.
|
||||
resolve(w.YT!);
|
||||
};
|
||||
const tag = document.createElement("script");
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
@@ -264,11 +133,6 @@ export default function PlayerModal({
|
||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
||||
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||
const errorStreakRef = useRef(0);
|
||||
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
|
||||
// iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
|
||||
// controls; the interaction overlay below also stops the iframe stealing focus on a video
|
||||
@@ -278,52 +142,6 @@ export default function PlayerModal({
|
||||
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
||||
// modal adjusts volume (not just the small player area).
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const volTimerRef = useRef<number | undefined>(undefined);
|
||||
// Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
|
||||
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
||||
// When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
|
||||
// cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
|
||||
// so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
|
||||
// navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
|
||||
// video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
|
||||
const [nativeControls, setNativeControls] = useState(false);
|
||||
// True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay
|
||||
// yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears
|
||||
// while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it
|
||||
// the native fullscreen button — permanently hidden after pressing F.
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const maximizedRef = useRef(false);
|
||||
maximizedRef.current = maximized;
|
||||
// Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would
|
||||
// otherwise read `false` forever — the house pattern here (see navRef/volumeRef).
|
||||
const isFullscreenRef = useRef(false);
|
||||
// Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a
|
||||
// history layer only while active). Mirrors the Esc ladder in the keydown handler below.
|
||||
useBackToExit(isFullscreen, exitFullscreen);
|
||||
useBackToExit(maximized, () => setMaximized(false));
|
||||
// Volume survives the modal, the next video and a reload: a YT.Player is created per video and
|
||||
// always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted
|
||||
// state (that's how YouTube's own control behaves), so there's no separate `muted` flag to
|
||||
// contradict it.
|
||||
//
|
||||
// Ref + explicit read/write rather than `useAccountPersistedObject`: nothing RENDERS the level
|
||||
// (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a
|
||||
// ref anyway, and the hook's write lives inside a `setState` updater — which is both a second
|
||||
// write on every settle and not something React must run while the component is unmounting.
|
||||
// Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see
|
||||
// lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of
|
||||
// travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not
|
||||
// on every render (the house pattern — see the frozen queue above).
|
||||
const [storedVolume] = useState(() =>
|
||||
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
||||
);
|
||||
const volumeRef = useRef(storedVolume);
|
||||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||||
const lastNudgeAtRef = useRef(0);
|
||||
|
||||
const focusModal = () => cardRef.current?.focus();
|
||||
const togglePlay = () => {
|
||||
const p = playerRef.current;
|
||||
@@ -332,84 +150,28 @@ export default function PlayerModal({
|
||||
p.pauseVideo?.(); // 1 === playing
|
||||
else p.playVideo?.();
|
||||
};
|
||||
/** F = maximise, deliberately WITHOUT the Fullscreen API.
|
||||
*
|
||||
* The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug):
|
||||
* a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the
|
||||
* browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player
|
||||
* never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a
|
||||
* click on it changes nothing visible. That is the reported bug: every other YouTube control
|
||||
* (gear, CC, volume) works, only its fullscreen button looks dead.
|
||||
*
|
||||
* So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page
|
||||
* stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does
|
||||
* anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar)
|
||||
* keep working, because our DOM is what's on screen. The one thing we give up is hiding the
|
||||
* browser's own chrome; YouTube's button provides that, correctly, when the user wants it.
|
||||
*/
|
||||
const toggleFullscreen = () => setMaximized((m) => !m);
|
||||
// True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps
|
||||
// OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too.
|
||||
// We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks
|
||||
// dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise.
|
||||
const toggleTrueFullscreen = () => {
|
||||
if (fullscreenElement()) exitFullscreen();
|
||||
else if (stageRef.current) requestFullscreen(stageRef.current);
|
||||
};
|
||||
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
|
||||
const exitBigView = () => {
|
||||
if (fullscreenElement()) exitFullscreen();
|
||||
setMaximized(false);
|
||||
};
|
||||
const flashVolume = (level: number) => {
|
||||
setVolumeUi(level);
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||||
};
|
||||
/** Write the current level out now, cancelling any pending debounce. */
|
||||
const persistVolume = () => {
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = undefined;
|
||||
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
|
||||
};
|
||||
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
|
||||
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
|
||||
const applyVolume = (level: number, flash = true) => {
|
||||
// Callers hand over an already-normalised level (stepVolume / the stored value), but the
|
||||
// player can still answer NaN, so re-normalise here against the LAST GOOD level — never the
|
||||
// default: jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
||||
const next = normalizeVolume(level, volumeRef.current);
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.setVolume === "function") {
|
||||
if (next > 0) p.unMute?.();
|
||||
p.setVolume(next);
|
||||
if (next === 0) p.mute?.();
|
||||
}
|
||||
volumeRef.current = next;
|
||||
// Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes
|
||||
// a pending write, so the last change is never lost.
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||||
if (flash) flashVolume(next);
|
||||
};
|
||||
const nudgeVolume = (delta: number) => {
|
||||
// No early return when the player isn't ready yet: the level lives in `volumeRef` and onReady
|
||||
// applies it, so a wheel spin in the first second after opening (the natural reaction to a
|
||||
// video that starts loud) now lands instead of being silently dropped.
|
||||
const p = playerRef.current;
|
||||
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
|
||||
// the player, because the user may have moved YouTube's own slider in the meantime.
|
||||
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
|
||||
// re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five
|
||||
// wheel notches moved 100 → 90 instead of 75).
|
||||
const now = Date.now();
|
||||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||||
lastNudgeAtRef.current = now;
|
||||
const live =
|
||||
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
|
||||
const cur = continuing || live === null ? volumeRef.current : live;
|
||||
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
|
||||
};
|
||||
// Per-account volume (wheel over the whole modal + ↑/↓): the level lives in the hook's ref and is
|
||||
// re-applied to each freshly-created player via reapplyStored (onReady).
|
||||
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
||||
playerRef,
|
||||
dialogRef,
|
||||
focusModal,
|
||||
});
|
||||
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||||
playerRef,
|
||||
stageRef,
|
||||
focusModal,
|
||||
});
|
||||
const {
|
||||
isFullscreen,
|
||||
maximized,
|
||||
maximizedRef,
|
||||
isFullscreenRef,
|
||||
toggleFullscreen,
|
||||
toggleTrueFullscreen,
|
||||
exitBigView,
|
||||
setMaximized,
|
||||
} = useYtBigView({ stageRef, playerRef, focusModal });
|
||||
|
||||
// The player can navigate to other videos (YouTube links in a description). The
|
||||
// currently-playing id may differ from the active item.
|
||||
@@ -481,36 +243,18 @@ export default function PlayerModal({
|
||||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||||
};
|
||||
|
||||
// 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 = useMemo(
|
||||
() =>
|
||||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
|
||||
playerAutoAdvance?: AutoMode;
|
||||
playerLoop?: LoopMode;
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
||||
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
||||
// Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on
|
||||
// login) so they apply to every player and survive reloads. The player mounts after `me` is cached,
|
||||
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
|
||||
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
|
||||
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
|
||||
const modeRef = useRef({ autoMode, loopMode });
|
||||
modeRef.current = { autoMode, loopMode };
|
||||
const persistPref = (patch: Record<string, string>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||
);
|
||||
};
|
||||
const cycleAuto = () => {
|
||||
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
|
||||
setAutoMode(next);
|
||||
persistPref({ playerAutoAdvance: next });
|
||||
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
|
||||
};
|
||||
const cycleLoop = () => {
|
||||
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
|
||||
setLoopMode(next);
|
||||
persistPref({ playerLoop: next });
|
||||
setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!);
|
||||
};
|
||||
const replayCurrent = () => {
|
||||
const p = playerRef.current;
|
||||
@@ -656,232 +400,29 @@ export default function PlayerModal({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
|
||||
// way to leave) must not drop the level the user just set.
|
||||
if (volSaveTimerRef.current != null) persistVolume();
|
||||
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
|
||||
// Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
|
||||
// swallows wheel events over its own native-control strips, but the central interaction overlay
|
||||
// is our element (wheel there bubbles here), and everything outside the video works directly.
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
|
||||
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, []);
|
||||
|
||||
// Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so
|
||||
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
const fsEl = fullscreenElement();
|
||||
// Refs only — this listener is bound once, so it must not close over render values.
|
||||
const p = playerRef.current;
|
||||
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||||
// LEAVING fullscreen is where the user got stranded: to reach YouTube's fullscreen button
|
||||
// they had to click INTO the player, which moves keyboard focus into the cross-origin iframe
|
||||
// — and it stays there afterwards. Our window-level shortcuts then never fire (F went to
|
||||
// YouTube instead, giving a second, control-less fullscreen), so the maximised view had no
|
||||
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
|
||||
if (!fsEl) focusModal();
|
||||
};
|
||||
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
|
||||
}, []);
|
||||
|
||||
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
|
||||
// bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
|
||||
// iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
|
||||
// menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
|
||||
// shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
|
||||
// card blurs the iframe → the parent window fires 'focus').
|
||||
useEffect(() => {
|
||||
const iframeFocused = () => {
|
||||
const p = playerRef.current;
|
||||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||
return !!f && document.activeElement === f;
|
||||
};
|
||||
// activeElement settles on the tick after blur, so defer the check.
|
||||
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
||||
const rearm = () => {
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
};
|
||||
const stage = stageRef.current;
|
||||
window.addEventListener("blur", onBlur);
|
||||
window.addEventListener("focus", rearm);
|
||||
stage?.addEventListener("mouseleave", rearm);
|
||||
return () => {
|
||||
window.removeEventListener("blur-sm", onBlur);
|
||||
window.removeEventListener("focus", rearm);
|
||||
stage?.removeEventListener("mouseleave", rearm);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create the player, resume from the saved position, persist progress, and
|
||||
// auto-mark watched once playback reaches the end.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = active.id;
|
||||
autoMarkedRef.current = false;
|
||||
setPlayerError(null);
|
||||
|
||||
// Auto-watch only applies to the active item — not to other videos the player
|
||||
// navigated to via description links.
|
||||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||||
// 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;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const persist = (): Promise<unknown> | void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
|
||||
// only clear the resume position, and firing both at once needlessly races the
|
||||
// watched write against the progress row it deletes.
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the feed video we opened with — a navigated-to video may not
|
||||
// be in this user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return api.saveProgress(id, cur, dur).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* player may be tearing down */
|
||||
}
|
||||
};
|
||||
|
||||
loadYouTubeApi().then((YT) => {
|
||||
if (cancelled || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
playsinline: 1,
|
||||
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
||||
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
||||
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
|
||||
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
||||
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
||||
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
||||
vq: "hd1080",
|
||||
},
|
||||
events: {
|
||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
||||
// restore the remembered volume — a fresh player always starts at 100.
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
applyVolume(volumeRef.current, false);
|
||||
},
|
||||
onStateChange: (e: YTPlayerEvent) => {
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
if (e?.data === 1) {
|
||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||
const p = playerRef.current;
|
||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||
if (d) setLiveData({ title: d.title, author: d.author });
|
||||
}
|
||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||
if (e?.data === 0 && currentIdRef.current === id) {
|
||||
if (!autoMarkedRef.current) {
|
||||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
}
|
||||
advanceOnEnd();
|
||||
}
|
||||
},
|
||||
// The embed couldn't play this video (embedding disabled, removed, private…).
|
||||
// Surface our own message + an "Open on YouTube" escape hatch.
|
||||
onError: (e: YTPlayerEvent) => {
|
||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
|
||||
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
|
||||
// bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
|
||||
// NOT mark the broken video watched (only the ended-handler does that).
|
||||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||||
const { autoMode: a } = modeRef.current;
|
||||
const { queue: q } = navRef.current;
|
||||
if (a === "off" || !q || q.length <= 1) return;
|
||||
errorStreakRef.current += 1;
|
||||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||||
advanceOnEnd();
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||
const timer = window.setInterval(persist, 5000);
|
||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
||||
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
||||
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
||||
// description-link video may not be in this user's library (the server would 404).
|
||||
const onHide = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||||
} catch {
|
||||
/* player tearing down */
|
||||
}
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the
|
||||
// next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2:
|
||||
// opening/closing the player through a watch session otherwise re-runs the whole infinite query
|
||||
// each time). Progress is already persisted server-side; only the on-screen resume bar waits.
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
||||
});
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.destroy === "function") {
|
||||
try {
|
||||
p.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
playerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active.id]);
|
||||
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
|
||||
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
|
||||
// the refs passed in. See lib/playerModalHooks.
|
||||
useYtProgress({
|
||||
activeId: active.id,
|
||||
resumeAt,
|
||||
mountRef,
|
||||
playerRef,
|
||||
currentIdRef,
|
||||
navRef,
|
||||
modeRef,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
});
|
||||
|
||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
||||
// must not render inside the page scroller: once that scroller is a stacking context (its
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
|
||||
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
|
||||
@@ -37,6 +39,12 @@ export function usePlaylistsActions(): PlaylistsActions {
|
||||
}
|
||||
|
||||
function readSelectedId(): number | null {
|
||||
// A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. Accept only a
|
||||
// positive integer id — a bare "?pl=" gives Number("")===0 (finite!) and "?pl=1.5" a fractional id,
|
||||
// both of which would select a non-existent playlist.
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("pl");
|
||||
const n = fromUrl ? Number(fromUrl) : NaN;
|
||||
if (Number.isInteger(n) && n > 0) return n;
|
||||
const s = getAccountRaw(LS.playlist);
|
||||
return s ? Number(s) : null;
|
||||
}
|
||||
@@ -59,6 +67,12 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
|
||||
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
|
||||
// params intact.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null });
|
||||
|
||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||
const actions = useMemo<PlaylistsActions>(
|
||||
() => ({ setSearch, setSelectedId }),
|
||||
|
||||
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SortControl from "./SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
|
||||
}, [plSort]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
|
||||
];
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
<FilterRail
|
||||
kind="playlists"
|
||||
title={t("playlists.title")}
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({
|
||||
plexFilterCount: () => 0,
|
||||
}));
|
||||
|
||||
import { CastStrip, PlexPersonView } from "./PlexBrowse";
|
||||
import { CastStrip } from "./plexCards";
|
||||
import { PlexPersonView } from "./plexViews";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
@@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
const collFade = useScrollFade();
|
||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
const qc = useQueryClient();
|
||||
async function createPlaylist() {
|
||||
const title = newPlaylist.trim();
|
||||
@@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
];
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("plex"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
actions={
|
||||
anyActive ? (
|
||||
<button
|
||||
@@ -423,9 +426,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
|
||||
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
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 { filtersToParams } from "../lib/urlState";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
@@ -131,14 +129,6 @@ export default function SavedViewsWidget({
|
||||
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
}
|
||||
|
||||
async function share(v: SavedView) {
|
||||
if (await copyText(shareUrl(asFilters(v.filters)))) {
|
||||
notify({ level: "success", message: t("views.shareCopied") });
|
||||
} else {
|
||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||
}
|
||||
}
|
||||
|
||||
async function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
@@ -177,7 +167,6 @@ export default function SavedViewsWidget({
|
||||
onRename={(name) => rename(v.id, name)}
|
||||
onCancelRename={() => setRenameId(null)}
|
||||
onToggleDefault={() => toggleDefault(v)}
|
||||
onShare={() => share(v)}
|
||||
onDelete={() => remove(v)}
|
||||
/>
|
||||
))}
|
||||
@@ -239,7 +228,6 @@ function ViewRow({
|
||||
onRename,
|
||||
onCancelRename,
|
||||
onToggleDefault,
|
||||
onShare,
|
||||
onDelete,
|
||||
}: {
|
||||
view: SavedView;
|
||||
@@ -250,7 +238,6 @@ function ViewRow({
|
||||
onRename: (name: string) => void;
|
||||
onCancelRename: () => void;
|
||||
onToggleDefault: () => void;
|
||||
onShare: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -334,29 +321,22 @@ function ViewRow({
|
||||
className={`p-1 rounded transition ${
|
||||
view.is_default
|
||||
? "text-accent"
|
||||
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
|
||||
: "text-muted reveal hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onShare}
|
||||
title={t("views.share")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartRename}
|
||||
title={t("views.rename")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
|
||||
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title={t("views.delete")}
|
||||
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-danger transition"
|
||||
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -198,7 +198,7 @@ function JobRow({
|
||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||
>
|
||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
|
||||
<Pencil className="w-3 h-3 reveal transition" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -2,19 +2,17 @@ import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
|
||||
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { shareUrl } from "../lib/urlState";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||
import TagManager from "./TagManager";
|
||||
import SavedViewsWidget from "./SavedViewsWidget";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
@@ -111,7 +109,9 @@ export default function Sidebar({
|
||||
const userTags = tags.filter((t) => !t.system);
|
||||
const [customDates, setCustomDates] = useState(false);
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "feed";
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const has = filters.tags.includes(id);
|
||||
@@ -126,14 +126,6 @@ export default function Sidebar({
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
const active = activeCount > 0;
|
||||
|
||||
async function shareView() {
|
||||
if (await copyText(shareUrl(filters))) {
|
||||
notify({ level: "success", message: t("sidebar.shareCopied") });
|
||||
} else {
|
||||
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
setCustomDates(false);
|
||||
setFilters(clearedFilters(filters));
|
||||
@@ -308,7 +300,7 @@ export default function Sidebar({
|
||||
|
||||
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
|
||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||
const items: PanelItem[] = defaultLayout("feed")
|
||||
const items: PanelItem[] = defaultLayout(source)
|
||||
.order.filter((id) => available[id])
|
||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||
|
||||
@@ -321,58 +313,49 @@ export default function Sidebar({
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={shareView}
|
||||
title={t("sidebar.shareView")}
|
||||
aria-label={t("sidebar.shareView")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("feed"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
actions={actions}
|
||||
top={
|
||||
/* Scope: your own subscriptions (Mine) vs the shared Library. */
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("header.scope.label")}
|
||||
fullWidth
|
||||
size="md"
|
||||
options={[
|
||||
{
|
||||
value: "my",
|
||||
label: t("header.scope.my"),
|
||||
icon: <User className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.myTip"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("header.scope.all"),
|
||||
icon: <Library className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.allTip"),
|
||||
},
|
||||
]}
|
||||
value={filters.scope}
|
||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("header.scope.label")}
|
||||
fullWidth
|
||||
size="md"
|
||||
options={[
|
||||
{
|
||||
value: "my",
|
||||
label: t("header.scope.my"),
|
||||
icon: <User className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.myTip"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("header.scope.all"),
|
||||
icon: <Library className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.allTip"),
|
||||
},
|
||||
]}
|
||||
value={filters.scope}
|
||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||
/>
|
||||
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</SidePanel>
|
||||
</FilterRail>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function Actions({
|
||||
// rather than silently rendering a half-clipped control outside the card.
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-wrap opacity-0 group-hover:opacity-100 transition",
|
||||
"flex flex-wrap reveal transition",
|
||||
dense ? "gap-0.5" : "gap-1",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
|
||||
@@ -23,9 +24,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
// Start fetching the next page this many rows before the end scrolls into view
|
||||
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||
const PREFETCH_ROWS = 4;
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||
function columnsFor(minCol: number | null, width: number): number {
|
||||
@@ -140,13 +138,17 @@ export default function VirtualGrid<T>({
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
|
||||
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
|
||||
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
||||
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
|
||||
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
|
||||
// fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles
|
||||
// (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a
|
||||
// fast scroll reaches the loaded end far less often.
|
||||
useEffect(() => {
|
||||
if (!fetchNextPage) return;
|
||||
if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||||
if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api } from "../lib/api";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { StatusPill, jobTitle } from "./downloadRow";
|
||||
|
||||
// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job
|
||||
// list. Split out of DownloadCenter (self-contained, admin-only surface).
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
function QuotaModal({
|
||||
userId,
|
||||
email,
|
||||
onClose,
|
||||
}: {
|
||||
userId: number;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: qk.dlQuota(userId),
|
||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||
});
|
||||
const [form, setForm] = useState<{
|
||||
gb: number;
|
||||
jobs: number;
|
||||
conc: number;
|
||||
unlimited: boolean;
|
||||
} | null>(null);
|
||||
const cur = q.data;
|
||||
const state =
|
||||
form ??
|
||||
(cur
|
||||
? {
|
||||
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
||||
jobs: cur.max_jobs,
|
||||
conc: cur.max_concurrent,
|
||||
unlimited: cur.unlimited,
|
||||
}
|
||||
: null);
|
||||
const done = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||
onClose();
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.adminSetDownloadQuota(userId, {
|
||||
max_bytes: Math.round(state!.gb * GiB),
|
||||
max_jobs: state!.jobs,
|
||||
max_concurrent: state!.conc,
|
||||
unlimited: state!.unlimited,
|
||||
}),
|
||||
onSuccess: done,
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: done,
|
||||
});
|
||||
if (!state) return null;
|
||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||
return (
|
||||
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxBytes")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.gb}
|
||||
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
disabled={state.unlimited}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxJobs")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.jobs}
|
||||
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxConcurrent")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.conc}
|
||||
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.unlimited}
|
||||
onChange={(e) => upd({ unlimited: e.target.checked })}
|
||||
/>
|
||||
{t("downloads.admin.unlimited")}
|
||||
</label>
|
||||
<div className="flex justify-between pt-1">
|
||||
<button
|
||||
onClick={() => reset.mutate()}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("downloads.admin.reset")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
||||
// footprint list below only shows users who already have files).
|
||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
const base = s
|
||||
? list.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||
)
|
||||
: list;
|
||||
return base.slice(0, 8);
|
||||
}, [q, list]);
|
||||
return (
|
||||
<div className="relative w-full sm:w-72">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSystem() {
|
||||
const { t } = useTranslation();
|
||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||
const s = storage.data;
|
||||
return (
|
||||
<div>
|
||||
{s && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
{[
|
||||
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||
[
|
||||
t("downloads.admin.cap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="rounded-xl glass-card p-3">
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||
<QuotaUserPicker onPick={setQuotaFor} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{s.per_user.map((u) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||
<button
|
||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||
title={t("downloads.admin.editQuota")}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.per_user.length === 0 && (
|
||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||
<div className="space-y-1.5">
|
||||
{(jobs.data ?? []).map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||
<StatusPill job={j} />
|
||||
<span className="text-muted w-16 text-right">
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{jobs.data && jobs.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quotaFor && (
|
||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { IconBtn } from "./downloadRow";
|
||||
|
||||
// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata
|
||||
// editing and the failure-detail window.
|
||||
|
||||
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
||||
// All are display-only overrides — the on-disk file is never renamed.
|
||||
export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
||||
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
||||
const [channelUrl, setChannelUrl] = useState(
|
||||
job.display_uploader_url || job.asset?.uploader_url || "",
|
||||
);
|
||||
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
||||
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
||||
|
||||
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
||||
const addLink = () => setLinks((ls) => [...ls, ""]);
|
||||
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateDownloadMeta(job.id, {
|
||||
display_name: name.trim(),
|
||||
display_uploader: channel.trim(),
|
||||
display_uploader_url: channelUrl.trim(),
|
||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputCls}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
||||
<input
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
||||
<input
|
||||
value={channelUrl}
|
||||
onChange={(e) => setChannelUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
||||
<div className="space-y-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
value={l}
|
||||
onChange={(e) => setLink(i, e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
<IconBtn
|
||||
onClick={() => removeLink(i)}
|
||||
title={t("downloads.edit.removeLink")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name.trim() || save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.edit.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
||||
|
||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||
export function ErrorModal({
|
||||
job,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const code = job.error_code || "unknown";
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const retryable = RETRYABLE_ERRORS.includes(code);
|
||||
return (
|
||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
||||
{job.error && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
className="text-xs text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("downloads.errors.fail.detailsToggle")}
|
||||
</button>
|
||||
{showRaw && (
|
||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||
{job.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
{retryable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onRetry();
|
||||
onClose();
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||
>
|
||||
{t("downloads.actions.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, Copy, Link2, Scissors } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||
|
||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||
// library, shared, and admin tabs.
|
||||
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
queued: "text-muted",
|
||||
running: "text-accent",
|
||||
paused: "text-warn",
|
||||
done: "text-success",
|
||||
error: "text-danger",
|
||||
canceled: "text-muted",
|
||||
};
|
||||
|
||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
||||
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
||||
const PHASE_KEYS = [
|
||||
"video",
|
||||
"audio",
|
||||
"editing",
|
||||
"merging",
|
||||
"audio_extract",
|
||||
"thumbnail",
|
||||
"sponsorblock",
|
||||
"metadata",
|
||||
"processing",
|
||||
];
|
||||
|
||||
export function StatusPill({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumb({ job }: { job: DownloadJob }) {
|
||||
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
|
||||
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
|
||||
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
|
||||
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
|
||||
const remote = job.asset?.thumbnail_url ?? null;
|
||||
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
|
||||
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
|
||||
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
||||
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||
const primary = poster || remote;
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
if (remote && src !== remote) setSrc(remote);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function jobTitle(job: DownloadJob): string {
|
||||
return job.display_name || job.asset?.title || job.source_ref;
|
||||
}
|
||||
|
||||
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
||||
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
||||
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
||||
return {
|
||||
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
||||
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||
danger ? "hover:text-danger" : "hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||
function displayUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
||||
function SourceRef({ url }: { url: string }) {
|
||||
const { t } = useTranslation();
|
||||
const copy = async () => {
|
||||
if (await copyText(url)) {
|
||||
notify({ level: "success", message: t("downloads.source.copied") });
|
||||
} else {
|
||||
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${t("downloads.source.open")} — ${url}`}
|
||||
className="truncate hover:text-accent hover:underline"
|
||||
>
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
<button
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.source.copy")}
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
||||
function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={url}
|
||||
className="text-muted hover:text-accent hover:underline"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
export function DownloadRow({
|
||||
job,
|
||||
onShowError,
|
||||
children,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onShowError?: (job: DownloadJob) => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const running = job.status === "running";
|
||||
return (
|
||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
{job.asset?.duration_s ? (
|
||||
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
||||
) : null}
|
||||
{job.status === "done" && job.created_at ? (
|
||||
<span
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
<button
|
||||
onClick={() => onShowError?.(job)}
|
||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||
title={t("downloads.errors.fail.title")}
|
||||
>
|
||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{job.source_url && <SourceRef url={job.source_url} />}
|
||||
{job.extra_links?.map((url) => (
|
||||
<SourceRef key={url} url={url} />
|
||||
))}
|
||||
{running && (
|
||||
<div className="mt-1.5">
|
||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
||||
const u = usage.data;
|
||||
if (!u) return null;
|
||||
const pct =
|
||||
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||
return (
|
||||
<div className="rounded-xl glass-card p-3 mb-4">
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||
<span className="text-muted">
|
||||
{formatBytes(u.footprint_bytes)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||
{u.unlimited
|
||||
? t("downloads.usage.unlimited")
|
||||
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||
</span>
|
||||
</div>
|
||||
{!u.unlimited && (
|
||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||
|
||||
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video
|
||||
// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or
|
||||
// neither ("off"). Both apply to any queued player (feed or playlist).
|
||||
export type AutoMode = "off" | "next" | "prev" | "random";
|
||||
export type LoopMode = "off" | "one" | "all";
|
||||
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
||||
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
||||
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
export function PlayerTransportControls({
|
||||
variant,
|
||||
index,
|
||||
queueLength,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCycleAuto,
|
||||
onCycleLoop,
|
||||
}: {
|
||||
variant: "docked" | "modal";
|
||||
index: number;
|
||||
queueLength: number;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onCycleAuto: () => void;
|
||||
onCycleLoop: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const docked = variant === "docked";
|
||||
const navBtn = docked
|
||||
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
||||
const navLabel = docked ? "hidden sm:inline" : "";
|
||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const toggle = (on: boolean) =>
|
||||
docked
|
||||
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`
|
||||
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||
}`;
|
||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className={navBtn}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
<span className={navLabel}>{t("player.previous")}</span>
|
||||
</button>
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className={navBtn}
|
||||
>
|
||||
<span className={navLabel}>{t("player.next")}</span>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<button
|
||||
onClick={onCycleAuto}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={toggle(autoMode !== "off")}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCycleLoop}
|
||||
title={t("player.loop.hint")}
|
||||
className={toggle(loopMode !== "off")}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
|
||||
import { type CSSProperties } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
|
||||
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
|
||||
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
|
||||
// all three stay visually distinct.
|
||||
const PLAYABLE_TINT: Record<string, string> = {
|
||||
direct: "bg-success",
|
||||
remux: "bg-warn",
|
||||
transcode: "bg-danger",
|
||||
};
|
||||
|
||||
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
|
||||
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
|
||||
// via `.reveal-idle` (hidden on touch, faded on hover); when there's no such control the pill is the
|
||||
// sole indicator and just fades on hover. (The single-episode card, where the pill is itself the
|
||||
// full-size watched marker with no fade, keeps its own inline rule.)
|
||||
const swapBadgeReveal = (hasControl: boolean): string =>
|
||||
hasControl ? "reveal-idle" : "group-hover:opacity-0";
|
||||
|
||||
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
|
||||
// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a
|
||||
// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green),
|
||||
// not-watched = Circle (empty, white).
|
||||
function WatchedIcon({ watched }: { watched: boolean }) {
|
||||
return watched ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-white" />
|
||||
);
|
||||
}
|
||||
|
||||
export function PlexPosterCard({
|
||||
card,
|
||||
onOpen,
|
||||
onInfo,
|
||||
onToggleWatched,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
onInfo: () => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
|
||||
const pct =
|
||||
inProgress && card.duration_seconds
|
||||
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
className="group text-left"
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={card.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-10 h-10" fill="currentColor" />
|
||||
<span className="text-xs font-medium">
|
||||
{card.type === "show"
|
||||
? t("plex.openShow")
|
||||
: inProgress
|
||||
? t("plex.resume")
|
||||
: t("plex.play")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<WatchedIcon watched={card.status === "watched"} />
|
||||
</button>
|
||||
)}
|
||||
{/* Media info page (movies/episodes). */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<Info className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card
|
||||
has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch,
|
||||
where the toggle is always shown); a watched show keeps the badge as its sole marker. */}
|
||||
{card.status === "watched" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||
isPlayable,
|
||||
)}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||
{card.type === "show" && card.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
{card.playable && (
|
||||
<span
|
||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${card.playable}`)}
|
||||
/>
|
||||
)}
|
||||
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
||||
title={t(
|
||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||
)}
|
||||
>
|
||||
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||
</span>
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted">
|
||||
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeasonCard({
|
||||
se,
|
||||
onOpen,
|
||||
onToggleWatched,
|
||||
onAdd,
|
||||
}: {
|
||||
se: PlexSeasonDetail;
|
||||
onOpen: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
onAdd?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const watched = se.status === "watched";
|
||||
return (
|
||||
<div className="group text-left">
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={se.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-9 h-9" fill="currentColor" />
|
||||
<span className="text-xs font-medium">{t("plex.openShow")}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick watched toggle (whole season), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
title={
|
||||
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
|
||||
}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<WatchedIcon watched={watched} />
|
||||
</button>
|
||||
)}
|
||||
{/* Add whole season to a playlist, on hover. */}
|
||||
{onAdd && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd();
|
||||
}}
|
||||
title={t("plex.playlist.addSeason")}
|
||||
className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<ListPlus className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{watched && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||
!!onToggleWatched,
|
||||
)}`}
|
||||
>
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible
|
||||
on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown
|
||||
toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue
|
||||
and must not be a reveal-idle swap-badge (which display:none-s on touch). */}
|
||||
{se.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
{se.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CastStrip({
|
||||
cast,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
cast: PlexCastMember[];
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const clickable = !!(onOpenPerson || onFilter);
|
||||
return (
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div
|
||||
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
|
||||
>
|
||||
{c.thumb ? (
|
||||
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
||||
</>
|
||||
);
|
||||
return clickable ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() =>
|
||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||
}
|
||||
title={
|
||||
onOpenPerson
|
||||
? t("plex.info.viewPerson", { name: c.name })
|
||||
: t("plex.info.filterActor", { name: c.name })
|
||||
}
|
||||
className="group/cast w-20 shrink-0 text-center"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RelatedStrip({
|
||||
related,
|
||||
onOpen,
|
||||
}: {
|
||||
related: PlexCard[];
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{related.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-[130px] shrink-0 text-left"
|
||||
>
|
||||
<div className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200">
|
||||
<img
|
||||
src={r.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
{r.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight min-h-[2lh]">
|
||||
{r.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function EpisodeCard({
|
||||
ep,
|
||||
onPlay,
|
||||
onAdd,
|
||||
onToggleWatched,
|
||||
withShowTitle,
|
||||
}: {
|
||||
ep: PlexCard;
|
||||
onPlay: () => void;
|
||||
onAdd?: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
withShowTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
const pct =
|
||||
inProgress && ep.duration_seconds
|
||||
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div className="group">
|
||||
<div
|
||||
onClick={onPlay}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onPlay();
|
||||
}
|
||||
}}
|
||||
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={ep.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-9 h-9 text-white" fill="currentColor" />
|
||||
</div>
|
||||
{/* Quick watched toggle (single episode), on hover. */}
|
||||
{onToggleWatched && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched();
|
||||
}}
|
||||
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<WatchedIcon watched={ep.status === "watched"} />
|
||||
</button>
|
||||
)}
|
||||
{ep.status === "watched" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
|
||||
onToggleWatched ? "reveal-idle" : ""
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 text-success" />
|
||||
</span>
|
||||
)}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{withShowTitle && ep.show_title && (
|
||||
<div className="text-2xs text-muted truncate">
|
||||
{ep.show_title}
|
||||
{ep.season_number != null && ep.episode_number != null
|
||||
? ` · S${ep.season_number}·E${ep.episode_number}`
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
|
||||
{ep.title}
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
|
||||
</div>
|
||||
{onAdd && (
|
||||
<button
|
||||
onClick={onAdd}
|
||||
title={t("plex.playlist.addEpisode")}
|
||||
aria-label={t("plex.playlist.addEpisode")}
|
||||
className="shrink-0 rounded-sm p-1 text-muted transition hover:text-accent"
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api";
|
||||
import { plexLang } from "../i18n";
|
||||
import { pickRatingBadge } from "../lib/plexRating";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
DetailCustomizeMenu,
|
||||
DetailHero,
|
||||
epLabel,
|
||||
Filterable,
|
||||
useArtBackdrop,
|
||||
useDetailPrefs,
|
||||
WatchedToggle,
|
||||
} from "../lib/plexDetailUi";
|
||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
|
||||
// detail-page UI from lib/plexDetailUi.
|
||||
|
||||
// Lazy: the rich movie info panel loads only when an info page is opened.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
export function PlexInfoView({
|
||||
id,
|
||||
onBack,
|
||||
onPlay,
|
||||
onPlayItem,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onPlayItem: (id: string) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lang = plexLang();
|
||||
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !q.data ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={q.data.library ?? undefined}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
onOpenPerson={onOpenPerson}
|
||||
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
|
||||
export function PlexPersonView({
|
||||
name,
|
||||
onBack,
|
||||
onOpenCard,
|
||||
onInfoCard,
|
||||
onFilter,
|
||||
}: {
|
||||
name: string;
|
||||
onBack: () => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
||||
|
||||
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
|
||||
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||
// helper; also invalidate the library so its grid agrees.
|
||||
const toggle = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) =>
|
||||
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
resync: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
},
|
||||
});
|
||||
|
||||
const section = (titleKey: string, cards: PlexCard[]) =>
|
||||
cards.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{cards.map((c) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
const d = q.data;
|
||||
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||
return (
|
||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
{q.isError && !d ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", {
|
||||
movies: d.movies.length,
|
||||
shows: d.shows.length,
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onFilter({ actors: [d.name] })}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{section("plex.person.movies", d.movies)}
|
||||
{section("plex.person.shows", d.shows)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
||||
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
||||
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
||||
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
|
||||
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
|
||||
// Add the whole show to a collection.
|
||||
|
||||
export function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenSeason,
|
||||
onOpenShow,
|
||||
onFilter,
|
||||
onOpenPerson,
|
||||
}: {
|
||||
showId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[]) => void;
|
||||
onOpenSeason: (seasonId: string) => void;
|
||||
onOpenShow: (id: string) => void;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const lang = plexLang();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [collOpen, setCollOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const watched = show?.status === "watched";
|
||||
const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
|
||||
useArtBackdrop(show?.art, artBg);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexShowState(showId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markSeason(seasonRk: string, w: boolean) {
|
||||
try {
|
||||
await api.plexSeasonState(seasonRk, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !show ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<DetailHero
|
||||
thumb={show.thumb}
|
||||
posterTitle={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!show.resume}
|
||||
showPlayOverlay={!!show.resume}
|
||||
onPosterClick={() => show.resume && onPlay(show.resume.id, allKeys)}
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={show.cast.length > 0}
|
||||
showRelated={showRelated}
|
||||
onToggleRelated={toggleRelated}
|
||||
hasRelated={d.related.length > 0}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
|
||||
{show.year != null && (
|
||||
<Filterable
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{show.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.content_rating && (
|
||||
<Filterable
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ contentRatings: [show.content_rating!] })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
· {show.content_rating}
|
||||
</Filterable>
|
||||
)}
|
||||
{show.season_count != null && (
|
||||
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
||||
)}
|
||||
{(() => {
|
||||
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
|
||||
// rating instead of dropping the badge entirely.
|
||||
const badge = pickRatingBadge(show.imdb_rating, show.rating);
|
||||
if (!badge) return null;
|
||||
const { score } = badge;
|
||||
return (
|
||||
<button
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||
>
|
||||
{/* decorative rating gold — intentionally NOT a status token */}
|
||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{show.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{show.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
className="text-2xs rounded-full bg-surface px-2 py-0.5 text-muted"
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{show.summary && (
|
||||
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(show.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={show.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(show.resume)}
|
||||
/>
|
||||
)}
|
||||
{show.first && show.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(show.first!.id, allKeys)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.playFromStart")}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markShowUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markShowWatched")}
|
||||
/>
|
||||
{allKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addShow")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && showLib && (
|
||||
<ActionBtn
|
||||
onClick={() => setCollOpen(true)}
|
||||
icon={Layers}
|
||||
label={t("plex.series.addShowCollection")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||
<section
|
||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||
{d.seasons.map((se) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
se={se}
|
||||
onOpen={() => onOpenSeason(se.id)}
|
||||
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||
onAdd={
|
||||
se.episodes.length > 0
|
||||
? () =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: se.episodes.map((e) => e.id),
|
||||
title: `${show.title} — ${se.title}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{show.cast.length > 0 && showCast && (
|
||||
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||
)}
|
||||
{d.related.length > 0 && showRelated && (
|
||||
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
{collOpen && show && showLib && (
|
||||
<PlexCollectionEditor
|
||||
item={{ id: show.id, title: show.title }}
|
||||
library={showLib}
|
||||
memberOf={show.collection_keys}
|
||||
onClose={() => setCollOpen(false)}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlexSeasonView({
|
||||
showId,
|
||||
seasonId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
showId: string;
|
||||
seasonId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
});
|
||||
const d = q.data;
|
||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
|
||||
useArtBackdrop(d?.show.art, artBg);
|
||||
async function markAll(w: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexSeasonState(seasonId, w);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
async function markEpisode(ep: PlexCard) {
|
||||
try {
|
||||
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1200px] mx-auto glass-media">
|
||||
<BackBtn onBack={onBack} label={t("plex.series.backToShow")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d || !se ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
thumb={se.thumb}
|
||||
posterTitle={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
posterDisabled={!se.resume}
|
||||
showPlayOverlay={!!se.resume}
|
||||
onPosterClick={() => se.resume && onPlay(se.resume.id, allKeys)}
|
||||
posterSizeClass="w-28 sm:w-40"
|
||||
customizeMenu={
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-muted hover:text-accent transition text-left"
|
||||
>
|
||||
{d.show.title}
|
||||
</button>
|
||||
<h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{t("plex.series.episodeCount", { count: se.episode_count })}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{se.resume && (
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(se.resume!.id, allKeys)}
|
||||
icon={Play}
|
||||
label={se.status === "new" ? t("plex.play") : t("plex.resume")}
|
||||
sub={epLabel(se.resume)}
|
||||
/>
|
||||
)}
|
||||
{se.first && se.status !== "new" && (
|
||||
<ActionBtn
|
||||
onClick={() => onPlay(se.first!.id, allKeys)}
|
||||
icon={RotateCcw}
|
||||
label={t("plex.series.playFromStart")}
|
||||
/>
|
||||
)}
|
||||
<WatchedToggle
|
||||
watched={watched}
|
||||
busy={busy}
|
||||
onToggle={() => markAll(!watched)}
|
||||
watchedLabel={t("plex.series.markSeasonUnwatched")}
|
||||
unwatchedLabel={t("plex.series.markSeasonWatched")}
|
||||
/>
|
||||
{seasonKeys.length > 0 && (
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
setAddTarget({
|
||||
kind: "group",
|
||||
ratingKeys: seasonKeys,
|
||||
title: `${d.show.title} — ${se.title}`,
|
||||
})
|
||||
}
|
||||
icon={ListPlus}
|
||||
label={t("plex.playlist.addSeason")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
ep={ep}
|
||||
onPlay={() => onPlay(ep.id, allKeys)}
|
||||
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
|
||||
onToggleWatched={() => markEpisode(ep)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_LOOKAHEAD_ROWS,
|
||||
PREFETCH_ROWS,
|
||||
PREFETCH_VIEWPORTS,
|
||||
readAheadLookahead,
|
||||
shouldPrefetchNext,
|
||||
} from "./readAhead";
|
||||
|
||||
describe("readAheadLookahead", () => {
|
||||
it("floors at PREFETCH_ROWS when nothing (or little) is rendered", () => {
|
||||
expect(readAheadLookahead(0)).toBe(PREFETCH_ROWS);
|
||||
expect(readAheadLookahead(1)).toBe(PREFETCH_ROWS); // 1*2=2 < floor 4
|
||||
});
|
||||
|
||||
it("scales with the rendered row count for a normal viewport", () => {
|
||||
expect(readAheadLookahead(10)).toBe(10 * PREFETCH_VIEWPORTS); // 20
|
||||
expect(readAheadLookahead(15)).toBe(30);
|
||||
});
|
||||
|
||||
it("caps at MAX_LOOKAHEAD_ROWS so a tall window doesn't prefetch unboundedly", () => {
|
||||
// 30 rendered rows would be 60 uncapped — the cap is what bounds per-mount feed prefetch.
|
||||
expect(readAheadLookahead(30)).toBe(MAX_LOOKAHEAD_ROWS);
|
||||
expect(readAheadLookahead(1000)).toBe(MAX_LOOKAHEAD_ROWS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPrefetchNext", () => {
|
||||
it("fetches once the last rendered row enters the read-ahead window", () => {
|
||||
// 100 rows loaded, viewport renders 10 → lookahead 20. Fetch when lastIndex >= 100-1-20 = 79.
|
||||
expect(shouldPrefetchNext(78, 100, 10)).toBe(false);
|
||||
expect(shouldPrefetchNext(79, 100, 10)).toBe(true);
|
||||
expect(shouldPrefetchNext(99, 100, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches eagerly while fewer rows are loaded than the lookahead (early pages)", () => {
|
||||
// Only 15 rows loaded, lookahead 20 → the whole list is inside the window.
|
||||
expect(shouldPrefetchNext(8, 15, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fetch near the top of a long loaded list", () => {
|
||||
expect(shouldPrefetchNext(12, 500, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Infinite-scroll read-ahead policy for VirtualGrid, extracted as a pure function so the decision is
|
||||
// unit-testable without a layout engine.
|
||||
//
|
||||
// The next page is pulled once the last rendered row comes within `lookahead` rows of the loaded end,
|
||||
// where lookahead scales with the number of rows the viewport currently renders (so it adapts to
|
||||
// window height / column count / row height) but is clamped to a floor and a ceiling:
|
||||
// - floor PREFETCH_ROWS: the finite-list / first-paint case where nothing is rendered yet.
|
||||
// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would
|
||||
// eagerly prefetch several pages on every mount / filter change — wasted work on the app's
|
||||
// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead.
|
||||
export const PREFETCH_ROWS = 4;
|
||||
export const PREFETCH_VIEWPORTS = 2;
|
||||
export const MAX_LOOKAHEAD_ROWS = 40;
|
||||
|
||||
/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering
|
||||
* `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */
|
||||
export function readAheadLookahead(renderedRows: number): number {
|
||||
return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS));
|
||||
}
|
||||
|
||||
/** Whether to fetch the next page: true once the last rendered row (`lastIndex`) is within the
|
||||
* read-ahead window of the loaded end (`rowCount` total loaded rows). Because the caller re-runs
|
||||
* this as each page settles, it chains — refilling the buffer as the user consumes it. */
|
||||
export function shouldPrefetchNext(
|
||||
lastIndex: number,
|
||||
rowCount: number,
|
||||
renderedRows: number,
|
||||
): boolean {
|
||||
return lastIndex >= rowCount - 1 - readAheadLookahead(renderedRows);
|
||||
}
|
||||
@@ -28,9 +28,6 @@
|
||||
"reshuffle": "Reshuffle",
|
||||
"noMatchingTags": "No matching tags here",
|
||||
"manageTags": "Manage",
|
||||
"shareView": "Copy a link to this view",
|
||||
"shareCopied": "View link copied to clipboard",
|
||||
"shareFailed": "Couldn't copy the link",
|
||||
"widget": {
|
||||
"savedviews": "Saved views",
|
||||
"show": "Show",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"apply": "Apply this view",
|
||||
"makeDefault": "Set as default (loads on start)",
|
||||
"isDefault": "Default view — loads on start",
|
||||
"share": "Copy a link to this view",
|
||||
"shareCopied": "View link copied",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"reshuffle": "Újrakeverés",
|
||||
"noMatchingTags": "Nincs ide illő címke",
|
||||
"manageTags": "Kezelés",
|
||||
"shareView": "Hivatkozás másolása erre a nézetre",
|
||||
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
||||
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
||||
"widget": {
|
||||
"savedviews": "Mentett nézetek",
|
||||
"show": "Megjelenítés",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"apply": "Nézet alkalmazása",
|
||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
||||
"share": "Hivatkozás másolása a nézethez",
|
||||
"shareCopied": "Nézet hivatkozása a vágólapra másolva",
|
||||
"rename": "Átnevezés",
|
||||
"delete": "Törlés",
|
||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||
|
||||
@@ -536,6 +536,42 @@ body {
|
||||
0 14px 30px -14px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
|
||||
`opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover),
|
||||
but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is
|
||||
gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered
|
||||
or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the
|
||||
control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
|
||||
"watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
|
||||
dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
|
||||
at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
|
||||
.reveal-idle {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.group:hover .reveal,
|
||||
.group:focus-within .reveal,
|
||||
.reveal:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
|
||||
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
|
||||
corner for a keyboard user. */
|
||||
.group:hover .reveal-idle,
|
||||
.group:focus-within .reveal-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.reveal-idle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
||||
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
|
||||
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.
|
||||
|
||||
@@ -225,6 +225,15 @@ export const downloadsApi = {
|
||||
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
|
||||
: `/api/downloads/${id}/storyboard.jpg`;
|
||||
},
|
||||
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
|
||||
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
|
||||
// which reads as a blank Library thumbnail once the poster is the card's primary image.
|
||||
posterImageUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null
|
||||
? `/api/downloads/${id}/poster.jpg?account=${a}`
|
||||
: `/api/downloads/${id}/poster.jpg`;
|
||||
},
|
||||
|
||||
// admin
|
||||
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||
|
||||
@@ -10,25 +10,58 @@
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
export function useHistorySubview<T>(root: T): {
|
||||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||
export interface SubviewUrl<T> {
|
||||
/** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */
|
||||
key: string;
|
||||
/** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */
|
||||
encode: (view: T) => string | null;
|
||||
/** Parse a param value back to a sub-view, or null if it isn't a valid one. */
|
||||
decode: (raw: string) => T | null;
|
||||
}
|
||||
|
||||
export function useHistorySubview<T>(
|
||||
root: T,
|
||||
url?: SubviewUrl<T>,
|
||||
): {
|
||||
view: T;
|
||||
open: (next: T) => void;
|
||||
back: () => void;
|
||||
} {
|
||||
const [view, setView] = useState<T>(() => (window.history.state?._sub as T) ?? root);
|
||||
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
|
||||
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
|
||||
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
|
||||
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
|
||||
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
|
||||
const readView = (): T => {
|
||||
const fromState = window.history.state?._sub as T | undefined;
|
||||
if (fromState != null) return fromState;
|
||||
if (url) {
|
||||
const raw = new URLSearchParams(window.location.search).get(url.key);
|
||||
const decoded = raw != null ? url.decode(raw) : null;
|
||||
if (decoded != null) return decoded;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
const readViewRef = useRef(readView);
|
||||
readViewRef.current = readView;
|
||||
const [view, setView] = useState<T>(readView);
|
||||
useEffect(() => {
|
||||
const onPop = () => setView((window.history.state?._sub as T) ?? root);
|
||||
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
|
||||
const onPop = () => setView(readViewRef.current());
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return {
|
||||
view,
|
||||
open: (next: T) => {
|
||||
setView(next);
|
||||
window.history.pushState({ ...window.history.state, _sub: next }, "");
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
|
||||
},
|
||||
back: () => window.history.back(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PanelId } from "./panelLayout";
|
||||
|
||||
// The two browsable media sources the app filters over: the YouTube feed and the Plex library.
|
||||
// (A `PanelId` minus "playlists" — a rail, but not a filterable source.) The pair is already unified
|
||||
// in practice — one shared SortControl, the source-agnostic `ActiveFilter[]` chip row, PanelId-keyed
|
||||
// sidebar layouts, and the shared `FilterRail` shell. This names that abstraction as one narrow
|
||||
// contract, so a future third source (or a feature built across sources) targets `MediaSourceKind`
|
||||
// instead of re-deriving it from the YT/Plex twins. Deliberately thin — just the kind; widen it only
|
||||
// when a concrete cross-source feature needs more than the name.
|
||||
export type MediaSourceKind = Extract<PanelId, "feed" | "plex">;
|
||||
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume";
|
||||
import { LS, readAccountMerged, writeAccount } from "./storage";
|
||||
import {
|
||||
exitFullscreen,
|
||||
fullscreenElement,
|
||||
onFullscreenChange,
|
||||
requestFullscreen,
|
||||
} from "./fullscreen";
|
||||
import { useBackToExit } from "./history";
|
||||
import { api, type Video } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||
import type { AutoMode, LoopMode } from "../components/playerModalControls";
|
||||
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
const FINISH_MARGIN = 10;
|
||||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run
|
||||
// of dead videos — after the cap we stop on the error overlay instead of skipping forever.
|
||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||
|
||||
// --- IFrame Player API loader (singleton) ---
|
||||
let apiPromise: Promise<YTNamespace> | null = null;
|
||||
function loadYouTubeApi(): Promise<YTNamespace> {
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise<YTNamespace>((resolve) => {
|
||||
const w = window;
|
||||
if (w.YT && w.YT.Player) return resolve(w.YT);
|
||||
const prev = w.onYouTubeIframeAPIReady;
|
||||
w.onYouTubeIframeAPIReady = () => {
|
||||
if (typeof prev === "function") prev();
|
||||
// The API-ready callback only fires once the script has installed window.YT.
|
||||
resolve(w.YT!);
|
||||
};
|
||||
const tag = document.createElement("script");
|
||||
tag.src = "https://www.youtube.com/iframe_api";
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The
|
||||
// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts
|
||||
// object; each hook owns its own private state/timers and returns handlers + reactive values. The
|
||||
// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate
|
||||
// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks.
|
||||
|
||||
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
|
||||
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
|
||||
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
||||
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
|
||||
export function useYtVolume({
|
||||
playerRef,
|
||||
dialogRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
dialogRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
/** Level (0–100) to flash in the on-player overlay, or null when hidden. */
|
||||
volumeUi: number | null;
|
||||
/** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */
|
||||
reapplyStored: () => void;
|
||||
/** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */
|
||||
nudgeVolume: (delta: number) => void;
|
||||
} {
|
||||
const volTimerRef = useRef<number | undefined>(undefined);
|
||||
// Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null`
|
||||
// / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount.
|
||||
const [volumeUi, setVolumeUi] = useState<number | null>(null);
|
||||
const [storedVolume] = useState(() =>
|
||||
normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume),
|
||||
);
|
||||
const volumeRef = useRef(storedVolume);
|
||||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||||
const lastNudgeAtRef = useRef(0);
|
||||
|
||||
const flashVolume = (level: number) => {
|
||||
setVolumeUi(level);
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
|
||||
};
|
||||
const persistVolume = () => {
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = undefined;
|
||||
writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current });
|
||||
};
|
||||
const applyVolume = (level: number, flash = true) => {
|
||||
// Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and
|
||||
// jumping a quiet session to full blast is the surprise this feature exists to avoid.
|
||||
const next = normalizeVolume(level, volumeRef.current);
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.setVolume === "function") {
|
||||
if (next > 0) p.unMute?.();
|
||||
p.setVolume(next);
|
||||
if (next === 0) p.mute?.();
|
||||
}
|
||||
volumeRef.current = next;
|
||||
// Debounced: one write when the spin settles; the unmount cleanup flushes a pending write.
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||||
if (flash) flashVolume(next);
|
||||
};
|
||||
const nudgeVolume = (delta: number) => {
|
||||
// No early return when the player isn't ready: the level lives in volumeRef and onReady applies it,
|
||||
// so a wheel spin in the first second after opening still lands.
|
||||
const p = playerRef.current;
|
||||
// Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may
|
||||
// have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so
|
||||
// re-reading it mid-spin resurrects a stale level and silently drops notches.
|
||||
const now = Date.now();
|
||||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||||
lastNudgeAtRef.current = now;
|
||||
const live =
|
||||
p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null;
|
||||
const cur = continuing || live === null ? volumeRef.current : live;
|
||||
applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current }));
|
||||
};
|
||||
|
||||
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
|
||||
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
|
||||
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current;
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
|
||||
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
if (volSaveTimerRef.current != null) persistVolume();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
volumeUi,
|
||||
reapplyStored: () => applyVolume(volumeRef.current, false),
|
||||
nudgeVolume,
|
||||
};
|
||||
}
|
||||
|
||||
/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar /
|
||||
* CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds
|
||||
* focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to
|
||||
* end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer
|
||||
* leaves the video or the window regains focus. */
|
||||
export function useNativeControlsYield({
|
||||
playerRef,
|
||||
stageRef,
|
||||
focusModal,
|
||||
}: {
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
nativeControls: boolean;
|
||||
setNativeControls: (on: boolean) => void;
|
||||
} {
|
||||
const [nativeControls, setNativeControls] = useState(false);
|
||||
useEffect(() => {
|
||||
const iframeFocused = () => {
|
||||
const p = playerRef.current;
|
||||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||
return !!f && document.activeElement === f;
|
||||
};
|
||||
// activeElement settles on the tick after blur, so defer the check.
|
||||
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
|
||||
const rearm = () => {
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
};
|
||||
const stage = stageRef.current;
|
||||
window.addEventListener("blur", onBlur);
|
||||
window.addEventListener("focus", rearm);
|
||||
stage?.addEventListener("mouseleave", rearm);
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
window.removeEventListener("focus", rearm);
|
||||
stage?.removeEventListener("mouseleave", rearm);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return { nativeControls, setNativeControls };
|
||||
}
|
||||
|
||||
/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport,
|
||||
* deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working)
|
||||
* and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on
|
||||
* screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This
|
||||
* is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the
|
||||
* once-bound keyboard handler reads. */
|
||||
export function useYtBigView({
|
||||
stageRef,
|
||||
playerRef,
|
||||
focusModal,
|
||||
}: {
|
||||
stageRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
focusModal: () => void;
|
||||
}): {
|
||||
isFullscreen: boolean;
|
||||
maximized: boolean;
|
||||
maximizedRef: MutableRefObject<boolean>;
|
||||
isFullscreenRef: MutableRefObject<boolean>;
|
||||
toggleFullscreen: () => void;
|
||||
toggleTrueFullscreen: () => void;
|
||||
exitBigView: () => void;
|
||||
setMaximized: Dispatch<SetStateAction<boolean>>;
|
||||
} {
|
||||
// True while OUR stage is the fullscreen element (drives the overlay's pointer-events).
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen.
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const maximizedRef = useRef(false);
|
||||
maximizedRef.current = maximized;
|
||||
// Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever.
|
||||
const isFullscreenRef = useRef(false);
|
||||
// Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while
|
||||
// active). Mirrors the Esc ladder in the component's keydown handler.
|
||||
useBackToExit(isFullscreen, exitFullscreen);
|
||||
useBackToExit(maximized, () => setMaximized(false));
|
||||
|
||||
const toggleFullscreen = () => setMaximized((m) => !m);
|
||||
// True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We
|
||||
// never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold
|
||||
// the lock" problem that made F avoid the Fullscreen API doesn't arise here.
|
||||
const toggleTrueFullscreen = () => {
|
||||
if (fullscreenElement()) exitFullscreen();
|
||||
else if (stageRef.current) requestFullscreen(stageRef.current);
|
||||
};
|
||||
// Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise.
|
||||
const exitBigView = () => {
|
||||
if (fullscreenElement()) exitFullscreen();
|
||||
setMaximized(false);
|
||||
};
|
||||
|
||||
// Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is
|
||||
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
|
||||
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
|
||||
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
const fsEl = fullscreenElement();
|
||||
// Refs only — this listener is bound once, so it must not close over render values.
|
||||
const p = playerRef.current;
|
||||
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
|
||||
isFullscreenRef.current = ours; // read by the once-bound keydown handler
|
||||
setIsFullscreen(ours); // drives the overlay's pointer-events
|
||||
if (!fsEl) focusModal();
|
||||
};
|
||||
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isFullscreen,
|
||||
maximized,
|
||||
maximizedRef,
|
||||
isFullscreenRef,
|
||||
toggleFullscreen,
|
||||
toggleTrueFullscreen,
|
||||
exitBigView,
|
||||
setMaximized,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint
|
||||
* progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed
|
||||
* on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound
|
||||
* event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress —
|
||||
* side-effect only, returns nothing. */
|
||||
export function useYtProgress({
|
||||
activeId,
|
||||
resumeAt,
|
||||
mountRef,
|
||||
playerRef,
|
||||
currentIdRef,
|
||||
navRef,
|
||||
modeRef,
|
||||
setWatched,
|
||||
setPlayerError,
|
||||
setLiveData,
|
||||
reapplyStored,
|
||||
focusModal,
|
||||
advanceOnEnd,
|
||||
qc,
|
||||
}: {
|
||||
activeId: string;
|
||||
resumeAt: number;
|
||||
mountRef: RefObject<HTMLDivElement | null>;
|
||||
playerRef: MutableRefObject<YTPlayer | null>;
|
||||
currentIdRef: MutableRefObject<string>;
|
||||
navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>;
|
||||
modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>;
|
||||
setWatched: (on: boolean) => void;
|
||||
setPlayerError: (code: number | null) => void;
|
||||
setLiveData: (d: { title?: string; author?: string } | null) => void;
|
||||
reapplyStored: () => void;
|
||||
focusModal: () => void;
|
||||
advanceOnEnd: () => void;
|
||||
qc: QueryClient;
|
||||
}): void {
|
||||
const autoMarkedRef = useRef(false);
|
||||
const errorStreakRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = activeId;
|
||||
autoMarkedRef.current = false;
|
||||
setPlayerError(null);
|
||||
|
||||
// Auto-watch only applies to the active item — not to other videos the player navigated to via
|
||||
// description links. Cap the finish margin at half the clip so a very short video isn't auto-marked
|
||||
// almost immediately.
|
||||
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||||
const margin = Math.min(FINISH_MARGIN, duration / 2);
|
||||
if (current > duration - margin) {
|
||||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const persist = (): Promise<unknown> | void => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
// If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume
|
||||
// position, racing the watched write against the progress row it deletes).
|
||||
if (maybeAutoWatch(cur, dur)) return;
|
||||
// Only checkpoint the library item we opened with — a navigated-to video may not be in this
|
||||
// user's library, so the server would 404 on it.
|
||||
if (currentIdRef.current === id) {
|
||||
return api.saveProgress(id, cur, dur).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
/* player may be tearing down */
|
||||
}
|
||||
};
|
||||
|
||||
loadYouTubeApi().then((YT) => {
|
||||
if (cancelled || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
start: resumeAt || undefined,
|
||||
rel: 0, // limit "related" to the same channel (full removal is no longer possible)
|
||||
enablejsapi: 1,
|
||||
origin: window.location.origin,
|
||||
playsinline: 1,
|
||||
// Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size).
|
||||
vq: "hd1080",
|
||||
},
|
||||
events: {
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
reapplyStored();
|
||||
},
|
||||
onStateChange: (e: YTPlayerEvent) => {
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
if (e?.data === 1) {
|
||||
errorStreakRef.current = 0; // a successful play breaks any unplayable run
|
||||
const p = playerRef.current;
|
||||
const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
|
||||
if (d) setLiveData({ title: d.title, author: d.author });
|
||||
}
|
||||
// 0 === ended → mark watched, then advance to the next queue item if any.
|
||||
if (e?.data === 0 && currentIdRef.current === id) {
|
||||
if (!autoMarkedRef.current) {
|
||||
autoMarkedRef.current = true;
|
||||
setWatched(true);
|
||||
}
|
||||
advanceOnEnd();
|
||||
}
|
||||
},
|
||||
onError: (e: YTPlayerEvent) => {
|
||||
setPlayerError(typeof e?.data === "number" ? e.data : -1);
|
||||
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
|
||||
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead
|
||||
// items can't spin. We do NOT mark the broken video watched.
|
||||
if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
|
||||
const { autoMode: a } = modeRef.current;
|
||||
const { queue: q } = navRef.current;
|
||||
if (a === "off" || !q || q.length <= 1) return;
|
||||
errorStreakRef.current += 1;
|
||||
if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
|
||||
advanceOnEnd();
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||
const timer = window.setInterval(persist, 5000);
|
||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A
|
||||
// keepalive beacon so the POST survives the unload. Only for the library item we opened with.
|
||||
const onHide = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
|
||||
try {
|
||||
const cur = p.getCurrentTime();
|
||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
||||
if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
|
||||
} catch {
|
||||
/* player tearing down */
|
||||
}
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next
|
||||
// natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2).
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
||||
});
|
||||
const p = playerRef.current;
|
||||
if (p && typeof p.destroy === "function") {
|
||||
try {
|
||||
p.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
playerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeId]);
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { qk } from "./queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Play,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDismiss } from "./useDismiss";
|
||||
import { api } from "./api";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { PlexCard } from "./api";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
@@ -12,42 +18,21 @@ import { api } from "./api";
|
||||
// customize menu that toggles them.
|
||||
|
||||
export function useDetailPrefs() {
|
||||
const qc = useQueryClient();
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||
{}) as {
|
||||
plexInfoArtBg?: boolean;
|
||||
plexInfoCast?: boolean;
|
||||
plexInfoRelated?: boolean;
|
||||
};
|
||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||||
const save = (patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||
);
|
||||
};
|
||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||
// usePref seeds from the cache (no localStorage mirror).
|
||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||
const savePref = useSavePref();
|
||||
return {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => {
|
||||
const next = !artBg;
|
||||
setArtBg(next);
|
||||
save({ plexInfoArtBg: next });
|
||||
},
|
||||
toggleCast: () => {
|
||||
const next = !showCast;
|
||||
setShowCast(next);
|
||||
save({ plexInfoCast: next });
|
||||
},
|
||||
toggleRelated: () => {
|
||||
const next = !showRelated;
|
||||
setShowRelated(next);
|
||||
save({ plexInfoRelated: next });
|
||||
},
|
||||
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => setArtBg(!artBg),
|
||||
toggleCast: () => setShowCast(!showCast),
|
||||
toggleRelated: () => setShowRelated(!showRelated),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,3 +192,130 @@ export function PrefToggle({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Series detail-page shared UI (the show page + the season subpage share these) -------------------
|
||||
// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim.
|
||||
|
||||
export function epLabel(ep?: PlexCard | null): string | undefined {
|
||||
if (!ep) return undefined;
|
||||
if (ep.season_number != null && ep.episode_number != null)
|
||||
return `S${ep.season_number} · E${ep.episode_number}`;
|
||||
return ep.title;
|
||||
}
|
||||
|
||||
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionBtn({
|
||||
onClick,
|
||||
icon: Icon,
|
||||
label,
|
||||
sub,
|
||||
primary,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sub?: string;
|
||||
primary?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
||||
primary ? "btn-primary shadow-xs" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span>{label}</span>
|
||||
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
|
||||
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
|
||||
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
|
||||
* `children`. Poster is slightly larger on the show page (default) than the season page. */
|
||||
export function DetailHero({
|
||||
thumb,
|
||||
posterTitle,
|
||||
posterDisabled,
|
||||
showPlayOverlay,
|
||||
onPosterClick,
|
||||
posterSizeClass = "w-32 sm:w-44",
|
||||
customizeMenu,
|
||||
children,
|
||||
}: {
|
||||
thumb: string;
|
||||
posterTitle: string;
|
||||
posterDisabled: boolean;
|
||||
showPlayOverlay: boolean;
|
||||
onPosterClick: () => void;
|
||||
posterSizeClass?: string;
|
||||
customizeMenu: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
|
||||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||
<button
|
||||
onClick={onPosterClick}
|
||||
disabled={posterDisabled}
|
||||
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||
title={posterTitle}
|
||||
>
|
||||
<img
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||
/>
|
||||
{showPlayOverlay && (
|
||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||
<Play className="w-10 h-10 text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||
* the show and season heroes; the labels differ, so the caller passes both. */
|
||||
export function WatchedToggle({
|
||||
watched,
|
||||
busy,
|
||||
onToggle,
|
||||
watchedLabel,
|
||||
unwatchedLabel,
|
||||
}: {
|
||||
watched: boolean;
|
||||
busy?: boolean;
|
||||
onToggle: () => void;
|
||||
watchedLabel: string;
|
||||
unwatchedLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<ActionBtn
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
icon={watched ? CheckCheck : Check}
|
||||
label={watched ? watchedLabel : unwatchedLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type PlexCard } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
|
||||
// browse grid and the person page, each of which owns a different cache shape.
|
||||
export async function toggleWatchState(
|
||||
card: PlexCard,
|
||||
t: TFunction,
|
||||
handlers: { optimistic: (next: string) => void; resync: () => void },
|
||||
) {
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
handlers.optimistic(next);
|
||||
try {
|
||||
await api.plexSetState(card.id, next);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
}
|
||||
handlers.resync();
|
||||
}
|
||||
@@ -14,6 +14,55 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.70.0",
|
||||
date: "2026-08-20",
|
||||
summary:
|
||||
"The address bar now shows a real link for what you're viewing, so you can bookmark or share a channel, a Plex title, a playlist, or a filtered feed — and card buttons work on touch screens.",
|
||||
features: [
|
||||
"You can now bookmark or share the exact view you're on. The address bar reflects the open channel page, a Plex movie/show, a selected playlist, or the feed with its filters applied, so copying the link reproduces that view for you or anyone you send it to. This replaces the old “Share view” button, which is no longer needed. (The browser tab keeps showing the short page name.)",
|
||||
],
|
||||
fixes: [
|
||||
"On touch screens the buttons on a video or Plex card — mark watched, save, add to playlist, info — are now always visible and tappable, instead of only appearing on mouse hover. Watched items also show a filled check, not colour alone, so the state is clear without relying on colour.",
|
||||
"Opening and closing a dialog no longer closes a channel or Plex page you reached from a bookmarked or shared link.",
|
||||
],
|
||||
chores: [
|
||||
"A large internal front-end refactor with no behaviour change: several oversized screens (the Plex browser, the video player, the downloads page) were split into focused files; account preferences and the address-bar handling were folded into single reusable hooks; the three filter sidebars now share one shell; and the pop-up dialogs gained proper keyboard focus handling. Covered by the existing tests plus two review passes — a standard review and a multi-agent deep review.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.69.2",
|
||||
date: "2026-08-19",
|
||||
summary: "Download thumbnails now load for everyone, including multi-account sessions.",
|
||||
fixes: [
|
||||
"Fixed Library thumbnails staying blank when you're signed into more than one account. The poster image is loaded directly by the browser, which can't send the active-account header, so it resolved to your default account and returned “not found” for downloads owned by another of your accounts. The poster URL now carries the active account (the same way the file download and filmstrip already do), so the local poster loads regardless of which of your accounts is active.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.69.1",
|
||||
date: "2026-08-19",
|
||||
summary:
|
||||
"Smoother fast-scrolling in the feed, reliable download thumbnails, and a tidier channel About popover.",
|
||||
fixes: [
|
||||
"Download thumbnails are reliable again. The Library now shows the copy saved on the server instead of the source's own link — which for Facebook videos expires within days (and can be blocked by a browser tracker shield), leaving a blank thumbnail even though the image was on disk the whole time.",
|
||||
"Fast-scrolling the feed no longer stalls. A quick flick of the scroll wheel used to outrun the loader and leave you waiting several seconds on “Load more”; the feed now keeps a couple of screens of videos loaded ahead, so the content is usually already there when you reach it.",
|
||||
"The channel About popover stays on screen at higher browser zoom. A long description could push the top of the box above the edge of the window where you couldn't read it; it now shrinks to fit the available height and scrolls inside.",
|
||||
"Channel descriptions are tidier: the runs of blank lines YouTube pads its contact/link blocks with are stripped, so the About box is more compact.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.69.0",
|
||||
date: "2026-08-19",
|
||||
summary:
|
||||
"Mostly under-the-hood consolidation of the backend, with a few reliability wins you'll notice in Plex and downloads.",
|
||||
fixes: [
|
||||
"Movies and shows removed from Plex now disappear from your library on the next sync, instead of lingering and failing to play. A safety guard skips the cleanup whenever Plex returns an incomplete listing, so a temporary hiccup can never wipe a library.",
|
||||
"Downloads are more reliable: a slow re-encode is no longer mistaken for a stalled download and stopped; queuing a second copy of the same video while the first is still downloading no longer blocks the queue behind it; and canceling or deleting a download can no longer remove a file another download still shares, even when several run at once.",
|
||||
],
|
||||
chores: [
|
||||
"A large internal refactor with no behavior change: the Plex browse and download-worker god-modules were split into focused modules, download state transitions and file bookkeeping were centralized, five unused endpoints were removed, admin config settings were unified into one store, and the request contracts and module docs were tidied — all under new automated tests and four clean code-review passes.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.68.0",
|
||||
date: "2026-08-18",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so
|
||||
// each provider owns its own key without clobbering the others), clearParams (strip only transient
|
||||
// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete
|
||||
// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too.
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
urlWithParams,
|
||||
clearParams,
|
||||
filterParamUpdates,
|
||||
filtersToParams,
|
||||
paramsToFilters,
|
||||
} from "./urlState";
|
||||
import type { FeedFilters } from "./api";
|
||||
|
||||
function setLocation(search: string) {
|
||||
window.history.replaceState(null, "", `/app${search}`);
|
||||
}
|
||||
function queryOf(url: string): URLSearchParams {
|
||||
return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
||||
}
|
||||
|
||||
const DEFAULTS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
describe("urlWithParams", () => {
|
||||
beforeEach(() => setLocation(""));
|
||||
|
||||
it("sets a key while preserving the pathname and existing params", () => {
|
||||
setLocation("?page=plex");
|
||||
const url = urlWithParams({ v: "show:1" });
|
||||
expect(url.startsWith("/app?")).toBe(true);
|
||||
const q = queryOf(url);
|
||||
expect(q.get("v")).toBe("show:1");
|
||||
expect(q.get("page")).toBe("plex"); // untouched
|
||||
});
|
||||
|
||||
it("deletes a key on a null value and leaves the others", () => {
|
||||
setLocation("?page=plex&v=show:1&q=react");
|
||||
const q = queryOf(urlWithParams({ v: null }));
|
||||
expect(q.has("v")).toBe(false);
|
||||
expect(q.get("page")).toBe("plex");
|
||||
expect(q.get("q")).toBe("react");
|
||||
});
|
||||
|
||||
it("applies a mix of sets and deletes in one call", () => {
|
||||
setLocation("?page=plex&v=show:1&chan=UC1");
|
||||
const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null }));
|
||||
expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys
|
||||
});
|
||||
|
||||
it("returns a bare path when the query empties out", () => {
|
||||
setLocation("?page=channels");
|
||||
expect(urlWithParams({ page: null })).toBe("/app");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearParams", () => {
|
||||
beforeEach(() => setLocation(""));
|
||||
|
||||
it("removes only the named transient keys, keeping nav/filter params", () => {
|
||||
setLocation("?link=ok&page=plex&v=show:1&q=react");
|
||||
clearParams("link", "oauth", "admin", "access", "verify", "reset");
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
expect(q.has("link")).toBe(false);
|
||||
expect(q.get("page")).toBe("plex");
|
||||
expect(q.get("v")).toBe("show:1");
|
||||
expect(q.get("q")).toBe("react");
|
||||
});
|
||||
|
||||
it("is a no-op (no history write) when none of the keys are present", () => {
|
||||
setLocation("?page=plex");
|
||||
const before = window.location.search;
|
||||
clearParams("link", "oauth");
|
||||
expect(window.location.search).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterParamUpdates", () => {
|
||||
it("maps non-default filters to values and defaults to null (delete)", () => {
|
||||
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
|
||||
const upd = filterParamUpdates(f);
|
||||
expect(upd.q).toBe("react");
|
||||
expect(upd.tags).toBe("3,7");
|
||||
expect(upd.show).toBe("watched");
|
||||
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared
|
||||
expect(upd.scope).toBeNull();
|
||||
});
|
||||
|
||||
it("clears ALL filter keys when passed null (leaving the feed)", () => {
|
||||
const upd = filterParamUpdates(null);
|
||||
for (const v of Object.values(upd)) expect(v).toBeNull();
|
||||
// includes every recognized filter key
|
||||
expect(Object.keys(upd)).toEqual(
|
||||
expect.arrayContaining(["q", "tags", "show", "sort", "scope"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips through the URL (filters → params → filters)", () => {
|
||||
const f: FeedFilters = {
|
||||
...DEFAULTS,
|
||||
q: "hi",
|
||||
tags: [1, 2],
|
||||
scope: "all",
|
||||
includeShorts: true,
|
||||
};
|
||||
const back = paramsToFilters(filtersToParams(f), DEFAULTS);
|
||||
expect(back.q).toBe("hi");
|
||||
expect(back.tags).toEqual([1, 2]);
|
||||
expect(back.scope).toBe("all");
|
||||
expect(back.includeShorts).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
// Serialize feed filters to/from a compact, human-readable URL query string. Filters are
|
||||
// NOT kept in the address bar during normal use (localStorage is the single source of
|
||||
// truth); this serializer powers the explicit "Share view" link and the one-time hydration
|
||||
// when someone opens such a link. Only non-default values are emitted to keep URLs clean.
|
||||
// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
|
||||
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
|
||||
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
|
||||
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
|
||||
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
|
||||
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
|
||||
import type { FeedFilters } from "./api";
|
||||
|
||||
const KEYS = [
|
||||
@@ -46,6 +48,17 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
|
||||
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
|
||||
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
|
||||
* Feed the result into urlWithParams(). */
|
||||
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
|
||||
const p = f ? filtersToParams(f) : new URLSearchParams();
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const k of KEYS) out[k] = p.get(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge URL params onto a base filter set (typically the defaults). */
|
||||
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
|
||||
const f: FeedFilters = { ...base };
|
||||
@@ -117,20 +130,38 @@ export function readPage(): Page {
|
||||
return isPage(p) ? p : "feed";
|
||||
}
|
||||
|
||||
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
||||
* opt-in "Share view" action — filters are not otherwise written to the address bar. */
|
||||
export function shareUrl(f: FeedFilters, page: Page = "feed"): string {
|
||||
const params = filtersToParams(f);
|
||||
if (page !== "feed") params.set("page", page);
|
||||
/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value
|
||||
* deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan /
|
||||
* v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify-
|
||||
* write on the live query). The caller decides pushState vs replaceState with the returned string. */
|
||||
export function urlWithParams(updates: Record<string, string | null>): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v == null) params.delete(k);
|
||||
else params.set(k, v);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
return `${window.location.pathname}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Strip the query string from the address bar (after hydrating from a share/auth link),
|
||||
* leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp)
|
||||
* so it's safe to call at any time, not just before that stamp is written. */
|
||||
export function stripUrlParams(): void {
|
||||
if (window.location.search) {
|
||||
window.history.replaceState(window.history.state, "", window.location.pathname);
|
||||
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
|
||||
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
|
||||
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
|
||||
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
|
||||
export function clearParams(...keys: string[]): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let changed = false;
|
||||
for (const k of keys) {
|
||||
if (params.has(k)) {
|
||||
params.delete(k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
|
||||
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
|
||||
// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
|
||||
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
|
||||
const { store, savePrefs } = vi.hoisted(() => ({
|
||||
store: new Map<string, string>(),
|
||||
savePrefs: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("./storage", () => ({
|
||||
readAccount: (base: string, fallback: unknown) => {
|
||||
const raw = store.get(base);
|
||||
return raw == null ? fallback : JSON.parse(raw);
|
||||
},
|
||||
writeAccount: (base: string, value: unknown) => {
|
||||
store.set(base, JSON.stringify(value));
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
function seedMe(qc: QueryClient, preferences: Record<string, unknown>, id = 1) {
|
||||
qc.setQueryData(qk.me(), { id, preferences });
|
||||
}
|
||||
function wrapperFor(qc: QueryClient) {
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
}
|
||||
|
||||
describe("usePref", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
savePrefs.mockClear();
|
||||
});
|
||||
|
||||
it("seeds from the default when neither server nor mirror has the key", () => {
|
||||
const qc = makeClient();
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("cards");
|
||||
});
|
||||
|
||||
it("seeds from the server (me cache) when present", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "rows" });
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("rows");
|
||||
});
|
||||
|
||||
it("seeds from the localStorage mirror when there is no server value", () => {
|
||||
const qc = makeClient();
|
||||
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("tiles");
|
||||
});
|
||||
|
||||
it("prefers the server value over the mirror", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "rows" });
|
||||
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("rows");
|
||||
});
|
||||
|
||||
it("applies parse to a raw stored value", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "GRID" });
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { parse: (r) => String(r).toLowerCase() }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("grid");
|
||||
});
|
||||
|
||||
it("on set: updates state, writes the server, merges the me cache, and the mirror", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
act(() => result.current[1]("rows"));
|
||||
expect(result.current[0]).toBe("rows");
|
||||
expect(savePrefs).toHaveBeenCalledWith({ view: "rows" });
|
||||
expect(
|
||||
(qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> }).preferences.view,
|
||||
).toBe("rows");
|
||||
expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows"));
|
||||
});
|
||||
|
||||
it("does not write a localStorage mirror when no localKey is given", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(() => usePref<string>("playerLoop", "off"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
act(() => result.current[1]("all"));
|
||||
expect(store.size).toBe(0);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
||||
});
|
||||
|
||||
it("adopts the server value when the account id changes (login / switch)", async () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "cards" }, 1);
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("cards");
|
||||
// A different account resolves. The passive me observer notifies asynchronously (react-query's
|
||||
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
|
||||
act(() => seedMe(qc, { view: "tiles" }, 2));
|
||||
await waitFor(() => expect(result.current[0]).toBe("tiles"));
|
||||
});
|
||||
|
||||
describe("debounceMs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("coalesces rapid sets into one PUT after the delay", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(() => usePref<number>("vol", 0, { debounceMs: 300 }), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
act(() => {
|
||||
result.current[1](10);
|
||||
result.current[1](20);
|
||||
result.current[1](30);
|
||||
});
|
||||
// Local state is instant; the server write is deferred.
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(savePrefs).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavePref", () => {
|
||||
beforeEach(() => savePrefs.mockClear());
|
||||
|
||||
it("persists an arbitrary patch and merges it into the me cache", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { a: 1 });
|
||||
const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) });
|
||||
act(() => result.current({ plexHiddenStripSources: ["x"] }));
|
||||
expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] });
|
||||
const prefs = (qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> })
|
||||
.preferences;
|
||||
expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import type { Me } from "./api/account";
|
||||
import { readAccount, writeAccount } from "./storage";
|
||||
|
||||
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
||||
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
|
||||
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
|
||||
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
|
||||
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
|
||||
// all three into one hook so a new server-synced pref is a one-liner.
|
||||
//
|
||||
// Two mirror strategies, unified here (pick via `localKey`):
|
||||
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
|
||||
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
|
||||
// reload — pass `localKey`.
|
||||
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
|
||||
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
|
||||
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||
|
||||
export interface UsePrefOptions<T> {
|
||||
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
|
||||
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
|
||||
localKey?: string;
|
||||
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
||||
* local state + cache still update instantly; only the network write is deferred. */
|
||||
debounceMs?: number;
|
||||
/** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g.
|
||||
* mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */
|
||||
parse?: (raw: unknown) => T;
|
||||
}
|
||||
|
||||
function readServerPref(me: Me | null | undefined, key: string): unknown {
|
||||
return me?.preferences?.[key];
|
||||
}
|
||||
|
||||
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
|
||||
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
|
||||
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
|
||||
// shape lives in ONE place.
|
||||
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
|
||||
qc.setQueryData<Me | null>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
|
||||
);
|
||||
}
|
||||
|
||||
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge
|
||||
* it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or
|
||||
* multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles
|
||||
* (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */
|
||||
export function useSavePref(): (patch: Record<string, unknown>) => void {
|
||||
const qc = useQueryClient();
|
||||
return useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
mergePrefsCache(qc, patch);
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
}
|
||||
|
||||
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
|
||||
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
|
||||
export function usePref<T>(
|
||||
serverKey: string,
|
||||
defaultValue: T,
|
||||
options: UsePrefOptions<T> = {},
|
||||
): [T, (next: T) => void] {
|
||||
const { localKey, debounceMs, parse } = options;
|
||||
const qc = useQueryClient();
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
|
||||
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
|
||||
const parseRef = useRef(parse);
|
||||
parseRef.current = parse;
|
||||
const norm = useCallback((raw: unknown): T => {
|
||||
const p = parseRef.current;
|
||||
return p ? p(raw) : (raw as T);
|
||||
}, []);
|
||||
|
||||
const seed = useCallback((): T => {
|
||||
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
|
||||
if (server !== undefined) return norm(server);
|
||||
if (localKey) return norm(readAccount(localKey, defaultValue));
|
||||
return defaultValue;
|
||||
// defaultValue / serverKey / localKey are stable for a given call site.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [qc, serverKey, localKey, norm]);
|
||||
|
||||
const [value, setValue] = useState<T>(seed);
|
||||
|
||||
const saveTimer = useRef<number | undefined>(undefined);
|
||||
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
|
||||
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
|
||||
// value, and a later adopt could then overwrite the newer local value with the stale server one.
|
||||
const pendingSave = useRef<(() => void) | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
pendingSave.current?.();
|
||||
pendingSave.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
(next: T) => {
|
||||
setValue(next);
|
||||
if (localKey) writeAccount(localKey, next);
|
||||
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
|
||||
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
|
||||
const payload = { [serverKey]: next };
|
||||
mergePrefsCache(qc, payload);
|
||||
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
|
||||
// cache already reflect the change (idempotent PUT retries a transient blip).
|
||||
const flush = () => api.savePrefs(payload).catch(() => {});
|
||||
if (debounceMs) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
pendingSave.current = flush;
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
pendingSave.current = null;
|
||||
flush();
|
||||
}, debounceMs);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
[qc, serverKey, localKey, debounceMs],
|
||||
);
|
||||
|
||||
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
|
||||
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
|
||||
// keep the current (seeded / user-set) value rather than clobbering it with the default.
|
||||
const prevId = useRef<number | null | undefined>(me?.id);
|
||||
useEffect(() => {
|
||||
if (me?.id === prevId.current) return;
|
||||
prevId.current = me?.id;
|
||||
const server = readServerPref(me, serverKey);
|
||||
if (server === undefined) return;
|
||||
const val = norm(server);
|
||||
setValue(val);
|
||||
if (localKey) writeAccount(localKey, val);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
|
||||
// entry) whenever they change. Each provider owns its own key(s) and passes the delta it wants; a null
|
||||
// value clears that key, and urlWithParams preserves every other param (so providers don't clobber each
|
||||
// other). Extracted from the identical replaceState/urlWithParams blocks FeedFiltersProvider and
|
||||
// PlaylistsProvider used to hand-roll, so the mirroring contract lives in one place.
|
||||
export function useUrlMirror(updates: Record<string, string | null>): void {
|
||||
// The caller typically passes an inline object literal (new identity every render); key on its
|
||||
// serialized value so the effect only fires when a param actually changes, not every render.
|
||||
const dep = JSON.stringify(updates);
|
||||
useEffect(() => {
|
||||
window.history.replaceState(window.history.state, "", urlWithParams(updates));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dep]);
|
||||
}
|
||||
Reference in New Issue
Block a user