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 | ||
|
|
fa176a3e6a | ||
|
|
e8771f4d6b | ||
|
|
94dbe4b228 | ||
|
|
0ed4ac6958 | ||
|
|
8b8b0ca5ab | ||
|
|
6116ad4bf4 | ||
|
|
943143ef45 | ||
|
|
21a3aae700 | ||
|
|
6a35e83ded | ||
|
|
8c08ea3133 | ||
|
|
3af3799b25 |
@@ -0,0 +1,55 @@
|
||||
"""feed sort indexes: published_at DESC NULLS LAST composite(s) for the default feed (PERF-1)
|
||||
|
||||
The feed's default `newest` sort is `published_at DESC NULLS LAST, id ASC` (feedquery.order_by), but the
|
||||
only published_at index was a plain ASC btree (`ix_videos_published_at`). A btree read backwards satisfies
|
||||
`DESC NULLS FIRST`, not the `DESC NULLS LAST` the query asks for, so the planner could not use it for
|
||||
ordering — every feed page fell back to a full `videos` scan + top-N sort. Measured on a prod-sized reseed
|
||||
(257k videos): the default page went from ~44.8k shared buffers (dev planner) / ~169k (prod planner, the
|
||||
per-channel nested loop that is the likely "prod feed is slow" root cause) down to ~624 buffers with the
|
||||
matching index.
|
||||
|
||||
This adds two indexes in the exact feed order:
|
||||
- ix_videos_published_at_desc (published_at DESC NULLS LAST, id ASC) — the global feed / Library sort.
|
||||
- ix_videos_channel_pub_desc (channel_id, published_at DESC NULLS LAST, id ASC) — the per-channel feed
|
||||
(channel page, channel_ids filter). It leads with channel_id, so it is a strict superset of the old
|
||||
single-column `ix_videos_channel_id` (FK cascade, sync upsert, autotag, channel-detail all still ride
|
||||
it) — that redundant index is dropped.
|
||||
|
||||
The plain ASC `ix_videos_published_at` stays: it already serves the `oldest` sort (ASC NULLS LAST = btree
|
||||
default) as an Index Scan.
|
||||
|
||||
CONCURRENTLY (in alembic's autocommit_block, since it can't run inside a transaction) so a live prod builds
|
||||
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
|
||||
downgrade->re-upgrade round-trip idempotent.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0067_feed_sort_indexes"
|
||||
down_revision: Union[str, None] = "0066_notif_created_at_notnull"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_published_at_desc "
|
||||
"ON videos (published_at DESC NULLS LAST, id ASC)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_pub_desc "
|
||||
"ON videos (channel_id, published_at DESC NULLS LAST, id ASC)"
|
||||
)
|
||||
# Redundant now — ix_videos_channel_pub_desc leads with channel_id (a strict superset).
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_id ON videos (channel_id)"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_pub_desc")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_published_at_desc")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""videos.published_at NOT NULL DEFAULT now() (PERF-15)
|
||||
|
||||
Every video in practice carries a publish date (0 NULLs in the 257k-row prod-sized reseed), but the
|
||||
column was nullable, which forced the feed's keyset predicate into a non-sargable
|
||||
`... OR published_at IS NULL` disjunction — blocking the ix_videos_published_at_desc range so deep
|
||||
`newest` pages scanned from the top of the index and filtered (51.8k buffers). Making it NOT NULL lets
|
||||
the keyset drop that disjunction and take a `published_at <=` range bound (deep page -> ~0.5k buffers).
|
||||
|
||||
server_default now() is the safety net for the rare date-less ingest path (the enrich overwrites it with
|
||||
the real date); the app-side ingest paths also fall back to now() so they never write an explicit NULL.
|
||||
|
||||
Note: `SET NOT NULL` validates with a brief ACCESS EXCLUSIVE lock + one table scan (it cannot run
|
||||
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
|
||||
rather than the ~0 of 0067's CONCURRENTLY index build.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0068_published_at_not_null"
|
||||
down_revision: Union[str, None] = "0067_feed_sort_indexes"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
|
||||
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
|
||||
op.alter_column(
|
||||
"videos", "published_at", nullable=False, server_default=sa.text("now()")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column("videos", "published_at", nullable=True, server_default=None)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""scheduler-job partial indexes + boolean-index cleanup (PERF-17/18/3)
|
||||
|
||||
Two scheduler jobs full-seq-scanned the 257k-row videos table every run:
|
||||
- PERF-17: enrich_pending (`WHERE enriched_at IS NULL LIMIT N`, every few minutes) — 44.6k buffers /
|
||||
run even when nothing is pending. A partial index over just the pending rows makes it a 1-buffer
|
||||
(empty) index scan.
|
||||
- PERF-18: maintenance revalidate (`WHERE unavailable_since IS NULL ORDER BY last_checked_at NULLS
|
||||
FIRST LIMIT N`, ~300 pages/run) — 44.7k buffers/page; ix_videos_last_checked_at had idx_scan=0. A
|
||||
partial index in that exact order serves it as an index scan (~36 buffers/page) and replaces the
|
||||
unused plain one.
|
||||
|
||||
PERF-3: the videos table also carried plain btrees on low-cardinality boolean columns the planner never
|
||||
picks (a `col IS false` majority predicate can't ride a btree). Drop the never-useful ones (is_short,
|
||||
via_explore) and slim the rest to partial indexes over the small/shrinking subset the job queries target
|
||||
(via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — less write amplification on the
|
||||
biggest table, same lookups. live_status is left alone (a separate finding).
|
||||
|
||||
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
|
||||
downgrade->re-upgrade idempotent.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0069_job_index_hygiene"
|
||||
down_revision: Union[str, None] = "0068_published_at_not_null"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
# PERF-17: enrich-pending partial (empty when nothing is pending -> 1-buffer scan).
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_enrich_pending "
|
||||
"ON videos (id) WHERE enriched_at IS NULL"
|
||||
)
|
||||
# PERF-18: maintenance revalidate order, as a partial in the exact WHERE + ORDER BY shape.
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_recheck_order "
|
||||
"ON videos (last_checked_at ASC NULLS FIRST) WHERE unavailable_since IS NULL"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_last_checked_at")
|
||||
# PERF-3: drop the never-picked boolean btrees; slim the rest to partials over the target subset.
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_is_short")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_explore")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_search")
|
||||
# Predicate MUST match the query's `.is_(True)`/`.is_(False)` form (SQL `IS TRUE`/`IS FALSE`):
|
||||
# Postgres's partial-index prover does NOT match a `col IS TRUE` clause to a bare `col` predicate,
|
||||
# so `WHERE via_search` / `WHERE NOT shorts_probed` would leave the queries seq-scanning.
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_search "
|
||||
"ON videos (id) WHERE via_search IS TRUE"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shorts_probed")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_shorts_probed "
|
||||
"ON videos (id) WHERE shorts_probed IS FALSE"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_unavailable_since")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_unavailable_since "
|
||||
"ON videos (unavailable_since) WHERE unavailable_since IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_enrich_pending")
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_recheck_order")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_last_checked_at "
|
||||
"ON videos (last_checked_at)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_is_short ON videos (is_short)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_explore ON videos (via_explore)"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_search")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_search ON videos (via_search)"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shorts_probed")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_shorts_probed ON videos (shorts_probed)"
|
||||
)
|
||||
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_unavailable_since")
|
||||
op.execute(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_unavailable_since "
|
||||
"ON videos (unavailable_since)"
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
+143
-32
@@ -100,6 +100,18 @@ def serialize(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||
"""Group tag ids by their category (language/topic/other). The single source for the category split
|
||||
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||
two can't disagree on which selected tag belongs to which category."""
|
||||
if not tag_ids:
|
||||
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
|
||||
out: dict[str, list[int]] = {}
|
||||
for tid, cat in db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids))).all():
|
||||
out.setdefault(cat, []).append(tid)
|
||||
return out
|
||||
|
||||
|
||||
def filtered_query(
|
||||
db: Session,
|
||||
user: User,
|
||||
@@ -131,7 +143,6 @@ def filtered_query(
|
||||
either mode via the VideoState outer join."""
|
||||
state = aliased(VideoState)
|
||||
status_expr = func.coalesce(state.status, "new")
|
||||
position_expr = func.coalesce(state.position_seconds, 0)
|
||||
|
||||
query = select(*feed_columns(user, state)).join(
|
||||
Channel, Channel.id == Video.channel_id
|
||||
@@ -163,7 +174,14 @@ def filtered_query(
|
||||
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
||||
)
|
||||
|
||||
query = query.outerjoin(
|
||||
# The state-gated views (watched/hidden/in_progress) only ever return videos that HAVE a
|
||||
# video_states row, so an INNER join lets the planner drive from the user's own small video_states
|
||||
# set (~100 rows) instead of scanning all videos and outer-joining (PERF-16). status is NOT NULL on
|
||||
# a present row (server_default 'new'), so the raw-column filters below match the coalesce form
|
||||
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
|
||||
state_required = show in ("watched", "hidden", "in_progress")
|
||||
join_state = query.join if state_required else query.outerjoin
|
||||
query = join_state(
|
||||
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
||||
)
|
||||
|
||||
@@ -246,15 +264,25 @@ def filtered_query(
|
||||
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
|
||||
rank_expr = None
|
||||
if q:
|
||||
# Resolve the channels whose NAME matches up front (there are only a few hundred channels, so
|
||||
# this is a tiny standalone scan) and match videos by channel_id. ORing an unindexed
|
||||
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
||||
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
||||
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
||||
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone.
|
||||
like = func.unaccent(f"%{q}%")
|
||||
matching_channel_ids = (
|
||||
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
channel_match = (
|
||||
Video.channel_id.in_(matching_channel_ids) if matching_channel_ids else false()
|
||||
)
|
||||
ts_str = to_tsquery_str(q)
|
||||
if ts_str:
|
||||
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
||||
query = query.where(
|
||||
or_(
|
||||
Video.search_vector.op("@@")(tsq),
|
||||
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
|
||||
)
|
||||
)
|
||||
query = query.where(or_(Video.search_vector.op("@@")(tsq), channel_match))
|
||||
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
|
||||
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
|
||||
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
|
||||
@@ -262,21 +290,14 @@ def filtered_query(
|
||||
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
|
||||
else:
|
||||
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
|
||||
like = func.unaccent(f"%{q}%")
|
||||
query = query.where(
|
||||
or_(
|
||||
func.unaccent(Video.title).ilike(like),
|
||||
func.unaccent(Channel.title).ilike(like),
|
||||
)
|
||||
)
|
||||
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
||||
|
||||
if tags:
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a
|
||||
# category; the any/all toggle controls multiple topic tags.
|
||||
cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all()
|
||||
by_category: dict[str, list[int]] = {}
|
||||
for tag_id, category in cat_rows:
|
||||
by_category.setdefault(category, []).append(tag_id)
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
||||
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
||||
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||
# test_feed_summary_matches_count_plus_facets).
|
||||
by_category = tags_by_category(db, tags)
|
||||
# Facet counting drops the category being counted so its own chips don't zero each
|
||||
# other out (standard drill-down faceting: a category's count ignores its own
|
||||
# selections but still honours the other categories' filters).
|
||||
@@ -315,20 +336,96 @@ def filtered_query(
|
||||
if show == "unwatched":
|
||||
query = query.where(status_expr.notin_(("watched", "hidden")))
|
||||
elif show == "in_progress":
|
||||
# Started but not finished: a stored resume position, not yet watched/hidden.
|
||||
# Started but not finished: a stored resume position, not yet watched/hidden. INNER-joined
|
||||
# (state_required) so the raw state columns are safe and the planner can use video_states.
|
||||
query = query.where(
|
||||
and_(position_expr > 0, status_expr.notin_(("watched", "hidden")))
|
||||
and_(state.position_seconds > 0, state.status.notin_(("watched", "hidden")))
|
||||
)
|
||||
elif show == "watched":
|
||||
query = query.where(status_expr == "watched")
|
||||
query = query.where(state.status == "watched")
|
||||
elif show == "hidden":
|
||||
query = query.where(status_expr == "hidden")
|
||||
query = query.where(state.status == "hidden")
|
||||
else: # all
|
||||
query = query.where(status_expr != "hidden")
|
||||
|
||||
return query, rank_expr
|
||||
|
||||
|
||||
def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
"""Single-pass replacement for /feed/count + 3x /facets (PERF-13): the sidebar's total video count
|
||||
and every tag's distinct-channel facet count, derived in Python from ONE grouped query instead of
|
||||
four full passes over the subscribed set on every filter change.
|
||||
|
||||
The one query is the base filter with the TAG predicate removed, grouped by channel: it yields every
|
||||
channel that has a matching video, with its video count. From that ~few-hundred-row map we compute:
|
||||
- count = sum of the video counts of the channels that pass the SELECTED tag filter;
|
||||
- facets = per tag, the number of distinct channels that carry it AND pass the drill-down filter
|
||||
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
||||
old /facets exclude_tag_category logic exactly).
|
||||
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
||||
within, topic-AND requires ALL selected topics)."""
|
||||
tag_mode = params.get("tag_mode", "or")
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
base, _rank = filtered_query(db, user, **{**params, "tags": []})
|
||||
ch_col = base.with_only_columns(Video.channel_id).subquery()
|
||||
channel_counts: dict[str, int] = dict(
|
||||
db.execute(select(ch_col.c.channel_id, func.count()).group_by(ch_col.c.channel_id)).all()
|
||||
)
|
||||
if not channel_counts:
|
||||
return {"count": 0, "facets": {}}
|
||||
|
||||
# channel -> its visible tag ids, plus each tag's category (for grouping the facet output).
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
channel_tags: dict[str, set[int]] = {}
|
||||
tag_category: dict[int, str] = {}
|
||||
for cid, tid, cat in db.execute(
|
||||
select(ChannelTag.channel_id, ChannelTag.tag_id, Tag.category)
|
||||
.join(Tag, Tag.id == ChannelTag.tag_id)
|
||||
.where(visible, ChannelTag.channel_id.in_(channel_counts.keys()))
|
||||
).all():
|
||||
channel_tags.setdefault(cid, set()).add(tid)
|
||||
tag_category[tid] = cat
|
||||
|
||||
# Selected tags grouped by category (read from Tag via the shared helper, so a selected tag that no
|
||||
# base channel carries still filters everything out for its category — matching the SQL `IN (empty)`
|
||||
# behaviour).
|
||||
selected_by_cat: dict[str, set[int]] = {
|
||||
cat: set(ids) for cat, ids in tags_by_category(db, params.get("tags") or []).items()
|
||||
}
|
||||
|
||||
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
|
||||
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
|
||||
for cat, sel in selected_by_cat.items():
|
||||
if cat == exclude_cat:
|
||||
continue
|
||||
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
|
||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
|
||||
return False
|
||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||
return False
|
||||
return True
|
||||
|
||||
count = sum(
|
||||
n for cid, n in channel_counts.items() if passes(channel_tags.get(cid, set()), None)
|
||||
)
|
||||
|
||||
facets: dict[int, int] = {}
|
||||
for category in ("language", "topic", "other"):
|
||||
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||
exclude = None if (category == "topic" and tag_mode == "and") else category
|
||||
for cid, ch_tags in channel_tags.items():
|
||||
if not passes(ch_tags, exclude):
|
||||
continue
|
||||
for tid in ch_tags:
|
||||
if tag_category.get(tid) == category:
|
||||
facets[tid] = facets.get(tid, 0) + 1
|
||||
return {"count": count, "facets": facets}
|
||||
|
||||
|
||||
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||
#
|
||||
# Each sort is described as an ordered list of key columns (expression + direction
|
||||
@@ -339,7 +436,7 @@ def filtered_query(
|
||||
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
||||
# (OFFSET would shift and duplicate/skip rows).
|
||||
def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
|
||||
pub = {"expr": Video.published_at, "asc": False, "nullable": False, "kind": "dt"}
|
||||
table: dict[str, list[dict]] = {
|
||||
"newest": [pub],
|
||||
"oldest": [{**pub, "asc": True}],
|
||||
@@ -383,7 +480,15 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
|
||||
|
||||
Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL
|
||||
sorts after every non-NULL value, so a cursor sitting on a non-NULL value must
|
||||
also pull in the NULL group, and equality treats NULL == NULL."""
|
||||
also pull in the NULL group, and equality treats NULL == NULL.
|
||||
|
||||
Built recursively so that for a NON-nullable leading key we can prepend a non-strict
|
||||
range bound (`col <= v` for desc / `col >= v` for asc) as an AND conjunct — logically
|
||||
a no-op over the OR of strict-after branches, but it lets the planner bound the index
|
||||
scan instead of scanning from the top and filtering every earlier row (PERF-15: deep
|
||||
`newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the
|
||||
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
|
||||
NULL-aware OR form."""
|
||||
def eq(k):
|
||||
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
||||
|
||||
@@ -393,11 +498,17 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
|
||||
cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"]
|
||||
return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp
|
||||
|
||||
branches = []
|
||||
for j in range(len(keys)):
|
||||
branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j])))
|
||||
branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id))
|
||||
return or_(*branches)
|
||||
def rec(i: int):
|
||||
if i == len(keys):
|
||||
return Video.id > cursor_id # all keys tied -> break by the unique id
|
||||
k = keys[i]
|
||||
tail = or_(after(k), and_(eq(k), rec(i + 1)))
|
||||
if not k["nullable"] and k["value"] is not None:
|
||||
bound = k["expr"] >= k["value"] if k["asc"] else k["expr"] <= k["value"]
|
||||
return and_(bound, tail)
|
||||
return tail
|
||||
|
||||
return rec(0)
|
||||
|
||||
|
||||
def encode_cursor(keys: list[dict], row) -> str:
|
||||
|
||||
+25
-12
@@ -251,14 +251,23 @@ class Video(Base, TimestampMixin):
|
||||
__tablename__ = "videos"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
||||
# No standalone channel_id index: the composite `ix_videos_channel_pub_desc`
|
||||
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
|
||||
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
|
||||
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
|
||||
channel_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
||||
ForeignKey("channels.id", ondelete="CASCADE")
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
||||
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True
|
||||
# NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key
|
||||
# forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction
|
||||
# that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered —
|
||||
# PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills
|
||||
# the real date); the ix_videos_published_at index stays for the `oldest` sort.
|
||||
published_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
|
||||
)
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
@@ -276,18 +285,22 @@ class Video(Base, TimestampMixin):
|
||||
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
||||
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
||||
search_terms: Mapped[str | None] = mapped_column(Text)
|
||||
# No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked
|
||||
# boolean btrees and replaced the useful ones with partial indexes over the target subset
|
||||
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
||||
# search_vector GIN.
|
||||
is_short: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||
shorts_probed: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# True when this video first entered the catalog via a live YouTube search (not an
|
||||
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
||||
# Library by default; left False by the organic ingest paths.
|
||||
via_search: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
||||
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
||||
@@ -295,7 +308,7 @@ class Video(Base, TimestampMixin):
|
||||
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
||||
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||
via_explore: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# none | live | upcoming | premiere | was_live
|
||||
live_status: Mapped[str] = mapped_column(
|
||||
@@ -311,11 +324,14 @@ class Video(Base, TimestampMixin):
|
||||
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
||||
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
||||
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
|
||||
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
|
||||
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True
|
||||
DateTime(timezone=True)
|
||||
)
|
||||
unavailable_since: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True
|
||||
DateTime(timezone=True)
|
||||
)
|
||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||
|
||||
@@ -531,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}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.db import get_db
|
||||
from app.feedquery import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
feed_summary,
|
||||
filtered_query,
|
||||
keyset_predicate,
|
||||
order_by,
|
||||
@@ -127,6 +128,9 @@ def get_feed_count(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
# SUPERSEDED by /feed/summary (PERF-13) — the app no longer calls this. Kept as the reference
|
||||
# oracle that test_feed_summary_matches_count_plus_facets asserts /feed/summary's `count` against;
|
||||
# remove both together with that test if this endpoint is ever retired.
|
||||
query, _rank = filtered_query(db, user, **params)
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
return {"count": total or 0}
|
||||
@@ -146,7 +150,11 @@ def get_facets(
|
||||
|
||||
The count is the number of distinct channels that have at least one video in the current
|
||||
view, matching the existing channel-count chip semantics. JSON object keys are strings
|
||||
(tag ids)."""
|
||||
(tag ids).
|
||||
|
||||
SUPERSEDED by /feed/summary (PERF-13) — the app no longer calls this. Kept as the reference
|
||||
oracle for test_feed_summary_matches_count_plus_facets (it asserts /feed/summary's `facets`
|
||||
against these `counts`); retire the two together."""
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
counts: dict[int, int] = {}
|
||||
for category in ("language", "topic", "other"):
|
||||
@@ -174,6 +182,19 @@ def get_facets(
|
||||
return {"counts": counts}
|
||||
|
||||
|
||||
@router.get("/feed/summary")
|
||||
def get_feed_summary(
|
||||
params: dict = Depends(_feed_params),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The sidebar's total video count + per-tag channel facet counts in ONE request and ONE grouped
|
||||
query, replacing the separate /feed/count (1 pass) + /facets (3 passes) the FE fired on every filter
|
||||
change (PERF-13). Response: {"count": <videos>, "facets": {<tag_id>: <channel_count>}} — the same
|
||||
numbers /feed/count's `count` and /facets' `counts` return, so the two are interchangeable."""
|
||||
return feed_summary(db, user, **params)
|
||||
|
||||
|
||||
class VideoStateIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -194,10 +193,10 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
||||
db.execute(
|
||||
select(Video)
|
||||
.where(Video.unavailable_since.is_(None))
|
||||
.order_by(
|
||||
Video.last_checked_at.is_(None).desc(), # never-checked first
|
||||
Video.last_checked_at.asc(),
|
||||
)
|
||||
# NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old
|
||||
# `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the
|
||||
# partial ix_videos_recheck_order (migration 0069, PERF-18) so it rides that index.
|
||||
.order_by(Video.last_checked_at.asc().nulls_first())
|
||||
.limit(min(50, batch - checked))
|
||||
)
|
||||
.scalars()
|
||||
|
||||
@@ -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
|
||||
@@ -90,7 +90,9 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
||||
if not vid or not cid or vid in seen:
|
||||
continue
|
||||
seen.add(vid)
|
||||
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")))
|
||||
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
|
||||
# fallback here too so the constructor never carries an explicit NULL.
|
||||
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now)
|
||||
apply_video_details(v, it)
|
||||
v.enriched_at = now
|
||||
db.add(v)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,7 +90,10 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
||||
video_id = content.get("videoId")
|
||||
if not video_id:
|
||||
return None
|
||||
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt"))
|
||||
# published_at is NOT NULL (migration 0068); fall back to now() for the rare item that carries no
|
||||
# date at all, so the bulk stub insert never writes a NULL. The enrich pass replaces it if the
|
||||
# video's own snippet later yields a real publish date.
|
||||
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt")) or _now()
|
||||
return {
|
||||
"id": video_id,
|
||||
"channel_id": channel_id,
|
||||
@@ -243,7 +246,8 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||
video.title = normalize_title(raw_title)
|
||||
video.description = snippet.get("description")
|
||||
if not video.published_at:
|
||||
video.published_at = parse_dt(snippet.get("publishedAt"))
|
||||
# NOT NULL (0068): a fresh insert with no date yet falls back to now() (enrich normally has one).
|
||||
video.published_at = parse_dt(snippet.get("publishedAt")) or _now()
|
||||
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
||||
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||
|
||||
@@ -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
|
||||
@@ -41,14 +41,16 @@ def _seed(db):
|
||||
Subscription(user_id=user.id, channel_id="UCa", priority=5, hidden=False),
|
||||
Subscription(user_id=user.id, channel_id="UCb", priority=0, hidden=False),
|
||||
])
|
||||
# 7 normal/organic videos. Deliberate duplicates (published_at day 1 x2 and day 3 x2, view_count
|
||||
# 100 x2, duration 60 x2 / 30 x2) and NULLs spread across the sort columns.
|
||||
# 7 normal/organic videos. Deliberate duplicates (published_at day 1 x2, day 2 x2 and day 3 x2,
|
||||
# view_count 100 x2, duration 60 x2 / 30 x2) and NULLs spread across the still-nullable sort columns
|
||||
# (view_count / duration_seconds / title / subscriber_count) to exercise nulls_last. published_at is
|
||||
# NOT NULL (migration 0068), so no NULL there — the duplicates keep the id tiebreaker load-bearing.
|
||||
rows = [
|
||||
# id ch published views dur title
|
||||
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
|
||||
("vid01", "UCa", _dt(1), 100, 60, "beta"),
|
||||
("vid02", "UCa", _dt(2), None, None, "gamma"),
|
||||
("vid03", "UCb", None, 50, 120, None),
|
||||
("vid03", "UCb", _dt(2), 50, 120, None),
|
||||
("vid04", "UCb", _dt(3), 200, 30, "delta"),
|
||||
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
|
||||
("vid06", "UCa", _dt(28), None, None, "zeta"),
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Feed search + watch-state views (E2 S3), DB-lane.
|
||||
|
||||
Regression nets for two query rewrites that must stay behaviour-equivalent:
|
||||
- PERF-12: the search channel-name branch moved from an ORed `unaccent(channels.title) ILIKE` (which
|
||||
defeated the search_vector GIN) to a pre-resolved `channel_id IN (...)`. A search must STILL return
|
||||
both videos whose text matches AND videos whose CHANNEL name matches.
|
||||
- PERF-16: the watched/hidden/in_progress views moved from an outer join + `coalesce(state.status,'new')`
|
||||
to an inner join on video_states + the raw column. Each view must return exactly the same set.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, Video, VideoState
|
||||
|
||||
|
||||
def _dt(day: int) -> datetime:
|
||||
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _video(vid: str, ch: str, title: str) -> Video:
|
||||
return Video(
|
||||
id=vid, channel_id=ch, published_at=_dt(1), title=title,
|
||||
is_short=False, live_status="none", via_search=False, via_explore=False,
|
||||
)
|
||||
|
||||
|
||||
def _feed_ids(client, query: str) -> set[str]:
|
||||
resp = client.get(f"/api/feed?{query}&limit=50")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {it["id"] for it in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_search_matches_both_video_text_and_channel_name(db, as_user, make_user):
|
||||
# Two subscribed channels: one whose NAME matches the query (its video's text does not), and one
|
||||
# whose video TEXT matches (its name does not). The pre-resolved channel_id branch must still OR in
|
||||
# the name match, so both videos come back — and an unrelated video on a matching-name-less channel
|
||||
# stays out.
|
||||
user = make_user(email="search@example.com")
|
||||
db.add_all([
|
||||
Channel(id="UCkube", title="Kubernetes Weekly", subscriber_count=10),
|
||||
Channel(id="UCother", title="Some Other Channel", subscriber_count=10),
|
||||
])
|
||||
db.add_all([
|
||||
Subscription(user_id=user.id, channel_id="UCkube", priority=0, hidden=False),
|
||||
Subscription(user_id=user.id, channel_id="UCother", priority=0, hidden=False),
|
||||
])
|
||||
db.add_all([
|
||||
_video("vidChan", "UCkube", "Totally unrelated content"), # channel NAME matches
|
||||
_video("vidText", "UCother", "kubernetes deep dive"), # video TEXT matches
|
||||
_video("vidNone", "UCother", "cooking pasta at home"), # neither matches
|
||||
])
|
||||
db.commit()
|
||||
|
||||
ids = _feed_ids(as_user(user), "q=kubernetes&show=all")
|
||||
assert ids == {"vidChan", "vidText"}
|
||||
|
||||
|
||||
def test_watch_state_views_return_the_right_sets(db, as_user, make_user):
|
||||
user = make_user(email="views@example.com")
|
||||
db.add(Channel(id="UCv", title="Channel V", subscriber_count=10))
|
||||
db.add(Subscription(user_id=user.id, channel_id="UCv", priority=0, hidden=False))
|
||||
db.add_all([
|
||||
_video("vWatch", "UCv", "watched one"),
|
||||
_video("vHide", "UCv", "hidden one"),
|
||||
_video("vProg", "UCv", "in progress one"),
|
||||
_video("vNew", "UCv", "brand new one"),
|
||||
])
|
||||
db.flush()
|
||||
db.add_all([
|
||||
VideoState(user_id=user.id, video_id="vWatch", status="watched"),
|
||||
VideoState(user_id=user.id, video_id="vHide", status="hidden"),
|
||||
# in progress = a stored resume position while still "new" (not watched/hidden)
|
||||
VideoState(user_id=user.id, video_id="vProg", status="new", position_seconds=30),
|
||||
# vNew has NO state row -> "new"/unwatched
|
||||
])
|
||||
db.commit()
|
||||
|
||||
client = as_user(user)
|
||||
assert _feed_ids(client, "show=watched") == {"vWatch"}
|
||||
assert _feed_ids(client, "show=hidden") == {"vHide"}
|
||||
assert _feed_ids(client, "show=in_progress") == {"vProg"}
|
||||
# unwatched excludes watched + hidden, keeps new AND in-progress
|
||||
assert _feed_ids(client, "show=unwatched") == {"vProg", "vNew"}
|
||||
# "all" shows everything except hidden
|
||||
assert _feed_ids(client, "show=all") == {"vWatch", "vProg", "vNew"}
|
||||
|
||||
|
||||
def _seed_tagged(db, make_user):
|
||||
"""A user subscribed to 4 channels with a spread of language/topic/other tags and differing video
|
||||
counts, so the count sum and the per-category facet drill-down are all non-trivial."""
|
||||
user = make_user(email="summary@example.com")
|
||||
tags = {
|
||||
"L1": Tag(user_id=None, name="Hungarian", category="language"),
|
||||
"L2": Tag(user_id=None, name="English", category="language"),
|
||||
"T1": Tag(user_id=None, name="Tech", category="topic"),
|
||||
"T2": Tag(user_id=None, name="Gaming", category="topic"),
|
||||
"O1": Tag(user_id=None, name="Favourite", category="other"),
|
||||
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
|
||||
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
|
||||
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
|
||||
}
|
||||
db.add_all(tags.values())
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# channel -> (system) tag keys
|
||||
links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
|
||||
for cid, tkeys in links.items():
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
for tk in tkeys:
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tags[tk].id, user_id=None))
|
||||
# The user's own private link (visible only to them): chB carries U1 for this user.
|
||||
db.add(ChannelTag(channel_id="chB", tag_id=tags["U1"].id, user_id=user.id))
|
||||
# differing video counts per channel (chA:3, chB:2, chC:1, chD:2)
|
||||
counts = {"chA": 3, "chB": 2, "chC": 1, "chD": 2}
|
||||
for cid, n in counts.items():
|
||||
db.add_all([_video(f"{cid}v{i}", cid, f"{cid} clip {i}") for i in range(n)])
|
||||
db.commit()
|
||||
return user, {k: v.id for k, v in tags.items()}
|
||||
|
||||
|
||||
# Query-param combos (as a template with {L1}/{T1}/… placeholders) that stress every facet path:
|
||||
# no tags, single-category, cross-category, disjunctive + conjunctive topic modes, and a search filter.
|
||||
_SUMMARY_CASES = [
|
||||
"show=all",
|
||||
"show=all&tags={L1}",
|
||||
"show=all&tags={T1}",
|
||||
"show=all&tags={L1}&tags={T1}",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode=or",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode=and",
|
||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||
"show=all&q=clip",
|
||||
"show=unwatched",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
||||
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||
# across the full matrix of tag filters — it reimplements the tag drill-down in Python, so this is
|
||||
# the equivalence net.
|
||||
user, tag_ids = _seed_tagged(db, make_user)
|
||||
client = as_user(user)
|
||||
qs = case.format(**tag_ids)
|
||||
|
||||
summary = client.get(f"/api/feed/summary?{qs}")
|
||||
count = client.get(f"/api/feed/count?{qs}")
|
||||
facets = client.get(f"/api/facets?{qs}")
|
||||
assert summary.status_code == count.status_code == facets.status_code == 200
|
||||
|
||||
assert summary.json()["count"] == count.json()["count"], qs
|
||||
assert summary.json()["facets"] == facets.json()["counts"], qs
|
||||
@@ -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`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function Channels() {
|
||||
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
||||
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||
@@ -131,7 +131,7 @@ export default function Channels() {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
},
|
||||
});
|
||||
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -226,9 +226,11 @@ export default function Feed({
|
||||
}
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
const countQuery = useQuery({
|
||||
queryKey: qk.feedCount(queryFilters),
|
||||
queryFn: () => api.feedCount(queryFilters),
|
||||
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(queryFilters),
|
||||
queryFn: () => api.feedSummary(queryFilters),
|
||||
staleTime: 30_000,
|
||||
enabled: !ytActive,
|
||||
placeholderData: keepPreviousData,
|
||||
@@ -252,7 +254,11 @@ export default function Feed({
|
||||
api
|
||||
.setState(id, status)
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
||||
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
||||
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
||||
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||
if (status === "hidden") {
|
||||
const v = loadedRef.current.find((x) => x.id === id);
|
||||
notify({
|
||||
@@ -309,7 +315,8 @@ export default function Feed({
|
||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
||||
api
|
||||
.clearState(id)
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
||||
.catch(() =>
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
@@ -358,8 +365,7 @@ export default function Feed({
|
||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||
const dropFeedCaches = () => {
|
||||
qc.removeQueries({ queryKey: qk.feed() });
|
||||
qc.removeQueries({ queryKey: qk.feedCount() });
|
||||
qc.removeQueries({ queryKey: qk.facets() });
|
||||
qc.removeQueries({ queryKey: qk.feedSummary() });
|
||||
};
|
||||
|
||||
// --- Live YouTube search mode -------------------------------------------------------------
|
||||
@@ -622,10 +628,10 @@ export default function Feed({
|
||||
)}
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||
{countQuery.data
|
||||
{summaryQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: countQuery.data.count,
|
||||
formattedCount: countQuery.data.count.toLocaleString(),
|
||||
count: summaryQuery.data.count,
|
||||
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||
})
|
||||
: " "}
|
||||
</span>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function NotificationsPanel() {
|
||||
.setState(meta.videoId, "new")
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
||||
resolveVideo(meta.videoId);
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
onSuccess: () => {
|
||||
// Feed/channels/status now have content — refresh the app behind the wizard.
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
},
|
||||
|
||||
@@ -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,229 +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 refresh the feed + playlists so resume bars reflect this session.
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
});
|
||||
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";
|
||||
@@ -87,14 +85,16 @@ export default function Sidebar({
|
||||
// Live per-tag channel counts for the current filter context. Debounce the search term so
|
||||
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
|
||||
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
|
||||
const facetsQuery = useQuery({
|
||||
queryKey: qk.facets(facetFilters),
|
||||
queryFn: () => api.facets(facetFilters),
|
||||
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
|
||||
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: qk.feedSummary(facetFilters),
|
||||
queryFn: () => api.feedSummary(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const facetsReady = !!facetsQuery.data;
|
||||
const facetCounts = facetsQuery.data?.counts ?? {};
|
||||
const facetsReady = !!summaryQuery.data;
|
||||
const facetCounts = summaryQuery.data?.facets ?? {};
|
||||
const chipCount = (tag: Tag): number =>
|
||||
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
|
||||
const visibleChips = (list: Tag[]): Tag[] => {
|
||||
@@ -109,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);
|
||||
@@ -124,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));
|
||||
@@ -306,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) }));
|
||||
|
||||
@@ -319,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"),
|
||||
|
||||
@@ -113,6 +113,8 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
|
||||
export const feedApi = {
|
||||
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
||||
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
||||
// SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /feed/count
|
||||
// endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together.
|
||||
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||
req(`/api/feed/count?${filterParams(f).toString()}`),
|
||||
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
|
||||
@@ -129,8 +131,14 @@ export const feedApi = {
|
||||
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||
// SUPERSEDED by feedSummary (PERF-13) — no component calls this anymore; the backend /facets
|
||||
// endpoint it targets is kept only as the /feed/summary equivalence-test oracle. Retire together.
|
||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||
req(`/api/facets?${filterParams(f).toString()}`),
|
||||
// The sidebar's total count + per-tag channel facets in ONE request + ONE grouped query, replacing
|
||||
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
|
||||
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
|
||||
req(`/api/feed/summary?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||
setState: (id: string, status: string) =>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user