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)
|
# 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
|
# 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
|
# 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 = ""
|
trusted_proxy_ips: str = ""
|
||||||
|
|
||||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
# --- 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
|
# 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
|
# 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(
|
engine = create_engine(
|
||||||
settings.database_url,
|
settings.database_url,
|
||||||
pool_pre_ping=True,
|
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
|
return
|
||||||
|
|
||||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
# 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.
|
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||||
from app.worker import _download, _process_edit
|
from app.downloads.pipeline import _download, _process_edit
|
||||||
|
|
||||||
if job_kind == "edit":
|
if job_kind == "edit":
|
||||||
_process_edit(job_id, asset_id)
|
_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
|
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(
|
def enqueue(
|
||||||
db: Session,
|
db: Session,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -143,7 +171,7 @@ def enqueue(
|
|||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
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.
|
# Cache hit: the file already exists — no worker round-trip needed.
|
||||||
if asset.status == "ready":
|
if asset.status == "ready":
|
||||||
@@ -212,7 +240,7 @@ def enqueue_edit(
|
|||||||
queue_pos=next_queue_pos(db),
|
queue_pos=next_queue_pos(db),
|
||||||
)
|
)
|
||||||
db.add(job)
|
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
|
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||||
job.status = "done"
|
job.status = "done"
|
||||||
@@ -273,8 +301,83 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
|||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return
|
return
|
||||||
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
|
new_count = decr_ref_count(db, asset.id)
|
||||||
if asset.ref_count == 0 and asset.status == "ready":
|
if new_count == 0 and asset.status == "ready":
|
||||||
if asset.rel_path:
|
if asset.rel_path:
|
||||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||||
db.delete(asset)
|
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(
|
def filtered_query(
|
||||||
db: Session,
|
db: Session,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -131,7 +143,6 @@ def filtered_query(
|
|||||||
either mode via the VideoState outer join."""
|
either mode via the VideoState outer join."""
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
position_expr = func.coalesce(state.position_seconds, 0)
|
|
||||||
|
|
||||||
query = select(*feed_columns(user, state)).join(
|
query = select(*feed_columns(user, state)).join(
|
||||||
Channel, Channel.id == Video.channel_id
|
Channel, Channel.id == Video.channel_id
|
||||||
@@ -163,7 +174,14 @@ def filtered_query(
|
|||||||
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
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)
|
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.
|
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
|
||||||
rank_expr = None
|
rank_expr = None
|
||||||
if q:
|
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)
|
ts_str = to_tsquery_str(q)
|
||||||
if ts_str:
|
if ts_str:
|
||||||
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
||||||
query = query.where(
|
query = query.where(or_(Video.search_vector.op("@@")(tsq), channel_match))
|
||||||
or_(
|
|
||||||
Video.search_vector.op("@@")(tsq),
|
|
||||||
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
|
# 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
|
# 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
|
# 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)
|
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
|
||||||
else:
|
else:
|
||||||
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
|
# 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), channel_match))
|
||||||
query = query.where(
|
|
||||||
or_(
|
|
||||||
func.unaccent(Video.title).ilike(like),
|
|
||||||
func.unaccent(Channel.title).ilike(like),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
# AND across tag categories (e.g. language AND topic narrows), OR within a
|
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
||||||
# category; the any/all toggle controls multiple topic tags.
|
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
||||||
cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all()
|
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||||
by_category: dict[str, list[int]] = {}
|
# test_feed_summary_matches_count_plus_facets).
|
||||||
for tag_id, category in cat_rows:
|
by_category = tags_by_category(db, tags)
|
||||||
by_category.setdefault(category, []).append(tag_id)
|
|
||||||
# Facet counting drops the category being counted so its own chips don't zero each
|
# 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
|
# other out (standard drill-down faceting: a category's count ignores its own
|
||||||
# selections but still honours the other categories' filters).
|
# selections but still honours the other categories' filters).
|
||||||
@@ -315,20 +336,96 @@ def filtered_query(
|
|||||||
if show == "unwatched":
|
if show == "unwatched":
|
||||||
query = query.where(status_expr.notin_(("watched", "hidden")))
|
query = query.where(status_expr.notin_(("watched", "hidden")))
|
||||||
elif show == "in_progress":
|
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(
|
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":
|
elif show == "watched":
|
||||||
query = query.where(status_expr == "watched")
|
query = query.where(state.status == "watched")
|
||||||
elif show == "hidden":
|
elif show == "hidden":
|
||||||
query = query.where(status_expr == "hidden")
|
query = query.where(state.status == "hidden")
|
||||||
else: # all
|
else: # all
|
||||||
query = query.where(status_expr != "hidden")
|
query = query.where(status_expr != "hidden")
|
||||||
|
|
||||||
return query, rank_expr
|
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 ---------------------------------------------
|
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||||
#
|
#
|
||||||
# Each sort is described as an ordered list of key columns (expression + direction
|
# 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
|
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
||||||
# (OFFSET would shift and duplicate/skip rows).
|
# (OFFSET would shift and duplicate/skip rows).
|
||||||
def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
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]] = {
|
table: dict[str, list[dict]] = {
|
||||||
"newest": [pub],
|
"newest": [pub],
|
||||||
"oldest": [{**pub, "asc": True}],
|
"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
|
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
|
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):
|
def eq(k):
|
||||||
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
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"]
|
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
|
return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp
|
||||||
|
|
||||||
branches = []
|
def rec(i: int):
|
||||||
for j in range(len(keys)):
|
if i == len(keys):
|
||||||
branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j])))
|
return Video.id > cursor_id # all keys tied -> break by the unique id
|
||||||
branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id))
|
k = keys[i]
|
||||||
return or_(*branches)
|
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:
|
def encode_cursor(keys: list[dict], row) -> str:
|
||||||
|
|||||||
+25
-12
@@ -251,14 +251,23 @@ class Video(Base, TimestampMixin):
|
|||||||
__tablename__ = "videos"
|
__tablename__ = "videos"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
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(
|
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)
|
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
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(
|
# NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key
|
||||||
DateTime(timezone=True), index=True
|
# 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))
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
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
|
# 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.
|
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
||||||
search_terms: Mapped[str | None] = mapped_column(Text)
|
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(
|
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.
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
shorts_probed: Mapped[bool] = mapped_column(
|
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
|
# 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
|
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
||||||
# Library by default; left False by the organic ingest paths.
|
# Library by default; left False by the organic ingest paths.
|
||||||
via_search: Mapped[bool] = mapped_column(
|
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)
|
# 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
|
# 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
|
# 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.
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||||
via_explore: Mapped[bool] = mapped_column(
|
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
|
# none | live | upcoming | premiere | was_live
|
||||||
live_status: Mapped[str] = mapped_column(
|
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
|
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||||
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
||||||
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
# 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(
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True)
|
||||||
)
|
)
|
||||||
unavailable_since: Mapped[datetime | None] = mapped_column(
|
unavailable_since: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True)
|
||||||
)
|
)
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
|
|
||||||
@@ -531,9 +547,6 @@ class AppState(Base):
|
|||||||
sync_paused: Mapped[bool] = mapped_column(
|
sync_paused: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
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
|
# 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
|
# 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;
|
# 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 dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
TS_CONFIG = "public.unaccent_simple"
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
@@ -189,3 +189,62 @@ class LibraryFilters:
|
|||||||
|
|
||||||
def like_escape(s: str) -> str:
|
def like_escape(s: str) -> str:
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
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
|
(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.
|
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
|
The sync is a full reconcile of the enabled sections (upsert by rating_key) AND prunes rows for
|
||||||
disappeared from Plex is intentionally left for later (a follow-up can diff rating_keys per library).
|
titles that disappeared from Plex: each section diffs the rating_keys it just listed against the ones
|
||||||
Cast + intro/credit markers are fetched lazily on the item-detail/player path (P2), not here.
|
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
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -150,7 +155,7 @@ def sync(db: Session) -> dict:
|
|||||||
return {"skipped": "disabled"}
|
return {"skipped": "disabled"}
|
||||||
stats = {
|
stats = {
|
||||||
"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0,
|
"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)
|
wanted = _enabled_section_keys(db)
|
||||||
overlay_map = _overlay_map(db) # {huKey: enKey}
|
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
|
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
|
start = 0
|
||||||
|
total = None
|
||||||
while True:
|
while True:
|
||||||
items, total = plex.section_items(key, item_type, start, _PAGE)
|
items, total = plex.section_items(key, item_type, start, _PAGE)
|
||||||
if not items:
|
if not items:
|
||||||
@@ -292,6 +303,10 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
|
|||||||
break
|
break
|
||||||
if len(items) < _PAGE:
|
if len(items) < _PAGE:
|
||||||
break
|
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
|
# 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"))
|
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:
|
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||||
existing = {
|
existing = {
|
||||||
r.rating_key: r
|
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")
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||||
).scalars()
|
).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 "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
continue
|
continue
|
||||||
@@ -366,8 +401,10 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
|||||||
if row.id is None:
|
if row.id is None:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
_apply_item(row, lib.id, "movie", meta)
|
_apply_item(row, lib.id, "movie", meta)
|
||||||
|
seen.add(rk)
|
||||||
stats["movies"] += 1
|
stats["movies"] += 1
|
||||||
db.flush()
|
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:
|
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)
|
select(PlexShow).where(PlexShow.library_id == lib.id)
|
||||||
).scalars()
|
).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 "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
continue
|
continue
|
||||||
|
shows_seen.add(rk)
|
||||||
sh = shows.get(rk) or PlexShow(rating_key=rk)
|
sh = shows.get(rk) or PlexShow(rating_key=rk)
|
||||||
if sh.id is None:
|
if sh.id is None:
|
||||||
db.add(sh)
|
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)
|
select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id)
|
||||||
).scalars()
|
).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 "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
||||||
if not rk or sid is None:
|
if not rk or sid is None:
|
||||||
continue
|
continue
|
||||||
|
seasons_seen.add(rk)
|
||||||
se = seasons.get(rk) or PlexSeason(rating_key=rk)
|
se = seasons.get(rk) or PlexSeason(rating_key=rk)
|
||||||
if se.id is None:
|
if se.id is None:
|
||||||
db.add(se)
|
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")
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode")
|
||||||
).scalars()
|
).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 "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
continue
|
continue
|
||||||
|
episodes_seen.add(rk)
|
||||||
row = episodes.get(rk) or PlexItem(rating_key=rk)
|
row = episodes.get(rk) or PlexItem(rating_key=rk)
|
||||||
if row.id is None:
|
if row.id is None:
|
||||||
db.add(row)
|
db.add(row)
|
||||||
@@ -446,6 +492,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
stats["episodes"] += 1
|
stats["episodes"] += 1
|
||||||
db.flush()
|
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(
|
def _sync_overlay(
|
||||||
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
|
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 authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -187,6 +188,36 @@ def _oauth_redirect_uri(request: Request) -> str:
|
|||||||
return settings.oauth_redirect_url
|
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")
|
@router.get("/login")
|
||||||
async def login(request: Request, db: Session = Depends(get_db)):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
# 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")
|
@router.post("/request-access")
|
||||||
async def request_access(
|
async def request_access(
|
||||||
payload: dict,
|
body: EmailIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
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.
|
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
|
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."""
|
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):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
if not _request_access_limiter.allow(_client_ip(request)):
|
if not _request_access_limiter.allow(_client_ip(request)):
|
||||||
@@ -477,7 +508,7 @@ async def request_access(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/demo")
|
@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,
|
"""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,
|
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
|
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."""
|
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||||
if not _demo_limiter.allow(_client_ip(request)):
|
if not _demo_limiter.allow(_client_ip(request)):
|
||||||
return {"authenticated": False}
|
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):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
|
# 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")
|
@router.post("/register")
|
||||||
def register(
|
def register(
|
||||||
payload: dict,
|
body: EmailPasswordIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -565,8 +596,8 @@ def register(
|
|||||||
verification link, and records an access request for admin approval. Two gates before
|
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
|
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."""
|
the endpoint can't be used to discover which emails are registered."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
password = payload.get("password") or ""
|
password = body.password or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not valid_email(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
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")
|
@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
|
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||||
on any invalid/expired/used token."""
|
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:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="This verification link is invalid or has expired."
|
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")
|
@router.post("/verify/resend")
|
||||||
def verify_email_resend(
|
def verify_email_resend(
|
||||||
payload: dict,
|
body: EmailIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
) -> dict:
|
) -> 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."""
|
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)):
|
if not _verify_resend_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"} # silently throttle; uniform response
|
return {"status": "ok"} # silently throttle; uniform response
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
if valid_email(email):
|
if valid_email(email):
|
||||||
background.add_task(_resend_verify_if_eligible, email)
|
background.add_task(_resend_verify_if_eligible, email)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -688,7 +719,7 @@ def verify_email(token: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("/password-login")
|
@router.post("/password-login")
|
||||||
def password_login(
|
def password_login(
|
||||||
payload: dict,
|
body: EmailPasswordIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -700,8 +731,8 @@ def password_login(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429, detail="Too many attempts. Try again shortly."
|
status_code=429, detail="Too many attempts. Try again shortly."
|
||||||
)
|
)
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (body.email or "").strip().lower()
|
||||||
password = payload.get("password") or ""
|
password = body.password or ""
|
||||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
# 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.
|
# 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")
|
@router.post("/password-reset/request")
|
||||||
def password_reset_request(
|
def password_reset_request(
|
||||||
payload: dict,
|
body: EmailIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
background: BackgroundTasks,
|
background: BackgroundTasks,
|
||||||
) -> dict:
|
) -> 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."""
|
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)):
|
if not _reset_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"}
|
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
|
# 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
|
# 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.
|
# 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")
|
@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."""
|
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
|
||||||
token = payload.get("token")
|
token = body.token
|
||||||
new_password = payload.get("password") or ""
|
new_password = body.password or ""
|
||||||
if len(new_password) < PASSWORD_MIN_LEN:
|
if len(new_password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -872,7 +903,7 @@ async def link_google(
|
|||||||
|
|
||||||
@router.post("/set-password")
|
@router.post("/set-password")
|
||||||
def set_password(
|
def set_password(
|
||||||
payload: dict,
|
body: SetPasswordIn,
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -884,7 +915,7 @@ def set_password(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="Not available in the demo account."
|
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:
|
if len(new_password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -892,7 +923,7 @@ def set_password(
|
|||||||
)
|
)
|
||||||
if user.password_hash:
|
if user.password_hash:
|
||||||
if not verify_password(
|
if not verify_password(
|
||||||
payload.get("current_password") or "", user.password_hash
|
body.current_password or "", user.password_hash
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403, detail="Current password is incorrect."
|
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")
|
@router.get("/config")
|
||||||
def auth_config(db: Session = Depends(get_db)) -> dict:
|
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
|
"""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,
|
"""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
|
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
||||||
(unsubscribe) live behind the optional write scope and are added in a later phase."""
|
(subscribe / unsubscribe) that require the optional write scope."""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ from app.config import settings
|
|||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import formats, quota, service, storage
|
from app.downloads import quota, service, storage
|
||||||
from app.downloads.status import JobStatus
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -454,18 +453,12 @@ def update_download_meta(
|
|||||||
return _serialize_job(db, job)
|
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")
|
@router.post("/{job_id}/pause")
|
||||||
def pause_download(
|
def pause_download(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.status in ("queued", "running"):
|
service.pause(db, job)
|
||||||
_set_status(db, job, "paused") # a running worker aborts cooperatively via the hook
|
|
||||||
return _serialize_job(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)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.status in ("paused", "error"):
|
service.resume(db, job)
|
||||||
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")
|
|
||||||
return _serialize_job(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.")
|
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||||
if job.status in ("queued", "running", "paused"):
|
if job.status in ("queued", "running", "paused"):
|
||||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||||
|
service.redownload(db, job)
|
||||||
# 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()
|
|
||||||
return _serialize_job(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)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
service.cancel(db, job)
|
||||||
service.release_asset(db, job) # release while the job still counts as holding
|
|
||||||
job.status = JobStatus.CANCELED
|
|
||||||
db.commit()
|
|
||||||
return _serialize_job(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)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
service.release_asset(db, job)
|
service.delete(db, job)
|
||||||
db.delete(job)
|
|
||||||
db.commit()
|
|
||||||
return {"deleted": job_id}
|
return {"deleted": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.db import get_db
|
|||||||
from app.feedquery import (
|
from app.feedquery import (
|
||||||
decode_cursor,
|
decode_cursor,
|
||||||
encode_cursor,
|
encode_cursor,
|
||||||
|
feed_summary,
|
||||||
filtered_query,
|
filtered_query,
|
||||||
keyset_predicate,
|
keyset_predicate,
|
||||||
order_by,
|
order_by,
|
||||||
@@ -127,6 +128,9 @@ def get_feed_count(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
# 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)
|
query, _rank = filtered_query(db, user, **params)
|
||||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
return {"count": total or 0}
|
return {"count": total or 0}
|
||||||
@@ -146,7 +150,11 @@ def get_facets(
|
|||||||
|
|
||||||
The count is the number of distinct channels that have at least one video in the current
|
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
|
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)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
counts: dict[int, int] = {}
|
counts: dict[int, int] = {}
|
||||||
for category in ("language", "topic", "other"):
|
for category in ("language", "topic", "other"):
|
||||||
@@ -174,6 +182,19 @@ def get_facets(
|
|||||||
return {"counts": counts}
|
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):
|
class VideoStateIn(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Local playlists: per-user named, ordered collections of videos from the shared
|
"""Playlists: per-user named, ordered collections of videos from the shared catalog. Local
|
||||||
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
|
CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-way sync —
|
||||||
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, and_
|
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 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 = APIRouter()
|
||||||
router.include_router(admin.router)
|
router.include_router(admin.router)
|
||||||
router.include_router(browse.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(playback.router)
|
||||||
|
router.include_router(playlists.router)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexItem,
|
PlexItem,
|
||||||
|
PlexLibrary,
|
||||||
)
|
)
|
||||||
from app.plex.lookups import by_rating_key
|
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:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||||
return it
|
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),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
"""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."""
|
run). Persisted via sysconfig; the job reads it each run."""
|
||||||
value = body.revalidate_batch
|
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||||
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
|
try:
|
||||||
raise HTTPException(
|
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||||
status_code=400,
|
except (TypeError, ValueError) as exc:
|
||||||
detail=(
|
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||||
f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and "
|
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||||
f"{state.MAINTENANCE_BATCH_MAX}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
new = state.set_maintenance_batch(db, value)
|
|
||||||
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
|
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"revalidate_batch": new}
|
return {"revalidate_batch": new}
|
||||||
@@ -167,6 +163,7 @@ def get_scheduler(
|
|||||||
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
|
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
|
||||||
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
|
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
|
||||||
}
|
}
|
||||||
|
maint = sysconfig.spec("maintenance_revalidate_batch")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**snap,
|
**snap,
|
||||||
@@ -174,9 +171,9 @@ def get_scheduler(
|
|||||||
"queue": queue,
|
"queue": queue,
|
||||||
"quota": quota_info,
|
"quota": quota_info,
|
||||||
"maintenance": {
|
"maintenance": {
|
||||||
"revalidate_batch": state.get_maintenance_batch(db),
|
"revalidate_batch": sysconfig.get_int(db, "maintenance_revalidate_batch"),
|
||||||
"revalidate_batch_default": settings.maintenance_revalidate_batch,
|
"revalidate_batch_default": settings.maintenance_revalidate_batch,
|
||||||
"min": state.MAINTENANCE_BATCH_MIN,
|
"min": maint.min,
|
||||||
"max": state.MAINTENANCE_BATCH_MAX,
|
"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
|
`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
|
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
|
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
|
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:
|
except (ValueError, RuntimeError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
saved.append(key)
|
saved.append(key)
|
||||||
|
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||||
return {"saved": saved}
|
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.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.sync.runner import (
|
from app.sync.runner import estimate_deep_backfill
|
||||||
estimate_deep_backfill,
|
|
||||||
run_enrich,
|
|
||||||
run_recent_backfill,
|
|
||||||
run_rss_poll,
|
|
||||||
)
|
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.scheduler import running_job_ids
|
from app.scheduler import running_job_ids
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
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")
|
@router.post("/subscriptions")
|
||||||
def sync_subscriptions(
|
def sync_subscriptions(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
@@ -48,38 +31,6 @@ def sync_subscriptions(
|
|||||||
return result
|
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")
|
@router.get("/status")
|
||||||
def sync_status(
|
def sync_status(
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
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.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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.db import get_db
|
||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.sync.autotag import run_autotag_all
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||||
|
|
||||||
@@ -113,12 +112,3 @@ def delete_tag(
|
|||||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": tag_id}
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.models import AppState
|
from app.models import AppState
|
||||||
from app.security import hash_token
|
from app.security import hash_token
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.state")
|
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
|
# 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.
|
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
|
||||||
_configured_cache = False
|
_configured_cache = False
|
||||||
@@ -33,10 +28,12 @@ def is_sync_paused(db: Session) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def set_sync_paused(db: Session, paused: bool) -> None:
|
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 = _row(db)
|
||||||
row.sync_paused = paused
|
row.sync_paused = paused
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.flush()
|
||||||
log.info("Background sync %s", "paused" if paused else "resumed")
|
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
|
return False
|
||||||
stored = _row(db).setup_token_hash
|
stored = _row(db).setup_token_hash
|
||||||
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
|
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 import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, quota
|
from app import progress, quota, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Playlist, PlaylistItem, Video
|
from app.models import Playlist, PlaylistItem, Video
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.state import get_maintenance_batch
|
|
||||||
from app.sync.runner import get_service_user
|
from app.sync.runner import get_service_user
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
from app.utils import now_utc as _now
|
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:
|
def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
||||||
"""Phase 2: re-check the least-recently-checked currently-available videos and flag any
|
"""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)."""
|
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
|
flagged_new = 0
|
||||||
checked = 0
|
checked = 0
|
||||||
now = _now()
|
now = _now()
|
||||||
@@ -194,10 +193,10 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
|||||||
db.execute(
|
db.execute(
|
||||||
select(Video)
|
select(Video)
|
||||||
.where(Video.unavailable_since.is_(None))
|
.where(Video.unavailable_since.is_(None))
|
||||||
.order_by(
|
# NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old
|
||||||
Video.last_checked_at.is_(None).desc(), # never-checked first
|
# `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the
|
||||||
Video.last_checked_at.asc(),
|
# 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))
|
.limit(min(50, batch - checked))
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Read-direction YouTube playlist sync: mirror each user's own YouTube playlists into
|
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
||||||
local `source='youtube'` playlists (kept fresh by the scheduler). One-way (YT -> local);
|
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: push local
|
||||||
the write-back direction (local edits -> YouTube) is a later phase.
|
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
|
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
|
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:
|
if not vid or not cid or vid in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(vid)
|
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)
|
apply_video_details(v, it)
|
||||||
v.enriched_at = now
|
v.enriched_at = now
|
||||||
db.add(v)
|
db.add(v)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Channel, Subscription, User
|
from app.models import Channel, Subscription, User
|
||||||
|
from app.utils import normalize_description
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.sync")
|
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.title = snippet.get("title") or channel.title
|
||||||
channel.handle = snippet.get("customUrl")
|
channel.handle = snippet.get("customUrl")
|
||||||
channel.description = snippet.get("description")
|
channel.description = normalize_description(snippet.get("description"))
|
||||||
channel.thumbnail_url = (
|
channel.thumbnail_url = (
|
||||||
best_thumbnail(snippet.get("thumbnails")) or 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(
|
channel = Channel(
|
||||||
id=cid,
|
id=cid,
|
||||||
title=sub.get("title"),
|
title=sub.get("title"),
|
||||||
description=sub.get("description"),
|
description=normalize_description(sub.get("description")),
|
||||||
thumbnail_url=sub.get("thumbnail_url"),
|
thumbnail_url=sub.get("thumbnail_url"),
|
||||||
)
|
)
|
||||||
db.add(channel)
|
db.add(channel)
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
|||||||
video_id = content.get("videoId")
|
video_id = content.get("videoId")
|
||||||
if not video_id:
|
if not video_id:
|
||||||
return None
|
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 {
|
return {
|
||||||
"id": video_id,
|
"id": video_id,
|
||||||
"channel_id": channel_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.title = normalize_title(raw_title)
|
||||||
video.description = snippet.get("description")
|
video.description = snippet.get("description")
|
||||||
if not video.published_at:
|
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.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
||||||
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
||||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
# --- Batch sizes ---
|
# --- Batch sizes ---
|
||||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
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),
|
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) ---
|
# --- Discovery cleanup (search + explore ephemeral content) ---
|
||||||
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
# 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),
|
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"),
|
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.)
|
# (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),
|
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
|
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||||
# incremental watch-sync scheduler job that reads it lands.
|
# (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}
|
_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:
|
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]
|
s = _BY_KEY[key]
|
||||||
if s.type == "int":
|
if s.type == "int":
|
||||||
v = int(value)
|
v = int(value)
|
||||||
@@ -208,15 +214,16 @@ def set_value(db: Session, key: str, value) -> None:
|
|||||||
db.add(row)
|
db.add(row)
|
||||||
row.value = stored
|
row.value = stored
|
||||||
row.encrypted = encrypted
|
row.encrypted = encrypted
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def reset(db: Session, key: str) -> None:
|
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)
|
row = db.get(SystemConfig, key)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def describe(db: Session) -> dict:
|
def describe(db: Session) -> dict:
|
||||||
|
|||||||
@@ -36,3 +36,18 @@ def to_tsquery_str(q: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
tokens[-1] = tokens[-1] + ":*"
|
tokens[-1] = tokens[-1] + ":*"
|
||||||
return " & ".join(tokens)
|
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 logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yt_dlp
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import quota, service
|
||||||
from app.downloads import adapters, formats, quota, service, storage
|
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.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
|
||||||
from app.titles import normalize_title
|
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.worker")
|
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()
|
_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
class _Aborted(Exception):
|
|
||||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_signal(signum, frame):
|
def _handle_signal(signum, frame):
|
||||||
_stop.set()
|
_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 -----------------------------------------------------------------------
|
# --- claim + recovery -----------------------------------------------------------------------
|
||||||
|
|
||||||
def _wait_for_schema(timeout: float = 300.0) -> bool:
|
def _wait_for_schema(timeout: float = 300.0) -> bool:
|
||||||
@@ -176,7 +91,14 @@ def _recover_orphans() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _claim_job() -> int | 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
|
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
|
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
|
SELECT id, user_id FROM download_jobs
|
||||||
WHERE status='queued'
|
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 (
|
AND user_id NOT IN (
|
||||||
SELECT r.user_id
|
SELECT r.user_id
|
||||||
FROM download_jobs r
|
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)
|
_set_job(job_id, status="error", error=asset_error or "Download failed", phase=None)
|
||||||
return
|
return
|
||||||
if asset_status == "downloading":
|
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")
|
_requeue_if_running(job_id, "waiting")
|
||||||
time.sleep(2)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# asset_status == "pending": try to win the download / edit.
|
# asset_status == "pending": try to win the download / edit.
|
||||||
if not _claim_asset(asset_id):
|
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")
|
_requeue_if_running(job_id, "waiting")
|
||||||
time.sleep(2)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# The asset is now ours ('downloading'): run the heavy work in a supervised child process.
|
# 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()
|
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 -----------------------------------------------------------------------------------
|
# --- loop -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
def _worker_loop(idx: int) -> None:
|
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
|
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.
|
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
|
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(
|
j = DownloadJob(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
asset_id=asset_id,
|
||||||
source_kind="youtube",
|
source_kind="youtube",
|
||||||
source_ref="x" * 11,
|
source_ref="x" * 11,
|
||||||
status=status,
|
status=status,
|
||||||
@@ -26,6 +27,15 @@ def _job(db, user_id, queue_pos, status="queued"):
|
|||||||
return j
|
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):
|
def _quota(db, user_id, max_concurrent, unlimited=False):
|
||||||
db.add(
|
db.add(
|
||||||
DownloadQuota(
|
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)
|
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):
|
def test_unlimited_user_is_never_excluded(db):
|
||||||
u = User(email="unlimited@example.com")
|
u = User(email="unlimited@example.com")
|
||||||
db.add(u)
|
db.add(u)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
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
|
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="UCa", priority=5, hidden=False),
|
||||||
Subscription(user_id=user.id, channel_id="UCb", priority=0, 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
|
# 7 normal/organic videos. Deliberate duplicates (published_at day 1 x2, day 2 x2 and day 3 x2,
|
||||||
# 100 x2, duration 60 x2 / 30 x2) and NULLs spread across the sort columns.
|
# 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 = [
|
rows = [
|
||||||
# id ch published views dur title
|
# id ch published views dur title
|
||||||
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
|
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
|
||||||
("vid01", "UCa", _dt(1), 100, 60, "beta"),
|
("vid01", "UCa", _dt(1), 100, 60, "beta"),
|
||||||
("vid02", "UCa", _dt(2), None, None, "gamma"),
|
("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"),
|
("vid04", "UCb", _dt(3), 200, 30, "delta"),
|
||||||
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
|
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
|
||||||
("vid06", "UCa", _dt(28), None, None, "zeta"),
|
("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`
|
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
|
opens a session and UPDATE+COMMITs, `_job_status` opens one and SELECTs. Unthrottled, a long
|
||||||
re-encode is ~10^5 of each."""
|
re-encode is ~10^5 of each."""
|
||||||
from app import worker
|
import app.downloads.pipeline as pipeline
|
||||||
|
|
||||||
|
|
||||||
class TestProgressWriter:
|
class TestProgressWriter:
|
||||||
def test_writes_the_first_value_immediately(self, monkeypatch):
|
def test_writes_the_first_value_immediately(self, monkeypatch):
|
||||||
writes = []
|
writes = []
|
||||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||||
worker._ffmpeg_progress_writer(7, "processing")(12.4)
|
pipeline._ffmpeg_progress_writer(7, "processing")(12.4)
|
||||||
assert writes == [{"progress": 12, "phase": "processing"}]
|
assert writes == [{"progress": 12, "phase": "processing"}]
|
||||||
|
|
||||||
def test_a_burst_of_lines_costs_one_write(self, monkeypatch):
|
def test_a_burst_of_lines_costs_one_write(self, monkeypatch):
|
||||||
writes = []
|
writes = []
|
||||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||||
write = worker._ffmpeg_progress_writer(7, "processing")
|
write = pipeline._ffmpeg_progress_writer(7, "processing")
|
||||||
for pct in range(0, 40): # what one second of ffmpeg output looks like
|
for pct in range(0, 40): # what one second of ffmpeg output looks like
|
||||||
write(pct)
|
write(pct)
|
||||||
assert len(writes) == 1
|
assert len(writes) == 1
|
||||||
@@ -25,8 +25,8 @@ class TestProgressWriter:
|
|||||||
def test_the_next_second_writes_again(self, monkeypatch):
|
def test_the_next_second_writes_again(self, monkeypatch):
|
||||||
writes = []
|
writes = []
|
||||||
clock = {"t": 1000.0}
|
clock = {"t": 1000.0}
|
||||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||||
write(10)
|
write(10)
|
||||||
write(11) # same second → dropped
|
write(11) # same second → dropped
|
||||||
clock["t"] += 1.5
|
clock["t"] += 1.5
|
||||||
@@ -36,19 +36,51 @@ class TestProgressWriter:
|
|||||||
def test_an_unchanged_whole_percent_is_never_rewritten(self, monkeypatch):
|
def test_an_unchanged_whole_percent_is_never_rewritten(self, monkeypatch):
|
||||||
writes = []
|
writes = []
|
||||||
clock = {"t": 1000.0}
|
clock = {"t": 1000.0}
|
||||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: None)
|
||||||
|
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||||
write(10.1)
|
write(10.1)
|
||||||
clock["t"] += 5
|
clock["t"] += 5
|
||||||
write(10.9) # still 10% — the bar shows integers
|
write(10.9) # still 10% — the bar shows integers
|
||||||
assert [w["progress"] for w in writes] == [10]
|
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:
|
class TestCancelPoll:
|
||||||
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
|
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||||
cancelled = worker._cancel_poll(7)
|
cancelled = pipeline._cancel_poll(7)
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
assert cancelled() is False
|
assert cancelled() is False
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
@@ -61,8 +93,8 @@ class TestCancelPoll:
|
|||||||
calls.append(job_id)
|
calls.append(job_id)
|
||||||
return "cancelled"
|
return "cancelled"
|
||||||
|
|
||||||
monkeypatch.setattr(worker, "_job_status", status)
|
monkeypatch.setattr(pipeline, "_job_status", status)
|
||||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||||
assert cancelled() is True
|
assert cancelled() is True
|
||||||
clock["t"] += 60 # cancellation is sticky: no re-query, and no un-cancelling
|
clock["t"] += 60 # cancellation is sticky: no re-query, and no un-cancelling
|
||||||
assert cancelled() is True
|
assert cancelled() is True
|
||||||
@@ -71,8 +103,8 @@ class TestCancelPoll:
|
|||||||
def test_a_cancel_arriving_later_is_seen_on_the_next_tick(self, monkeypatch):
|
def test_a_cancel_arriving_later_is_seen_on_the_next_tick(self, monkeypatch):
|
||||||
clock = {"t": 1000.0}
|
clock = {"t": 1000.0}
|
||||||
state = {"status": "running"}
|
state = {"status": "running"}
|
||||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: state["status"])
|
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: state["status"])
|
||||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||||
assert cancelled() is False
|
assert cancelled() is False
|
||||||
state["status"] = "paused"
|
state["status"] = "paused"
|
||||||
clock["t"] += 1.0 # the throttle must not delay a cancel by more than its interval
|
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
|
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
|
import app.state as state
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
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")
|
user = _user(db, "outage@example.com")
|
||||||
db.commit()
|
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")
|
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -240,7 +240,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
|||||||
user = _user(db, "lazy@example.com")
|
user = _user(db, "lazy@example.com")
|
||||||
db.commit()
|
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")
|
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["imdb_id"] == "tt0068646"
|
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")
|
user = _user(db, "ep@example.com")
|
||||||
db.commit()
|
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")
|
resp = _get(db, user, monkeypatch, "/api/plex/item/e1")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
@@ -298,7 +298,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
|||||||
def _boom(db_, rk):
|
def _boom(db_, rk):
|
||||||
raise RuntimeError("plex unavailable")
|
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")
|
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
body = resp.json()["show"]
|
body = resp.json()["show"]
|
||||||
@@ -322,7 +322,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
# _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")
|
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
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 import sysconfig
|
||||||
from app.models import PlexItem, PlexLibrary
|
from app.models import PlexItem, PlexLibrary
|
||||||
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
|
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:
|
class _StubPlex:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.models import (
|
|||||||
PlexState,
|
PlexState,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.routes.plex.browse import _content_libs
|
from app.routes.plex._shared import _content_libs
|
||||||
|
|
||||||
NAME = "Robert De Niro"
|
NAME = "Robert De Niro"
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ class TestRollup:
|
|||||||
|
|
||||||
|
|
||||||
def _fake_paginate(by_type):
|
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 list(by_type.get(typ, []))
|
||||||
return _p
|
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)."""
|
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from app.utils import iso
|
from app.utils import iso, normalize_description
|
||||||
|
|
||||||
|
|
||||||
def test_none_maps_to_none():
|
def test_none_maps_to_none():
|
||||||
@@ -28,3 +28,30 @@ def test_matches_datetime_isoformat_contract():
|
|||||||
# hand-roll `x.isoformat() if x else None`.
|
# hand-roll `x.isoformat() if x else None`.
|
||||||
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
|
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
|
||||||
assert iso(dt) == dt.isoformat()
|
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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
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 type { Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
type PanelLayout,
|
type PanelLayout,
|
||||||
} from "./lib/panelLayout";
|
} from "./lib/panelLayout";
|
||||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
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 { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
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
|
// 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
|
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
// 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.
|
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||||
Boolean(readAccount(LS.filterCollapsed, true)),
|
localKey: LS.filterCollapsed,
|
||||||
);
|
parse: Boolean,
|
||||||
|
});
|
||||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||||
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
localKey: LS.playlistsCollapsed,
|
||||||
);
|
parse: Boolean,
|
||||||
function setFilterCollapsed(next: boolean) {
|
});
|
||||||
setFilterCollapsedState(next);
|
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||||
writeAccount(LS.filterCollapsed, next);
|
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||||
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 queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
// 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);
|
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (meQuery.data) stripUrlParams();
|
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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
|
// 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";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||||
}
|
}
|
||||||
stripUrlParams();
|
clearParams("link");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||||
stripUrlParams();
|
clearParams("oauth");
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
// 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
|
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
// `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"));
|
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||||
@@ -210,12 +194,13 @@ export default function App() {
|
|||||||
focusAccessRequestsTab();
|
focusAccessRequestsTab();
|
||||||
setPage("users");
|
setPage("users");
|
||||||
}
|
}
|
||||||
stripUrlParams();
|
clearParams("admin");
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||||
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||||
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||||
|
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
@@ -227,14 +212,6 @@ export default function App() {
|
|||||||
saveLayoutLocal(p, l);
|
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;
|
const lang = prefs.language;
|
||||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
// (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,
|
offset: 6,
|
||||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||||
hoverDelay: { open: 0, close: 160 },
|
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({
|
const { data: ch } = useQuery({
|
||||||
@@ -51,7 +55,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
</span>
|
</span>
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={pop}
|
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. */}
|
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||||
{ch ? (
|
{ch ? (
|
||||||
|
|||||||
@@ -131,7 +131,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
|||||||
size({
|
size({
|
||||||
padding: 8,
|
padding: 8,
|
||||||
apply({ availableHeight, elements }) {
|
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.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
|
// 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).
|
// 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.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
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.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
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
|
// 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 { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
CalendarClock,
|
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
Copy,
|
|
||||||
Download,
|
Download,
|
||||||
Link2,
|
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -19,11 +16,9 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import Tabs from "./Tabs";
|
import Tabs from "./Tabs";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
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
|
// 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).
|
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
@@ -35,726 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
|||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
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";
|
import { inputCls } from "./ui/form";
|
||||||
|
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||||
const GiB = 1024 ** 3;
|
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||||
|
import { AdminSystem } from "./downloadAdmin";
|
||||||
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 -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export default function DownloadCenter() {
|
export default function DownloadCenter() {
|
||||||
const me = useMe();
|
const me = useMe();
|
||||||
|
|||||||
@@ -226,9 +226,11 @@ export default function Feed({
|
|||||||
}
|
}
|
||||||
}, [query.dataUpdatedAt]);
|
}, [query.dataUpdatedAt]);
|
||||||
|
|
||||||
const countQuery = useQuery({
|
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||||
queryKey: qk.feedCount(queryFilters),
|
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||||
queryFn: () => api.feedCount(queryFilters),
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: qk.feedSummary(queryFilters),
|
||||||
|
queryFn: () => api.feedSummary(queryFilters),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
enabled: !ytActive,
|
enabled: !ytActive,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
@@ -252,7 +254,11 @@ export default function Feed({
|
|||||||
api
|
api
|
||||||
.setState(id, status)
|
.setState(id, status)
|
||||||
.then(() => {
|
.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") {
|
if (status === "hidden") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
notify({
|
notify({
|
||||||
@@ -309,7 +315,8 @@ export default function Feed({
|
|||||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
setOverrides((o) => ({ ...o, [id]: "new" }));
|
||||||
api
|
api
|
||||||
.clearState(id)
|
.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(() =>
|
.catch(() =>
|
||||||
setOverrides((o) => {
|
setOverrides((o) => {
|
||||||
const next = { ...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.
|
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||||
const dropFeedCaches = () => {
|
const dropFeedCaches = () => {
|
||||||
qc.removeQueries({ queryKey: qk.feed() });
|
qc.removeQueries({ queryKey: qk.feed() });
|
||||||
qc.removeQueries({ queryKey: qk.feedCount() });
|
qc.removeQueries({ queryKey: qk.feedSummary() });
|
||||||
qc.removeQueries({ queryKey: qk.facets() });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Live YouTube search mode -------------------------------------------------------------
|
// --- 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 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">
|
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||||
{countQuery.data
|
{summaryQuery.data
|
||||||
? t("feed.videoCount", {
|
? t("feed.videoCount", {
|
||||||
count: countQuery.data.count,
|
count: summaryQuery.data.count,
|
||||||
formattedCount: countQuery.data.count.toLocaleString(),
|
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||||
})
|
})
|
||||||
: " "}
|
: " "}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
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 { 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
|
// 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`
|
// 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 }) {
|
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||||
const [initialHadUrlFilters] = useState(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||||
);
|
);
|
||||||
@@ -108,6 +110,14 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
|||||||
if (key) writeJSON(key, next);
|
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 /
|
// 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
|
// 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
|
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import {
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
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 { parseFeedView, type FeedView } from "../lib/feedView";
|
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
|
// 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.
|
// 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/>.
|
// 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)
|
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||||
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
|
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||||
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
|
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||||
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
|
|
||||||
|
|
||||||
interface FeedViewActions {
|
interface FeedViewActions {
|
||||||
setView: (v: FeedView) => void;
|
setView: (v: FeedView) => void;
|
||||||
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||||
const [view, setViewState] = useState<FeedView>(() =>
|
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||||
parseFeedView(readAccount(LS.feedView, null)),
|
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||||
);
|
localKey: LS.feedView,
|
||||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
parse: parseFeedView,
|
||||||
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]);
|
|
||||||
|
|
||||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
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)}
|
onClick={() => openChat(c.partner)}
|
||||||
title={t("messages.popOut")}
|
title={t("messages.popOut")}
|
||||||
aria-label={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" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</button>
|
</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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
|
|||||||
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
|
||||||
import { useFullscreenElement } from "../lib/fullscreen";
|
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
|
// 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
|
// 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
|
// 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.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
useBackToClose(onClose);
|
||||||
const fsRoot = useFullscreenElement();
|
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
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
const id = pushLayer();
|
const id = pushLayer();
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && isTopmost(id)) {
|
if (!isTopmost(id)) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onCloseRef.current();
|
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);
|
window.addEventListener("keydown", onKey);
|
||||||
const prev = document.body.style.overflow;
|
const prev = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
removeLayer(id);
|
removeLayer(id);
|
||||||
document.body.style.overflow = prev;
|
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -65,10 +117,17 @@ export default function Modal({
|
|||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
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">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("common.close")}
|
title={t("common.close")}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
@@ -52,6 +52,12 @@ export interface NavigationActions {
|
|||||||
function loadInitialPage(): Page {
|
function loadInitialPage(): Page {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (params.has("page")) return readPage();
|
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);
|
const stored = getAccountRaw(PAGE_KEY);
|
||||||
return isPage(stored) ? stored : "feed";
|
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;
|
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
|
// 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 [channelView, setChannelView] = useState<ChannelView | null>(() => {
|
||||||
const c = window.history.state?._chan as string | undefined;
|
const st = window.history.state;
|
||||||
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
|
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
|
// 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
|
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
|
// 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,
|
// (_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.
|
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
|
||||||
window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, "");
|
// 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(
|
const setPage = useCallback(
|
||||||
@@ -130,9 +147,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
setPageState(next);
|
setPageState(next);
|
||||||
setAccountRaw(PAGE_KEY, next);
|
setAccountRaw(PAGE_KEY, next);
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
// 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
|
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||||
// _yt/_chan markers) so each page starts at its root.
|
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||||
window.history.pushState({ sfPage: next }, "");
|
// (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],
|
[markDirty],
|
||||||
);
|
);
|
||||||
@@ -143,13 +166,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const openChannel = useCallback((id: string, name?: string) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
setChannelView({ id, name });
|
setChannelView({ id, name });
|
||||||
const st = window.history.state || {};
|
const st = window.history.state || {};
|
||||||
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
const url = urlWithParams({ chan: id });
|
||||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
|
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(() => {
|
const closeChannel = useCallback(() => {
|
||||||
if (window.history.state?._chan) window.history.back();
|
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) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
@@ -189,19 +219,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
document.title = label ? `${label} · ${brand}` : brand;
|
document.title = label ? `${label} · ${brand}` : brand;
|
||||||
}, [page, channelView, i18n.language, t]);
|
}, [page, channelView, i18n.language, t]);
|
||||||
|
|
||||||
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
|
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
|
||||||
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
|
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
|
||||||
// later query-string strip in App.)
|
// the popstate handler only needs to read history.state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
|
// 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
|
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
|
||||||
// are always kept so a channel page survives F5.
|
// are always kept so a channel page survives F5.
|
||||||
const st = window.history.state || {};
|
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) {
|
if (st._yt && st._ytScrape) {
|
||||||
window.history.replaceState({ ...st, sfPage: page }, "");
|
window.history.replaceState({ ...st, sfPage: page }, "", url);
|
||||||
} else {
|
} else {
|
||||||
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
|
||||||
window.history.replaceState({ ...rest, sfPage: page }, "");
|
window.history.replaceState({ ...rest, sfPage: page }, "", url);
|
||||||
}
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
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
|
// 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.
|
// restores or clears them to match the entry we landed on.
|
||||||
setYtSearch((e.state?._yt as string) ?? null);
|
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);
|
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function NotificationsPanel() {
|
|||||||
.setState(meta.videoId, "new")
|
.setState(meta.videoId, "new")
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
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).
|
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
||||||
resolveVideo(meta.videoId);
|
resolveVideo(meta.videoId);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Feed/channels/status now have content — refresh the app behind the wizard.
|
// Feed/channels/status now have content — refresh the app behind the wizard.
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
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 { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { usePref } from "../lib/usePref";
|
||||||
|
import {
|
||||||
|
AUTO_MODES,
|
||||||
|
LOOP_MODES,
|
||||||
|
PlayerTransportControls,
|
||||||
|
type AutoMode,
|
||||||
|
type LoopMode,
|
||||||
|
} from "./playerModalControls";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -15,12 +23,7 @@ import {
|
|||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Repeat,
|
|
||||||
Repeat1,
|
|
||||||
Settings,
|
Settings,
|
||||||
Shuffle,
|
|
||||||
SkipBack,
|
|
||||||
SkipForward,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
@@ -31,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
|||||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
|
import type { YTPlayer } from "../lib/youtube";
|
||||||
import {
|
import {
|
||||||
channelYouTubeUrl,
|
channelYouTubeUrl,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -40,15 +43,14 @@ import {
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../lib/format";
|
||||||
import { renderDescription } from "../lib/descriptionLinks";
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
|
import { fullscreenElement } from "../lib/fullscreen";
|
||||||
|
import { useBackToClose } from "../lib/history";
|
||||||
import {
|
import {
|
||||||
exitFullscreen,
|
useNativeControlsYield,
|
||||||
fullscreenElement,
|
useYtBigView,
|
||||||
onFullscreenChange,
|
useYtProgress,
|
||||||
requestFullscreen,
|
useYtVolume,
|
||||||
} from "../lib/fullscreen";
|
} from "../lib/playerModalHooks";
|
||||||
import { useBackToClose, useBackToExit } from "../lib/history";
|
|
||||||
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
|
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// 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
|
// 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).
|
// 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)?
|
/** 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
|
* 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
|
* 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;
|
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({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
startAt,
|
||||||
@@ -264,11 +133,6 @@ export default function PlayerModal({
|
|||||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const playerRef = useRef<YTPlayer | 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
|
// 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
|
// 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
|
// 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
|
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
|
||||||
// modal adjusts volume (not just the small player area).
|
// modal adjusts volume (not just the small player area).
|
||||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
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 focusModal = () => cardRef.current?.focus();
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
@@ -332,84 +150,28 @@ export default function PlayerModal({
|
|||||||
p.pauseVideo?.(); // 1 === playing
|
p.pauseVideo?.(); // 1 === playing
|
||||||
else p.playVideo?.();
|
else p.playVideo?.();
|
||||||
};
|
};
|
||||||
/** F = maximise, deliberately WITHOUT the Fullscreen API.
|
// 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).
|
||||||
* The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug):
|
const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({
|
||||||
* a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the
|
playerRef,
|
||||||
* browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player
|
dialogRef,
|
||||||
* never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a
|
focusModal,
|
||||||
* 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.
|
const { nativeControls, setNativeControls } = useNativeControlsYield({
|
||||||
*
|
playerRef,
|
||||||
* So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page
|
stageRef,
|
||||||
* stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does
|
focusModal,
|
||||||
* 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
|
const {
|
||||||
* browser's own chrome; YouTube's button provides that, correctly, when the user wants it.
|
isFullscreen,
|
||||||
*/
|
maximized,
|
||||||
const toggleFullscreen = () => setMaximized((m) => !m);
|
maximizedRef,
|
||||||
// True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps
|
isFullscreenRef,
|
||||||
// OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too.
|
toggleFullscreen,
|
||||||
// We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks
|
toggleTrueFullscreen,
|
||||||
// dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise.
|
exitBigView,
|
||||||
const toggleTrueFullscreen = () => {
|
setMaximized,
|
||||||
if (fullscreenElement()) exitFullscreen();
|
} = useYtBigView({ stageRef, playerRef, focusModal });
|
||||||
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 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// The player can navigate to other videos (YouTube links in a description). The
|
// The player can navigate to other videos (YouTube links in a description). The
|
||||||
// currently-playing id may differ from the active item.
|
// 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);
|
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
|
// Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on
|
||||||
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
|
// login) so they apply to every player and survive reloads. The player mounts after `me` is cached,
|
||||||
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
|
||||||
const savedPrefs = useMemo(
|
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
|
||||||
() =>
|
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
|
||||||
(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");
|
|
||||||
const modeRef = useRef({ autoMode, loopMode });
|
const modeRef = useRef({ autoMode, loopMode });
|
||||||
modeRef.current = { 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 cycleAuto = () => {
|
||||||
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
|
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
|
||||||
setAutoMode(next);
|
|
||||||
persistPref({ playerAutoAdvance: next });
|
|
||||||
};
|
};
|
||||||
const cycleLoop = () => {
|
const cycleLoop = () => {
|
||||||
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
|
setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!);
|
||||||
setLoopMode(next);
|
|
||||||
persistPref({ playerLoop: next });
|
|
||||||
};
|
};
|
||||||
const replayCurrent = () => {
|
const replayCurrent = () => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
@@ -656,229 +400,29 @@ export default function PlayerModal({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
document.body.style.overflow = prevOverflow;
|
document.body.style.overflow = prevOverflow;
|
||||||
window.clearTimeout(volTimerRef.current);
|
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||||
// 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();
|
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
|
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
|
||||||
// Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
|
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
|
||||||
// swallows wheel events over its own native-control strips, but the central interaction overlay
|
// the refs passed in. See lib/playerModalHooks.
|
||||||
// is our element (wheel there bubbles here), and everything outside the video works directly.
|
useYtProgress({
|
||||||
useEffect(() => {
|
activeId: active.id,
|
||||||
const el = dialogRef.current;
|
resumeAt,
|
||||||
if (!el) return;
|
mountRef,
|
||||||
const onWheel = (e: WheelEvent) => {
|
playerRef,
|
||||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel
|
currentIdRef,
|
||||||
// (and ↑/↓); taking the modifier too left zoom unreachable while the player was open.
|
navRef,
|
||||||
if (e.ctrlKey || e.metaKey) return;
|
modeRef,
|
||||||
e.preventDefault();
|
setWatched,
|
||||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
setPlayerError,
|
||||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
setLiveData,
|
||||||
};
|
reapplyStored,
|
||||||
el.addEventListener("wheel", onWheel, { passive: false });
|
focusModal,
|
||||||
return () => el.removeEventListener("wheel", onWheel);
|
advanceOnEnd,
|
||||||
}, []);
|
qc,
|
||||||
|
});
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
|
// 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
|
// 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 { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
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
|
// 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
|
// — 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 {
|
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);
|
const s = getAccountRaw(LS.playlist);
|
||||||
return s ? Number(s) : null;
|
return s ? Number(s) : null;
|
||||||
}
|
}
|
||||||
@@ -59,6 +67,12 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
|||||||
[markDirty],
|
[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 state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||||
const actions = useMemo<PlaylistsActions>(
|
const actions = useMemo<PlaylistsActions>(
|
||||||
() => ({ setSearch, setSelectedId }),
|
() => ({ setSearch, setSelectedId }),
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format";
|
|||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../lib/panelLayout";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SortControl from "./SortControl";
|
import SortControl from "./SortControl";
|
||||||
import { type SortOption } from "../lib/columnSort";
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
@@ -58,7 +58,6 @@ export default function PlaylistsRail({
|
|||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
@@ -260,17 +259,15 @@ export default function PlaylistsRail({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind="playlists"
|
||||||
title={t("playlists.title")}
|
title={t("playlists.title")}
|
||||||
icon={<ListVideo className="w-4 h-4" />}
|
icon={<ListVideo className="w-4 h-4" />}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
/>
|
||||||
>
|
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
||||||
</SidePanel>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({
|
|||||||
plexFilterCount: () => 0,
|
plexFilterCount: () => 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { CastStrip, PlexPersonView } from "./PlexBrowse";
|
import { CastStrip } from "./plexCards";
|
||||||
|
import { PlexPersonView } from "./plexViews";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { plexLang } from "../i18n";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
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 { useDebounced } from "../lib/useDebounced";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||||
@@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
const collFade = useScrollFade();
|
const collFade = useScrollFade();
|
||||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
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();
|
const qc = useQueryClient();
|
||||||
async function createPlaylist() {
|
async function createPlaylist() {
|
||||||
const title = newPlaylist.trim();
|
const title = newPlaylist.trim();
|
||||||
@@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("plex"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={
|
actions={
|
||||||
anyActive ? (
|
anyActive ? (
|
||||||
<button
|
<button
|
||||||
@@ -423,9 +426,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : 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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -19,10 +19,8 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
||||||
import { filtersToParams, shareUrl } from "../lib/urlState";
|
import { filtersToParams } from "../lib/urlState";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { copyText } from "../lib/clipboard";
|
|
||||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
@@ -131,14 +129,6 @@ export default function SavedViewsWidget({
|
|||||||
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
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) {
|
async function onDragEnd(e: DragEndEvent) {
|
||||||
const { active, over } = e;
|
const { active, over } = e;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
@@ -177,7 +167,6 @@ export default function SavedViewsWidget({
|
|||||||
onRename={(name) => rename(v.id, name)}
|
onRename={(name) => rename(v.id, name)}
|
||||||
onCancelRename={() => setRenameId(null)}
|
onCancelRename={() => setRenameId(null)}
|
||||||
onToggleDefault={() => toggleDefault(v)}
|
onToggleDefault={() => toggleDefault(v)}
|
||||||
onShare={() => share(v)}
|
|
||||||
onDelete={() => remove(v)}
|
onDelete={() => remove(v)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -239,7 +228,6 @@ function ViewRow({
|
|||||||
onRename,
|
onRename,
|
||||||
onCancelRename,
|
onCancelRename,
|
||||||
onToggleDefault,
|
onToggleDefault,
|
||||||
onShare,
|
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
view: SavedView;
|
view: SavedView;
|
||||||
@@ -250,7 +238,6 @@ function ViewRow({
|
|||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
onCancelRename: () => void;
|
onCancelRename: () => void;
|
||||||
onToggleDefault: () => void;
|
onToggleDefault: () => void;
|
||||||
onShare: () => void;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -334,29 +321,22 @@ function ViewRow({
|
|||||||
className={`p-1 rounded transition ${
|
className={`p-1 rounded transition ${
|
||||||
view.is_default
|
view.is_default
|
||||||
? "text-accent"
|
? "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" : ""}`} />
|
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onStartRename}
|
onClick={onStartRename}
|
||||||
title={t("views.rename")}
|
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" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title={t("views.delete")}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function JobRow({
|
|||||||
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
||||||
>
|
>
|
||||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
· {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>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import { type ReactNode, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
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 { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
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 { useDebounced } from "../lib/useDebounced";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
import SidePanel from "./SidePanel";
|
import FilterRail from "./FilterRail";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
import SegmentedToggle from "./SegmentedToggle";
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
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
|
// 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.
|
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
|
||||||
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
|
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
|
||||||
const facetsQuery = useQuery({
|
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
|
||||||
queryKey: qk.facets(facetFilters),
|
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
|
||||||
queryFn: () => api.facets(facetFilters),
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: qk.feedSummary(facetFilters),
|
||||||
|
queryFn: () => api.feedSummary(facetFilters),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const facetsReady = !!facetsQuery.data;
|
const facetsReady = !!summaryQuery.data;
|
||||||
const facetCounts = facetsQuery.data?.counts ?? {};
|
const facetCounts = summaryQuery.data?.facets ?? {};
|
||||||
const chipCount = (tag: Tag): number =>
|
const chipCount = (tag: Tag): number =>
|
||||||
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
|
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
|
||||||
const visibleChips = (list: Tag[]): Tag[] => {
|
const visibleChips = (list: Tag[]): Tag[] => {
|
||||||
@@ -109,7 +109,9 @@ export default function Sidebar({
|
|||||||
const userTags = tags.filter((t) => !t.system);
|
const userTags = tags.filter((t) => !t.system);
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
const [tagManagerOpen, setTagManagerOpen] = 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) {
|
function toggleTag(id: number) {
|
||||||
const has = filters.tags.includes(id);
|
const has = filters.tags.includes(id);
|
||||||
@@ -124,14 +126,6 @@ export default function Sidebar({
|
|||||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||||
const active = activeCount > 0;
|
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() {
|
function clearAll() {
|
||||||
setCustomDates(false);
|
setCustomDates(false);
|
||||||
setFilters(clearedFilters(filters));
|
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;
|
// 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).
|
// 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])
|
.order.filter((id) => available[id])
|
||||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||||
|
|
||||||
@@ -319,58 +313,49 @@ export default function Sidebar({
|
|||||||
>
|
>
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</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 (
|
return (
|
||||||
<SidePanel
|
<FilterRail
|
||||||
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
count={activeCount}
|
count={activeCount}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
layout={layout}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
setLayout={setLayout}
|
||||||
onReset={() => setLayout(defaultLayout("feed"))}
|
items={items}
|
||||||
editHint={t("sidebar.editHint")}
|
|
||||||
actions={actions}
|
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 && (
|
{tagManagerOpen && (
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
<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.
|
// rather than silently rendering a half-clipped control outside the card.
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex flex-wrap opacity-0 group-hover:opacity-100 transition",
|
"flex flex-wrap reveal transition",
|
||||||
dense ? "gap-0.5" : "gap-1",
|
dense ? "gap-0.5" : "gap-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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
|
// 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
|
// 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).
|
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||||
const GRID_GAP = 16;
|
const GRID_GAP = 16;
|
||||||
const LIST_GAP = 4;
|
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`). */
|
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||||
function columnsFor(minCol: number | null, width: number): number {
|
function columnsFor(minCol: number | null, width: number): number {
|
||||||
@@ -140,13 +138,17 @@ export default function VirtualGrid<T>({
|
|||||||
|
|
||||||
const virtualRows = virtualizer.getVirtualItems();
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
|
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
||||||
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!fetchNextPage) return;
|
if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||||
const last = virtualRows[virtualRows.length - 1];
|
const last = virtualRows[virtualRows.length - 1];
|
||||||
if (!last) return;
|
if (!last) return;
|
||||||
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, 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",
|
"reshuffle": "Reshuffle",
|
||||||
"noMatchingTags": "No matching tags here",
|
"noMatchingTags": "No matching tags here",
|
||||||
"manageTags": "Manage",
|
"manageTags": "Manage",
|
||||||
"shareView": "Copy a link to this view",
|
|
||||||
"shareCopied": "View link copied to clipboard",
|
|
||||||
"shareFailed": "Couldn't copy the link",
|
|
||||||
"widget": {
|
"widget": {
|
||||||
"savedviews": "Saved views",
|
"savedviews": "Saved views",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
"apply": "Apply this view",
|
"apply": "Apply this view",
|
||||||
"makeDefault": "Set as default (loads on start)",
|
"makeDefault": "Set as default (loads on start)",
|
||||||
"isDefault": "Default view — loads on start",
|
"isDefault": "Default view — loads on start",
|
||||||
"share": "Copy a link to this view",
|
|
||||||
"shareCopied": "View link copied",
|
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||||
|
|||||||
@@ -28,9 +28,6 @@
|
|||||||
"reshuffle": "Újrakeverés",
|
"reshuffle": "Újrakeverés",
|
||||||
"noMatchingTags": "Nincs ide illő címke",
|
"noMatchingTags": "Nincs ide illő címke",
|
||||||
"manageTags": "Kezelés",
|
"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": {
|
"widget": {
|
||||||
"savedviews": "Mentett nézetek",
|
"savedviews": "Mentett nézetek",
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
"apply": "Nézet alkalmazása",
|
"apply": "Nézet alkalmazása",
|
||||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||||
"isDefault": "Alapértelmezett nézet — 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",
|
"rename": "Átnevezés",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
"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);
|
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
|
/* 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
|
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.
|
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?account=${a}`
|
||||||
: `/api/downloads/${id}/storyboard.jpg`;
|
: `/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
|
// admin
|
||||||
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
|
|||||||
export const feedApi = {
|
export const feedApi = {
|
||||||
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
||||||
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
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 }> =>
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||||
req(`/api/feed/count?${filterParams(f).toString()}`),
|
req(`/api/feed/count?${filterParams(f).toString()}`),
|
||||||
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
|
// 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.
|
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||||
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||||
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
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> }> =>
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||||
req(`/api/facets?${filterParams(f).toString()}`),
|
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
|
// 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()).
|
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||||
setState: (id: string, status: string) =>
|
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.
|
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { urlWithParams } from "./urlState";
|
||||||
|
|
||||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
// --- 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;
|
view: T;
|
||||||
open: (next: T) => void;
|
open: (next: T) => void;
|
||||||
back: () => 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(() => {
|
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);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
return {
|
return {
|
||||||
view,
|
view,
|
||||||
open: (next: T) => {
|
open: (next: T) => {
|
||||||
setView(next);
|
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(),
|
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 { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { qk } from "./queryKeys";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import {
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
ArrowLeft,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
Play,
|
||||||
|
SlidersHorizontal,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useDismiss } from "./useDismiss";
|
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,
|
// 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
|
// 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.
|
// customize menu that toggles them.
|
||||||
|
|
||||||
export function useDetailPrefs() {
|
export function useDetailPrefs() {
|
||||||
const qc = useQueryClient();
|
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||||
{}) as {
|
// usePref seeds from the cache (no localStorage mirror).
|
||||||
plexInfoArtBg?: boolean;
|
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||||
plexInfoCast?: boolean;
|
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||||
plexInfoRelated?: boolean;
|
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||||
};
|
const savePref = useSavePref();
|
||||||
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,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
artBg,
|
artBg,
|
||||||
showCast,
|
showCast,
|
||||||
showRelated,
|
showRelated,
|
||||||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||||
toggleArtBg: () => {
|
toggleArtBg: () => setArtBg(!artBg),
|
||||||
const next = !artBg;
|
toggleCast: () => setShowCast(!showCast),
|
||||||
setArtBg(next);
|
toggleRelated: () => setShowRelated(!showRelated),
|
||||||
save({ plexInfoArtBg: next });
|
|
||||||
},
|
|
||||||
toggleCast: () => {
|
|
||||||
const next = !showCast;
|
|
||||||
setShowCast(next);
|
|
||||||
save({ plexInfoCast: next });
|
|
||||||
},
|
|
||||||
toggleRelated: () => {
|
|
||||||
const next = !showRelated;
|
|
||||||
setShowRelated(next);
|
|
||||||
save({ plexInfoRelated: next });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,3 +192,130 @@ export function PrefToggle({
|
|||||||
</button>
|
</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