Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc1f196a2c | ||
|
|
e3dac34bb0 | ||
|
|
938135bb59 | ||
|
|
7d2bee59a3 | ||
|
|
6af238c2ea | ||
|
|
b71b33654a | ||
|
|
5d99a5f253 | ||
|
|
c0d869089d | ||
|
|
15f5a5694a | ||
|
|
14a44bff6e | ||
|
|
37d11b602f | ||
|
|
da3cb5e694 | ||
|
|
1e5232f47a | ||
|
|
dddaeb9a38 | ||
|
|
70a74f8629 | ||
|
|
8f1309fa18 | ||
|
|
b7a4eaff8d | ||
|
|
a20e27800c | ||
|
|
fa7a09a420 | ||
|
|
f983a491c0 | ||
|
|
f85dc12819 | ||
|
|
9798e506cf | ||
|
|
a60164edc4 | ||
|
|
6c7038cf1f | ||
|
|
f8e144a658 | ||
|
|
b4984dc38b | ||
|
|
21c9b34540 | ||
|
|
041cd526b7 | ||
|
|
9477fea3dc | ||
|
|
e4b19b5320 | ||
|
|
7692253636 | ||
|
|
e90603d4db | ||
|
|
dd76e9dcf7 | ||
|
|
fca989c098 | ||
|
|
7ebbf45921 | ||
|
|
829cadf5fb | ||
|
|
8f08073564 | ||
|
|
af08a7850b | ||
|
|
d2193ccd1b | ||
|
|
cda70164ba | ||
|
|
5e1cc4d981 | ||
|
|
4ee452b854 | ||
|
|
edbc9dede5 | ||
|
|
53ca4e2849 | ||
|
|
1801a58f6b | ||
|
|
011acd919e | ||
|
|
45d8b47366 | ||
|
|
c3820a0b44 | ||
|
|
c7b393901f | ||
|
|
f1e14c94c5 | ||
|
|
ddb171fca3 | ||
|
|
ec3bbc78dc | ||
|
|
1be1dc9d06 | ||
|
|
6109e0c6f1 | ||
|
|
5851ddd329 | ||
|
|
ad0cf5827b | ||
|
|
f6192aa6bf | ||
|
|
6b4febcc95 | ||
|
|
cb5ac09a80 | ||
|
|
bad2aae753 | ||
|
|
8f6714e678 | ||
|
|
13fb2b4e61 | ||
|
|
5316f48af2 | ||
|
|
0c746f5bcd | ||
|
|
caced8664c | ||
|
|
29f2c9059e | ||
|
|
b83248c510 | ||
|
|
2b5de89eda | ||
|
|
ff45db70ae | ||
|
|
7d40a8780d | ||
|
|
d56a791c04 | ||
|
|
187b8007f8 | ||
|
|
f10915c223 | ||
|
|
f9b63745f9 | ||
|
|
5b5e76b73d | ||
|
|
91be3d0535 | ||
|
|
3bde114b14 | ||
|
|
fca3fa13ef | ||
|
|
df60bc5ff5 | ||
|
|
99d63d272e | ||
|
|
482dd28006 | ||
|
|
0b7180ce8f | ||
|
|
d2f2085f1f | ||
|
|
062015ba27 | ||
|
|
0401dae689 | ||
|
|
e8a1c252a9 | ||
|
|
fa8bdf83a8 | ||
|
|
868ff533ee | ||
|
|
00bf9a39f7 | ||
|
|
640a8efeed | ||
|
|
23ac884b42 | ||
|
|
1579a81395 | ||
|
|
4fe6f38f1b | ||
|
|
94a608053a | ||
|
|
aea99cc71a | ||
|
|
180ab56379 | ||
|
|
0b592345e6 | ||
|
|
4800df4208 | ||
|
|
8bfc01684f | ||
|
|
48aa1a25f5 | ||
|
|
c82cf25bcf | ||
|
|
634e9bb17c | ||
|
|
ce2954cf2b | ||
|
|
2229b01a91 | ||
|
|
b17b47f54e | ||
|
|
1e2b3c5d29 | ||
|
|
d065902404 | ||
|
|
a300977ed4 | ||
|
|
ddb7818d38 | ||
|
|
48787af11c | ||
|
|
b733ee84fa | ||
|
|
96413c3d5e | ||
|
|
d9677e0e3a | ||
|
|
2b804300d5 | ||
|
|
3f047bbad1 | ||
|
|
8a97c79d37 | ||
|
|
d37b6ef07f | ||
|
|
88c5d5c5cb | ||
|
|
cff8bed62b | ||
|
|
bc79720e81 | ||
|
|
b02a9e37e8 | ||
|
|
b2013a079a | ||
|
|
0fab0ce779 | ||
|
|
4ace486618 | ||
|
|
3334a8e895 | ||
|
|
e2362e1ed1 | ||
|
|
7d89bc8b8e | ||
|
|
f0a0214d97 | ||
|
|
e824fe0ae9 | ||
|
|
49011940d6 | ||
|
|
52b6535dc6 | ||
|
|
9bf7da2cbe | ||
|
|
3301976375 |
@@ -0,0 +1,47 @@
|
||||
"""Fold the maintenance re-validation batch from app_state into sysconfig (B-core-11)
|
||||
|
||||
The admin-tunable `maintenance_revalidate_batch` lived in its own `app_state` column with a
|
||||
hand-rolled override-or-default reader in `app.state`, duplicating the sysconfig SPECS pattern. It's
|
||||
now a sysconfig key (`system_config` store), so every admin knob lives in one place. This migration
|
||||
carries any existing override into the sysconfig store, then drops the dead column. Reversible.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0070_fold_maintenance_batch"
|
||||
down_revision: Union[str, None] = "0069_job_index_hygiene"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Preserve a customized override: copy it into the sysconfig store before the column goes.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO system_config (key, value, encrypted)
|
||||
SELECT 'maintenance_revalidate_batch', maintenance_revalidate_batch::text, false
|
||||
FROM app_state
|
||||
WHERE id = 1 AND maintenance_revalidate_batch IS NOT NULL
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
op.drop_column("app_state", "maintenance_revalidate_batch")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"app_state",
|
||||
sa.Column("maintenance_revalidate_batch", sa.Integer(), nullable=True),
|
||||
)
|
||||
# Carry the value back onto the row, then drop the sysconfig override (symmetric restore).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE app_state SET maintenance_revalidate_batch =
|
||||
(SELECT value::int FROM system_config WHERE key = 'maintenance_revalidate_batch')
|
||||
WHERE id = 1
|
||||
AND EXISTS (SELECT 1 FROM system_config WHERE key = 'maintenance_revalidate_batch')
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM system_config WHERE key = 'maintenance_revalidate_batch'")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""plex TMDB enrichment columns (E5 S2)
|
||||
|
||||
Additive TMDB-sourced fields on plex_items + plex_shows — keywords, full crew, budget/revenue, TMDb
|
||||
community score, production companies/countries, recommendations, and a backdrop-path pool — plus a
|
||||
`tmdb_id` cache and a `tmdb_enriched_at` watermark. Filled by the new TMDB enrichment job (matched by
|
||||
the stored canonical `guid`), SERVED FROM THE ROW (never a live TMDB call). Distinct from the existing
|
||||
Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people.tmdb_person_id` (the S2
|
||||
foundation for S6's person bios). ADD-only, all nullable → no table rewrite, no ACCESS EXCLUSIVE lock.
|
||||
No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0071_plex_tmdb_fields"
|
||||
down_revision: Union[str, None] = "0070_fold_maintenance_batch"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_TABLES = ("plex_items", "plex_shows")
|
||||
|
||||
# (name, type) — same set on both movie/leaf rows and show rows. budget/revenue stay NULL for shows.
|
||||
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
|
||||
("tmdb_id", sa.Integer()),
|
||||
("tmdb_enriched_at", sa.DateTime(timezone=True)),
|
||||
("tmdb_rating", sa.Float()),
|
||||
("tmdb_vote_count", sa.Integer()),
|
||||
("budget", sa.BigInteger()),
|
||||
("revenue", sa.BigInteger()),
|
||||
("keywords", postgresql.JSONB()),
|
||||
("crew", postgresql.JSONB()),
|
||||
("studios", postgresql.JSONB()),
|
||||
("production_countries", postgresql.JSONB()),
|
||||
("recommendations", postgresql.JSONB()),
|
||||
("backdrops", postgresql.JSONB()),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for t in _TABLES:
|
||||
for name, type_ in _COLS:
|
||||
op.add_column(t, sa.Column(name, type_, nullable=True))
|
||||
op.add_column("plex_people", sa.Column("tmdb_person_id", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("plex_people", "tmdb_person_id")
|
||||
for t in _TABLES:
|
||||
for name, _ in reversed(_COLS):
|
||||
op.drop_column(t, name)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""plex person TMDB bio columns (E5 S6)
|
||||
|
||||
Additive TMDB-sourced person fields on plex_people — biography, birth/death dates, place of birth,
|
||||
known-for department, also-known-as, and a `known_for` credit pool ([{tmdb_id, media_type, title}]
|
||||
resolved to library rows by guid at serve time) — plus a `tmdb_enriched_at` watermark. Filled the
|
||||
first time a person page is opened (from /person/{tmdb_person_id}), then SERVED FROM THE ROW. The
|
||||
`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no
|
||||
ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0072_plex_person_bio"
|
||||
down_revision: Union[str, None] = "0071_plex_tmdb_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = (
|
||||
("biography", sa.Text()),
|
||||
("birthday", sa.String(length=10)),
|
||||
("deathday", sa.String(length=10)),
|
||||
("place_of_birth", sa.Text()),
|
||||
("known_for_department", sa.String(length=64)),
|
||||
("also_known_as", postgresql.JSONB()),
|
||||
("known_for", postgresql.JSONB()),
|
||||
("tmdb_enriched_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name, type_ in _COLS:
|
||||
op.add_column("plex_people", sa.Column(name, type_, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name, _ in reversed(_COLS):
|
||||
op.drop_column("plex_people", name)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""saved_views.source (ask 4: per-module saved views)
|
||||
|
||||
Saved "smart views" were feed-only; the Plex module now has them too. Add a `source` discriminator
|
||||
(default "feed" so every existing row stays a feed view) and widen the per-user unique name constraint
|
||||
to (user_id, source, name) so a feed view and a Plex view can share a name. `source` is NOT NULL with a
|
||||
server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
|
||||
small per-user table.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0073_saved_view_source"
|
||||
down_revision: Union[str, None] = "0072_plex_person_bio"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"saved_views",
|
||||
sa.Column("source", sa.String(length=16), nullable=False, server_default="feed"),
|
||||
)
|
||||
op.drop_constraint("uq_user_saved_view", "saved_views", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_user_source_saved_view", "saved_views", ["user_id", "source", "name"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# LOSSY / effectively forward-only: upgrade lets a user hold a feed view and a plex view of the same
|
||||
# name, so re-creating the narrow (user_id, name) constraint can raise IntegrityError on such a pair.
|
||||
# De-dup by dropping the non-feed rows first (they can't exist in the pre-0073 schema anyway) so the
|
||||
# narrow constraint re-applies cleanly for the drift test's downgrade→re-upgrade cycle.
|
||||
op.execute("DELETE FROM saved_views WHERE source <> 'feed'")
|
||||
op.drop_constraint("uq_user_source_saved_view", "saved_views", type_="unique")
|
||||
op.create_unique_constraint("uq_user_saved_view", "saved_views", ["user_id", "name"])
|
||||
op.drop_column("saved_views", "source")
|
||||
@@ -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`.
|
||||
+15
-1
@@ -57,7 +57,7 @@ class Settings(BaseSettings):
|
||||
# VPS Caddy front door. A request from any other peer (someone hitting the app port directly)
|
||||
# is NOT trusted and its raw socket IP is used, so it can't spoof its rate-limit identity via
|
||||
# XFF. Empty = trust no proxy (use the direct peer) — correct for a directly-exposed / local-dev
|
||||
# deploy. Comma-separated. See auth._client_ip + the deploy docs.
|
||||
# deploy. Comma-separated. See routes.auth._client_ip + the deploy docs.
|
||||
trusted_proxy_ips: str = ""
|
||||
|
||||
# --- Outbound email (onboarding: access-request + approval notices) ---
|
||||
@@ -245,6 +245,20 @@ class Settings(BaseSettings):
|
||||
# local disk, so the default is fine there.
|
||||
plex_hls_dir: str = ""
|
||||
|
||||
# --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) ---
|
||||
# TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the
|
||||
# admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget /
|
||||
# revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the
|
||||
# stored canonical guid, and SERVED FROM THE DB — never a live TMDB call at serve time.
|
||||
tmdb_api_key: str = ""
|
||||
tmdb_api_base: str = "https://api.themoviedb.org/3"
|
||||
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
|
||||
tmdb_language: str = "en-US"
|
||||
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
|
||||
tmdb_enrich_interval_min: int = 120
|
||||
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
|
||||
tmdb_enrich_batch: int = 100
|
||||
|
||||
@property
|
||||
def allowed_email_set(self) -> set[str]:
|
||||
return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ class Base(DeclarativeBase):
|
||||
|
||||
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
||||
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
||||
# (see routes/plex.py), so this headroom is only for the many brief sessions, not long ones.
|
||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
|
||||
|
||||
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||
|
||||
def _set_job(job_id: int, **fields) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
return
|
||||
for k, v in fields.items():
|
||||
setattr(job, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _job_status(job_id: int) -> str | None:
|
||||
with SessionLocal() as db:
|
||||
return db.execute(
|
||||
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _touch_job(job_id: int) -> None:
|
||||
"""Bump a running job's `updated_at` without changing any value — a stall-watchdog heartbeat for
|
||||
phases that legitimately write no progress. Extraction writes nothing, and a multi-client-set
|
||||
fallback re-writes identical values (progress 0 / phase 'downloading'), so ORM `onupdate` would
|
||||
NOT fire and two slow extraction attempts could otherwise cross the stall timeout and be killed
|
||||
mid-extraction though they'd have succeeded."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("UPDATE download_jobs SET updated_at=now() WHERE id=:id AND status='running'"),
|
||||
{"id": job_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _requeue_if_running(job_id: int, phase: str) -> None:
|
||||
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
||||
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
||||
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
||||
requeue a no-op unless we still own a running job."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
||||
"WHERE id=:id AND status='running'"
|
||||
),
|
||||
{"id": job_id, "phase": phase},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _reset_asset_pending(asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset and asset.status == "downloading":
|
||||
asset.status = "pending"
|
||||
db.commit()
|
||||
|
||||
|
||||
def _staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
||||
|
||||
|
||||
def _edit_staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, formats, service, storage
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_job_status,
|
||||
_reset_asset_pending,
|
||||
_set_job,
|
||||
_staging_dir,
|
||||
_touch_job,
|
||||
)
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
log = logging.getLogger("siftlode.worker")
|
||||
|
||||
|
||||
class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
# opens a session and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so unthrottled they cost
|
||||
# ~10^5 queries on a long job. ONE interval, ONE implementation, used by both paths — three
|
||||
# hand-rolled copies is how the ffmpeg side ended up unthrottled while the download side was fine.
|
||||
_DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
# stream as a ready asset.
|
||||
_YTDLP_TEMP_RE = re.compile(
|
||||
r"\.part(-Frag\d+)?$" # in-flight download / fragment
|
||||
r"|\.ytdl$" # resume state
|
||||
r"|\.temp\.[^.]+$" # merge/postprocess temp
|
||||
r"|\.f\d+\.[^.]+$", # per-format stream kept before the merge
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _notify_download_done(
|
||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
||||
) -> None:
|
||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
if user_id is None:
|
||||
return
|
||||
try:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||
data={"title": title, "kind": kind},
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("failed to write download-done notification for job %s", job_id)
|
||||
|
||||
|
||||
def _parse_upload_date(raw) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
|
||||
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
|
||||
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset is None or asset.title:
|
||||
return
|
||||
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
|
||||
# (e.g. a title that was only a social engagement prefix); the completion path later sets
|
||||
# the final title with its own source_ref fallback.
|
||||
asset.title = normalize_title(info.get("title")) or info.get("id")
|
||||
asset.uploader = info.get("uploader") or info.get("channel")
|
||||
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
|
||||
asset.thumbnail_url = info.get("thumbnail")
|
||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _make_progress_hook(job_id: int, asset_id: int):
|
||||
state = {"meta_done": False}
|
||||
cancelled = _cancel_poll(job_id) # same policy as the ffmpeg path
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S)
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||
if cancelled():
|
||||
raise _Aborted
|
||||
if not state["meta_done"] and d.get("info_dict"):
|
||||
state["meta_done"] = True
|
||||
try:
|
||||
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||
pass
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
if not allow_write():
|
||||
return
|
||||
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||
# Label the phase so the user understands the bar resetting between streams instead of
|
||||
# seeing a mysterious 95%→5% jump.
|
||||
info = d.get("info_dict") or {}
|
||||
vcodec = info.get("vcodec") or "none"
|
||||
acodec = info.get("acodec") or "none"
|
||||
if vcodec != "none" and acodec == "none":
|
||||
phase = "video"
|
||||
elif acodec != "none" and vcodec == "none":
|
||||
phase = "audio"
|
||||
else:
|
||||
phase = "media"
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
done = d.get("downloaded_bytes") or 0
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
_set_job(
|
||||
job_id,
|
||||
progress=min(pct, 99),
|
||||
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _rate_limiter(interval_s: float, *, now=time.monotonic):
|
||||
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
|
||||
`now` is injectable so tests drive a clock instead of patching the global one."""
|
||||
# `None`, not 0.0: "never ran" has to be a VALUE, not a falsy timestamp. A truthiness test here
|
||||
# reads a legitimate `now() == 0.0` as "never ran" and lets a second call straight through —
|
||||
# unreachable with real `time.monotonic()`, but `now` exists to be injected, and a test clock
|
||||
# starting at 0 is the obvious choice. That would let a throttle test encode the wrong
|
||||
# behaviour and still pass.
|
||||
state: dict[str, float | None] = {"at": None}
|
||||
|
||||
def should_run() -> bool:
|
||||
t = now()
|
||||
last = state["at"]
|
||||
if last is not None and t - last < interval_s:
|
||||
return False
|
||||
state["at"] = t
|
||||
return True
|
||||
|
||||
return should_run
|
||||
|
||||
|
||||
def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, now=time.monotonic):
|
||||
"""Cooperative "has this job been paused/cancelled?" poll, rate-limited and sticky: once it
|
||||
says yes it never queries again. Shared by the yt-dlp hook and run_ffmpeg so cancel latency is
|
||||
one policy, not one per call site."""
|
||||
allow = _rate_limiter(interval_s, now=now)
|
||||
state = {"cancelled": False}
|
||||
|
||||
def cancelled() -> bool:
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
||||
"""`on_progress` for run_ffmpeg: persist a progress row write at most once per interval and only
|
||||
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
|
||||
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
|
||||
# Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the
|
||||
# round-1 concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat —
|
||||
# so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that
|
||||
# same interval, keeping active downloads at ~1 write/interval, not two.
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||
state = {"pct": -1}
|
||||
last_touch: dict = {"t": None} # monotonic time of the last DB write/heartbeat
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
if whole != state["pct"]:
|
||||
if allow_write():
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
last_touch["t"] = now()
|
||||
return
|
||||
# Same integer percent, but ffmpeg IS still working — a slow re-encode (AV1/VP9 -> H.264 or a
|
||||
# crop on the CPU-only host) can sit on one percent for minutes. Heartbeat updated_at so the
|
||||
# stall watchdog doesn't kill a live job (B-sub-4) — but only if no write/beat already touched
|
||||
# the row this interval, so a progressing download doesn't pay a redundant write.
|
||||
t = now()
|
||||
if last_touch["t"] is None or t - last_touch["t"] >= _DB_WRITE_INTERVAL_S:
|
||||
_touch_job(job_id)
|
||||
last_touch["t"] = t
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _pp_phase(pp: str) -> str:
|
||||
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||
pulse — this makes the long merge/finalize legible instead of a silent 'Processing'."""
|
||||
if "Merg" in pp:
|
||||
return "merging"
|
||||
if "ExtractAudio" in pp:
|
||||
return "audio_extract"
|
||||
if "Thumbnail" in pp:
|
||||
return "thumbnail"
|
||||
if "SponsorBlock" in pp or "ModifyChapters" in pp:
|
||||
return "sponsorblock"
|
||||
if "Metadata" in pp:
|
||||
return "metadata"
|
||||
return "processing"
|
||||
|
||||
|
||||
def _make_pp_hook(job_id: int):
|
||||
"""Postprocessor hook: surface the current ffmpeg step so the bar shows a concrete label
|
||||
(Merging / Embedding thumbnail / …) instead of freezing at 100% after the streams finish."""
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
if d.get("status") in ("started", "processing"):
|
||||
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||
for p in staging.iterdir():
|
||||
if p != media and p.suffix.lower() in _THUMB_SUFFIXES:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _produced_file(info: dict, staging: Path) -> Path:
|
||||
"""The media file yt-dlp actually wrote.
|
||||
|
||||
`requested_downloads[0].filepath` is the authoritative answer on YouTube, but an arbitrary-URL
|
||||
extractor may not fill it in at all — and `Path("")` is `Path(".")`, which *passes* `.exists()`,
|
||||
so a missing value used to sail past the fallback and blow up two steps later (`with_name` on an
|
||||
empty name: the BBC-news failure). Anything that isn't a real file counts as absent; then fall
|
||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
req = (info.get("requested_downloads") or [{}])[0]
|
||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||
if fp:
|
||||
produced = Path(str(fp))
|
||||
if produced.is_file():
|
||||
return produced
|
||||
cands = [
|
||||
p
|
||||
for p in staging.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix.lower() not in _THUMB_SUFFIXES
|
||||
and not _YTDLP_TEMP_RE.search(p.name)
|
||||
]
|
||||
if not cands:
|
||||
raise RuntimeError("yt-dlp produced no output file")
|
||||
return max(cands, key=lambda p: p.stat().st_size)
|
||||
|
||||
|
||||
def _media_info(info: dict) -> dict:
|
||||
"""Unwrap a playlist/multi_video extraction to the entry that carries the download.
|
||||
|
||||
A single non-YouTube page (a news article's video, a multi-rendition embed) can extract as a
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on an entry. Pick the
|
||||
FIRST entry that actually downloaded something: on a page with a skipped teaser in front of the
|
||||
real video, entries[0] carries no file, and taking it blindly would label the produced file
|
||||
with the wrong id/title/duration."""
|
||||
entries = [e for e in (info.get("entries") or []) if e]
|
||||
if not entries or info.get("requested_downloads"):
|
||||
return info
|
||||
chosen = next((e for e in entries if e.get("requested_downloads")), entries[0])
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
for i, clients in enumerate(client_sets):
|
||||
opts = formats.build_ydl_opts(
|
||||
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=True)
|
||||
except yt_dlp.utils.DownloadError as exc:
|
||||
last_exc = exc
|
||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
||||
for p in list(staging.iterdir()): # drop partials before the retry
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_set_job(job_id, progress=0, phase="downloading")
|
||||
continue
|
||||
raise
|
||||
raise last_exc # unreachable (loop either returns or raises)
|
||||
|
||||
|
||||
def _download_settings() -> tuple[str, int]:
|
||||
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
|
||||
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
|
||||
with SessionLocal() as db:
|
||||
return (
|
||||
sysconfig.get_str(db, "download_layout"),
|
||||
sysconfig.get_int(db, "download_retention_days"),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
|
||||
if spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
||||
if not needs:
|
||||
return produced
|
||||
|
||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
|
||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||
# re-encode still shows real progress instead of a frozen 0%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
editmod.run_ffmpeg(
|
||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
if reenc_v:
|
||||
info["vcodec"] = "h264"
|
||||
if reenc_a:
|
||||
info["acodec"] = "aac"
|
||||
return dest
|
||||
|
||||
|
||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||
staging = _staging_dir(asset_id)
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
|
||||
produced = _produced_file(info, staging)
|
||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||
ext = produced.suffix.lstrip(".").lower()
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(info.get("title")) or source_ref,
|
||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||
duration_s=int(info["duration"]) if info.get("duration") else None,
|
||||
description=info.get("description"),
|
||||
thumbnail_url=info.get("thumbnail"),
|
||||
)
|
||||
|
||||
layout, retention_days = _download_settings()
|
||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||
thumb = _find_thumbnail(staging, produced)
|
||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
||||
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.duration_s = meta.duration_s
|
||||
asset.width = int(info["width"]) if info.get("width") else None
|
||||
asset.height = int(info["height"]) if info.get("height") else None
|
||||
asset.container = ext
|
||||
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
asset.upload_date = meta.upload_date
|
||||
asset.thumbnail_url = meta.thumbnail_url
|
||||
# Record a self-hosted poster for every download: it's the reliable link-preview
|
||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
asset.nfo_written = nfo_ok
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||
"eta_s": None}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
fields["display_name"] = meta.title[:255]
|
||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||
log.info("job %s done: %s", job_id, rel)
|
||||
|
||||
except (_Aborted, editmod.EditAborted):
|
||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("job %s failed: %s", job_id, msg)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running (see the download error path).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("edit job %s failed: %s", job_id, msg)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
src = db.get(MediaAsset, job.source_asset_id) if job and job.source_asset_id else None
|
||||
edit_spec = dict(job.edit_spec or {}) if job else {}
|
||||
user_id = job.user_id if job else None
|
||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||
src_rel = src.rel_path if src else None
|
||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
return
|
||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
||||
if not src_path.exists():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||
owner_id = job.user_id if job else None
|
||||
dl_title = asset.title if asset else None
|
||||
# Atomic terminal write (see the download path): don't resurrect a route-stopped job.
|
||||
finalized = service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||
)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||
log.info("edit job %s done: %s", job_id, rel)
|
||||
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
@@ -38,8 +38,8 @@ def main() -> None:
|
||||
return
|
||||
|
||||
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
|
||||
# real work, and to keep the import graph obvious: runner is a thin entry over worker's helpers.
|
||||
from app.worker import _download, _process_edit
|
||||
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
|
||||
from app.downloads.pipeline import _download, _process_edit
|
||||
|
||||
if job_kind == "edit":
|
||||
_process_edit(job_id, asset_id)
|
||||
|
||||
@@ -112,6 +112,34 @@ def next_queue_pos(db: Session) -> int:
|
||||
return (db.execute(select(func.coalesce(func.max(DownloadJob.queue_pos), 0))).scalar() or 0) + 1
|
||||
|
||||
|
||||
def incr_ref_count(db: Session, asset_id: int) -> None:
|
||||
"""Atomically +1 an asset's hold count AT THE DB. A Python read-modify-write
|
||||
(`asset.ref_count = asset.ref_count + 1`) loses an update when two enqueues of the same
|
||||
(source,format) run in parallel — both read the same value and write the same +1 — leaving the
|
||||
count one short, so the first release can free a file another job still holds (B-sub-5)."""
|
||||
db.execute(
|
||||
update(MediaAsset)
|
||||
.where(MediaAsset.id == asset_id)
|
||||
.values(ref_count=MediaAsset.ref_count + 1)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
|
||||
def decr_ref_count(db: Session, asset_id: int) -> int:
|
||||
"""Atomically -1 an asset's hold count (clamped at 0) and RETURN the new value. The caller decides
|
||||
whether to free the asset from the returned count while still holding the row's write lock, so two
|
||||
concurrent releases can't both observe the same pre-decrement value and double-free — Postgres
|
||||
serialises the two UPDATEs on the row lock (B-sub-5). synchronize_session=False: the ORM copy is
|
||||
intentionally left stale; callers read the returned value, not `asset.ref_count`."""
|
||||
return db.execute(
|
||||
update(MediaAsset)
|
||||
.where(MediaAsset.id == asset_id)
|
||||
.values(ref_count=func.greatest(MediaAsset.ref_count - 1, 0))
|
||||
.returning(MediaAsset.ref_count)
|
||||
.execution_options(synchronize_session=False)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def enqueue(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -143,7 +171,7 @@ def enqueue(
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
# Cache hit: the file already exists — no worker round-trip needed.
|
||||
if asset.status == "ready":
|
||||
@@ -212,7 +240,7 @@ def enqueue_edit(
|
||||
queue_pos=next_queue_pos(db),
|
||||
)
|
||||
db.add(job)
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
incr_ref_count(db, asset.id)
|
||||
|
||||
if asset.status == "ready": # identical clip already produced for this user → instant
|
||||
job.status = "done"
|
||||
@@ -273,8 +301,83 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset is None:
|
||||
return
|
||||
asset.ref_count = max(0, (asset.ref_count or 0) - 1)
|
||||
if asset.ref_count == 0 and asset.status == "ready":
|
||||
new_count = decr_ref_count(db, asset.id)
|
||||
if new_count == 0 and asset.status == "ready":
|
||||
if asset.rel_path:
|
||||
storage.delete_asset_files(settings.download_root, asset.rel_path)
|
||||
db.delete(asset)
|
||||
|
||||
|
||||
# --- Job state transitions ---------------------------------------------------------------------
|
||||
# The single home for the pause/resume/cancel/delete/redownload transitions + their asset
|
||||
# bookkeeping (release, failed-asset reset, fresh-asset rebuild), so the routes are thin callers and
|
||||
# the worker/GC could share the same definitions (B-sub-8). Each commits its own transition; the
|
||||
# route keeps only auth + HTTP mapping + serialization.
|
||||
|
||||
|
||||
def pause(db: Session, job: DownloadJob) -> None:
|
||||
"""Pause a queued/running job (a running worker aborts cooperatively via the cancel poll); a no-op
|
||||
in any other state."""
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.RUNNING):
|
||||
job.status = JobStatus.PAUSED
|
||||
db.commit()
|
||||
|
||||
|
||||
def resume(db: Session, job: DownloadJob) -> None:
|
||||
"""Requeue a paused/errored job for a fresh attempt, clearing its error. If the SHARED asset itself
|
||||
failed, reset it to pending so the worker actually re-downloads — otherwise the requeued job would
|
||||
instantly re-inherit the asset's stale error (the worker short-circuits an errored asset)."""
|
||||
if job.status in (JobStatus.PAUSED, JobStatus.ERROR):
|
||||
job.progress = 0
|
||||
job.error = None
|
||||
job.error_code = None
|
||||
if job.asset_id:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset and asset.status == "error": # MediaAsset.status (no enum), not a JobStatus
|
||||
asset.status = "pending"
|
||||
asset.error = None
|
||||
job.status = JobStatus.QUEUED
|
||||
db.commit()
|
||||
|
||||
|
||||
def cancel(db: Session, job: DownloadJob) -> None:
|
||||
"""Cancel a job unless it's already done/canceled, releasing its asset hold WHILE it still counts
|
||||
as holding — `canceled` ∉ HOLDING, so a later delete won't double-release."""
|
||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||
release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = JobStatus.CANCELED
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete(db: Session, job: DownloadJob) -> None:
|
||||
"""Delete a job, releasing its asset hold first (which frees the file iff this was the last holder)."""
|
||||
release_asset(db, job)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
|
||||
|
||||
def redownload(db: Session, job: DownloadJob) -> None:
|
||||
"""Rebuild a finished/failed job's file into a FRESH, private asset (the cache sig is salted with
|
||||
the job id + current asset id, so it never reuses the dedup pool) and requeue the SAME job row —
|
||||
so any public share link (DownloadLink -> job_id) keeps resolving to the newly-downloaded file.
|
||||
The old hold is dropped first (freeing the old file iff this job was its last holder). The caller
|
||||
has already rejected edit clips + still-in-progress jobs (HTTP 409)."""
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
||||
asset = get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
||||
populate_from_catalog(db, asset)
|
||||
# Drop the old hold BEFORE re-pointing the job — release_asset keys off the job's current asset.
|
||||
release_asset(db, job)
|
||||
incr_ref_count(db, asset.id)
|
||||
job.asset_id = asset.id
|
||||
job.status = JobStatus.QUEUED
|
||||
job.progress = 0
|
||||
job.phase = None
|
||||
job.error = None
|
||||
job.speed_bps = None
|
||||
job.eta_s = None
|
||||
# Re-derive the display name from the freshly cleaned title on completion (else an older, un-cleaned
|
||||
# name would survive the rebuild).
|
||||
job.display_name = None
|
||||
job.queue_pos = next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||
db.commit()
|
||||
|
||||
+11
-10
@@ -117,7 +117,7 @@ def filtered_query(
|
||||
user: User,
|
||||
*,
|
||||
tags: list[int],
|
||||
tag_mode: str,
|
||||
tag_modes: dict[str, str],
|
||||
channel_ids: list[str],
|
||||
q: str | None,
|
||||
min_duration: int | None,
|
||||
@@ -293,9 +293,10 @@ def filtered_query(
|
||||
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
||||
|
||||
if tags:
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
||||
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
||||
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
|
||||
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
|
||||
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
|
||||
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||
# test_feed_summary_matches_count_plus_facets).
|
||||
by_category = tags_by_category(db, tags)
|
||||
# Facet counting drops the category being counted so its own chips don't zero each
|
||||
@@ -305,7 +306,7 @@ def filtered_query(
|
||||
by_category.pop(exclude_tag_category, None)
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
for category, ids in by_category.items():
|
||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||
sub = (
|
||||
select(ChannelTag.channel_id)
|
||||
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
(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")
|
||||
within, a category in "and" mode requires ALL its selected tags)."""
|
||||
tag_modes = params.get("tag_modes", {})
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
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
|
||||
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
|
||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
|
||||
return False
|
||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||
return False
|
||||
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
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
|
||||
exclude = None if tag_modes.get(category, "or") == "and" else category
|
||||
for cid, ch_tags in channel_tags.items():
|
||||
if not passes(ch_tags, exclude):
|
||||
continue
|
||||
|
||||
+56
-8
@@ -547,9 +547,6 @@ class AppState(Base):
|
||||
sync_paused: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
# Admin override for the maintenance job's rolling re-validation batch (videos re-checked
|
||||
# per run). NULL = use the config/env default (settings.maintenance_revalidate_batch).
|
||||
maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer)
|
||||
# First-run install wizard. `configured` is False on a fresh instance (the app runs in
|
||||
# setup mode: only the wizard works) and flipped True when the wizard finishes. While
|
||||
# unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard;
|
||||
@@ -649,19 +646,23 @@ class PlaylistItem(Base):
|
||||
|
||||
|
||||
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
|
||||
"""A per-user named snapshot of a module's filter/sort state (a "smart view").
|
||||
|
||||
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
|
||||
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
|
||||
applied automatically on load — at most one per user, enforced by the API on set."""
|
||||
`source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
|
||||
serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
|
||||
(drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
|
||||
one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
|
||||
|
||||
__tablename__ = "saved_views"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
filters: Mapped[dict] = mapped_column(JSON)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
@@ -1070,6 +1071,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive;
|
||||
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
|
||||
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
@@ -1187,6 +1202,24 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link
|
||||
tagline: Mapped[str | None] = mapped_column(Text)
|
||||
full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}]
|
||||
# --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
|
||||
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
|
||||
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
|
||||
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
|
||||
# unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). ---
|
||||
tmdb_id: Mapped[int | None] = mapped_column(Integer)
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10)
|
||||
tmdb_vote_count: Mapped[int | None] = mapped_column(Integer)
|
||||
budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none)
|
||||
revenue: Mapped[int | None] = mapped_column(BigInteger)
|
||||
keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}]
|
||||
studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str]
|
||||
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
# [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time.
|
||||
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB backdrop file_paths [str] (crossfade pool, S8)
|
||||
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
|
||||
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
|
||||
title_hu: Mapped[str | None] = mapped_column(String(512))
|
||||
@@ -1227,6 +1260,21 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
||||
# TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person
|
||||
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person.
|
||||
tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person
|
||||
# page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
|
||||
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
|
||||
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
|
||||
biography: Mapped[str | None] = mapped_column(Text)
|
||||
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
|
||||
deathday: Mapped[str | None] = mapped_column(String(10))
|
||||
place_of_birth: Mapped[str | None] = mapped_column(Text)
|
||||
known_for_department: Mapped[str | None] = mapped_column(String(64))
|
||||
also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
|
||||
known_for: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{tmdb_id, media_type, title}]
|
||||
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||
|
||||
@@ -6,14 +6,32 @@ router. Pure functions — no DB, no request state."""
|
||||
from app.models import PlexItem, PlexShow, PlexState
|
||||
|
||||
|
||||
# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item
|
||||
# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an
|
||||
# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that
|
||||
# tried to use it, so gate it on the stored key and let the client fall back to the poster.
|
||||
def _art_url(rating_key: str, art_key: str | None) -> str | None:
|
||||
return f"/api/plex/image/{rating_key}?variant=art" if art_key else None
|
||||
|
||||
|
||||
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
|
||||
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve
|
||||
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
|
||||
def _card_summary(summary: str | None) -> str | None:
|
||||
return (summary or "")[:300] or None
|
||||
|
||||
|
||||
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"type": "movie",
|
||||
"title": it.title,
|
||||
"year": it.year,
|
||||
"rating": it.rating,
|
||||
"summary": _card_summary(it.summary),
|
||||
"duration_seconds": it.duration_s,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": _art_url(it.rating_key, it.art_key),
|
||||
"playable": it.playable,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
@@ -26,7 +44,10 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
|
||||
"type": "show",
|
||||
"title": sh.title,
|
||||
"year": sh.year,
|
||||
"rating": sh.rating,
|
||||
"summary": _card_summary(sh.summary),
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"art": _art_url(sh.rating_key, sh.art_key),
|
||||
"season_count": sh.child_count,
|
||||
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||
"status": status,
|
||||
|
||||
+129
-5
@@ -6,7 +6,7 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, func, or_, type_coerce
|
||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
@@ -29,6 +29,11 @@ def title_col(model, lang: str):
|
||||
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
|
||||
|
||||
|
||||
def summary_col(model, lang: str):
|
||||
"""DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this."""
|
||||
return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary
|
||||
|
||||
|
||||
def genres_col(model, lang: str):
|
||||
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
|
||||
if lang == "hu":
|
||||
@@ -36,6 +41,58 @@ def genres_col(model, lang: str):
|
||||
return model.genres
|
||||
|
||||
|
||||
def top_art_keys(
|
||||
db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=()
|
||||
) -> dict[str, list[str]]:
|
||||
"""For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a
|
||||
collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as
|
||||
{key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top.
|
||||
The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned."""
|
||||
if not lib_ids or not keys:
|
||||
return {}
|
||||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||||
expanded = (
|
||||
select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k)
|
||||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds)
|
||||
.subquery()
|
||||
)
|
||||
ranked = select(
|
||||
expanded.c.rk,
|
||||
expanded.c.k,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=expanded.c.k,
|
||||
order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()),
|
||||
)
|
||||
.label("rn"),
|
||||
).subquery()
|
||||
out: dict[str, list[str]] = {}
|
||||
for rk, key in db.execute(
|
||||
select(ranked.c.rk, ranked.c.k)
|
||||
.where(ranked.c.k.in_(keys), ranked.c.rn <= per)
|
||||
.order_by(ranked.c.k, ranked.c.rn)
|
||||
).all():
|
||||
out.setdefault(key, []).append(rk)
|
||||
return out
|
||||
|
||||
|
||||
def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]:
|
||||
"""Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the
|
||||
JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime."""
|
||||
if not lib_ids or not keys:
|
||||
return {}
|
||||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||||
exp = (
|
||||
select(func.coalesce(model.duration_s, 0).label("d"), k)
|
||||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array")
|
||||
.subquery()
|
||||
)
|
||||
rows = db.execute(
|
||||
select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k)
|
||||
).all()
|
||||
return {key: int(total or 0) for key, total in rows}
|
||||
|
||||
|
||||
def vector_col(model, lang: str):
|
||||
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
|
||||
# (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering.
|
||||
@@ -135,8 +192,12 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
|
||||
stds = split_csv(p.get("studios"))
|
||||
if stds: # OR
|
||||
conds.append(model.studio.in_(stds))
|
||||
if p.get("collection"):
|
||||
conds.append(model.collection_keys.contains([p["collection"]]))
|
||||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
||||
conds.append(model.keywords.contains([kw]))
|
||||
csel = split_csv(p.get("collections"))
|
||||
if csel: # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
|
||||
cc = [model.collection_keys.contains([c]) for c in csel]
|
||||
conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc))
|
||||
return conds
|
||||
|
||||
|
||||
@@ -166,7 +227,9 @@ class LibraryFilters:
|
||||
"any" # how multiple actors combine: any (OR, default) | all (AND)
|
||||
)
|
||||
studios: str | None = None
|
||||
collection: str | None = None
|
||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||
collections: str | None = None # CSV of collection rating_keys
|
||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
@@ -183,9 +246,70 @@ class LibraryFilters:
|
||||
"actors": self.actors,
|
||||
"actor_mode": self.actor_mode,
|
||||
"studios": self.studios,
|
||||
"collection": self.collection,
|
||||
"keywords": self.keywords,
|
||||
"collections": self.collections,
|
||||
"collection_mode": self.collection_mode,
|
||||
}
|
||||
|
||||
|
||||
def like_escape(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
||||
#
|
||||
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
||||
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
|
||||
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
|
||||
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
|
||||
# home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_
|
||||
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
|
||||
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
|
||||
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
|
||||
|
||||
|
||||
def movie_status_case(st):
|
||||
"""The per-movie status label (new | in_progress | watched) from its PlexState alias `st`."""
|
||||
return case(
|
||||
(st.status == "watched", "watched"),
|
||||
(
|
||||
and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")),
|
||||
"in_progress",
|
||||
),
|
||||
else_="new",
|
||||
)
|
||||
|
||||
|
||||
def show_status_case(tot, wat, inp):
|
||||
"""The per-show status label rolled up from its episodes' totals (total/watched/in-progress)."""
|
||||
return case(
|
||||
(and_(tot > 0, wat >= tot), "watched"),
|
||||
(or_(wat > 0, inp > 0), "in_progress"),
|
||||
else_="new",
|
||||
)
|
||||
|
||||
|
||||
def apply_movie_state_filter(query, st, show: str):
|
||||
"""Restrict a movie query to the `show` scope via its PlexState alias `st`. The default (`all`)
|
||||
excludes hidden movies — the exclusion that must match between grid and facets."""
|
||||
if show == "watched":
|
||||
return query.where(st.status == "watched")
|
||||
if show == "in_progress":
|
||||
return query.where(
|
||||
st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")
|
||||
)
|
||||
if show == "unwatched":
|
||||
return query.where(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||
return query.where(or_(st.status.is_(None), st.status != "hidden"))
|
||||
|
||||
|
||||
def apply_show_state_filter(query, tot, wat, inp, show: str):
|
||||
"""Restrict a show query to the `show` scope via its rollup totals. `all` is a no-op (shows have
|
||||
no hidden concept); the caller only joins the rollup for the three non-`all` scopes."""
|
||||
if show == "watched":
|
||||
return query.where(tot > 0, wat >= tot)
|
||||
if show == "in_progress":
|
||||
return query.where(or_(wat > 0, inp > 0), wat < tot)
|
||||
if show == "unwatched":
|
||||
return query.where(wat == 0, inp == 0)
|
||||
return query
|
||||
|
||||
+41
-18
@@ -42,27 +42,32 @@ def rollup(cards: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def agg_subquery(db: Session, user_id: int):
|
||||
def agg_subquery(db: Session, user_id: int, include_resumed: bool = False):
|
||||
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
||||
grid by aggregate watch-state and (b) compute each card's badge."""
|
||||
grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover
|
||||
continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the
|
||||
show's episodes, so the rail can order shows by how recently they were last progressed."""
|
||||
ps = aliased(PlexState)
|
||||
return (
|
||||
select(
|
||||
PlexItem.show_id.label("show_id"),
|
||||
func.count(PlexItem.id).label("total"),
|
||||
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||
func.count(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
ps.position_seconds > 0,
|
||||
or_(ps.status.is_(None), ps.status != "watched"),
|
||||
),
|
||||
1,
|
||||
)
|
||||
cols = [
|
||||
PlexItem.show_id.label("show_id"),
|
||||
func.count(PlexItem.id).label("total"),
|
||||
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||
func.count(
|
||||
case(
|
||||
(
|
||||
and_(
|
||||
ps.position_seconds > 0,
|
||||
or_(ps.status.is_(None), ps.status != "watched"),
|
||||
),
|
||||
1,
|
||||
)
|
||||
).label("inprog"),
|
||||
)
|
||||
)
|
||||
).label("inprog"),
|
||||
]
|
||||
if include_resumed:
|
||||
cols.append(func.max(ps.progress_updated_at).label("resumed_at"))
|
||||
return (
|
||||
select(*cols)
|
||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||
.group_by(PlexItem.show_id)
|
||||
@@ -76,3 +81,21 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||
agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids))
|
||||
).all()
|
||||
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||
|
||||
|
||||
def item_state_map(
|
||||
db: Session, user_id: int, item_ids: list[int]
|
||||
) -> dict[int, PlexState]:
|
||||
"""A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the
|
||||
leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched
|
||||
fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`)."""
|
||||
if not item_ids:
|
||||
return {}
|
||||
return {
|
||||
s.item_id: s
|
||||
for s in db.execute(
|
||||
select(PlexState).where(
|
||||
PlexState.user_id == user_id, PlexState.item_id.in_(item_ids)
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ Playback is from the local physical file, so this only mirrors METADATA + the ph
|
||||
(no ffprobe needed) to classify each item's browser playability. Movies + episodes become playable
|
||||
LEAF rows (plex_items); shows/seasons are mirrored for the drill-down + episode ordering.
|
||||
|
||||
The sync is a full reconcile of the enabled sections (upsert by rating_key). Pruning of items that
|
||||
disappeared from Plex is intentionally left for later (a follow-up can diff rating_keys per library).
|
||||
Cast + intro/credit markers are fetched lazily on the item-detail/player path (P2), not here.
|
||||
The sync is a full reconcile of the enabled sections (upsert by rating_key) AND prunes rows for
|
||||
titles that disappeared from Plex: each section diffs the rating_keys it just listed against the ones
|
||||
stored, and deletes the stale ones (their watch-state / playlist links cascade). The prune is guarded
|
||||
on a NON-empty listing — a section that returns zero items is left untouched, so a transient Plex
|
||||
glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental
|
||||
mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the
|
||||
dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the
|
||||
item-detail/player path (P2), not here.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
@@ -150,7 +155,7 @@ def sync(db: Session) -> dict:
|
||||
return {"skipped": "disabled"}
|
||||
stats = {
|
||||
"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0,
|
||||
"collections": 0, "overlaid": 0,
|
||||
"collections": 0, "overlaid": 0, "pruned": 0,
|
||||
}
|
||||
wanted = _enabled_section_keys(db)
|
||||
overlay_map = _overlay_map(db) # {huKey: enKey}
|
||||
@@ -279,8 +284,14 @@ def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary
|
||||
return lib
|
||||
|
||||
|
||||
def _paginate(plex: PlexClient, key: str, item_type: int):
|
||||
def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | None = None):
|
||||
"""Yield a section's items page by page. If `progress` is given, set `progress["complete"]` True
|
||||
ONLY when the walk reached the server's reported `total` — a truncated fetch (an empty/short page
|
||||
mid-sequence, e.g. a transient Plex hiccup, before `total` is reached) leaves it False so the
|
||||
caller can skip pruning. Pruning on a partial listing would delete rows the listing merely failed
|
||||
to mention (the metadata is re-syncable, but that still cascades watch-state)."""
|
||||
start = 0
|
||||
total = None
|
||||
while True:
|
||||
items, total = plex.section_items(key, item_type, start, _PAGE)
|
||||
if not items:
|
||||
@@ -292,6 +303,10 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
|
||||
break
|
||||
if len(items) < _PAGE:
|
||||
break
|
||||
if progress is not None:
|
||||
# A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows
|
||||
# (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative.
|
||||
progress["complete"] = bool(total) and start >= total
|
||||
|
||||
|
||||
# The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this
|
||||
@@ -351,6 +366,24 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||
row.playable = classify_playable(mf.get("codec_video"), mf.get("codec_audio"), mf.get("container"))
|
||||
|
||||
|
||||
def _prune_stale(
|
||||
db: Session, existing: dict, seen: set[str], stats: dict, *, complete: bool
|
||||
) -> None:
|
||||
"""Delete stored rows whose rating_key is no longer in the freshly-listed `seen` set — the titles
|
||||
that disappeared from Plex (their watch-state / playlist links cascade via the FKs). GUARDED on a
|
||||
COMPLETE, non-empty listing: a truncated fetch (`complete` False — a mid-sequence empty/short page
|
||||
before the server's total) or a zero-item listing prunes NOTHING, so a transient Plex hiccup can
|
||||
never wipe a library over rows it merely failed to list. Callers pass the rating_key -> row map
|
||||
they already built for the upsert."""
|
||||
if not complete or not seen:
|
||||
return
|
||||
for rk, row in existing.items():
|
||||
if rk not in seen:
|
||||
db.delete(row)
|
||||
stats["pruned"] = stats.get("pruned", 0) + 1
|
||||
db.flush()
|
||||
|
||||
|
||||
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
existing = {
|
||||
r.rating_key: r
|
||||
@@ -358,7 +391,9 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
||||
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
|
||||
seen: set[str] = set()
|
||||
progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE, progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
@@ -366,8 +401,10 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
_apply_item(row, lib.id, "movie", meta)
|
||||
seen.add(rk)
|
||||
stats["movies"] += 1
|
||||
db.flush()
|
||||
_prune_stale(db, existing, seen, stats, complete=progress.get("complete", False))
|
||||
|
||||
|
||||
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||
@@ -378,10 +415,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexShow).where(PlexShow.library_id == lib.id)
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
|
||||
shows_seen: set[str] = set()
|
||||
shows_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW, shows_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
shows_seen.add(rk)
|
||||
sh = shows.get(rk) or PlexShow(rating_key=rk)
|
||||
if sh.id is None:
|
||||
db.add(sh)
|
||||
@@ -407,11 +447,14 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id)
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
|
||||
seasons_seen: set[str] = set()
|
||||
seasons_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON, seasons_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
||||
if not rk or sid is None:
|
||||
continue
|
||||
seasons_seen.add(rk)
|
||||
se = seasons.get(rk) or PlexSeason(rating_key=rk)
|
||||
if se.id is None:
|
||||
db.add(se)
|
||||
@@ -431,10 +474,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode")
|
||||
).scalars()
|
||||
}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
|
||||
episodes_seen: set[str] = set()
|
||||
episodes_progress: dict = {}
|
||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE, episodes_progress):
|
||||
rk = str(meta.get("ratingKey") or "")
|
||||
if not rk:
|
||||
continue
|
||||
episodes_seen.add(rk)
|
||||
row = episodes.get(rk) or PlexItem(rating_key=rk)
|
||||
if row.id is None:
|
||||
db.add(row)
|
||||
@@ -446,6 +492,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||
stats["episodes"] += 1
|
||||
db.flush()
|
||||
|
||||
# Prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly, before its
|
||||
# parent's cascade would reach it — avoids deleting the same ORM object twice. Each level is
|
||||
# independently guarded on a non-empty listing by _prune_stale.
|
||||
_prune_stale(db, episodes, episodes_seen, stats, complete=episodes_progress.get("complete", False))
|
||||
_prune_stale(db, seasons, seasons_seen, stats, complete=seasons_progress.get("complete", False))
|
||||
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
|
||||
|
||||
|
||||
def _sync_overlay(
|
||||
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
"""TMDB enrichment for the mirrored Plex catalog (E5 S2).
|
||||
|
||||
A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items /
|
||||
plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find).
|
||||
Server-side ONLY (the api_key from sysconfig). Everything fetched is PERSISTED and served from the DB
|
||||
row — there is never a live TMDB call at serve time (the user's explicit rule). Additive: the existing
|
||||
Plex-sourced `imdb_rating` / `rating` are left untouched; `tmdb_rating` is the distinct TMDb score.
|
||||
|
||||
One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `apply_tmdb_detail` writes
|
||||
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.models import PlexItem, PlexPerson, PlexShow
|
||||
|
||||
log = logging.getLogger("siftlode.tmdb")
|
||||
|
||||
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
|
||||
_MIN_INTERVAL = 0.03
|
||||
|
||||
# The crew jobs worth surfacing on an info page, in DISPLAY-priority order (the stored crew is sorted by
|
||||
# this — TMDB's own crew order is arbitrary and buries the lead roles). MOVIE and TV differ on purpose:
|
||||
# on a TV show `Director`/`Editor`/`Director of Photography` are PER-EPISODE credits (dozens of names
|
||||
# that would flood the list and bury the showrunner), so TV keys on `Creator` (from the payload's
|
||||
# top-level `created_by`, not the crew list) + the show-level producers/writers instead.
|
||||
_MOVIE_CREW_JOBS = (
|
||||
"Director",
|
||||
"Writer",
|
||||
"Screenplay",
|
||||
"Story",
|
||||
"Producer",
|
||||
"Executive Producer",
|
||||
"Editor",
|
||||
"Director of Photography",
|
||||
"Original Music Composer",
|
||||
)
|
||||
_TV_CREW_JOBS = ("Creator", "Executive Producer", "Writer", "Producer", "Original Music Composer")
|
||||
_MOVIE_PRIORITY = {job: i for i, job in enumerate(_MOVIE_CREW_JOBS)}
|
||||
_TV_PRIORITY = {job: i for i, job in enumerate(_TV_CREW_JOBS)}
|
||||
|
||||
# append_to_response bundles the sub-resources into ONE request per title (keywords + credits +
|
||||
# recommendations + images + external ids). TV uses aggregate_credits (per-person aggregated jobs).
|
||||
_MOVIE_APPEND = "keywords,credits,recommendations,images,external_ids"
|
||||
_TV_APPEND = "keywords,aggregate_credits,recommendations,images,external_ids"
|
||||
|
||||
|
||||
class TmdbError(Exception):
|
||||
"""Any transient failure talking to TMDB (network, non-404 HTTP, bad payload). The caller leaves
|
||||
the row's `tmdb_enriched_at` NULL so the next run retries."""
|
||||
|
||||
|
||||
class TmdbNotConfigured(TmdbError):
|
||||
"""No TMDB API key is set — the module can't do anything."""
|
||||
|
||||
|
||||
class TmdbClient:
|
||||
"""Bound to the configured api_key + language. Use as a context manager so the httpx client closes."""
|
||||
|
||||
def __init__(self, db: Session, timeout: float = 30.0):
|
||||
self.key = sysconfig.get_str(db, "tmdb_api_key")
|
||||
if not self.key:
|
||||
raise TmdbNotConfigured("TMDB API key is not configured")
|
||||
self.base = settings.tmdb_api_base.rstrip("/")
|
||||
self.language = sysconfig.get_str(db, "tmdb_language") or settings.tmdb_language
|
||||
# `timeout` is caller-tunable so the request-path lazy person enrich can cap how long it may
|
||||
# block a worker thread (the batch job keeps the generous default).
|
||||
self._http = httpx.Client(timeout=timeout)
|
||||
self._last = 0.0
|
||||
|
||||
def __enter__(self) -> "TmdbClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._http.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def _throttle(self) -> None:
|
||||
wait = self._last + _MIN_INTERVAL - time.monotonic()
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
self._last = time.monotonic()
|
||||
|
||||
def _get(self, path: str, params: dict | None = None) -> dict | None:
|
||||
"""GET a TMDB path. Returns the JSON dict, None on a 404 (a definitive "no such id"), and raises
|
||||
TmdbError on any transient failure (network / other HTTP status / non-JSON)."""
|
||||
self._throttle()
|
||||
p = {"api_key": self.key, "language": self.language, **(params or {})}
|
||||
try:
|
||||
r = self._http.get(f"{self.base}{path}", params=p)
|
||||
except httpx.HTTPError as e:
|
||||
# NEVER put str(e)/the URL in the message: the request URL carries `?api_key=<secret>` in
|
||||
# its query string, and this message is logged (enrich_batch). Report the type + path only.
|
||||
raise TmdbError(f"TMDB request error on {path}: {type(e).__name__}") from None
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Same reason: the HTTPStatusError string embeds the full URL incl. the api_key. Sever the
|
||||
# cause (`from None`) so no traceback can resurface it, and log only status + path.
|
||||
raise TmdbError(f"TMDB {path} returned {e.response.status_code}") from None
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError as e:
|
||||
raise TmdbError("TMDB returned a non-JSON response") from e
|
||||
|
||||
def fetch(self, tmdb_id: int, media_type: str) -> dict | None:
|
||||
"""Full enrichment payload for a movie or show (one request via append_to_response)."""
|
||||
if media_type == "tv":
|
||||
return self._get(f"/tv/{tmdb_id}", {"append_to_response": _TV_APPEND})
|
||||
return self._get(f"/movie/{tmdb_id}", {"append_to_response": _MOVIE_APPEND})
|
||||
|
||||
def find(self, external_id: str, source: str) -> dict | None:
|
||||
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
|
||||
return self._get(f"/find/{external_id}", {"external_source": source})
|
||||
|
||||
def fetch_person(self, person_id: int) -> dict | None:
|
||||
"""One person's bio + combined credits (E5 S6), in a single append_to_response request."""
|
||||
return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"})
|
||||
|
||||
|
||||
def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None:
|
||||
"""Map a stored canonical guid to a TMDB id. `tmdb:` is direct; `imdb:`/`tvdb:` go through /find
|
||||
(picking the movie_results or tv_results list per `media_type`). Returns None for an unmatchable
|
||||
guid — the caller then stamps the watermark so it isn't retried every run."""
|
||||
if not guid or ":" not in guid:
|
||||
return None
|
||||
provider, _, ext = guid.partition(":")
|
||||
if provider == "tmdb":
|
||||
try:
|
||||
return int(ext)
|
||||
except ValueError:
|
||||
return None
|
||||
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
|
||||
if not source:
|
||||
return None
|
||||
res = tmdb.find(ext, source)
|
||||
if not res:
|
||||
return None
|
||||
items = res.get("tv_results" if media_type == "tv" else "movie_results") or []
|
||||
return items[0].get("id") if items else None
|
||||
|
||||
|
||||
def _dedup_crew(crew: list[dict]) -> list[dict]:
|
||||
seen: set[tuple] = set()
|
||||
out: list[dict] = []
|
||||
for c in crew:
|
||||
key = (c["name"], c["job"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def parse_tmdb(payload: dict, media_type: str) -> dict:
|
||||
"""Pure parser: pull the enrichment fields out of a TMDB movie/tv payload (append_to_response form).
|
||||
Best-effort — any missing sub-resource is just omitted. Returns the row fields plus a `people` list
|
||||
([{name, tmdb_id}] over cast+crew) for the plex_people id-linking. TMDB image fields are stored as
|
||||
bare paths ("/abc.jpg"); the client builds the image.tmdb.org URL."""
|
||||
is_tv = media_type == "tv"
|
||||
votes = payload.get("vote_count") or 0
|
||||
|
||||
kw_container = payload.get("keywords") or {}
|
||||
kw_list = kw_container.get("results") if is_tv else kw_container.get("keywords")
|
||||
keywords = [k.get("name") for k in (kw_list or []) if k.get("name")][:20]
|
||||
|
||||
priority = _TV_PRIORITY if is_tv else _MOVIE_PRIORITY
|
||||
credits = payload.get("aggregate_credits" if is_tv else "credits") or {}
|
||||
crew: list[dict] = []
|
||||
# A TV show's creators live at the top level (created_by), not in the crew list — surface them.
|
||||
if is_tv:
|
||||
for c in payload.get("created_by") or []:
|
||||
if c.get("name"):
|
||||
crew.append(
|
||||
{"name": c["name"], "job": "Creator", "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
|
||||
)
|
||||
for c in credits.get("crew") or []:
|
||||
# Movie crew entries carry `job`; TV aggregate_credits carry a `jobs` array per person.
|
||||
job = (c.get("jobs") or [{}])[0].get("job") if is_tv else c.get("job")
|
||||
if job in priority and c.get("name"):
|
||||
crew.append(
|
||||
{"name": c["name"], "job": job, "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
|
||||
)
|
||||
# Dedup by (name, job) — a person can hold two distinct key roles (Coppola: Director + Writer) — then
|
||||
# order by display priority so the lead roles come first, and cap.
|
||||
crew = _dedup_crew(crew)
|
||||
crew.sort(key=lambda c: priority.get(c["job"], 99))
|
||||
crew = crew[:12]
|
||||
|
||||
# name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids).
|
||||
people: list[dict] = []
|
||||
for c in (credits.get("cast") or [])[:30]:
|
||||
if c.get("name") and c.get("id"):
|
||||
people.append({"name": c["name"], "tmdb_id": c["id"]})
|
||||
for c in crew:
|
||||
if c.get("tmdb_id"):
|
||||
people.append({"name": c["name"], "tmdb_id": c["tmdb_id"]})
|
||||
|
||||
recs: list[dict] = []
|
||||
for r in ((payload.get("recommendations") or {}).get("results") or [])[:20]:
|
||||
recs.append(
|
||||
{
|
||||
"tmdb_id": r.get("id"),
|
||||
"title": r.get("title") or r.get("name"),
|
||||
"poster_path": r.get("poster_path"),
|
||||
"media_type": r.get("media_type") or media_type,
|
||||
}
|
||||
)
|
||||
|
||||
backdrops = [
|
||||
b.get("file_path")
|
||||
for b in ((payload.get("images") or {}).get("backdrops") or [])
|
||||
if b.get("file_path")
|
||||
][:10]
|
||||
|
||||
return {
|
||||
"tmdb_id": payload.get("id"),
|
||||
# vote_average is meaningless with no votes (TMDB returns 0.0) → treat as unknown.
|
||||
"tmdb_rating": payload.get("vote_average") if votes else None,
|
||||
"tmdb_vote_count": votes or None,
|
||||
# 0 is TMDB's "unknown" for money fields; TV has neither.
|
||||
"budget": (payload.get("budget") or None) if not is_tv else None,
|
||||
"revenue": (payload.get("revenue") or None) if not is_tv else None,
|
||||
"keywords": keywords or None,
|
||||
"crew": crew or None,
|
||||
"studios": [c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")][:8]
|
||||
or None,
|
||||
"production_countries": [
|
||||
c.get("name") for c in (payload.get("production_countries") or []) if c.get("name")
|
||||
]
|
||||
or None,
|
||||
"recommendations": recs or None,
|
||||
"backdrops": backdrops or None,
|
||||
"people": people,
|
||||
}
|
||||
|
||||
|
||||
def apply_tmdb_detail(row, parsed: dict) -> None:
|
||||
"""Persist the parsed TMDB fields onto a movie/show row. Does NOT touch full_cast / imdb_rating /
|
||||
rating (Plex-owned). The caller stamps `tmdb_enriched_at` and links people separately."""
|
||||
row.tmdb_id = parsed["tmdb_id"]
|
||||
row.tmdb_rating = parsed["tmdb_rating"]
|
||||
row.tmdb_vote_count = parsed["tmdb_vote_count"]
|
||||
row.budget = parsed["budget"]
|
||||
row.revenue = parsed["revenue"]
|
||||
row.keywords = parsed["keywords"]
|
||||
row.crew = parsed["crew"]
|
||||
row.studios = parsed["studios"]
|
||||
row.production_countries = parsed["production_countries"]
|
||||
row.recommendations = parsed["recommendations"]
|
||||
row.backdrops = parsed["backdrops"]
|
||||
|
||||
|
||||
def _link_people(db: Session, links: dict[str, int]) -> None:
|
||||
"""Upsert plex_people by name, stamping `tmdb_person_id` — keeping any id already set (first wins),
|
||||
filling it where NULL, inserting a bare row where the person isn't cached yet. Deduped by name so a
|
||||
single statement can't hit the conflict target twice (the same rule as people.enrich_row_people)."""
|
||||
values = [{"name": nm, "tmdb_person_id": pid} for nm, pid in links.items() if nm and pid]
|
||||
if not values:
|
||||
return
|
||||
stmt = pg_insert(PlexPerson).values(values)
|
||||
db.execute(
|
||||
stmt.on_conflict_do_update(
|
||||
index_elements=[PlexPerson.name],
|
||||
set_={"tmdb_person_id": func.coalesce(PlexPerson.tmdb_person_id, stmt.excluded.tmdb_person_id)},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
|
||||
"""Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the remainder —
|
||||
mirrors _enrich_people_batch). A transient TmdbError leaves the watermark NULL for retry; a
|
||||
definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched
|
||||
every run. One commit at the end. Returns the number processed (matched OR definitively unmatched)."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.guid.is_not(None),
|
||||
PlexItem.tmdb_enriched_at.is_(None),
|
||||
)
|
||||
.order_by(PlexItem.id)
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
shows: list[PlexShow] = []
|
||||
if len(movies) < limit:
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.guid.is_not(None), PlexShow.tmdb_enriched_at.is_(None))
|
||||
.order_by(PlexShow.id)
|
||||
.limit(limit - len(movies))
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
processed = 0
|
||||
links: dict[str, int] = {}
|
||||
for row, media_type in [(m, "movie") for m in movies] + [(s, "tv") for s in shows]:
|
||||
try:
|
||||
tmdb_id = guid_to_tmdb_id(row.guid, media_type, tmdb)
|
||||
payload = tmdb.fetch(tmdb_id, media_type) if tmdb_id else None
|
||||
except TmdbError as e:
|
||||
log.warning("tmdb enrich %s failed (will retry): %s", row.rating_key, e)
|
||||
continue # transient — leave tmdb_enriched_at NULL for the next run
|
||||
if payload:
|
||||
parsed = parse_tmdb(payload, media_type)
|
||||
apply_tmdb_detail(row, parsed)
|
||||
for p in parsed["people"]:
|
||||
links.setdefault(p["name"], p["tmdb_id"])
|
||||
# Stamp even on a definitive no-match so an unresolvable title isn't re-fetched every run.
|
||||
row.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
processed += 1
|
||||
|
||||
if links:
|
||||
_link_people(db, links)
|
||||
db.commit()
|
||||
return processed
|
||||
|
||||
|
||||
def run_enrich(db: Session) -> dict:
|
||||
"""Scheduler entry point. No-op (never an error) when the TMDB key isn't configured."""
|
||||
if not sysconfig.get_str(db, "tmdb_api_key"):
|
||||
return {"enriched": 0, "skipped": "tmdb_api_key not set"}
|
||||
with TmdbClient(db) as tmdb:
|
||||
n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch)
|
||||
return {"enriched": n}
|
||||
|
||||
|
||||
def resolve_library_guids(
|
||||
db: Session,
|
||||
entries: list[dict] | None,
|
||||
*,
|
||||
movie_lib_ids: list[int] | None = None,
|
||||
show_lib_ids: list[int] | None = None,
|
||||
) -> tuple[dict[str, PlexItem], dict[str, PlexShow]]:
|
||||
"""Match a list of TMDB credits/recs (each `{tmdb_id, media_type, …}`) to our library rows by the
|
||||
canonical guid `tmdb:<id>`, returning `(movies_by_guid, shows_by_guid)` of the ORM rows.
|
||||
|
||||
⭐ TMDB movie ids and tv ids are INDEPENDENT sequences (id 603 can be BOTH a movie and a show), so
|
||||
each entry matches ONLY its own media_type's table — a movie credit against a library movie, a tv
|
||||
credit against a library show; a blind numeric match would resolve to an unrelated same-id title.
|
||||
Pass `movie_lib_ids`/`show_lib_ids` to scope to the enabled libraries (the person page); leave them
|
||||
None to match across all libraries (the detail-page recs). An explicit empty list means "that side
|
||||
has no enabled library" → its bucket is skipped entirely."""
|
||||
if not entries:
|
||||
return {}, {}
|
||||
movie_guids = [
|
||||
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") != "tv"
|
||||
]
|
||||
tv_guids = [
|
||||
f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") == "tv"
|
||||
]
|
||||
movies: dict[str, PlexItem] = {}
|
||||
if movie_guids and movie_lib_ids != []:
|
||||
conds = [PlexItem.kind == "movie", PlexItem.guid.in_(movie_guids)]
|
||||
if movie_lib_ids is not None:
|
||||
conds.append(PlexItem.library_id.in_(movie_lib_ids))
|
||||
for m in db.execute(select(PlexItem).where(*conds)).scalars():
|
||||
movies.setdefault(m.guid, m)
|
||||
shows: dict[str, PlexShow] = {}
|
||||
if tv_guids and show_lib_ids != []:
|
||||
conds = [PlexShow.guid.in_(tv_guids)]
|
||||
if show_lib_ids is not None:
|
||||
conds.append(PlexShow.library_id.in_(show_lib_ids))
|
||||
for s in db.execute(select(PlexShow).where(*conds)).scalars():
|
||||
shows.setdefault(s.guid, s)
|
||||
return movies, shows
|
||||
|
||||
|
||||
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
|
||||
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed
|
||||
by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link
|
||||
the ones we have and merely display the rest."""
|
||||
if not recs:
|
||||
return []
|
||||
movies, shows = resolve_library_guids(db, recs)
|
||||
out: list[dict] = []
|
||||
for r in recs:
|
||||
g = f"tmdb:{r.get('tmdb_id')}"
|
||||
row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g)
|
||||
out.append(
|
||||
{**r, "in_library": row is not None, "rating_key": row.rating_key if row else None}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def serve_block(db: Session, row) -> dict | None:
|
||||
"""The TMDB extras for a movie/show detail response, served from the row (no live TMDB call).
|
||||
None until the row has been TMDB-enriched (the client then shows nothing extra)."""
|
||||
if row.tmdb_enriched_at is None:
|
||||
return None
|
||||
return {
|
||||
"tmdb_id": row.tmdb_id,
|
||||
"rating": row.tmdb_rating,
|
||||
"vote_count": row.tmdb_vote_count,
|
||||
"budget": row.budget,
|
||||
"revenue": row.revenue,
|
||||
"keywords": row.keywords or [],
|
||||
"crew": row.crew or [],
|
||||
"studios": row.studios or [],
|
||||
"production_countries": row.production_countries or [],
|
||||
"recommendations": resolve_recs(db, row.recommendations),
|
||||
"backdrops": row.backdrops or [],
|
||||
}
|
||||
|
||||
|
||||
# --- Person enrichment (E5 S6) -------------------------------------------------------------------
|
||||
# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open)
|
||||
# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person
|
||||
# endpoint resolves them to library rows by guid at serve time (like recommendations).
|
||||
|
||||
_KNOWN_FOR_MAX = 15
|
||||
|
||||
|
||||
def parse_person(payload: dict) -> dict:
|
||||
"""TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's
|
||||
most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later."""
|
||||
credits = (payload.get("combined_credits") or {}).get("cast") or []
|
||||
# Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences
|
||||
# (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair
|
||||
# is two distinct credits and both must survive.
|
||||
seen: set[tuple[int, str]] = set()
|
||||
known: list[dict] = []
|
||||
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True):
|
||||
tid, mt = c.get("id"), c.get("media_type")
|
||||
if not tid or mt not in ("movie", "tv") or (tid, mt) in seen:
|
||||
continue
|
||||
seen.add((tid, mt))
|
||||
known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")})
|
||||
if len(known) >= _KNOWN_FOR_MAX:
|
||||
break
|
||||
return {
|
||||
"biography": (payload.get("biography") or "").strip() or None,
|
||||
"birthday": payload.get("birthday") or None,
|
||||
"deathday": payload.get("deathday") or None,
|
||||
"place_of_birth": payload.get("place_of_birth") or None,
|
||||
"known_for_department": payload.get("known_for_department") or None,
|
||||
"also_known_as": payload.get("also_known_as") or None,
|
||||
"known_for": known or None,
|
||||
}
|
||||
|
||||
|
||||
def apply_person(row: PlexPerson, parsed: dict) -> None:
|
||||
row.biography = parsed["biography"]
|
||||
row.birthday = parsed["birthday"]
|
||||
row.deathday = parsed["deathday"]
|
||||
row.place_of_birth = parsed["place_of_birth"]
|
||||
row.known_for_department = parsed["known_for_department"]
|
||||
row.also_known_as = parsed["also_known_as"]
|
||||
row.known_for = parsed["known_for"]
|
||||
row.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def enrich_person(db: Session, person: PlexPerson) -> bool:
|
||||
"""Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it
|
||||
survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient
|
||||
error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the
|
||||
watermark with empty fields so we don't refetch it every open."""
|
||||
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
|
||||
return False
|
||||
try:
|
||||
# Called from a background task (off the request path — see people._enrich_person_bg), so it no
|
||||
# longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB
|
||||
# doesn't tie up a threadpool worker for the default 30s.
|
||||
with TmdbClient(db, timeout=8.0) as tmdb:
|
||||
payload = tmdb.fetch_person(person.tmdb_person_id)
|
||||
except TmdbError:
|
||||
return False
|
||||
if payload is None:
|
||||
person.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
apply_person(person, parse_person(payload))
|
||||
db.commit()
|
||||
return True
|
||||
+53
-33
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -187,6 +188,36 @@ def _oauth_redirect_uri(request: Request) -> str:
|
||||
return settings.oauth_redirect_url
|
||||
|
||||
|
||||
|
||||
# --- Request bodies (typed contracts; B-core-6) ---------------------------------------------
|
||||
# Fields are `str | None` (not required) so the handlers keep their exact prior behaviour — a
|
||||
# missing/null field reads as None and the existing `(... or "")` coercions still apply — while
|
||||
# FastAPI now validates the body is a JSON object and publishes the schema.
|
||||
|
||||
|
||||
class EmailIn(BaseModel):
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class TokenPasswordIn(BaseModel):
|
||||
token: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class SetPasswordIn(BaseModel):
|
||||
password: str | None = None
|
||||
current_password: str | None = None
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
@@ -448,7 +479,7 @@ async def logout(request: Request):
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -462,7 +493,7 @@ async def request_access(
|
||||
is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google.
|
||||
Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the
|
||||
endpoint can't be probed for throttling either."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not _request_access_limiter.allow(_client_ip(request)):
|
||||
@@ -477,7 +508,7 @@ async def request_access(
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
|
||||
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
|
||||
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
|
||||
@@ -485,7 +516,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||
if not _demo_limiter.allow(_client_ip(request)):
|
||||
return {"authenticated": False}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email) and is_demo_allowed(db, email):
|
||||
demo = get_or_create_demo_user(db)
|
||||
# Isolate the demo: drop any real-account wallet on this tab first, so the demo session
|
||||
@@ -556,7 +587,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
|
||||
@router.post("/register")
|
||||
def register(
|
||||
payload: dict,
|
||||
body: EmailPasswordIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -565,8 +596,8 @@ def register(
|
||||
verification link, and records an access request for admin approval. Two gates before
|
||||
sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
|
||||
the endpoint can't be used to discover which emails are registered."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
# Input validation is safe to reveal (independent of whether the email exists).
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
@@ -628,10 +659,10 @@ def _register_account(email: str, password: str) -> None:
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||
on any invalid/expired/used token."""
|
||||
user = _consume_token(db, payload.get("token"), "verify")
|
||||
user = _consume_token(db, body.token, "verify")
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This verification link is invalid or has expired."
|
||||
@@ -643,7 +674,7 @@ def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
|
||||
@router.post("/verify/resend")
|
||||
def verify_email_resend(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> dict:
|
||||
@@ -653,7 +684,7 @@ def verify_email_resend(
|
||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _verify_resend_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
if valid_email(email):
|
||||
background.add_task(_resend_verify_if_eligible, email)
|
||||
return {"status": "ok"}
|
||||
@@ -688,7 +719,7 @@ def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/password-login")
|
||||
def password_login(
|
||||
payload: dict,
|
||||
body: EmailPasswordIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -700,8 +731,8 @@ def password_login(
|
||||
raise HTTPException(
|
||||
status_code=429, detail="Too many attempts. Try again shortly."
|
||||
)
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
password = payload.get("password") or ""
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
||||
# response time is the same whether or not the email exists — no timing-based enumeration.
|
||||
@@ -731,7 +762,7 @@ def password_login(
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
def password_reset_request(
|
||||
payload: dict,
|
||||
body: EmailIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> dict:
|
||||
@@ -739,7 +770,7 @@ def password_reset_request(
|
||||
password account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _reset_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
email = (body.email or "").strip().lower()
|
||||
# Do the account lookup + token issue + email OFF the response path (a background task with its
|
||||
# own session), so the endpoint takes the same time for any valid email whether or not it has a
|
||||
# password account — no timing-based enumeration. Any valid email schedules the same task.
|
||||
@@ -761,10 +792,10 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Set a new password from a valid reset token, then invalidate any other reset tokens."""
|
||||
token = payload.get("token")
|
||||
new_password = payload.get("password") or ""
|
||||
token = body.token
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -872,7 +903,7 @@ async def link_google(
|
||||
|
||||
@router.post("/set-password")
|
||||
def set_password(
|
||||
payload: dict,
|
||||
body: SetPasswordIn,
|
||||
request: Request,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -884,7 +915,7 @@ def set_password(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not available in the demo account."
|
||||
)
|
||||
new_password = payload.get("password") or ""
|
||||
new_password = body.password or ""
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -892,7 +923,7 @@ def set_password(
|
||||
)
|
||||
if user.password_hash:
|
||||
if not verify_password(
|
||||
payload.get("current_password") or "", user.password_hash
|
||||
body.current_password or "", user.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Current password is incorrect."
|
||||
@@ -941,17 +972,6 @@ async def upgrade(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(current_user)) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def auth_config(db: Session = Depends(get_db)) -> dict:
|
||||
"""Public: which sign-in options this instance offers, so the login page can hide what's off
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Channel manager: the user's subscriptions with their personal overrides (priority,
|
||||
hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes
|
||||
(unsubscribe) live behind the optional write scope and are added in a later phase."""
|
||||
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
|
||||
(subscribe / unsubscribe) that require the optional write scope."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import formats, quota, service, storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.downloads import quota, service, storage
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
@@ -454,18 +453,12 @@ def update_download_meta(
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
def _set_status(db: Session, job: DownloadJob, status: str) -> None:
|
||||
job.status = status
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{job_id}/pause")
|
||||
def pause_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status in ("queued", "running"):
|
||||
_set_status(db, job, "paused") # a running worker aborts cooperatively via the hook
|
||||
service.pause(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -474,19 +467,7 @@ def resume_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status in ("paused", "error"):
|
||||
job.progress = 0
|
||||
job.error = None
|
||||
job.error_code = None
|
||||
# If the shared asset itself failed, reset it to pending so the worker actually
|
||||
# re-downloads — otherwise the requeued job would instantly re-inherit the asset's
|
||||
# stale error (the worker short-circuits a job whose asset is already errored).
|
||||
if job.asset_id:
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
if asset and asset.status == "error":
|
||||
asset.status = "pending"
|
||||
asset.error = None
|
||||
_set_status(db, job, "queued")
|
||||
service.resume(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -510,33 +491,7 @@ def redownload_download(
|
||||
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
|
||||
if job.status in ("queued", "running", "paused"):
|
||||
raise HTTPException(status_code=409, detail="This download is still in progress.")
|
||||
|
||||
# Resolve the fresh private asset FIRST (before touching any file). Salting with the current
|
||||
# asset id makes it distinct from both the dedup pool and this job's current asset, so the
|
||||
# insert never collides — no IntegrityError/rollback can strand us after a file was deleted.
|
||||
spec = formats.normalize(job.profile_snapshot)
|
||||
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
|
||||
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
|
||||
service.populate_from_catalog(db, asset)
|
||||
|
||||
# Now drop the old hold — `service.release_asset` deletes the old file + row only if this job was its
|
||||
# last holder (a co-held asset is left intact for its other holders).
|
||||
service.release_asset(db, job)
|
||||
|
||||
asset.ref_count = (asset.ref_count or 0) + 1
|
||||
job.asset_id = asset.id
|
||||
job.status = "queued"
|
||||
job.progress = 0
|
||||
job.phase = None
|
||||
job.error = None
|
||||
job.speed_bps = None
|
||||
job.eta_s = None
|
||||
# Re-derive the display name (card title + Content-Disposition filename) from the freshly
|
||||
# cleaned title — otherwise a name auto-set from an older, un-cleaned title (e.g. still carrying
|
||||
# a "1.6M views · … |" social prefix) would survive the rebuild. The worker fills it on completion.
|
||||
job.display_name = None
|
||||
job.queue_pos = service.next_queue_pos(db) # tail of the queue, not its stale original slot
|
||||
db.commit()
|
||||
service.redownload(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -545,10 +500,7 @@ def cancel_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||
service.release_asset(db, job) # release while the job still counts as holding
|
||||
job.status = JobStatus.CANCELED
|
||||
db.commit()
|
||||
service.cancel(db, job)
|
||||
return _serialize_job(db, job)
|
||||
|
||||
|
||||
@@ -557,9 +509,7 @@ def delete_download(
|
||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
job = _own_job(db, user, job_id)
|
||||
service.release_asset(db, job)
|
||||
db.delete(job)
|
||||
db.commit()
|
||||
service.delete(db, job)
|
||||
return {"deleted": job_id}
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
|
||||
# Shared query parameters for /feed and /feed/count.
|
||||
def _feed_params(
|
||||
tags: list[int] = Query(default=[]),
|
||||
tag_mode: str = "or",
|
||||
# Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
|
||||
# its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
|
||||
tag_mode_language: str = "or",
|
||||
tag_mode_topic: str = "or",
|
||||
tag_mode_other: str = "or",
|
||||
channel_ids: list[str] = Query(default=[]),
|
||||
q: str | None = None,
|
||||
min_duration: int | None = None,
|
||||
@@ -65,7 +69,11 @@ def _feed_params(
|
||||
) -> dict:
|
||||
return {
|
||||
"tags": tags,
|
||||
"tag_mode": tag_mode,
|
||||
"tag_modes": {
|
||||
"language": tag_mode_language,
|
||||
"topic": tag_mode_topic,
|
||||
"other": tag_mode_other,
|
||||
},
|
||||
"channel_ids": channel_ids,
|
||||
"q": q,
|
||||
"min_duration": min_duration,
|
||||
@@ -162,7 +170,7 @@ def get_facets(
|
||||
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
||||
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||
conjunctive = params.get("tag_modes", {}).get(category) == "and"
|
||||
base, _rank = filtered_query(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Local playlists: per-user named, ordered collections of videos from the shared
|
||||
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
|
||||
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
|
||||
"""Playlists: per-user named, ordered collections of videos from the shared catalog. Local
|
||||
CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-way sync —
|
||||
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, and_
|
||||
|
||||
@@ -10,9 +10,23 @@ wiring expects (`plex_routes.router`), so main.py is unchanged.
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.routes.plex import admin, browse, playback
|
||||
from app.routes.plex import (
|
||||
admin,
|
||||
browse,
|
||||
collections,
|
||||
detail,
|
||||
discover,
|
||||
people,
|
||||
playback,
|
||||
playlists,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(admin.router)
|
||||
router.include_router(browse.router)
|
||||
router.include_router(collections.router)
|
||||
router.include_router(detail.router)
|
||||
router.include_router(discover.router)
|
||||
router.include_router(people.router)
|
||||
router.include_router(playback.router)
|
||||
router.include_router(playlists.router)
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
)
|
||||
from app.plex.lookups import by_rating_key
|
||||
|
||||
@@ -18,3 +20,22 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||
return it
|
||||
|
||||
|
||||
def _norm_lang(lang: str | None) -> str:
|
||||
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
|
||||
return "hu" if lang == "hu" else "en"
|
||||
|
||||
|
||||
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5)."""
|
||||
libs = db.execute(
|
||||
select(PlexLibrary).where(
|
||||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||||
)
|
||||
).scalars().all()
|
||||
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||||
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||||
return movie_lib_ids, show_lib_ids
|
||||
|
||||
+49
-1241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
"""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,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import like_escape, runtime_by_key, top_art_keys
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||
_CERT_RANK = {
|
||||
"G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1,
|
||||
"PG": 2, "TV-PG": 2,
|
||||
"PG-13": 3, "TV-14": 3,
|
||||
"R": 4, "TV-MA": 4,
|
||||
"NC-17": 5,
|
||||
}
|
||||
|
||||
|
||||
def _collection_cert(certs: list[str | None]) -> str | None:
|
||||
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
|
||||
return max(ranked, key=lambda x: x[1])[0] if ranked else None
|
||||
|
||||
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()
|
||||
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
|
||||
# members (a collection lives in one library, so exactly one side has art) — a show collection would
|
||||
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
|
||||
# Runtime is movie-only (shows have no single duration).
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
keys = [c.rating_key for c in rows]
|
||||
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
|
||||
|
||||
def _art(c: PlexCollection) -> list[str]:
|
||||
member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
|
||||
return member or ([c.rating_key] if c.thumb_key else [])
|
||||
|
||||
return {
|
||||
"collections": [
|
||||
{
|
||||
**_collection_card(c),
|
||||
"art": _art(c),
|
||||
"total_runtime_s": runtime.get(c.rating_key, 0),
|
||||
}
|
||||
for c in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections/{rating_key}")
|
||||
def collection_detail(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One collection's hero data + its members (movies + shows), ordered by release year then title —
|
||||
the Collection info page. Read-only here; edits go through the admin mutation endpoints below."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
movies = db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
|
||||
).scalars().all()
|
||||
shows = db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title))
|
||||
).scalars().all()
|
||||
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
|
||||
sstate = rollup.state_map(db, user.id, [s.id for s in shows])
|
||||
# Merge the two type-homogeneous lists into ONE member list ordered by (year, title).
|
||||
tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [
|
||||
(s.year, (s.title or "").lower(), "show", s) for s in shows
|
||||
]
|
||||
tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1]))
|
||||
items = [
|
||||
movie_card(o, mstate.get(o.id)) if kind == "movie" else show_card(o, sstate.get(o.id, "new"))
|
||||
for _, _, kind, o in tagged
|
||||
]
|
||||
years = [y for y, _, _, _ in tagged if y is not None]
|
||||
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
|
||||
return {
|
||||
**_collection_card(col),
|
||||
"content_rating": _collection_cert(certs),
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"total_runtime_s": sum(m.duration_s or 0 for m in movies),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _reject_duplicate_name(
|
||||
db: Session, library_id: int, title: str, exclude_rk: str | None = None
|
||||
) -> None:
|
||||
"""No two collections in a section may share a name: Plex CONFLATES same-named collections'
|
||||
membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
|
||||
front on create + rename rather than mirror the mess."""
|
||||
dup = db.execute(
|
||||
select(PlexCollection).where(
|
||||
PlexCollection.library_id == library_id,
|
||||
func.lower(PlexCollection.title) == title.lower(),
|
||||
)
|
||||
).scalars().first()
|
||||
if dup is not None and dup.rating_key != exclude_rk:
|
||||
raise HTTPException(status_code=409, detail="A collection with this name already exists")
|
||||
|
||||
|
||||
def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
|
||||
"""Map a PlexError raised inside a mutate-then-resync block to the honest message. If the mutating
|
||||
call itself failed (``wrote`` False) the write never landed → "Plex write failed". If the write
|
||||
succeeded but the follow-up resync READ (Plex metadata/children) failed, the change IS on the Plex
|
||||
server → don't claim a write failure; say the local copy will reconcile on the next sync.
|
||||
|
||||
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
|
||||
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
|
||||
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||
if wrote:
|
||||
detail = (
|
||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
||||
f"it will reconcile on the next sync ({e})"
|
||||
)
|
||||
else:
|
||||
detail = f"Plex write failed: {e}"
|
||||
return HTTPException(status_code=422, detail=detail)
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
if library:
|
||||
lib = db.execute(
|
||||
select(PlexLibrary).where(PlexLibrary.plex_key == library)
|
||||
).scalars().first()
|
||||
else:
|
||||
# The "New collection" flow only knows the seed title → derive its library.
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
new = plex.create_collection(lib.plex_key, title, seed)
|
||||
wrote = True
|
||||
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 _write_or_pending_error(wrote, 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)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.add_collection_item(rating_key, str(item_rating_key))
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, 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)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.remove_collection_item(rating_key, str(item_rating_key))
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, 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")
|
||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.rename_collection(lib.plex_key, rating_key, title)
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise _write_or_pending_error(wrote, 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,443 @@
|
||||
"""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 import tmdb as plex_tmdb
|
||||
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 [],
|
||||
# TMDB enrichment (E5 S2), served from the row.
|
||||
"tmdb": plex_tmdb.serve_block(db, sh),
|
||||
},
|
||||
"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),
|
||||
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched).
|
||||
"tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None,
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Plex Discover: the rail-surface read-model (E5 S4).
|
||||
|
||||
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
|
||||
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
|
||||
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
|
||||
recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
|
||||
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||||
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||||
card paths); the HU overlay coalesces only in the main grid.
|
||||
|
||||
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||||
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||||
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||||
the client hides). Genre-language matches the grid; duration is movie-only.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, 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, PlexShow, PlexState, User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import leaf_card, movie_card, show_card
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_show_state_filter,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
RAIL_LIMIT = 20
|
||||
GENRE_TILES = 12
|
||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||
|
||||
|
||||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
"""The movie/show library ids visible for this scope (empty list = out of scope)."""
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
return (
|
||||
movie_ids if scope in ("movie", "both") else [],
|
||||
show_ids if scope in ("show", "both") else [],
|
||||
)
|
||||
|
||||
|
||||
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||||
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||||
q = apply_meta_filters(q, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||||
return q
|
||||
|
||||
|
||||
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
|
||||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||
return [movie_card(it, sts.get(it.id)) for it in items]
|
||||
|
||||
|
||||
def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
|
||||
statuses = rollup.state_map(db, uid, [s.id for s in shows])
|
||||
return [show_card(s, statuses.get(s.id, "new")) for s in shows]
|
||||
|
||||
|
||||
def _recently_added(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||
movies = (
|
||||
db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
.order_by(PlexItem.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).scalars().all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||
)
|
||||
.order_by(PlexShow.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).scalars().all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
|
||||
# per-card state lookups stay batched (no N+1). The `(is not None, value)` key sorts a missing
|
||||
# timestamp last under reverse.
|
||||
tagged = [(m.added_at, "movie", m) for m in movies] + [
|
||||
(s.added_at, "show", s) for s in shows
|
||||
]
|
||||
tagged.sort(key=lambda t: (t[0] is not None, t[0]), reverse=True)
|
||||
top = tagged[:RAIL_LIMIT]
|
||||
# Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never
|
||||
# share one), so the merge doesn't depend on object identity staying alive.
|
||||
top_movies = [o for _, kind, o in top if kind == "movie"]
|
||||
top_shows = [o for _, kind, o in top if kind == "show"]
|
||||
mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))}
|
||||
scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))}
|
||||
return [
|
||||
mcards[o.rating_key] if kind == "movie" else scards[o.rating_key]
|
||||
for _, kind, o in top
|
||||
]
|
||||
|
||||
|
||||
def _recently_watched(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||||
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||||
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||||
rows: list[tuple[object, dict]] = [] # (watched_at, card)
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
)
|
||||
for it, st in db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all():
|
||||
rows.append((st.watched_at, leaf_card(it, st)))
|
||||
if show_ids:
|
||||
eq = (
|
||||
select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
for ep, st, sh_title, sh_rk in db.execute(eq).all():
|
||||
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
|
||||
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _continue_watching(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||||
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||||
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
|
||||
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.position_seconds > 0,
|
||||
# A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) —
|
||||
# `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE.
|
||||
or_(
|
||||
PlexState.status.is_(None),
|
||||
PlexState.status.notin_(["watched", "hidden"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
mrows = db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for it, st in mrows:
|
||||
rows.append((st.progress_updated_at, movie_card(it, st)))
|
||||
|
||||
if show_ids:
|
||||
# Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus
|
||||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_ids))
|
||||
srows = db.execute(
|
||||
apply_meta_filters(sq, PlexShow, p, lang)
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
for sh, resumed_at in srows:
|
||||
rows.append((resumed_at, show_card(sh, "in_progress")))
|
||||
|
||||
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _genre_tiles(
|
||||
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||||
) -> list[dict]:
|
||||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||||
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||||
counts: dict[str, int] = {}
|
||||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||
if not ids:
|
||||
continue
|
||||
gcol = genres_col(model, lang)
|
||||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||||
model.library_id.in_(ids),
|
||||
func.jsonb_typeof(gcol) == "array",
|
||||
*meta_filter_conds(model, p, lang),
|
||||
)
|
||||
if model is PlexItem:
|
||||
base = base.where(model.kind == "movie")
|
||||
expanded = base.subquery()
|
||||
for g, c in db.execute(
|
||||
select(expanded.c.g, func.count()).group_by(expanded.c.g)
|
||||
).all():
|
||||
counts[g] = counts.get(g, 0) + c
|
||||
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
|
||||
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||||
art = top_art_keys(
|
||||
db,
|
||||
PlexItem,
|
||||
movie_ids,
|
||||
genres_col(PlexItem, lang),
|
||||
[g for g, _ in top],
|
||||
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||||
)
|
||||
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||||
|
||||
|
||||
def _because_you_watched(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> dict | None:
|
||||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||||
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||||
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||||
if not movie_ids:
|
||||
return None
|
||||
anchor = db.execute(
|
||||
select(PlexItem)
|
||||
.join(
|
||||
PlexState,
|
||||
and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid),
|
||||
)
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
PlexItem.recommendations.isnot(None),
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if anchor is None:
|
||||
return None
|
||||
resolved = resolve_recs(db, anchor.recommendations)
|
||||
# Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself.
|
||||
rks = [
|
||||
r["rating_key"]
|
||||
for r in resolved
|
||||
if r.get("in_library") and r.get("rating_key") != anchor.rating_key
|
||||
]
|
||||
if not rks:
|
||||
return None
|
||||
movies = {
|
||||
m.rating_key: m
|
||||
for m in db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
).scalars().all()
|
||||
}
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||||
)
|
||||
).scalars().all()
|
||||
}
|
||||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||
ordered_shows = [shows[rk] for rk in rks if rk in shows]
|
||||
# rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()).
|
||||
mcards = {m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))}
|
||||
scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))}
|
||||
items: list[dict] = []
|
||||
for rk in rks:
|
||||
if rk in mcards:
|
||||
items.append(mcards[rk])
|
||||
elif rk in scards:
|
||||
items.append(scards[rk])
|
||||
if not items:
|
||||
return None
|
||||
return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items}
|
||||
|
||||
|
||||
@router.get("/discover")
|
||||
def discover(
|
||||
scope: str = "both",
|
||||
lang: str = "en",
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||||
is null when there's no anchor."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
return {
|
||||
"scope": scope,
|
||||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
def surprise(
|
||||
scope: str = "both",
|
||||
lang: str = "en",
|
||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||||
the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
|
||||
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||||
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||||
one type."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
# Exclude movies this user has watched.
|
||||
watched_movie = (
|
||||
select(PlexState.id)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
).scalars().all()
|
||||
shows = []
|
||||
if show_ids:
|
||||
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||||
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||||
prog = rollup.agg_subquery(db, user.id).subquery()
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow, p, lang,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(per)
|
||||
).scalars().all()
|
||||
mcards = _movie_cards(db, user.id, movies)
|
||||
scards = _show_cards(db, user.id, shows)
|
||||
# Interleave movie/show cards, then trim to `limit`.
|
||||
items: list[dict] = []
|
||||
for i in range(max(len(mcards), len(scards))):
|
||||
if i < len(mcards):
|
||||
items.append(mcards[i])
|
||||
if i < len(scards):
|
||||
items.append(scards[i])
|
||||
return {"scope": scope, "items": items[:limit]}
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexPerson,
|
||||
PlexShow,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
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"])
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
|
||||
def _resolve_known_for(
|
||||
db: Session,
|
||||
known_for: list | None,
|
||||
uid: int,
|
||||
movie_lib_ids: list[int],
|
||||
show_lib_ids: list[int],
|
||||
) -> list[dict]:
|
||||
"""The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in
|
||||
popularity order. Matched by the canonical guid `tmdb:<id>`; out-of-library credits are dropped."""
|
||||
if not known_for:
|
||||
return []
|
||||
# Match each credit to a library row by guid, bucketed by media_type (movie ids and tv ids collide
|
||||
# numerically) and scoped to the enabled libraries — the shared helper owns that collision-safe
|
||||
# logic (the detail-page recs use the same one).
|
||||
movies, shows = plex_tmdb.resolve_library_guids(
|
||||
db, known_for, movie_lib_ids=movie_lib_ids, show_lib_ids=show_lib_ids
|
||||
)
|
||||
m_states = rollup.item_state_map(db, uid, [m.id for m in movies.values()])
|
||||
show_statuses = rollup.state_map(db, uid, [s.id for s in shows.values()])
|
||||
out: list[dict] = []
|
||||
for k in known_for:
|
||||
g = f"tmdb:{k.get('tmdb_id')}"
|
||||
# A credit resolves against its OWN media_type's bucket only.
|
||||
if k.get("media_type") == "tv":
|
||||
if g in shows:
|
||||
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new")))
|
||||
elif g in movies:
|
||||
out.append(movie_card(movies[g], m_states.get(movies[g].id)))
|
||||
return out
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# A person's bio is enriched at most once per this window: it dedups concurrent opens of the same person
|
||||
# (each would otherwise schedule its own 8s TMDB task) AND rate-limits re-triggering when TMDB is
|
||||
# down/slow (a transient failure leaves tmdb_enriched_at NULL, so without this every reload would spawn a
|
||||
# fresh task and pile up on the shared threadpool). In-process + best-effort: a restart just clears it, at
|
||||
# worst costing a few extra fetches. A successful enrich stamps the watermark, so the reservation only
|
||||
# ever expires harmlessly.
|
||||
_ENRICH_COOLDOWN_S = 60.0
|
||||
_enrich_inflight: dict[int, float] = {}
|
||||
_enrich_lock = threading.Lock()
|
||||
|
||||
|
||||
def _should_schedule_enrich(person_id: int) -> bool:
|
||||
"""True (and reserves a cooldown) if this person isn't already being enriched / recently attempted."""
|
||||
now = time.monotonic()
|
||||
with _enrich_lock:
|
||||
deadline = _enrich_inflight.get(person_id)
|
||||
if deadline is not None and now < deadline:
|
||||
return False
|
||||
_enrich_inflight[person_id] = now + _ENRICH_COOLDOWN_S
|
||||
# Bound the map: drop expired reservations once it grows (a large library has many people).
|
||||
if len(_enrich_inflight) > 1024:
|
||||
for pid in [p for p, d in _enrich_inflight.items() if d <= now]:
|
||||
del _enrich_inflight[pid]
|
||||
return True
|
||||
|
||||
|
||||
def _enrich_person_bg(person_id: int) -> None:
|
||||
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
|
||||
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
|
||||
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
|
||||
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
|
||||
next open, and the bio lands from the row by then."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
person = db.get(PlexPerson, person_id)
|
||||
if person is not None:
|
||||
plex_tmdb.enrich_person(db, person)
|
||||
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
|
||||
db.rollback()
|
||||
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/person/{name}")
|
||||
def person_detail(
|
||||
name: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
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.
|
||||
|
||||
E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily
|
||||
fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open.
|
||||
The fetch runs OFF the request path (a background task); the first open serves whatever's in the row
|
||||
(bio possibly still null) and the bio lands from the row by the next open.
|
||||
"""
|
||||
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
||||
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
|
||||
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
|
||||
# can't pile background tasks onto the shared threadpool.
|
||||
if (
|
||||
person is not None
|
||||
and person.tmdb_person_id
|
||||
and person.tmdb_enriched_at is None
|
||||
and _should_schedule_enrich(person.id)
|
||||
):
|
||||
background_tasks.add_task(_enrich_person_bg, person.id)
|
||||
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()
|
||||
states = rollup.item_state_map(db, user.id, [it.id for it in items])
|
||||
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]
|
||||
|
||||
known_for = _resolve_known_for(
|
||||
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"photo": person.photo_thumb if person else None,
|
||||
"biography": person.biography if person else None,
|
||||
"birthday": person.birthday if person else None,
|
||||
"deathday": person.deathday if person else None,
|
||||
"place_of_birth": person.place_of_birth if person else None,
|
||||
"known_for_department": person.known_for_department if person else None,
|
||||
"also_known_as": (person.also_known_as if person else None) or [],
|
||||
"known_for": known_for,
|
||||
"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}
|
||||
@@ -9,11 +9,19 @@ from app.db import get_db
|
||||
from app.models import SavedView, User
|
||||
from app.routes._common import owned_or_404
|
||||
|
||||
# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
|
||||
# require_human so the shared demo account can't pollute a communal list (mirrors how other
|
||||
# personal-state writes are scoped).
|
||||
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
||||
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||
# (mirrors how other personal-state writes are scoped).
|
||||
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
|
||||
|
||||
# The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller
|
||||
# (or an older client) that omits `source` keeps the original feed behaviour.
|
||||
_SOURCES = {"feed", "plex"}
|
||||
|
||||
|
||||
def _norm_source(source: str) -> str:
|
||||
return source if source in _SOURCES else "feed"
|
||||
|
||||
|
||||
def _serialize(v: SavedView) -> dict:
|
||||
return {
|
||||
@@ -27,12 +35,15 @@ def _serialize(v: SavedView) -> dict:
|
||||
|
||||
@router.get("")
|
||||
def list_views(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
source: str = "feed",
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
source = _norm_source(source)
|
||||
rows = (
|
||||
db.execute(
|
||||
select(SavedView)
|
||||
.where(SavedView.user_id == user.id)
|
||||
.where(SavedView.user_id == user.id, SavedView.source == source)
|
||||
.order_by(SavedView.position, SavedView.id)
|
||||
)
|
||||
.scalars()
|
||||
@@ -44,6 +55,7 @@ def list_views(
|
||||
class CreateViewIn(BaseModel):
|
||||
name: str
|
||||
filters: dict
|
||||
source: str = "feed"
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -55,13 +67,19 @@ def create_view(
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
source = _norm_source(body.source)
|
||||
# Position is per (user, source), so each module's views number 0..N independently.
|
||||
maxpos = db.execute(
|
||||
select(func.coalesce(func.max(SavedView.position), -1)).where(
|
||||
SavedView.user_id == user.id
|
||||
SavedView.user_id == user.id, SavedView.source == source
|
||||
)
|
||||
).scalar_one()
|
||||
view = SavedView(
|
||||
user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
|
||||
user_id=user.id,
|
||||
source=source,
|
||||
name=name[:80],
|
||||
filters=body.filters,
|
||||
position=maxpos + 1,
|
||||
)
|
||||
db.add(view)
|
||||
try:
|
||||
@@ -107,10 +125,14 @@ def update_view(
|
||||
if "is_default" in sent:
|
||||
make_default = bool(body.is_default)
|
||||
if make_default:
|
||||
# At most one default per user: clear the flag on the others first.
|
||||
# At most one default per (user, source): clear the flag on this module's others first.
|
||||
db.execute(
|
||||
update(SavedView)
|
||||
.where(SavedView.user_id == user.id, SavedView.id != view.id)
|
||||
.where(
|
||||
SavedView.user_id == user.id,
|
||||
SavedView.source == view.source,
|
||||
SavedView.id != view.id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
view.is_default = make_default
|
||||
|
||||
@@ -90,17 +90,13 @@ def set_maintenance_settings(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
||||
run). Persisted to app_state; the job reads it each run."""
|
||||
value = body.revalidate_batch
|
||||
if not (state.MAINTENANCE_BATCH_MIN <= value <= state.MAINTENANCE_BATCH_MAX):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"revalidate_batch must be between {state.MAINTENANCE_BATCH_MIN} and "
|
||||
f"{state.MAINTENANCE_BATCH_MAX}"
|
||||
),
|
||||
)
|
||||
new = state.set_maintenance_batch(db, value)
|
||||
run). Persisted via sysconfig; the job reads it each run."""
|
||||
# set_value validates against the spec's min/max (one source of bounds), mirroring the config PATCH.
|
||||
try:
|
||||
sysconfig.set_value(db, "maintenance_revalidate_batch", body.revalidate_batch)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
||||
new = sysconfig.get_int(db, "maintenance_revalidate_batch")
|
||||
audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new})
|
||||
db.commit()
|
||||
return {"revalidate_batch": new}
|
||||
@@ -167,6 +163,7 @@ def get_scheduler(
|
||||
"daily_budget": sysconfig.get_int(db, "quota_daily_budget"),
|
||||
"backfill_reserve": sysconfig.get_int(db, "backfill_quota_reserve"),
|
||||
}
|
||||
maint = sysconfig.spec("maintenance_revalidate_batch")
|
||||
|
||||
return {
|
||||
**snap,
|
||||
@@ -174,9 +171,9 @@ def get_scheduler(
|
||||
"queue": queue,
|
||||
"quota": quota_info,
|
||||
"maintenance": {
|
||||
"revalidate_batch": state.get_maintenance_batch(db),
|
||||
"revalidate_batch": sysconfig.get_int(db, "maintenance_revalidate_batch"),
|
||||
"revalidate_batch_default": settings.maintenance_revalidate_batch,
|
||||
"min": state.MAINTENANCE_BATCH_MIN,
|
||||
"max": state.MAINTENANCE_BATCH_MAX,
|
||||
"min": maint.min,
|
||||
"max": maint.max,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Ingested results are flagged (videos.via_search / channels.from_search) so the L
|
||||
this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never
|
||||
ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are
|
||||
dropped from the search response up front)."""
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -43,6 +44,8 @@ from app.youtube import search_scrape
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.youtube.shorts import make_client, probe_is_short
|
||||
|
||||
log = logging.getLogger("siftlode.search")
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
# search.list costs this many quota units per page.
|
||||
@@ -136,6 +139,10 @@ def _ingest_candidates(
|
||||
|
||||
# 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO
|
||||
# NOTHING), so an organically-synced video stays organic.
|
||||
# published_at is NOT NULL (migration 0068); the scrape source exposes no publish date
|
||||
# (only a relative "3 years ago"), so fall back to now() to satisfy the constraint — the
|
||||
# enrich in step 3 overwrites it with the video's real snippet date.
|
||||
now = datetime.now(timezone.utc)
|
||||
insert_stubs(
|
||||
db,
|
||||
[
|
||||
@@ -143,7 +150,7 @@ def _ingest_candidates(
|
||||
"id": it["id"],
|
||||
"channel_id": it["channel_id"],
|
||||
"title": it["title"],
|
||||
"published_at": parse_dt(it["published_at"]),
|
||||
"published_at": parse_dt(it["published_at"]) or now,
|
||||
"thumbnail_url": it["thumbnail_url"],
|
||||
"via_search": True,
|
||||
}
|
||||
@@ -158,13 +165,13 @@ def _ingest_candidates(
|
||||
detail_by_id = {it["id"]: it for it in yt.get_videos(ids)}
|
||||
except YouTubeError:
|
||||
detail_by_id = {}
|
||||
now = datetime.now(timezone.utc)
|
||||
enriched_at = datetime.now(timezone.utc) # when THIS enrich ran (distinct from the stub placeholder)
|
||||
for v in videos:
|
||||
item = detail_by_id.get(v.id)
|
||||
if item is None:
|
||||
continue
|
||||
apply_video_details(v, item)
|
||||
v.enriched_at = now
|
||||
v.enriched_at = enriched_at
|
||||
db.commit()
|
||||
|
||||
# 4) Confirm/deny Shorts (no quota) so none enter via search.
|
||||
@@ -282,7 +289,19 @@ def search_youtube(
|
||||
and it["channel_id"]
|
||||
and it["channel_id"] not in blocked
|
||||
]
|
||||
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||
# Ingest (DB writes + enrich) runs outside the page-fetch try above; guard it too so a
|
||||
# write/shape failure degrades to a 422 with a message on this JSON endpoint instead of a
|
||||
# bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1).
|
||||
try:
|
||||
collected.extend(_ingest_candidates(db, yt, user, candidates))
|
||||
except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError
|
||||
db.rollback()
|
||||
log.exception("live search ingest failed for %r", term)
|
||||
if collected:
|
||||
break # keep what earlier pages already gathered
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"YouTube search failed: {exc}"
|
||||
) from exc
|
||||
next_cursor = page["next_page_token"]
|
||||
# The API source pages cost 100 units each — never auto-page; one page per request.
|
||||
if source == "api" or len(collected) >= limit or not next_cursor:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`GET /api/setup/status` is public (the SPA uses it to decide whether to show the wizard). Every
|
||||
mutating step goes through `require_setup`: it rejects once the instance is configured AND requires
|
||||
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
|
||||
X-Setup-Token header). The wizard steps themselves are added in epic 6c.
|
||||
X-Setup-Token header).
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
@@ -96,6 +96,7 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
saved.append(key)
|
||||
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
||||
return {"saved": saved}
|
||||
|
||||
|
||||
|
||||
@@ -7,30 +7,13 @@ from app.audit import AuditAction
|
||||
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import (
|
||||
estimate_deep_backfill,
|
||||
run_enrich,
|
||||
run_recent_backfill,
|
||||
run_rss_poll,
|
||||
)
|
||||
from app.sync.runner import estimate_deep_backfill
|
||||
from app.sync.subscriptions import import_subscriptions
|
||||
from app.scheduler import running_job_ids
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
def _user_channels(db: Session, user: User) -> list[Channel]:
|
||||
return (
|
||||
db.execute(
|
||||
select(Channel)
|
||||
.join(Subscription, Subscription.channel_id == Channel.id)
|
||||
.where(Subscription.user_id == user.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
@@ -48,38 +31,6 @@ def sync_subscriptions(
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/rss")
|
||||
def sync_rss(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
# A partial failure comes back in `failed_feeds` so a manual poll reports it instead of an
|
||||
# innocent-looking "0 new"; a TOTAL outage raises out of run_rss_poll and surfaces as a 500,
|
||||
# which is the honest answer for a poll that reached nothing.
|
||||
result = run_rss_poll(db, _user_channels(db, user))
|
||||
return {"new_videos": result["new"], "failed_feeds": result["failed"]}
|
||||
|
||||
|
||||
@router.post("/backfill")
|
||||
def sync_backfill(
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
|
||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||
result["quota_used_estimate"] = m.used
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/enrich")
|
||||
def sync_enrich(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
|
||||
enriched = run_enrich(db)
|
||||
return {"enriched": enriched, "quota_used_estimate": m.used}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def sync_status(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
|
||||
@@ -4,11 +4,10 @@ from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import admin_user, current_user
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ChannelTag, Tag, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.sync.autotag import run_autotag_all
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
@@ -113,12 +112,3 @@ def delete_tag(
|
||||
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||
db.commit()
|
||||
return {"deleted": tag_id}
|
||||
|
||||
|
||||
@router.post("/recompute")
|
||||
def recompute(
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
only_missing: bool = False,
|
||||
) -> dict:
|
||||
return run_autotag_all(db, only_missing=only_missing)
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.demo import reset_demo_state
|
||||
from app.downloads.gc import run_download_gc
|
||||
from app.models import SchedulerSetting, User
|
||||
from app.plex.sync import sync as run_plex_sync
|
||||
from app.plex.tmdb import run_enrich as run_tmdb_enrich
|
||||
from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync
|
||||
from app.notifications import create_notification
|
||||
from app.state import is_sync_paused
|
||||
@@ -68,6 +69,7 @@ JOB_INTERVALS: dict[str, int] = {
|
||||
"plex_sync": settings.plex_sync_interval_min,
|
||||
"plex_watch_sync": settings.plex_watch_sync_interval_min,
|
||||
"plex_watch_reconcile": settings.plex_watch_reconcile_interval_min,
|
||||
"tmdb_enrich": settings.tmdb_enrich_interval_min,
|
||||
"audit_gc": settings.audit_gc_minutes,
|
||||
}
|
||||
|
||||
@@ -351,6 +353,13 @@ def _plex_watch_reconcile_job() -> None:
|
||||
_job("plex_watch_reconcile", run_plex_watch_reconcile)
|
||||
|
||||
|
||||
def _tmdb_enrich_job() -> None:
|
||||
# Enrich a batch of mirrored Plex titles from TMDB (keywords/crew/budget/revenue/TMDb score/
|
||||
# recommendations/backdrops), matched by guid. External metadata (no YouTube quota); a no-op when
|
||||
# the TMDB key is unset or the Plex catalog is empty.
|
||||
_job("tmdb_enrich", run_tmdb_enrich)
|
||||
|
||||
|
||||
def _audit_gc_job() -> None:
|
||||
# Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep
|
||||
# forever). Pure DB, no quota.
|
||||
@@ -378,6 +387,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||
"plex_sync": _plex_sync_job,
|
||||
"plex_watch_sync": _plex_watch_sync_job,
|
||||
"plex_watch_reconcile": _plex_watch_reconcile_job,
|
||||
"tmdb_enrich": _tmdb_enrich_job,
|
||||
"audit_gc": _audit_gc_job,
|
||||
}
|
||||
|
||||
|
||||
+3
-23
@@ -4,16 +4,11 @@ import secrets
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models import AppState
|
||||
from app.security import hash_token
|
||||
|
||||
log = logging.getLogger("siftlode.state")
|
||||
|
||||
# Bounds for the admin-set maintenance re-validation batch (videos re-checked per run).
|
||||
MAINTENANCE_BATCH_MIN = 100
|
||||
MAINTENANCE_BATCH_MAX = 100_000
|
||||
|
||||
# Once configuration completes in this process it never reverts, so cache it to skip the per-request
|
||||
# DB hit in the setup-mode gate (see main.setup_gate). Starts False; refreshed from the DB.
|
||||
_configured_cache = False
|
||||
@@ -33,10 +28,12 @@ def is_sync_paused(db: Session) -> bool:
|
||||
|
||||
|
||||
def set_sync_paused(db: Session, paused: bool) -> None:
|
||||
# Flushes, doesn't commit — the caller owns the transaction so the change lands with its audit
|
||||
# row in one transaction (B-core-10).
|
||||
row = _row(db)
|
||||
row.sync_paused = paused
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.flush()
|
||||
log.info("Background sync %s", "paused" if paused else "resumed")
|
||||
|
||||
|
||||
@@ -90,20 +87,3 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
|
||||
return False
|
||||
stored = _row(db).setup_token_hash
|
||||
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
|
||||
|
||||
|
||||
def get_maintenance_batch(db: Session) -> int:
|
||||
"""Effective maintenance re-validation batch: the admin override if set, else the
|
||||
env/config default."""
|
||||
return _row(db).maintenance_revalidate_batch or settings.maintenance_revalidate_batch
|
||||
|
||||
|
||||
def set_maintenance_batch(db: Session, value: int) -> int:
|
||||
"""Clamp and persist the maintenance batch override. Returns the applied value."""
|
||||
value = max(MAINTENANCE_BATCH_MIN, min(MAINTENANCE_BATCH_MAX, int(value)))
|
||||
row = _row(db)
|
||||
row.maintenance_revalidate_batch = value
|
||||
db.add(row)
|
||||
db.commit()
|
||||
log.info("Maintenance re-validation batch set to %s", value)
|
||||
return value
|
||||
|
||||
@@ -25,11 +25,10 @@ from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import progress, quota
|
||||
from app import progress, quota, sysconfig
|
||||
from app.config import settings
|
||||
from app.models import Playlist, PlaylistItem, Video
|
||||
from app.notifications import create_notification
|
||||
from app.state import get_maintenance_batch
|
||||
from app.sync.runner import get_service_user
|
||||
from app.sync.videos import apply_video_details, parse_dt
|
||||
from app.utils import now_utc as _now
|
||||
@@ -182,7 +181,7 @@ def _recheck_flagged(db: Session, yt: YouTubeClient) -> dict:
|
||||
def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
||||
"""Phase 2: re-check the least-recently-checked currently-available videos and flag any
|
||||
that have become unplayable (hidden immediately; deleted later if they don't recover)."""
|
||||
batch = get_maintenance_batch(db) # admin override, else config default
|
||||
batch = sysconfig.get_int(db, "maintenance_revalidate_batch") # admin override, else config default
|
||||
flagged_new = 0
|
||||
checked = 0
|
||||
now = _now()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Read-direction YouTube playlist sync: mirror each user's own YouTube playlists into
|
||||
local `source='youtube'` playlists (kept fresh by the scheduler). One-way (YT -> local);
|
||||
the write-back direction (local edits -> YouTube) is a later phase.
|
||||
"""YouTube playlist sync, both directions. Read: mirror each user's own YouTube playlists
|
||||
into local `source='youtube'` playlists (kept fresh by the scheduler). Write-back: push local
|
||||
edits out to YouTube (`plan_push` / `push_playlist`), behind the optional write scope.
|
||||
|
||||
YouTube's special Watch Later / History playlists are not exposed by the Data API and are
|
||||
never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Channel, Subscription, User
|
||||
from app.utils import normalize_description
|
||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||
|
||||
log = logging.getLogger("siftlode.sync")
|
||||
@@ -55,7 +56,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||
|
||||
channel.title = snippet.get("title") or channel.title
|
||||
channel.handle = snippet.get("customUrl")
|
||||
channel.description = snippet.get("description")
|
||||
channel.description = normalize_description(snippet.get("description"))
|
||||
channel.thumbnail_url = (
|
||||
best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url
|
||||
)
|
||||
@@ -104,7 +105,7 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||
channel = Channel(
|
||||
id=cid,
|
||||
title=sub.get("title"),
|
||||
description=sub.get("description"),
|
||||
description=normalize_description(sub.get("description")),
|
||||
thumbnail_url=sub.get("thumbnail_url"),
|
||||
)
|
||||
db.add(channel)
|
||||
|
||||
@@ -245,9 +245,15 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||
video.original_title = raw_title
|
||||
video.title = normalize_title(raw_title)
|
||||
video.description = snippet.get("description")
|
||||
if not video.published_at:
|
||||
# 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()
|
||||
# published_at is NOT NULL (0068). Prefer the enrich snippet's real date whenever it has one —
|
||||
# this also corrects a now() placeholder written at stub time (e.g. a scrape-search stub, which
|
||||
# carries no publish date). Only fall back to now() when the row still has nothing at all; never
|
||||
# clobber an existing real date with now() (a re-enrich whose snippet lacks a date).
|
||||
snippet_published = parse_dt(snippet.get("publishedAt"))
|
||||
if snippet_published:
|
||||
video.published_at = snippet_published
|
||||
elif not video.published_at:
|
||||
video.published_at = _now()
|
||||
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
||||
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||
|
||||
@@ -59,6 +59,10 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
# --- Batch sizes ---
|
||||
ConfigSpec("enrich_batch_size", "int", "batch", "enrich_batch_size", min=1, max=50),
|
||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
||||
# --- Maintenance (rolling re-validation of already-known videos) ---
|
||||
# Videos re-checked per maintenance run. Folded here from app_state (B-core-11) so every admin
|
||||
# knob lives in one store; read by sync/maintenance.py, tuned from the Scheduler dashboard.
|
||||
ConfigSpec("maintenance_revalidate_batch", "int", "maintenance", "maintenance_revalidate_batch", min=100, max=100_000),
|
||||
# --- Discovery cleanup (search + explore ephemeral content) ---
|
||||
# Days an explored-but-unsubscribed channel is kept before the cleanup job purges it.
|
||||
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
|
||||
@@ -96,8 +100,13 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||
# NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the
|
||||
# incremental watch-sync scheduler job that reads it lands.
|
||||
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
|
||||
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
|
||||
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---
|
||||
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
|
||||
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
|
||||
# (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch
|
||||
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
|
||||
)
|
||||
|
||||
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||
@@ -182,7 +191,9 @@ def get_bool(db: Session, key: str) -> bool:
|
||||
|
||||
|
||||
def set_value(db: Session, key: str, value) -> None:
|
||||
"""Validate, coerce, and persist a DB override for `key`. Secrets are encrypted."""
|
||||
"""Validate, coerce, and stage a DB override for `key` (secrets are encrypted). Flushes but does
|
||||
NOT commit — the caller owns the transaction, so the change lands together with its audit row
|
||||
(B-core-10). The staged value is visible to a same-session `get()` before the commit."""
|
||||
s = _BY_KEY[key]
|
||||
if s.type == "int":
|
||||
v = int(value)
|
||||
@@ -208,15 +219,16 @@ def set_value(db: Session, key: str, value) -> None:
|
||||
db.add(row)
|
||||
row.value = stored
|
||||
row.encrypted = encrypted
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def reset(db: Session, key: str) -> None:
|
||||
"""Remove the DB override for `key` (fall back to the env/config default)."""
|
||||
"""Remove the DB override for `key` (fall back to the env/config default). Flushes but does NOT
|
||||
commit — the caller owns the transaction (B-core-10)."""
|
||||
row = db.get(SystemConfig, key)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
db.flush()
|
||||
|
||||
|
||||
def describe(db: Session) -> dict:
|
||||
|
||||
@@ -36,3 +36,18 @@ def to_tsquery_str(q: str) -> str | None:
|
||||
return None
|
||||
tokens[-1] = tokens[-1] + ":*"
|
||||
return " & ".join(tokens)
|
||||
|
||||
|
||||
def normalize_description(text: str | None) -> str | None:
|
||||
"""Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel
|
||||
descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage,
|
||||
bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and
|
||||
right-strips each kept line; `str.splitlines()` also folds CR / CRLF and Unicode line separators
|
||||
to "\\n" (a benign line-ending normalization). None stays None; all-blank collapses to "".
|
||||
|
||||
Deliberately CHANNEL-only — video descriptions are left with their original formatting: they are
|
||||
user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block."""
|
||||
if text is None:
|
||||
return None
|
||||
kept = [line.rstrip() for line in text.splitlines() if line.strip()]
|
||||
return "\n".join(kept)
|
||||
|
||||
+25
-635
@@ -17,125 +17,40 @@ Design:
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import yt_dlp
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import edit as editmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import adapters, formats, quota, service, storage
|
||||
from app.downloads import quota, service
|
||||
from app.downloads.jobdb import (
|
||||
_edit_staging_dir,
|
||||
_requeue_if_running,
|
||||
_reset_asset_pending,
|
||||
_set_job,
|
||||
_staging_dir,
|
||||
)
|
||||
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.notifications import create_notification
|
||||
from app.titles import normalize_title
|
||||
|
||||
log = logging.getLogger("siftlode.worker")
|
||||
|
||||
|
||||
def _notify_download_done(
|
||||
db, user_id: int | None, title: str | None, job_id, *, kind: str
|
||||
) -> None:
|
||||
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
|
||||
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
|
||||
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
|
||||
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
|
||||
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
|
||||
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
|
||||
if user_id is None:
|
||||
return
|
||||
try:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type="download_done",
|
||||
title=f"Ready to watch: {title}" if title else "Ready to watch",
|
||||
data={"title": title, "kind": kind},
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
log.exception("failed to write download-done notification for job %s", job_id)
|
||||
|
||||
_stop = threading.Event()
|
||||
|
||||
|
||||
class _Aborted(Exception):
|
||||
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
|
||||
|
||||
|
||||
def _handle_signal(signum, frame):
|
||||
_stop.set()
|
||||
|
||||
|
||||
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
||||
|
||||
def _set_job(job_id: int, **fields) -> None:
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
if job is None:
|
||||
return
|
||||
for k, v in fields.items():
|
||||
setattr(job, k, v)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _job_status(job_id: int) -> str | None:
|
||||
with SessionLocal() as db:
|
||||
return db.execute(
|
||||
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _touch_job(job_id: int) -> None:
|
||||
"""Bump a running job's `updated_at` without changing any value — a stall-watchdog heartbeat for
|
||||
phases that legitimately write no progress. Extraction writes nothing, and a multi-client-set
|
||||
fallback re-writes identical values (progress 0 / phase 'downloading'), so ORM `onupdate` would
|
||||
NOT fire and two slow extraction attempts could otherwise cross the stall timeout and be killed
|
||||
mid-extraction though they'd have succeeded."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("UPDATE download_jobs SET updated_at=now() WHERE id=:id AND status='running'"),
|
||||
{"id": job_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _requeue_if_running(job_id: int, phase: str) -> None:
|
||||
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
||||
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
||||
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
||||
requeue a no-op unless we still own a running job."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
||||
"WHERE id=:id AND status='running'"
|
||||
),
|
||||
{"id": job_id, "phase": phase},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _parse_upload_date(raw) -> date | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(raw), "%Y%m%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --- claim + recovery -----------------------------------------------------------------------
|
||||
|
||||
def _wait_for_schema(timeout: float = 300.0) -> bool:
|
||||
@@ -176,7 +91,14 @@ def _recover_orphans() -> None:
|
||||
|
||||
|
||||
def _claim_job() -> int | None:
|
||||
"""Claim the oldest queued job whose user is under their per-user concurrency limit.
|
||||
"""Claim the oldest queued job whose user is under their per-user concurrency limit AND whose
|
||||
shared asset isn't already being downloaded by another worker.
|
||||
|
||||
The asset exclusion (`NOT EXISTS … media_assets.status='downloading'`) stops the same-asset ping-
|
||||
pong (B-sub-3): without it, a second worker claims a queued job that shares an in-flight asset,
|
||||
finds it busy, requeues it, and immediately re-claims it — burning a worker slot behind one
|
||||
in-flight asset and starving everyone else. Excluded here, that job simply waits until its asset
|
||||
settles, so `_process_job` no longer needs to sleep on the busy/lost-race paths.
|
||||
|
||||
Users already AT their concurrency limit are excluded IN THE SQL, so one user flooding the
|
||||
queue can't monopolize the fetch window and starve everyone behind them. (The old fixed
|
||||
@@ -193,6 +115,10 @@ def _claim_job() -> int | None:
|
||||
"""
|
||||
SELECT id, user_id FROM download_jobs
|
||||
WHERE status='queued'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_assets a
|
||||
WHERE a.id = download_jobs.asset_id AND a.status = 'downloading'
|
||||
)
|
||||
AND user_id NOT IN (
|
||||
SELECT r.user_id
|
||||
FROM download_jobs r
|
||||
@@ -463,15 +389,16 @@ def _process_job(job_id: int) -> None:
|
||||
_set_job(job_id, status="error", error=asset_error or "Download failed", phase=None)
|
||||
return
|
||||
if asset_status == "downloading":
|
||||
# Another worker owns this asset; wait and let the job be reclaimed later.
|
||||
# Another worker won this asset between our claim and here (a narrow race — the claim SQL
|
||||
# excludes downloading-asset jobs). Requeue and let it be reclaimed once the asset settles;
|
||||
# no sleep — the claim exclusion keeps it out of the window until then, so no busy-loop.
|
||||
_requeue_if_running(job_id, "waiting")
|
||||
time.sleep(2)
|
||||
return
|
||||
|
||||
# asset_status == "pending": try to win the download / edit.
|
||||
if not _claim_asset(asset_id):
|
||||
# Lost the pending->downloading race; requeue (same reasoning — the exclusion prevents a spin).
|
||||
_requeue_if_running(job_id, "waiting")
|
||||
time.sleep(2)
|
||||
return
|
||||
|
||||
# The asset is now ours ('downloading'): run the heavy work in a supervised child process.
|
||||
@@ -494,543 +421,6 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
|
||||
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
|
||||
real title + thumbnail while it downloads, not just the URL. Zero extra network cost."""
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset is None or asset.title:
|
||||
return
|
||||
# `or info.get("id")` avoids a blank card while downloading when the cleaned title is empty
|
||||
# (e.g. a title that was only a social engagement prefix); the completion path later sets
|
||||
# the final title with its own source_ref fallback.
|
||||
asset.title = normalize_title(info.get("title")) or info.get("id")
|
||||
asset.uploader = info.get("uploader") or info.get("channel")
|
||||
asset.uploader_url = adapters.resolve_adapter(None, None, info).uploader_url(info)
|
||||
asset.thumbnail_url = info.get("thumbnail")
|
||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _make_progress_hook(job_id: int, asset_id: int):
|
||||
state = {"meta_done": False}
|
||||
cancelled = _cancel_poll(job_id) # same policy as the ffmpeg path
|
||||
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S)
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
# Cooperative pause/cancel: if the job is no longer 'running', abort the download.
|
||||
if cancelled():
|
||||
raise _Aborted
|
||||
if not state["meta_done"] and d.get("info_dict"):
|
||||
state["meta_done"] = True
|
||||
try:
|
||||
_fill_asset_meta_early(asset_id, d["info_dict"])
|
||||
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
|
||||
pass
|
||||
if d.get("status") != "downloading":
|
||||
return
|
||||
if not allow_write():
|
||||
return
|
||||
# yt-dlp downloads the video and audio streams SEPARATELY (each 0→100%), then merges.
|
||||
# Label the phase so the user understands the bar resetting between streams instead of
|
||||
# seeing a mysterious 95%→5% jump.
|
||||
info = d.get("info_dict") or {}
|
||||
vcodec = info.get("vcodec") or "none"
|
||||
acodec = info.get("acodec") or "none"
|
||||
if vcodec != "none" and acodec == "none":
|
||||
phase = "video"
|
||||
elif acodec != "none" and vcodec == "none":
|
||||
phase = "audio"
|
||||
else:
|
||||
phase = "media"
|
||||
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
||||
done = d.get("downloaded_bytes") or 0
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
_set_job(
|
||||
job_id,
|
||||
progress=min(pct, 99),
|
||||
speed_bps=int(d["speed"]) if d.get("speed") else None,
|
||||
eta_s=int(d["eta"]) if d.get("eta") else None,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
|
||||
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
|
||||
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
|
||||
# opens a session and SELECTs; `_set_job` opens one and UPDATE+COMMITs), so unthrottled they cost
|
||||
# ~10^5 queries on a long job. ONE interval, ONE implementation, used by both paths — three
|
||||
# hand-rolled copies is how the ffmpeg side ended up unthrottled while the download side was fine.
|
||||
_DB_WRITE_INTERVAL_S = 1.0
|
||||
_CANCEL_POLL_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
def _rate_limiter(interval_s: float, *, now=time.monotonic):
|
||||
"""`should_run()` → True at most once per `interval_s` (the first call always passes).
|
||||
`now` is injectable so tests drive a clock instead of patching the global one."""
|
||||
# `None`, not 0.0: "never ran" has to be a VALUE, not a falsy timestamp. A truthiness test here
|
||||
# reads a legitimate `now() == 0.0` as "never ran" and lets a second call straight through —
|
||||
# unreachable with real `time.monotonic()`, but `now` exists to be injected, and a test clock
|
||||
# starting at 0 is the obvious choice. That would let a throttle test encode the wrong
|
||||
# behaviour and still pass.
|
||||
state: dict[str, float | None] = {"at": None}
|
||||
|
||||
def should_run() -> bool:
|
||||
t = now()
|
||||
last = state["at"]
|
||||
if last is not None and t - last < interval_s:
|
||||
return False
|
||||
state["at"] = t
|
||||
return True
|
||||
|
||||
return should_run
|
||||
|
||||
|
||||
def _cancel_poll(job_id: int, interval_s: float = _CANCEL_POLL_INTERVAL_S, *, now=time.monotonic):
|
||||
"""Cooperative "has this job been paused/cancelled?" poll, rate-limited and sticky: once it
|
||||
says yes it never queries again. Shared by the yt-dlp hook and run_ffmpeg so cancel latency is
|
||||
one policy, not one per call site."""
|
||||
allow = _rate_limiter(interval_s, now=now)
|
||||
state = {"cancelled": False}
|
||||
|
||||
def cancelled() -> bool:
|
||||
if not state["cancelled"] and allow():
|
||||
state["cancelled"] = _job_status(job_id) not in ("running", None)
|
||||
return state["cancelled"]
|
||||
|
||||
return cancelled
|
||||
|
||||
|
||||
def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
|
||||
"""`on_progress` for run_ffmpeg: persist at most one row write per interval, and only when the
|
||||
whole-percent value actually changed (the bar shows integers)."""
|
||||
allow = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
|
||||
state = {"pct": -1}
|
||||
|
||||
def write(pct: float) -> None:
|
||||
whole = int(pct)
|
||||
if whole == state["pct"] or not allow():
|
||||
return
|
||||
state["pct"] = whole
|
||||
_set_job(job_id, progress=whole, phase=phase)
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _pp_phase(pp: str) -> str:
|
||||
"""Map a yt-dlp postprocessor class name to a user-facing phase label. ffmpeg post-steps
|
||||
aren't byte-progress operations (no %), so the UI shows the step name + an indeterminate
|
||||
pulse — this makes the long merge/finalize legible instead of a silent 'Processing'."""
|
||||
if "Merg" in pp:
|
||||
return "merging"
|
||||
if "ExtractAudio" in pp:
|
||||
return "audio_extract"
|
||||
if "Thumbnail" in pp:
|
||||
return "thumbnail"
|
||||
if "SponsorBlock" in pp or "ModifyChapters" in pp:
|
||||
return "sponsorblock"
|
||||
if "Metadata" in pp:
|
||||
return "metadata"
|
||||
return "processing"
|
||||
|
||||
|
||||
def _make_pp_hook(job_id: int):
|
||||
"""Postprocessor hook: surface the current ffmpeg step so the bar shows a concrete label
|
||||
(Merging / Embedding thumbnail / …) instead of freezing at 100% after the streams finish."""
|
||||
|
||||
def hook(d: dict) -> None:
|
||||
if d.get("status") in ("started", "processing"):
|
||||
_set_job(job_id, phase=_pp_phase(d.get("postprocessor") or ""), speed_bps=None, eta_s=None)
|
||||
|
||||
return hook
|
||||
|
||||
|
||||
def _staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"asset-{asset_id}"
|
||||
|
||||
|
||||
_THUMB_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp")
|
||||
# yt-dlp's working files in the staging dir. None of them is ever the finished media, but several
|
||||
# CAN be the biggest file there (a video-only per-format stream, an abandoned fragment run), so the
|
||||
# staging fallback must exclude them by name — picking one would store e.g. a silent video-only
|
||||
# stream as a ready asset.
|
||||
_YTDLP_TEMP_RE = re.compile(
|
||||
r"\.part(-Frag\d+)?$" # in-flight download / fragment
|
||||
r"|\.ytdl$" # resume state
|
||||
r"|\.temp\.[^.]+$" # merge/postprocess temp
|
||||
r"|\.f\d+\.[^.]+$", # per-format stream kept before the merge
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _find_thumbnail(staging: Path, media: Path) -> Path | None:
|
||||
for p in staging.iterdir():
|
||||
if p != media and p.suffix.lower() in _THUMB_SUFFIXES:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _produced_file(info: dict, staging: Path) -> Path:
|
||||
"""The media file yt-dlp actually wrote.
|
||||
|
||||
`requested_downloads[0].filepath` is the authoritative answer on YouTube, but an arbitrary-URL
|
||||
extractor may not fill it in at all — and `Path("")` is `Path(".")`, which *passes* `.exists()`,
|
||||
so a missing value used to sail past the fallback and blow up two steps later (`with_name` on an
|
||||
empty name: the BBC-news failure). Anything that isn't a real file counts as absent; then fall
|
||||
back to the largest file left in staging that is neither a thumbnail nor one of yt-dlp's own
|
||||
working files (see `_YTDLP_TEMP_RE`)."""
|
||||
req = (info.get("requested_downloads") or [{}])[0]
|
||||
fp = req.get("filepath") or req.get("_filename") or info.get("filepath") or info.get("_filename")
|
||||
if fp:
|
||||
produced = Path(str(fp))
|
||||
if produced.is_file():
|
||||
return produced
|
||||
cands = [
|
||||
p
|
||||
for p in staging.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix.lower() not in _THUMB_SUFFIXES
|
||||
and not _YTDLP_TEMP_RE.search(p.name)
|
||||
]
|
||||
if not cands:
|
||||
raise RuntimeError("yt-dlp produced no output file")
|
||||
return max(cands, key=lambda p: p.stat().st_size)
|
||||
|
||||
|
||||
def _media_info(info: dict) -> dict:
|
||||
"""Unwrap a playlist/multi_video extraction to the entry that carries the download.
|
||||
|
||||
A single non-YouTube page (a news article's video, a multi-rendition embed) can extract as a
|
||||
wrapper whose own dict has no `requested_downloads` — the media lives on an entry. Pick the
|
||||
FIRST entry that actually downloaded something: on a page with a skipped teaser in front of the
|
||||
real video, entries[0] carries no file, and taking it blindly would label the produced file
|
||||
with the wrong id/title/duration."""
|
||||
entries = [e for e in (info.get("entries") or []) if e]
|
||||
if not entries or info.get("requested_downloads"):
|
||||
return info
|
||||
chosen = next((e for e in entries if e.get("requested_downloads")), entries[0])
|
||||
return {**info, **chosen}
|
||||
|
||||
|
||||
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter):
|
||||
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
|
||||
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
|
||||
error); other sources make a single attempt. Returns the info dict of the successful attempt."""
|
||||
client_sets = adapter.client_sets()
|
||||
outtmpl = str(staging / "%(id)s.%(ext)s")
|
||||
last_exc: Exception | None = None
|
||||
for i, clients in enumerate(client_sets):
|
||||
opts = formats.build_ydl_opts(
|
||||
spec, outtmpl, _make_progress_hook(job_id, asset_id),
|
||||
extra=adapter.ydl_opts_extra(clients),
|
||||
)
|
||||
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
|
||||
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
|
||||
# multi-client-set fallback would otherwise cross the stall timeout with a stale updated_at.
|
||||
# (Per-ATTEMPT only; a single extract_info that itself runs past the stall timeout is still
|
||||
# killed — acceptable, as socket_timeout + extractor_retries bound one attempt well under it.)
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=True)
|
||||
except yt_dlp.utils.DownloadError as exc:
|
||||
last_exc = exc
|
||||
if i < len(client_sets) - 1 and adapter.retriable(str(exc)):
|
||||
log.info("job %s: clients %s failed (%s); retrying with fallback", job_id, clients, str(exc)[:70])
|
||||
for p in list(staging.iterdir()): # drop partials before the retry
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_set_job(job_id, progress=0, phase="downloading")
|
||||
continue
|
||||
raise
|
||||
raise last_exc # unreachable (loop either returns or raises)
|
||||
|
||||
|
||||
def _download_settings() -> tuple[str, int]:
|
||||
"""Layout + retention days from the DB config (admin-overridable on the Configuration page),
|
||||
falling back to the env defaults. Read here, not from `settings`, so an admin edit takes effect."""
|
||||
with SessionLocal() as db:
|
||||
return (
|
||||
sysconfig.get_str(db, "download_layout"),
|
||||
sysconfig.get_int(db, "download_retention_days"),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_browser_playable(job_id: int, produced: Path, spec: dict, info: dict) -> Path:
|
||||
"""Make an mp4-compat-profile download play in every browser: H.264 + AAC/MP3 in mp4. If the
|
||||
download (whatever the source shipped) isn't already that, re-encode the offending stream(s) —
|
||||
or just remux when the codecs are fine but the container isn't. **Scope:** only the mp4 compat
|
||||
profile is normalized; an explicit `vcodec` or a non-mp4 custom profile is the user opting out
|
||||
of the guarantee and is left as-is (so a `webm` profile can still ship VP9/Opus). Returns the
|
||||
file to store (converted, or the original) and corrects `info`'s codec fields to match disk."""
|
||||
if spec.get("container") != "mp4" or spec.get("vcodec"):
|
||||
return produced
|
||||
vcodec, acodec = editmod.probe_codecs(produced)
|
||||
is_mp4 = produced.suffix.lower() == ".mp4"
|
||||
needs, reenc_v, reenc_a = editmod.browser_transcode_flags(vcodec, acodec, is_mp4)
|
||||
if not needs:
|
||||
return produced
|
||||
|
||||
dest = produced.with_name(produced.stem + ".browser.mp4")
|
||||
action = "re-encode" if (reenc_v or reenc_a) else "remux"
|
||||
log.info("job %s: %s (v=%s a=%s mp4=%s) -> browser-safe mp4", job_id, action, vcodec, acodec, is_mp4)
|
||||
_set_job(job_id, phase="processing", progress=0, speed_bps=None, eta_s=None)
|
||||
# Prefer the info duration; probe the file when the extractor didn't provide one, so a long
|
||||
# re-encode still shows real progress instead of a frozen 0%.
|
||||
total_s = int(info["duration"]) if info.get("duration") else editmod.probe_duration(produced)
|
||||
editmod.run_ffmpeg(
|
||||
editmod.build_browser_safe_cmd(produced, dest, reenc_v, reenc_a),
|
||||
total_s=total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "processing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
produced.unlink(missing_ok=True)
|
||||
# Keep the stored asset metadata truthful about what's now on disk.
|
||||
if reenc_v:
|
||||
info["vcodec"] = "h264"
|
||||
if reenc_a:
|
||||
info["acodec"] = "aac"
|
||||
return dest
|
||||
|
||||
|
||||
def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spec: dict) -> None:
|
||||
staging = _staging_dir(asset_id)
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_set_job(job_id, phase="downloading", progress=0)
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref)
|
||||
url = service.source_url(source_kind, source_ref)
|
||||
info = _media_info(_extract_with_fallback(job_id, asset_id, url, spec, staging, adapter))
|
||||
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
|
||||
# identifies post-extraction) so the .nfo uniqueid type + uploader_url agree on one source.
|
||||
adapter = adapters.resolve_adapter(source_kind, source_ref, info)
|
||||
|
||||
produced = _produced_file(info, staging)
|
||||
produced = _ensure_browser_playable(job_id, produced, spec, info)
|
||||
ext = produced.suffix.lstrip(".").lower()
|
||||
meta = storage.MediaMeta(
|
||||
video_id=info.get("id") or source_ref,
|
||||
title=normalize_title(info.get("title")) or source_ref,
|
||||
uploader=info.get("uploader") or info.get("channel") or "Unknown Channel",
|
||||
upload_date=_parse_upload_date(info.get("upload_date")),
|
||||
uniqueid_type=adapter.nfo_uniqueid_type(),
|
||||
duration_s=int(info["duration"]) if info.get("duration") else None,
|
||||
description=info.get("description"),
|
||||
thumbnail_url=info.get("thumbnail"),
|
||||
)
|
||||
|
||||
layout, retention_days = _download_settings()
|
||||
rel = storage.rel_path(meta, ext, layout, asset_id=asset_id)
|
||||
thumb = _find_thumbnail(staging, produced)
|
||||
# Convert the raw thumbnail (webp/png/jpeg) to a real JPEG ourselves — robustly — for the
|
||||
# poster + og:image sidecar, now that yt-dlp's fatal thumbnail convertor is gone. On failure
|
||||
# this is None and write_sidecars writes no poster, so ensure_poster cuts a frame instead.
|
||||
poster_jpg = editmod.thumbnail_to_jpg(thumb, staging / "poster.jpg")
|
||||
storage.place_file(produced, settings.download_root, rel)
|
||||
nfo_ok = storage.write_sidecars(settings.download_root, rel, meta, poster_jpg)
|
||||
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.duration_s = meta.duration_s
|
||||
asset.width = int(info["width"]) if info.get("width") else None
|
||||
asset.height = int(info["height"]) if info.get("height") else None
|
||||
asset.container = ext
|
||||
asset.vcodec = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
||||
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||
asset.title = meta.title
|
||||
asset.uploader = meta.uploader
|
||||
asset.uploader_url = adapter.uploader_url(info)
|
||||
asset.upload_date = meta.upload_date
|
||||
asset.thumbnail_url = meta.thumbnail_url
|
||||
# Record a self-hosted poster for every download: it's the reliable link-preview
|
||||
# (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
|
||||
# and can't be relied on cross-origin. write_sidecars already wrote <base>.jpg from
|
||||
# the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
|
||||
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
||||
asset.source_webpage_url = info.get("webpage_url")
|
||||
asset.nfo_written = nfo_ok
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||
owner_id = job.user_id if job else None
|
||||
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None, "speed_bps": None,
|
||||
"eta_s": None}
|
||||
if job and not job.display_name and meta.title:
|
||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
||||
fields["display_name"] = meta.title[:255]
|
||||
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
|
||||
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
|
||||
finalized = service.finalize_if_running(db, job_id, **fields)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
||||
log.info("job %s done: %s", job_id, rel)
|
||||
|
||||
except (_Aborted, editmod.EditAborted):
|
||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
|
||||
msg = str(exc)[:500]
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("job %s failed: %s", job_id, msg)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def _reset_asset_pending(asset_id: int) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset and asset.status == "downloading":
|
||||
asset.status = "pending"
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- editor derivatives (phase 2: trim/crop via ffmpeg) -------------------------------------
|
||||
|
||||
def _edit_staging_dir(asset_id: int) -> Path:
|
||||
return Path(settings.download_root) / ".staging" / f"edit-{asset_id}"
|
||||
|
||||
|
||||
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "error"
|
||||
asset.error = msg
|
||||
# Atomic fail: only while still running (see the download error path).
|
||||
service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||
error_code=dlerrors.classify(msg), phase=None,
|
||||
)
|
||||
db.commit()
|
||||
log.warning("edit job %s failed: %s", job_id, msg)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
job = db.get(DownloadJob, job_id)
|
||||
src = db.get(MediaAsset, job.source_asset_id) if job and job.source_asset_id else None
|
||||
edit_spec = dict(job.edit_spec or {}) if job else {}
|
||||
user_id = job.user_id if job else None
|
||||
src_ready = src is not None and src.status == "ready" and bool(src.rel_path)
|
||||
src_rel = src.rel_path if src else None
|
||||
src_w, src_h, src_dur = (src.width, src.height, src.duration_s) if src else (None, None, None)
|
||||
|
||||
if not src_ready or not src_rel:
|
||||
_fail_edit(job_id, asset_id, "The source download is no longer available.")
|
||||
return
|
||||
src_path = storage.abs_path(settings.download_root, src_rel)
|
||||
if not src_path.exists():
|
||||
_fail_edit(job_id, asset_id, "The source file is missing.")
|
||||
return
|
||||
|
||||
src_ext = src_path.suffix.lstrip(".").lower() or "mp4"
|
||||
crop = editmod.has_crop(edit_spec)
|
||||
out_ext = "mp4" if editmod.needs_reencode(edit_spec) else src_ext
|
||||
total_s = editmod.clip_duration(edit_spec, src_dur) or src_dur or 0
|
||||
staging = _edit_staging_dir(asset_id)
|
||||
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
dest_staging = staging / f"out.{out_ext}"
|
||||
|
||||
_set_job(job_id, phase="editing", progress=0, speed_bps=None, eta_s=None)
|
||||
if edit_spec.get("segments"):
|
||||
cmd, prep = editmod.build_concat_plan(src_path, dest_staging, edit_spec, out_ext, staging)
|
||||
for p, content in prep.items():
|
||||
p.write_text(content, encoding="utf-8")
|
||||
else:
|
||||
cmd = editmod.build_edit_cmd(src_path, dest_staging, edit_spec, out_ext)
|
||||
editmod.run_ffmpeg(
|
||||
cmd,
|
||||
total_s,
|
||||
on_progress=_ffmpeg_progress_writer(job_id, "editing"),
|
||||
should_cancel=_cancel_poll(job_id),
|
||||
)
|
||||
if not dest_staging.exists():
|
||||
raise RuntimeError("ffmpeg produced no output file")
|
||||
|
||||
rel = storage.edit_rel_path(user_id, asset_id, out_ext)
|
||||
storage.place_file(dest_staging, settings.download_root, rel)
|
||||
final = storage.abs_path(settings.download_root, rel)
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
_, retention_days = _download_settings()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=retention_days)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
if asset:
|
||||
asset.status = "ready"
|
||||
asset.rel_path = rel
|
||||
asset.size_bytes = size
|
||||
asset.container = out_ext
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
asset.expires_at = expires
|
||||
job = db.get(DownloadJob, job_id)
|
||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||
owner_id = job.user_id if job else None
|
||||
dl_title = asset.title if asset else None
|
||||
# Atomic terminal write (see the download path): don't resurrect a route-stopped job.
|
||||
finalized = service.finalize_if_running(
|
||||
db, job_id, status=JobStatus.DONE, progress=100, phase=None
|
||||
)
|
||||
db.commit()
|
||||
if finalized:
|
||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
||||
log.info("edit job %s done: %s", job_id, rel)
|
||||
|
||||
except editmod.EditAborted:
|
||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||
_reset_asset_pending(asset_id)
|
||||
log.info("edit job %s aborted (pause/cancel)", job_id)
|
||||
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
|
||||
_fail_edit(job_id, asset_id, str(exc)[:500])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
# --- loop -----------------------------------------------------------------------------------
|
||||
|
||||
def _worker_loop(idx: int) -> None:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""E3 S3 (B-core-10): the config/state mutators flush but do NOT self-commit, so the caller owns the
|
||||
transaction and a change lands together with its audit row. These lock that contract — a rollback
|
||||
before the caller commits discards the change (proving no hidden internal commit), while a same-session
|
||||
read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from app import state, sysconfig
|
||||
|
||||
|
||||
def test_set_value_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
default = sysconfig.get_int(db, "quota_daily_budget")
|
||||
new = default + 4321
|
||||
|
||||
sysconfig.set_value(db, "quota_daily_budget", new)
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == new # visible in-session (flushed)
|
||||
|
||||
db.rollback() # the caller never committed
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default # discarded — no hidden self-commit
|
||||
|
||||
|
||||
def test_reset_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
default = sysconfig.get_int(db, "quota_daily_budget")
|
||||
sysconfig.set_value(db, "quota_daily_budget", default + 10)
|
||||
db.commit() # a persisted override to reset
|
||||
|
||||
sysconfig.reset(db, "quota_daily_budget")
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default # reset visible in-session
|
||||
db.rollback()
|
||||
assert sysconfig.get_int(db, "quota_daily_budget") == default + 10 # reset discarded until committed
|
||||
|
||||
|
||||
def test_set_sync_paused_flushes_but_leaves_the_commit_to_the_caller(db):
|
||||
assert state.is_sync_paused(db) is False
|
||||
|
||||
state.set_sync_paused(db, True)
|
||||
assert state.is_sync_paused(db) is True # visible in-session
|
||||
|
||||
db.rollback()
|
||||
assert state.is_sync_paused(db) is False # discarded — the caller owns the commit
|
||||
@@ -9,13 +9,14 @@
|
||||
Uses the `db` fixture (real Postgres) — skipped when no database is reachable. `_claim_job` runs in
|
||||
its OWN session, so every test COMMITS its setup before calling it.
|
||||
"""
|
||||
from app.models import DownloadJob, DownloadQuota, User
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
|
||||
from app.worker import _claim_job
|
||||
|
||||
|
||||
def _job(db, user_id, queue_pos, status="queued"):
|
||||
def _job(db, user_id, queue_pos, status="queued", asset_id=None):
|
||||
j = DownloadJob(
|
||||
user_id=user_id,
|
||||
asset_id=asset_id,
|
||||
source_kind="youtube",
|
||||
source_ref="x" * 11,
|
||||
status=status,
|
||||
@@ -26,6 +27,15 @@ def _job(db, user_id, queue_pos, status="queued"):
|
||||
return j
|
||||
|
||||
|
||||
def _asset(db, status, ref):
|
||||
a = MediaAsset(
|
||||
source_kind="url", source_ref=ref, format_sig="s", status=status, ref_count=1
|
||||
)
|
||||
db.add(a)
|
||||
db.flush()
|
||||
return a
|
||||
|
||||
|
||||
def _quota(db, user_id, max_concurrent, unlimited=False):
|
||||
db.add(
|
||||
DownloadQuota(
|
||||
@@ -104,6 +114,27 @@ def test_stored_zero_concurrency_is_floored_not_starving(db):
|
||||
assert db.get(DownloadJob, claimed).user_id == a.id # A's own first job runs (floored to 1)
|
||||
|
||||
|
||||
def test_job_whose_asset_is_already_downloading_is_skipped(db):
|
||||
"""B-sub-3: a queued job whose shared asset is ALREADY `downloading` (another worker owns it) must
|
||||
not be claimed — otherwise a second worker claims it, finds the asset busy, requeues, and ping-
|
||||
pongs on it, starving everyone behind. The claim SQL excludes it, so the job behind it (on a
|
||||
different, free asset) is served instead."""
|
||||
u = User(email="asset-excl@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
_quota(db, u.id, max_concurrent=5) # high → the ASSET rule, not the per-user rule, does the excluding
|
||||
busy = _asset(db, status="downloading", ref="busy")
|
||||
free = _asset(db, status="pending", ref="free")
|
||||
_job(db, u.id, queue_pos=1, status="running", asset_id=busy.id) # the holder actually downloading it
|
||||
competitor = _job(db, u.id, queue_pos=2, asset_id=busy.id) # same asset — must be skipped
|
||||
behind = _job(db, u.id, queue_pos=3, asset_id=free.id) # different asset — should be served
|
||||
db.commit()
|
||||
|
||||
assert _claim_job() == behind.id # the competitor on the busy asset was skipped
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, competitor.id).status == "queued" # still queued, never claimed
|
||||
|
||||
|
||||
def test_unlimited_user_is_never_excluded(db):
|
||||
u = User(email="unlimited@example.com")
|
||||
db.add(u)
|
||||
|
||||
@@ -6,7 +6,7 @@ with "PosixPath('.') has an empty name" instead of downloading."""
|
||||
import pytest
|
||||
|
||||
from app.downloads.edit import _concat_quote, parse_probe_streams
|
||||
from app.worker import _media_info, _produced_file
|
||||
from app.downloads.pipeline import _media_info, _produced_file
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""E3 S2 characterization net — MediaAsset.ref_count enqueue/release accounting (B-sub-5).
|
||||
|
||||
`ref_count` tracks how many DownloadJobs still hold a shared cache asset; when it hits zero a READY
|
||||
asset's file + row are freed. Enqueue +1's it and release_asset -1's it. Today those are Python
|
||||
read-modify-writes (`asset.ref_count = (asset.ref_count or 0) ± 1`), which lose an update under two
|
||||
concurrent enqueues/releases and can delete a file out from under a live holder. The fix makes the
|
||||
increment/decrement atomic at the DB; this net locks the OBSERVABLE single-threaded semantics so the
|
||||
conversion is provably behaviour-equivalent (concurrency itself can't be exercised in one session).
|
||||
|
||||
DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.downloads import service
|
||||
from app.models import DownloadJob, MediaAsset, User
|
||||
|
||||
|
||||
def _user(db, email="ref@example.com"):
|
||||
u = User(email=email)
|
||||
db.add(u)
|
||||
db.flush()
|
||||
return u
|
||||
|
||||
|
||||
def _ref_count(db, asset_id) -> int | None:
|
||||
# Read straight from the row — the atomic-UPDATE fix leaves the ORM copy stale on purpose.
|
||||
return db.execute(
|
||||
text("SELECT ref_count FROM media_assets WHERE id = :id"), {"id": asset_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def _holding_asset(db, *, status="ready", ref_count=1, rel_path=None) -> MediaAsset:
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref=f"r{ref_count}-{status}", format_sig="sig",
|
||||
status=status, ref_count=ref_count, rel_path=rel_path,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _job(db, user_id, asset_id, status="done") -> DownloadJob:
|
||||
j = DownloadJob(
|
||||
user_id=user_id, asset_id=asset_id, source_kind="url", source_ref="r",
|
||||
status=status, queue_pos=1, profile_snapshot={},
|
||||
)
|
||||
db.add(j)
|
||||
db.flush()
|
||||
return j
|
||||
|
||||
|
||||
def test_two_enqueues_share_one_asset_and_stack_ref_count(db):
|
||||
u = _user(db)
|
||||
j1 = service.enqueue(db, u.id, "url", "http://example.com/v", {})
|
||||
j2 = service.enqueue(db, u.id, "url", "http://example.com/v", {})
|
||||
# One shared cache asset, two jobs on it, ref_count == 2.
|
||||
assert j1.asset_id == j2.asset_id
|
||||
assets = db.execute(select(MediaAsset)).scalars().all()
|
||||
assert len(assets) == 1
|
||||
assert _ref_count(db, j1.asset_id) == 2
|
||||
|
||||
|
||||
def test_release_keeps_asset_until_last_holder_then_deletes_row(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="ready", ref_count=2)
|
||||
a = _job(db, u.id, asset.id, status="done")
|
||||
b = _job(db, u.id, asset.id, status="done")
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, a)
|
||||
db.commit()
|
||||
assert _ref_count(db, asset.id) == 1
|
||||
assert db.get(MediaAsset, asset.id) is not None # still held by b
|
||||
|
||||
service.release_asset(db, b)
|
||||
db.commit()
|
||||
# Last holder gone → the ready asset's row is freed (rel_path None, so no file to unlink).
|
||||
assert db.execute(select(MediaAsset)).scalars().all() == []
|
||||
|
||||
|
||||
def test_release_is_a_noop_for_a_non_holding_job(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="ready", ref_count=1)
|
||||
canceled = _job(db, u.id, asset.id, status="canceled") # canceled ∉ HOLDING
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, canceled)
|
||||
db.commit()
|
||||
assert _ref_count(db, asset.id) == 1 # untouched — a canceled job already released while holding
|
||||
|
||||
|
||||
def test_release_never_drives_ref_count_negative(db):
|
||||
u = _user(db)
|
||||
# error-status asset (kept at zero, unlike ready) so we isolate the clamp from the delete branch.
|
||||
asset = _holding_asset(db, status="error", ref_count=0) # already at zero (defensive)
|
||||
j = _job(db, u.id, asset.id, status="error") # error ∈ HOLDING, so release runs
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, j)
|
||||
db.commit()
|
||||
# The count clamps at 0 (never negative) even when released with nothing left to release.
|
||||
assert _ref_count(db, asset.id) == 0
|
||||
assert db.get(MediaAsset, asset.id) is not None
|
||||
|
||||
|
||||
def test_errored_asset_is_not_deleted_at_zero(db):
|
||||
u = _user(db)
|
||||
asset = _holding_asset(db, status="error", ref_count=1)
|
||||
j = _job(db, u.id, asset.id, status="error")
|
||||
db.commit()
|
||||
|
||||
service.release_asset(db, j)
|
||||
db.commit()
|
||||
# An errored row is cheap (no file) and may be re-enqueued → never deleted here, only ready is.
|
||||
assert _ref_count(db, asset.id) == 0
|
||||
assert db.get(MediaAsset, asset.id) is not None
|
||||
@@ -0,0 +1,86 @@
|
||||
"""E3 S2 characterization net — download job state transitions (B-sub-8).
|
||||
|
||||
pause / resume / cancel / delete / redownload currently live inline in the routes, each hand-rolling
|
||||
the asset bookkeeping (release, error-reset, fresh-asset rebuild). This pins their OBSERVABLE HTTP
|
||||
behaviour so the logic can move into `service.*` methods (with the routes as thin callers) provably
|
||||
behaviour-equivalently. DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.models import DownloadJob, MediaAsset, User
|
||||
|
||||
|
||||
def _seed(db, *, job_status, asset_status="ready", ref_count=1, rel_path=None):
|
||||
u = User(email=f"tr-{job_status}-{asset_status}-{ref_count}@example.com")
|
||||
db.add(u)
|
||||
db.flush()
|
||||
asset = MediaAsset(
|
||||
source_kind="url", source_ref=f"r-{u.id}", format_sig="s",
|
||||
status=asset_status, ref_count=ref_count, rel_path=rel_path,
|
||||
)
|
||||
db.add(asset)
|
||||
db.flush()
|
||||
job = DownloadJob(
|
||||
user_id=u.id, asset_id=asset.id, source_kind="url", source_ref="r",
|
||||
status=job_status, queue_pos=1, profile_snapshot={},
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
return u, asset, job
|
||||
|
||||
|
||||
def _ref(db, asset_id):
|
||||
return db.execute(
|
||||
text("SELECT ref_count FROM media_assets WHERE id = :id"), {"id": asset_id}
|
||||
).scalar()
|
||||
|
||||
|
||||
def test_cancel_releases_the_asset_and_marks_canceled(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="running", asset_status="downloading", ref_count=1)
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/cancel")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "canceled"
|
||||
assert _ref(db, asset.id) == 0 # the hold was released while the job still counted as holding
|
||||
|
||||
|
||||
def test_pause_then_resume_round_trips_queued(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="queued", asset_status="pending")
|
||||
c = as_user(u)
|
||||
assert c.post(f"/api/downloads/{job.id}/pause").status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "paused"
|
||||
assert c.post(f"/api/downloads/{job.id}/resume").status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "queued"
|
||||
|
||||
|
||||
def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="error", asset_status="error")
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/resume")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "queued"
|
||||
# A failed shared asset is reset so the worker actually re-downloads instead of re-inheriting the error.
|
||||
assert db.get(MediaAsset, asset.id).status == "pending"
|
||||
|
||||
|
||||
def test_delete_releases_and_removes_the_job(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
||||
r = as_user(u).request("DELETE", f"/api/downloads/{job.id}")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id) is None
|
||||
assert db.get(MediaAsset, asset.id) is None # last holder gone → ready asset freed
|
||||
|
||||
|
||||
def test_redownload_rebuilds_into_a_fresh_asset(db, as_user):
|
||||
u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None)
|
||||
old_asset_id = asset.id
|
||||
r = as_user(u).post(f"/api/downloads/{job.id}/redownload")
|
||||
assert r.status_code == 200, r.text
|
||||
db.expire_all()
|
||||
j = db.get(DownloadJob, job.id)
|
||||
assert j.status == "queued"
|
||||
assert j.progress == 0
|
||||
assert j.asset_id != old_asset_id # a fresh private asset, not the old (now-released) one
|
||||
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
|
||||
"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"),
|
||||
"O2": Tag(user_id=None, name="Docs", 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"),
|
||||
@@ -104,8 +105,17 @@ def _seed_tagged(db, make_user):
|
||||
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": []}
|
||||
# channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
|
||||
# AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
|
||||
# language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
|
||||
# {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (For topic, chA and chB each carry a DIFFERENT topic tag
|
||||
# — T1 vs T2 — so topic {T1,T2} → OR {chA,chB,chC} vs AND {} empty.)
|
||||
links = {
|
||||
"chA": ["L1", "L2", "T1", "O1"],
|
||||
"chB": ["L1", "T2"],
|
||||
"chC": ["L2", "T1", "O1", "O2"],
|
||||
"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:
|
||||
@@ -127,8 +137,12 @@ _SUMMARY_CASES = [
|
||||
"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={T1}&tags={T2}&tag_mode_topic=or",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
|
||||
# Per-category any/all is NOT topic-only — language and other must drill down identically too.
|
||||
"show=all&tags={L1}&tags={L2}&tag_mode_language=or",
|
||||
"show=all&tags={L1}&tags={L2}&tag_mode_language=and",
|
||||
"show=all&tags={O1}&tags={O2}&tag_mode_other=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",
|
||||
|
||||
@@ -4,20 +4,20 @@ run_ffmpeg calls `on_progress`/`should_cancel` once per ffmpeg output LINE (`-pr
|
||||
emits a block of ~12 every half second), and both callbacks are database round-trips: `_set_job`
|
||||
opens a session and UPDATE+COMMITs, `_job_status` opens one and SELECTs. Unthrottled, a long
|
||||
re-encode is ~10^5 of each."""
|
||||
from app import worker
|
||||
import app.downloads.pipeline as pipeline
|
||||
|
||||
|
||||
class TestProgressWriter:
|
||||
def test_writes_the_first_value_immediately(self, monkeypatch):
|
||||
writes = []
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
worker._ffmpeg_progress_writer(7, "processing")(12.4)
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
pipeline._ffmpeg_progress_writer(7, "processing")(12.4)
|
||||
assert writes == [{"progress": 12, "phase": "processing"}]
|
||||
|
||||
def test_a_burst_of_lines_costs_one_write(self, monkeypatch):
|
||||
writes = []
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing")
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing")
|
||||
for pct in range(0, 40): # what one second of ffmpeg output looks like
|
||||
write(pct)
|
||||
assert len(writes) == 1
|
||||
@@ -25,8 +25,8 @@ class TestProgressWriter:
|
||||
def test_the_next_second_writes_again(self, monkeypatch):
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10)
|
||||
write(11) # same second → dropped
|
||||
clock["t"] += 1.5
|
||||
@@ -36,19 +36,51 @@ class TestProgressWriter:
|
||||
def test_an_unchanged_whole_percent_is_never_rewritten(self, monkeypatch):
|
||||
writes = []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(worker, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
write = worker._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: None)
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10.1)
|
||||
clock["t"] += 5
|
||||
write(10.9) # still 10% — the bar shows integers
|
||||
assert [w["progress"] for w in writes] == [10]
|
||||
|
||||
def test_a_slow_same_percent_stretch_heartbeats_the_watchdog(self, monkeypatch):
|
||||
"""B-sub-4: a re-encode that sits on one integer percent for minutes must NOT be killed as
|
||||
stalled — the writer touches the row (heartbeat) each interval even though it writes no new
|
||||
progress, so the stall watchdog keeps seeing a fresh updated_at."""
|
||||
writes, touches = [], []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: touches.append(job_id))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(10.1) # first real write → progress 10
|
||||
for _ in range(3): # three more intervals stuck on 10%
|
||||
clock["t"] += 1.5
|
||||
write(10.4)
|
||||
assert [w["progress"] for w in writes] == [10] # progress never rewritten
|
||||
assert touches == [7, 7, 7] # …but each interval heartbeats the watchdog
|
||||
|
||||
def test_no_redundant_heartbeat_when_a_progress_write_just_happened(self, monkeypatch):
|
||||
"""A progress write already refreshes updated_at, so an unchanged-percent call in the SAME
|
||||
interval must NOT also heartbeat — that would be a redundant DB write (~2x during an active
|
||||
download). The heartbeat only fires once no write/beat touched the row for an interval."""
|
||||
writes, touches = [], []
|
||||
clock = {"t": 1000.0}
|
||||
monkeypatch.setattr(pipeline, "_set_job", lambda job_id, **f: writes.append(f))
|
||||
monkeypatch.setattr(pipeline, "_touch_job", lambda job_id: touches.append(job_id))
|
||||
write = pipeline._ffmpeg_progress_writer(7, "processing", now=lambda: clock["t"])
|
||||
write(6.0) # progress write at t=1000 → refreshes updated_at
|
||||
clock["t"] += 0.3
|
||||
write(6.4) # same percent, SAME interval → the write already touched the row, so no heartbeat
|
||||
assert [w["progress"] for w in writes] == [6]
|
||||
assert touches == []
|
||||
|
||||
|
||||
class TestCancelPoll:
|
||||
def test_asks_once_then_reuses_the_answer(self, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||
cancelled = worker._cancel_poll(7)
|
||||
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: calls.append(job_id) or "running")
|
||||
cancelled = pipeline._cancel_poll(7)
|
||||
for _ in range(40):
|
||||
assert cancelled() is False
|
||||
assert len(calls) == 1
|
||||
@@ -61,8 +93,8 @@ class TestCancelPoll:
|
||||
calls.append(job_id)
|
||||
return "cancelled"
|
||||
|
||||
monkeypatch.setattr(worker, "_job_status", status)
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_job_status", status)
|
||||
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is True
|
||||
clock["t"] += 60 # cancellation is sticky: no re-query, and no un-cancelling
|
||||
assert cancelled() is True
|
||||
@@ -71,8 +103,8 @@ class TestCancelPoll:
|
||||
def test_a_cancel_arriving_later_is_seen_on_the_next_tick(self, monkeypatch):
|
||||
clock = {"t": 1000.0}
|
||||
state = {"status": "running"}
|
||||
monkeypatch.setattr(worker, "_job_status", lambda job_id: state["status"])
|
||||
cancelled = worker._cancel_poll(7, now=lambda: clock["t"])
|
||||
monkeypatch.setattr(pipeline, "_job_status", lambda job_id: state["status"])
|
||||
cancelled = pipeline._cancel_poll(7, now=lambda: clock["t"])
|
||||
assert cancelled() is False
|
||||
state["status"] = "paused"
|
||||
clock["t"] += 1.0 # the throttle must not delay a cancel by more than its interval
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed.
|
||||
|
||||
E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile
|
||||
and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would
|
||||
resolve to a blank image on every card that tried to use it, so it must be null and let the client fall
|
||||
back to the poster."""
|
||||
|
||||
from app.models import PlexItem, PlexShow
|
||||
from app.plex.cards import movie_card, show_card
|
||||
|
||||
|
||||
def test_movie_card_emits_art_when_art_key_present():
|
||||
it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1")
|
||||
card = movie_card(it, None)
|
||||
assert card["thumb"] == "/api/plex/image/m1"
|
||||
assert card["art"] == "/api/plex/image/m1?variant=art"
|
||||
|
||||
|
||||
def test_movie_card_art_is_none_without_art_key():
|
||||
it = PlexItem(rating_key="m2", kind="movie", title="M2")
|
||||
assert movie_card(it, None)["art"] is None
|
||||
|
||||
|
||||
def test_show_card_emits_art_when_art_key_present():
|
||||
sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2")
|
||||
assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art"
|
||||
|
||||
|
||||
def test_show_card_art_is_none_without_art_key():
|
||||
sh = PlexShow(rating_key="s2", title="S2")
|
||||
assert show_card(sh)["art"] is None
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Piece 1a: the Plex collection filter is multi-select with any/all combine (a title can belong to
|
||||
several collections). `collections=A,B` = ANY (OR, default); `&collection_mode=all` = ALL (AND).
|
||||
DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import app.state as state
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.plex.client import PlexError
|
||||
from app.routes.plex import collections as coll_routes
|
||||
|
||||
|
||||
def _seed(db):
|
||||
"""Three movies: one in collection A, one in B, one in both — so any/all are distinguishable."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
common = dict(
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
db.add_all([
|
||||
PlexItem(rating_key="mA", title="Only A", collection_keys=["A"],
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="mB", title="Only B", collection_keys=["B"],
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="mAB", title="A and B", collection_keys=["A", "B"],
|
||||
added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), **common),
|
||||
])
|
||||
user = User(email="plexcoll@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_single_collection_filters_to_its_members(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A")
|
||||
assert _titles(resp) == {"Only A", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_any_is_the_union(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B")
|
||||
assert _titles(resp) == {"Only A", "Only B", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_all_is_the_intersection(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch,
|
||||
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
|
||||
)
|
||||
assert _titles(resp) == {"A and B"}
|
||||
|
||||
|
||||
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
|
||||
# The browse-tile montage (top films, best-rated first) + the collection's total runtime.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", child_count=2))
|
||||
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", title="M1", duration_s=3600, rating=8.0,
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="m2", title="M2", duration_s=1800, rating=9.0,
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
|
||||
])
|
||||
user = User(email="collart@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
if c["id"] == "A")
|
||||
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
|
||||
assert card["total_runtime_s"] == 5400 # 3600 + 1800
|
||||
|
||||
|
||||
def test_create_collection_rejects_duplicate_name(db, monkeypatch):
|
||||
# Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
|
||||
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Marvel"))
|
||||
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
|
||||
user = User(email="dup@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
try:
|
||||
r = TestClient(app).post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
|
||||
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
|
||||
# members ordered by release year then title.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2))
|
||||
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
|
||||
db.add_all([
|
||||
PlexItem(rating_key="later", title="Later", year=1996, duration_s=3600, content_rating="PG", **common),
|
||||
PlexItem(rating_key="early", title="Early", year=1992, duration_s=1800, content_rating="G", **common),
|
||||
])
|
||||
user = User(email="cd@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
d = _get(db, user, monkeypatch, "/api/plex/collections/A").json()
|
||||
assert d["title"] == "Coll A" and d["summary"] == "S"
|
||||
assert d["year_min"] == 1992 and d["year_max"] == 1996
|
||||
assert d["content_rating"] == "PG" # most restrictive of {G, PG}
|
||||
assert d["total_runtime_s"] == 5400
|
||||
assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order
|
||||
|
||||
|
||||
# --- Write-through collection mutations (create/add/remove/rename write to Plex, then resync the local
|
||||
# mirror). We stub PlexClient so the DB lane can drive the happy path, the 422-on-mutation-failure
|
||||
# rollback, and the 502-on-resync-failure split (a successful write whose follow-up refresh read fails
|
||||
# must NOT be reported as a write failure). ------------------------------------------------------------
|
||||
class _CollStub:
|
||||
"""A PlexClient stand-in recording calls; `fail_on` makes one method raise PlexError."""
|
||||
|
||||
def __init__(self, fail_on: str | None = None):
|
||||
self.fail_on = fail_on
|
||||
self.calls: list[str] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def _maybe(self, name: str):
|
||||
self.calls.append(name)
|
||||
if self.fail_on == name:
|
||||
raise PlexError("boom")
|
||||
|
||||
def create_collection(self, section_key, title, item_rating_key):
|
||||
self._maybe("create_collection")
|
||||
return {"ratingKey": "NEW"}
|
||||
|
||||
def add_collection_item(self, rk, item):
|
||||
self._maybe("add_collection_item")
|
||||
|
||||
def remove_collection_item(self, rk, item):
|
||||
self._maybe("remove_collection_item")
|
||||
|
||||
def rename_collection(self, section_key, rk, title):
|
||||
self._maybe("rename_collection")
|
||||
|
||||
def delete_collection(self, rk):
|
||||
self._maybe("delete_collection")
|
||||
|
||||
|
||||
def _resync_ok(db, plex, lib, rk):
|
||||
"""Stand in for the post-write refresh: return the existing local row (no live Plex read)."""
|
||||
return db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
|
||||
|
||||
|
||||
def _seed_editable(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# Plain, admin-editable collection (title isn't a provider word, not smart → source "collection").
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="My Coll", editable=True,
|
||||
child_count=1))
|
||||
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1",
|
||||
collection_keys=["A"]))
|
||||
user = User(email="mut@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return lib, user
|
||||
|
||||
|
||||
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
|
||||
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_rename_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "rename_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.delete("/api/plex/collections/A/items/m1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "remove_collection_item" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub(fail_on="rename_collection")
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "write failed" in r.json()["detail"].lower()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch):
|
||||
# The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
|
||||
# write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
|
||||
# on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
|
||||
def _resync_boom(db_, plex, lib_, rk):
|
||||
# Simulate resync having flushed a partial local change before its Plex read blew up — the
|
||||
# route's rollback must undo it (this is what makes the rollback assertion non-vacuous).
|
||||
row = db_.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
|
||||
row.title = "PARTIAL_SHOULD_ROLL_BACK"
|
||||
db_.flush()
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "rename_collection" in stub.calls # the write DID happen
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
row = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
|
||||
assert row.title == "My Coll" # the partial resync upsert was rolled back
|
||||
|
||||
|
||||
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
|
||||
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the
|
||||
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="S", library_id=lib.id, title="Show Coll", child_count=2))
|
||||
db.add_all([
|
||||
PlexShow(rating_key="s1", library_id=lib.id, title="S1", collection_keys=["S"], rating=7.0,
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc)),
|
||||
PlexShow(rating_key="s2", library_id=lib.id, title="S2", collection_keys=["S"], rating=9.0,
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc)),
|
||||
])
|
||||
user = User(email="showcoll@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
if c["id"] == "S")
|
||||
assert card["art"] == ["s2", "s1"] # show members, best-rated first
|
||||
assert card["total_runtime_s"] == 0
|
||||
@@ -13,7 +13,7 @@ from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.routes.plex.browse as browse
|
||||
import app.routes.plex.detail as detail_routes
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
@@ -216,7 +216,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||
user = _user(db, "outage@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
@@ -240,7 +240,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
user = _user(db, "lazy@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["imdb_id"] == "tt0068646"
|
||||
@@ -267,7 +267,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||
user = _user(db, "ep@example.com")
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/item/e1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
@@ -298,7 +298,7 @@ def test_show_detail_serves_from_row(db, monkeypatch):
|
||||
def _boom(db_, rk):
|
||||
raise RuntimeError("plex unavailable")
|
||||
|
||||
monkeypatch.setattr(browse, "_show_enrichment", _boom)
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", _boom)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()["show"]
|
||||
@@ -322,7 +322,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
||||
monkeypatch.setattr(browse, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
||||
monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["show"]["imdb_id"] == "tt0068646"
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail
|
||||
payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB,
|
||||
and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped
|
||||
without Postgres.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, PlexState, User
|
||||
|
||||
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _seed(db):
|
||||
"""A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2
|
||||
(in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode."""
|
||||
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add_all([lib_m, lib_s])
|
||||
db.flush()
|
||||
|
||||
m1 = PlexItem(
|
||||
rating_key="m1", library_id=lib_m.id, kind="movie", title="Anchor Movie",
|
||||
guid="tmdb:111", genres=["Drama"], year=2001,
|
||||
added_at=NOW - timedelta(days=1),
|
||||
tmdb_enriched_at=NOW - timedelta(days=1),
|
||||
recommendations=[
|
||||
{"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"}
|
||||
],
|
||||
)
|
||||
m2 = PlexItem(
|
||||
rating_key="m2", library_id=lib_m.id, kind="movie", title="Rec Movie",
|
||||
guid="tmdb:555", genres=["Comedy"], year=2002,
|
||||
added_at=NOW - timedelta(days=2),
|
||||
)
|
||||
m3 = PlexItem(
|
||||
rating_key="m3", library_id=lib_m.id, kind="movie", title="Half-Watched Movie",
|
||||
guid="tmdb:333", genres=["Drama"], year=2003, duration_s=6000,
|
||||
added_at=NOW - timedelta(days=3),
|
||||
)
|
||||
sh1 = PlexShow(rating_key="sh1", library_id=lib_s.id, title="A Show", child_count=1,
|
||||
added_at=NOW - timedelta(hours=1)) # newest → leads recently-added
|
||||
db.add_all([m1, m2, m3, sh1])
|
||||
db.flush()
|
||||
ep1 = PlexItem(
|
||||
rating_key="sh1e1", library_id=lib_s.id, kind="episode", title="Ep 1",
|
||||
show_id=sh1.id, season_number=1, episode_number=1,
|
||||
)
|
||||
ep2 = PlexItem(
|
||||
rating_key="sh1e2", library_id=lib_s.id, kind="episode", title="Ep 2",
|
||||
show_id=sh1.id, season_number=1, episode_number=2,
|
||||
)
|
||||
db.add_all([ep1, ep2])
|
||||
|
||||
user = User(email="discover@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
db.add_all([
|
||||
# m1 watched most recently → the because-you-watched anchor.
|
||||
PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW),
|
||||
# m3 in-progress → continue watching.
|
||||
PlexState(user_id=user.id, item_id=m3.id, status="new", position_seconds=1200,
|
||||
progress_updated_at=NOW - timedelta(hours=1)),
|
||||
# sh1's ep1 in-progress → the show is in continue watching.
|
||||
PlexState(user_id=user.id, item_id=ep1.id, status="new", position_seconds=300,
|
||||
progress_updated_at=NOW - timedelta(minutes=30)),
|
||||
])
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _ids(cards):
|
||||
return [c["id"] for c in cards]
|
||||
|
||||
|
||||
def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both")
|
||||
assert resp.status_code == 200, resp.text
|
||||
d = resp.json()
|
||||
assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"}
|
||||
# Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3.
|
||||
assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"]
|
||||
# Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2).
|
||||
genres = {g["value"]: g["count"] for g in d["genres"]}
|
||||
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
||||
|
||||
|
||||
def test_genre_tiles_carry_poster_art(db, monkeypatch):
|
||||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||||
assert art["Comedy"] == ["m2"]
|
||||
|
||||
|
||||
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
ids = _ids(d["continue_watching"])
|
||||
# The in-progress movie and the show with an in-progress episode are both present; the watched
|
||||
# anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3).
|
||||
assert ids == ["sh1", "m3"]
|
||||
assert "m1" not in ids
|
||||
|
||||
|
||||
def test_continue_watching_null_status_in_hidden_out(db, monkeypatch):
|
||||
# A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL
|
||||
# (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it.
|
||||
# A hidden started movie must be excluded.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
m_null = PlexItem(rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1")
|
||||
m_hidden = PlexItem(rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2")
|
||||
db.add_all([m_null, m_hidden])
|
||||
db.flush()
|
||||
user = User(email="cw-null@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexState(user_id=user.id, item_id=m_null.id, status=None, position_seconds=500,
|
||||
progress_updated_at=NOW),
|
||||
PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden", position_seconds=500,
|
||||
progress_updated_at=NOW - timedelta(hours=1)),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"])
|
||||
assert "mn" in ids # NULL-status started movie IS in progress
|
||||
assert "mh" not in ids # hidden is excluded
|
||||
|
||||
|
||||
def test_continue_watching_excludes_fully_watched_show(db, monkeypatch):
|
||||
# A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must
|
||||
# keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it).
|
||||
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib_s)
|
||||
db.flush()
|
||||
sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1)
|
||||
db.add(sh)
|
||||
db.flush()
|
||||
ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1",
|
||||
show_id=sh.id, season_number=1, episode_number=1)
|
||||
db.add(ep)
|
||||
db.flush()
|
||||
user = User(email="fullywatched@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0,
|
||||
progress_updated_at=NOW))
|
||||
db.commit()
|
||||
|
||||
ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"])
|
||||
assert "shw" not in ids # fully watched → excluded from continue-watching
|
||||
|
||||
|
||||
def test_because_you_watched_resolves_in_library_recs(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
bw = d["because_you_watched"]
|
||||
assert bw is not None
|
||||
assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie"
|
||||
# m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card.
|
||||
assert _ids(bw["items"]) == ["m2"]
|
||||
|
||||
|
||||
def test_because_you_watched_null_without_history(db, monkeypatch):
|
||||
_seed(db)
|
||||
fresh = User(email="fresh-discover@example.com")
|
||||
db.add(fresh)
|
||||
db.commit()
|
||||
d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
# A user with no watched-and-enriched movie gets no recommendation rail.
|
||||
assert d["because_you_watched"] is None
|
||||
assert d["continue_watching"] == []
|
||||
|
||||
|
||||
def test_surprise_scope_and_bound(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2")
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = resp.json()["items"]
|
||||
assert len(items) <= 2
|
||||
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
||||
|
||||
|
||||
def test_recently_watched_movies_and_episodes(db, monkeypatch):
|
||||
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
|
||||
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
|
||||
user = _seed(db)
|
||||
ep2 = db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key == "sh1e2")
|
||||
).scalars().one()
|
||||
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
|
||||
db.commit()
|
||||
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"]
|
||||
by_id = {c["id"]: c for c in rw}
|
||||
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
||||
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
|
||||
assert by_id["sh1e2"]["show_title"] == "A Show" # episode carries its show's title
|
||||
# ep2 was watched at NOW (newest) → it leads over m1 (watched earlier in _seed... same NOW ok).
|
||||
assert set(_ids(rw)) == {"m1", "sh1e2"}
|
||||
|
||||
|
||||
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
|
||||
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
|
||||
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||||
user = _seed(db)
|
||||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"]
|
||||
ids = _ids(items)
|
||||
assert "m1" not in ids # watched → excluded
|
||||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
||||
assert len([c for c in items if c["type"] == "movie"]) <= 3
|
||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||
|
||||
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
|
||||
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||||
# The show drops out of continue watching under the movie scope.
|
||||
assert _ids(d["continue_watching"]) == ["m3"]
|
||||
|
||||
|
||||
def test_filters_narrow_every_discover_rail(db, monkeypatch):
|
||||
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
|
||||
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json()
|
||||
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
|
||||
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
||||
assert _ids(d["continue_watching"]) == ["m3"]
|
||||
# Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
|
||||
assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
|
||||
# Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
|
||||
assert d["because_you_watched"] is None
|
||||
|
||||
|
||||
def test_filters_narrow_surprise(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
items = _get(
|
||||
db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||||
).json()["items"]
|
||||
# Only the one Comedy movie qualifies under the filter.
|
||||
assert _ids(items) == ["m2"]
|
||||
@@ -12,7 +12,33 @@ import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexLibrary, PlexShow, User
|
||||
from app.models import PlexItem, PlexLibrary, PlexShow, User
|
||||
|
||||
|
||||
def test_facets_returns_studio_vocabulary(db, monkeypatch):
|
||||
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
|
||||
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
|
||||
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
|
||||
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
|
||||
])
|
||||
user = User(email="studiofacets@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert resp.status_code == 200, resp.text
|
||||
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
|
||||
assert studios == {"Pixar": 2, "A24": 1}
|
||||
|
||||
|
||||
def test_facets_survives_json_null_genres(db, monkeypatch):
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=<kw>, which
|
||||
narrows the unified library to rows whose persisted `keywords` JSONB array contains it. DB lane
|
||||
(`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, User
|
||||
|
||||
|
||||
def _seed(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Heist Movie",
|
||||
keywords=["heist", "bank robbery"]),
|
||||
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Space Movie",
|
||||
keywords=["space", "aliens"]),
|
||||
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"),
|
||||
])
|
||||
user = User(email="kwfilter@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_keyword_filter_narrows_to_matching_rows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _titles(resp) == {"Heist Movie"}
|
||||
|
||||
|
||||
def test_no_keyword_filter_returns_all(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie")
|
||||
assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"}
|
||||
|
||||
|
||||
def test_unknown_keyword_matches_nothing(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch")
|
||||
assert resp.json()["items"] == []
|
||||
@@ -11,7 +11,7 @@ import app.plex.sync as syncmod
|
||||
from app import sysconfig
|
||||
from app.models import PlexItem, PlexLibrary
|
||||
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
|
||||
from app.routes.plex.browse import _content_libs
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.models import (
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.routes.plex.browse import _content_libs
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
NAME = "Robert De Niro"
|
||||
|
||||
@@ -100,7 +100,20 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body == {"name": "Ghost Actor", "photo": None, "movies": [], "shows": []}
|
||||
# An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null).
|
||||
assert body == {
|
||||
"name": "Ghost Actor",
|
||||
"photo": None,
|
||||
"biography": None,
|
||||
"birthday": None,
|
||||
"deathday": None,
|
||||
"place_of_birth": None,
|
||||
"known_for_department": None,
|
||||
"also_known_as": [],
|
||||
"known_for": [],
|
||||
"movies": [],
|
||||
"shows": [],
|
||||
}
|
||||
|
||||
|
||||
def test_content_libs_partitions_only_enabled(db):
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a
|
||||
popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a
|
||||
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
|
||||
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexItem, PlexLibrary, PlexPerson, User
|
||||
from app.plex import tmdb as plex_tmdb
|
||||
|
||||
PAYLOAD = {
|
||||
"biography": " A celebrated actor. ",
|
||||
"birthday": "1970-01-01",
|
||||
"deathday": None,
|
||||
"place_of_birth": "Springfield",
|
||||
"known_for_department": "Acting",
|
||||
"also_known_as": ["AKA One", "AKA Two"],
|
||||
"combined_credits": {
|
||||
"cast": [
|
||||
{"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50},
|
||||
{"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80},
|
||||
{"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id
|
||||
{"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1},
|
||||
{"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_parse_person_shapes_bio_and_ranked_known_for():
|
||||
p = plex_tmdb.parse_person(PAYLOAD)
|
||||
assert p["biography"] == "A celebrated actor." # stripped
|
||||
assert p["birthday"] == "1970-01-01"
|
||||
assert p["known_for_department"] == "Acting"
|
||||
assert p["also_known_as"] == ["AKA One", "AKA Two"]
|
||||
# Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1).
|
||||
assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30]
|
||||
assert p["known_for"][0]["media_type"] == "tv"
|
||||
|
||||
|
||||
def test_parse_person_empty_payload():
|
||||
p = plex_tmdb.parse_person({})
|
||||
assert p["biography"] is None and p["known_for"] is None
|
||||
|
||||
|
||||
def test_parse_person_keeps_same_id_across_media_types():
|
||||
# TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works
|
||||
# and BOTH must survive the dedup (keyed on (id, media_type), not the bare id).
|
||||
payload = {
|
||||
"combined_credits": {
|
||||
"cast": [
|
||||
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
|
||||
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
|
||||
{"id": 42, "media_type": "movie", "title": "dup movie 42", "popularity": 5}, # real dup
|
||||
]
|
||||
}
|
||||
}
|
||||
kf = plex_tmdb.parse_person(payload)["known_for"]
|
||||
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [(42, "movie"), (42, "tv")] # both kept, dup dropped
|
||||
|
||||
|
||||
class _FakeTmdb:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fetch_person(self, pid):
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_enrich_person_persists_and_stamps(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor X", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
|
||||
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdb(PAYLOAD))
|
||||
assert plex_tmdb.enrich_person(db, person) is True
|
||||
assert person.biography == "A celebrated actor."
|
||||
assert person.tmdb_enriched_at is not None
|
||||
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
|
||||
|
||||
|
||||
class _FakeTmdbResult:
|
||||
"""Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload."""
|
||||
|
||||
def __init__(self, *, raises=False, payload=None):
|
||||
self._raises = raises
|
||||
self._payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fetch_person(self, pid):
|
||||
if self._raises:
|
||||
raise plex_tmdb.TmdbError("429 rate limited")
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
|
||||
# A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently
|
||||
# blank a person after one blip).
|
||||
person = PlexPerson(name="Transient P", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
|
||||
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True))
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
assert person.tmdb_enriched_at is None # left NULL → retried next open
|
||||
|
||||
|
||||
def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
|
||||
# A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open.
|
||||
person = PlexPerson(name="Gone P", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
|
||||
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None))
|
||||
assert plex_tmdb.enrich_person(db, person) is True
|
||||
assert person.tmdb_enriched_at is not None # stamped
|
||||
assert person.biography is None # nothing fetched
|
||||
|
||||
|
||||
def test_enrich_person_noop_without_key(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
assert person.tmdb_enriched_at is None
|
||||
|
||||
|
||||
def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
|
||||
person = PlexPerson(name="Actor Z", tmdb_person_id=None)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key")
|
||||
assert plex_tmdb.enrich_person(db, person) is False
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch):
|
||||
# The bio fetch runs OFF the request path (a background task after the response), so the serve never
|
||||
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
|
||||
# the enrich and the page still renders immediately with whatever's in the row (bio still null).
|
||||
from app.routes.plex import people
|
||||
|
||||
lib = PlexLibrary(plex_key="9", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexPerson(name="Lazy P", tmdb_person_id=777))
|
||||
user = User(email="lazybg@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
scheduled: list[int] = []
|
||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
||||
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open
|
||||
assert len(scheduled) == 1 # the enrich was scheduled off the request path
|
||||
|
||||
|
||||
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
|
||||
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
|
||||
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
|
||||
from app.routes.plex import people
|
||||
|
||||
calls = {"rollback": 0, "close": 0, "enriched": 0}
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, person):
|
||||
self._person = person
|
||||
|
||||
def get(self, model, pid):
|
||||
return self._person
|
||||
|
||||
def rollback(self):
|
||||
calls["rollback"] += 1
|
||||
|
||||
def close(self):
|
||||
calls["close"] += 1
|
||||
|
||||
# Success path: person found → enrich called, session closed, no rollback.
|
||||
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
|
||||
monkeypatch.setattr(
|
||||
people.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1)
|
||||
)
|
||||
people._enrich_person_bg(5)
|
||||
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
|
||||
|
||||
# Error path: enrich raises → rollback + close, no exception escapes.
|
||||
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
||||
|
||||
def _boom(db, p):
|
||||
raise RuntimeError("enrich blew up")
|
||||
|
||||
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
|
||||
people._enrich_person_bg(5) # must not raise
|
||||
assert calls["rollback"] == 1 and calls["close"] == 1
|
||||
|
||||
# Missing person (deleted id): None-guard skips enrich, session still closed.
|
||||
calls.update({"rollback": 0, "close": 0, "enriched": 0})
|
||||
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
|
||||
people._enrich_person_bg(9)
|
||||
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
|
||||
|
||||
|
||||
def test_should_schedule_enrich_dedups_within_cooldown():
|
||||
# Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window.
|
||||
from app.routes.plex import people
|
||||
|
||||
people._enrich_inflight.clear()
|
||||
assert people._should_schedule_enrich(12345) is True # first open reserves
|
||||
assert people._should_schedule_enrich(12345) is False # within cooldown → deduped
|
||||
assert people._should_schedule_enrich(99999) is True # a different person is independent
|
||||
people._enrich_inflight.clear()
|
||||
|
||||
|
||||
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch):
|
||||
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.routes.plex import people
|
||||
|
||||
lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexPerson(name="Done P", tmdb_person_id=888,
|
||||
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), biography="Bio."))
|
||||
user = User(email="donebg@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
scheduled: list[int] = []
|
||||
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Done P")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["biography"] == "Bio."
|
||||
assert scheduled == [] # already enriched → no background work
|
||||
|
||||
|
||||
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
|
||||
db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie",
|
||||
guid="tmdb:100", cast_names=["Actor X"]))
|
||||
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
|
||||
from datetime import datetime, timezone
|
||||
db.add(PlexPerson(name="Actor X", tmdb_person_id=555,
|
||||
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
biography="Bio here.", birthday="1970-01-01", known_for_department="Acting",
|
||||
also_known_as=["Alt Name"],
|
||||
known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
|
||||
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}]))
|
||||
user = User(email="personbio@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X")
|
||||
assert resp.status_code == 200, resp.text
|
||||
j = resp.json()
|
||||
assert j["biography"] == "Bio here."
|
||||
assert j["birthday"] == "1970-01-01"
|
||||
assert j["also_known_as"] == ["Alt Name"]
|
||||
# Only the in-library known_for credit resolves to a card; the out-of-library one is dropped.
|
||||
assert [c["title"] for c in j["known_for"]] == ["Known Movie"]
|
||||
@@ -149,7 +149,9 @@ class TestRollup:
|
||||
|
||||
|
||||
def _fake_paginate(by_type):
|
||||
def _p(_plex, _key, typ):
|
||||
def _p(_plex, _key, typ, progress=None):
|
||||
if progress is not None:
|
||||
progress["complete"] = True # the fake returns the whole list — a complete listing
|
||||
return list(by_type.get(typ, []))
|
||||
return _p
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""E3 S1 catalog-prune (B-sub-6): a sync now deletes rows for titles that disappeared from Plex,
|
||||
diffing each section's freshly-listed rating_keys against the stored ones. The prune is GUARDED on a
|
||||
non-empty listing so a transient Plex glitch (an empty page) can never wipe a whole library.
|
||||
|
||||
DB lane (`db` fixture) — skipped without Postgres. Drives `_sync_movies` / `_sync_shows` directly
|
||||
with a stub Plex (the same seam the overlay test uses), so no live server is needed.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexSeason,
|
||||
PlexShow,
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex.sync import (
|
||||
_TYPE_EPISODE,
|
||||
_TYPE_MOVIE,
|
||||
_TYPE_SEASON,
|
||||
_TYPE_SHOW,
|
||||
_sync_movies,
|
||||
_sync_shows,
|
||||
)
|
||||
|
||||
|
||||
class _StubPlex:
|
||||
"""Returns canned section listings per Plex item type — the current 'truth' the sync reconciles
|
||||
the stored rows against. `_paginate` is the only method the sync calls on it."""
|
||||
|
||||
def __init__(self, by_type: dict[int, list[dict]]):
|
||||
self.by_type = by_type
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
items = self.by_type.get(item_type, [])
|
||||
return items[start : start + size], len(items)
|
||||
|
||||
|
||||
class _TruncatedPlex:
|
||||
"""Simulates a truncated listing (a transient Plex hiccup): it reports a large `total` but returns
|
||||
only one short page, so pagination stops BEFORE reaching total. Prune must treat this as an
|
||||
incomplete fetch and delete nothing — the un-listed rows are present, just not mentioned."""
|
||||
|
||||
def __init__(self, items: list[dict], claimed_total: int):
|
||||
self._items = items
|
||||
self._total = claimed_total
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
if start == 0:
|
||||
return list(self._items), self._total # one short page, total says there's more
|
||||
return [], self._total
|
||||
|
||||
|
||||
def _seed_movies(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
m1 = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Kept Movie")
|
||||
m2 = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Gone Movie")
|
||||
db.add_all([m1, m2])
|
||||
user = User(email="prune@example.com")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
# A watch-state on EACH movie: the kept one's must survive, the pruned one's must cascade away.
|
||||
db.add_all([
|
||||
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
|
||||
PlexState(user_id=user.id, item_id=m2.id, status="watched"),
|
||||
])
|
||||
db.commit()
|
||||
return lib, m1.id, m2.id, user.id
|
||||
|
||||
|
||||
def test_movie_prune_deletes_disappeared_titles_and_cascades_state(db):
|
||||
lib, m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
# Plex now lists only m1 — m2 disappeared.
|
||||
stub = _StubPlex({_TYPE_MOVIE: [{"ratingKey": "m1", "title": "Kept Movie"}]})
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1"}, "the disappeared movie must be pruned"
|
||||
assert stats["pruned"] == 1
|
||||
# The kept movie's watch-state survives; the pruned movie's cascaded away.
|
||||
states = {s.item_id for s in db.execute(select(PlexState)).scalars()}
|
||||
assert states == {m1_id}
|
||||
|
||||
|
||||
def test_empty_listing_prunes_nothing(db):
|
||||
"""The safety guard: a section that lists ZERO items (a real empty library, or a transient Plex
|
||||
glitch we can't distinguish) must leave the stored rows untouched — never mass-delete."""
|
||||
lib, _m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
stub = _StubPlex({_TYPE_MOVIE: []}) # Plex returned nothing this run
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1", "m2"}, "an empty listing must not prune anything"
|
||||
assert stats["pruned"] == 0
|
||||
|
||||
|
||||
def test_truncated_listing_prunes_nothing(db):
|
||||
"""A partial fetch (Plex reports 5 items but the listing returns only 1 then stops) must NOT prune
|
||||
the 4 it failed to mention — the completeness guard, not just the empty-listing guard."""
|
||||
lib, m1_id, _m2_id, _user_id = _seed_movies(db)
|
||||
# Only m1 is listed, but total claims 5 → the walk never reaches total → incomplete → no prune.
|
||||
stub = _TruncatedPlex([{"ratingKey": "m1", "title": "Kept Movie"}], claimed_total=5)
|
||||
stats = {"movies": 0, "pruned": 0}
|
||||
|
||||
_sync_movies(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert live == {"m1", "m2"}, "a truncated listing must not prune the un-listed rows"
|
||||
assert stats["pruned"] == 0
|
||||
|
||||
|
||||
def test_show_prune_removes_stale_show_season_and_episode(db):
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# Stored: one kept show (s1 -> se1 -> e1) and one stale show (s2 -> se2 -> e2) that Plex dropped.
|
||||
s1 = PlexShow(rating_key="s1", library_id=lib.id, title="Kept Show")
|
||||
s2 = PlexShow(rating_key="s2", library_id=lib.id, title="Gone Show")
|
||||
db.add_all([s1, s2])
|
||||
db.flush()
|
||||
se1 = PlexSeason(rating_key="se1", show_id=s1.id, season_number=1)
|
||||
se2 = PlexSeason(rating_key="se2", show_id=s2.id, season_number=1)
|
||||
db.add_all([se1, se2])
|
||||
db.flush()
|
||||
e1 = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Kept Ep",
|
||||
show_id=s1.id, season_id=se1.id, season_number=1, episode_number=1)
|
||||
e2 = PlexItem(rating_key="e2", library_id=lib.id, kind="episode", title="Gone Ep",
|
||||
show_id=s2.id, season_id=se2.id, season_number=1, episode_number=1)
|
||||
db.add_all([e1, e2])
|
||||
db.commit()
|
||||
|
||||
# Plex now lists only s1's subtree.
|
||||
stub = _StubPlex({
|
||||
_TYPE_SHOW: [{"ratingKey": "s1", "title": "Kept Show"}],
|
||||
_TYPE_SEASON: [{"ratingKey": "se1", "parentRatingKey": "s1", "index": 1, "title": "Season 1"}],
|
||||
_TYPE_EPISODE: [{
|
||||
"ratingKey": "e1", "grandparentRatingKey": "s1", "parentRatingKey": "se1",
|
||||
"parentIndex": 1, "index": 1, "title": "Kept Ep",
|
||||
}],
|
||||
})
|
||||
stats = {"shows": 0, "seasons": 0, "episodes": 0, "pruned": 0}
|
||||
|
||||
_sync_shows(db, stub, lib, stats)
|
||||
db.commit()
|
||||
|
||||
shows = {r.rating_key for r in db.execute(select(PlexShow)).scalars()}
|
||||
seasons = {r.rating_key for r in db.execute(select(PlexSeason)).scalars()}
|
||||
eps = {r.rating_key for r in db.execute(select(PlexItem)).scalars()}
|
||||
assert shows == {"s1"}, "the stale show must be pruned"
|
||||
assert seasons == {"se1"}, "the stale season must be pruned"
|
||||
assert eps == {"e1"}, "the stale episode must be pruned"
|
||||
assert stats["pruned"] == 3 # s2 + se2 + e2
|
||||
@@ -0,0 +1,365 @@
|
||||
"""TMDB enrichment engine (E5 S2): the pure parser + persister, guid→tmdb-id resolution, and the
|
||||
DB-lane batch/serve integration (enrich stamps the watermark + links people; serve_block resolves
|
||||
recommendations to library rows; the whole thing no-ops without an API key).
|
||||
|
||||
The DB tests use the `db` fixture (real Postgres) — skipped when none is reachable."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
|
||||
from app.plex import tmdb
|
||||
|
||||
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
|
||||
MOVIE_PAYLOAD = {
|
||||
"id": 238,
|
||||
"vote_average": 8.7,
|
||||
"vote_count": 20000,
|
||||
"budget": 6000000,
|
||||
"revenue": 245066411,
|
||||
"keywords": {"keywords": [{"id": 1, "name": "mafia"}, {"id": 2, "name": "crime family"}]},
|
||||
"credits": {
|
||||
"cast": [
|
||||
{"id": 3084, "name": "Marlon Brando", "character": "Vito", "profile_path": "/a.jpg", "order": 0},
|
||||
{"id": 1158, "name": "Al Pacino", "character": "Michael", "profile_path": "/b.jpg", "order": 1},
|
||||
],
|
||||
"crew": [
|
||||
{"id": 1776, "name": "Francis Ford Coppola", "job": "Director", "profile_path": "/c.jpg"},
|
||||
{"id": 1776, "name": "Francis Ford Coppola", "job": "Writer", "profile_path": "/c.jpg"},
|
||||
{"id": 9, "name": "Some Gaffer", "job": "Gaffer"}, # not a key job → dropped
|
||||
],
|
||||
},
|
||||
"production_companies": [{"name": "Paramount"}, {"name": "Alfran"}],
|
||||
"production_countries": [{"name": "United States of America"}],
|
||||
"recommendations": {
|
||||
"results": [
|
||||
{"id": 240, "title": "The Godfather Part II", "poster_path": "/p.jpg", "media_type": "movie"},
|
||||
{"id": 999, "title": "Not In Library", "poster_path": "/q.jpg", "media_type": "movie"},
|
||||
]
|
||||
},
|
||||
"images": {"backdrops": [{"file_path": "/bd1.jpg"}, {"file_path": "/bd2.jpg"}]},
|
||||
"external_ids": {"imdb_id": "tt0068646"},
|
||||
}
|
||||
|
||||
# A TMDB /tv payload: keywords under `results`, aggregate_credits with per-person `jobs`, no budget.
|
||||
TV_PAYLOAD = {
|
||||
"id": 1396,
|
||||
"vote_average": 9.0,
|
||||
"vote_count": 12000,
|
||||
"created_by": [{"id": 66633, "name": "Vince Gilligan", "profile_path": "/vg.jpg"}],
|
||||
"keywords": {"results": [{"name": "drugs"}, {"name": "new mexico"}]},
|
||||
"aggregate_credits": {
|
||||
"cast": [{"id": 17419, "name": "Bryan Cranston"}],
|
||||
"crew": [
|
||||
{"id": 66633, "name": "Vince Gilligan", "jobs": [{"job": "Executive Producer"}]},
|
||||
{"id": 5, "name": "Someone", "jobs": [{"job": "Sound"}]}, # dropped
|
||||
],
|
||||
},
|
||||
"production_companies": [{"name": "Sony Pictures Television"}],
|
||||
"production_countries": [{"name": "United States of America"}],
|
||||
"recommendations": {"results": []},
|
||||
"images": {"backdrops": []},
|
||||
}
|
||||
|
||||
|
||||
class FakeTmdb:
|
||||
"""Stand-in for TmdbClient in the parse/batch tests — no network."""
|
||||
|
||||
def __init__(self, payloads=None, find_map=None):
|
||||
self.payloads = payloads or {} # (media_type, tmdb_id) -> payload
|
||||
self.find_map = find_map or {} # external_id -> /find result
|
||||
|
||||
def fetch(self, tmdb_id, media_type):
|
||||
return self.payloads.get((media_type, tmdb_id))
|
||||
|
||||
def find(self, external_id, source):
|
||||
return self.find_map.get(external_id)
|
||||
|
||||
|
||||
# ---- pure parse/persist --------------------------------------------------------------------------
|
||||
|
||||
def test_parse_movie_pulls_all_fields():
|
||||
d = tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")
|
||||
assert d["tmdb_id"] == 238
|
||||
assert d["tmdb_rating"] == 8.7
|
||||
assert d["tmdb_vote_count"] == 20000
|
||||
assert d["budget"] == 6000000
|
||||
assert d["revenue"] == 245066411
|
||||
assert d["keywords"] == ["mafia", "crime family"]
|
||||
assert d["crew"] == [
|
||||
{"name": "Francis Ford Coppola", "job": "Director", "tmdb_id": 1776, "profile_path": "/c.jpg"},
|
||||
{"name": "Francis Ford Coppola", "job": "Writer", "tmdb_id": 1776, "profile_path": "/c.jpg"},
|
||||
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
|
||||
assert d["studios"] == ["Paramount", "Alfran"]
|
||||
assert d["production_countries"] == ["United States of America"]
|
||||
assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999]
|
||||
assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"]
|
||||
# cast + crew names → tmdb person ids for the plex_people foundation
|
||||
links = {(p["name"], p["tmdb_id"]) for p in d["people"]}
|
||||
assert ("Marlon Brando", 3084) in links
|
||||
assert ("Al Pacino", 1158) in links
|
||||
assert ("Francis Ford Coppola", 1776) in links
|
||||
|
||||
|
||||
def test_parse_tv_uses_aggregate_credits_and_no_money():
|
||||
d = tmdb.parse_tmdb(TV_PAYLOAD, "tv")
|
||||
assert d["budget"] is None and d["revenue"] is None # TV has neither
|
||||
assert d["keywords"] == ["drugs", "new mexico"] # from keywords.results
|
||||
# The showrunner (created_by → Creator) leads; the aggregate-credits EP follows; the Sound person is
|
||||
# filtered; TV jobs are read from jobs[0]. Crew is display-priority ordered (Creator before EP).
|
||||
assert d["crew"] == [
|
||||
{"name": "Vince Gilligan", "job": "Creator", "tmdb_id": 66633, "profile_path": "/vg.jpg"},
|
||||
{"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None},
|
||||
]
|
||||
assert d["tmdb_rating"] == 9.0
|
||||
assert ("Bryan Cranston", 17419) in {(p["name"], p["tmdb_id"]) for p in d["people"]}
|
||||
|
||||
|
||||
def test_parse_zero_votes_and_money_are_unknown():
|
||||
d = tmdb.parse_tmdb({"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie")
|
||||
assert d["tmdb_rating"] is None # 0.0 with no votes is meaningless
|
||||
assert d["tmdb_vote_count"] is None
|
||||
assert d["budget"] is None and d["revenue"] is None
|
||||
|
||||
|
||||
def test_apply_writes_row_fields_without_touching_plex_owned():
|
||||
it = PlexItem(rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}])
|
||||
tmdb.apply_tmdb_detail(it, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
|
||||
assert it.tmdb_id == 238
|
||||
assert it.budget == 6000000
|
||||
assert it.keywords == ["mafia", "crime family"]
|
||||
assert it.imdb_rating == 8.7 and it.full_cast == [{"x": 1}] # Plex-owned, untouched
|
||||
|
||||
|
||||
def test_guid_to_tmdb_id_direct_find_and_bad():
|
||||
fake = FakeTmdb(find_map={"tt0068646": {"movie_results": [{"id": 238}], "tv_results": []}})
|
||||
assert tmdb.guid_to_tmdb_id("tmdb:238", "movie", fake) == 238
|
||||
assert tmdb.guid_to_tmdb_id("imdb:tt0068646", "movie", fake) == 238 # resolved via /find
|
||||
assert tmdb.guid_to_tmdb_id("imdb:tt0000000", "movie", fake) is None # not in find_map
|
||||
assert tmdb.guid_to_tmdb_id("tmdb:notanumber", "movie", fake) is None
|
||||
assert tmdb.guid_to_tmdb_id(None, "movie", fake) is None
|
||||
assert tmdb.guid_to_tmdb_id("local://abc", "movie", fake) is None
|
||||
|
||||
|
||||
def test_guid_to_tmdb_id_tvdb_source_and_tv_media_type():
|
||||
# A tvdb guid for a TV item goes through /find and must pick the tv_results list (not movie_results),
|
||||
# and a tvdb source maps to the tvdb_id external_source. The media_type branch is the whole point.
|
||||
fake = FakeTmdb(find_map={"81189": {"movie_results": [], "tv_results": [{"id": 1396}]}})
|
||||
assert tmdb.guid_to_tmdb_id("tvdb:81189", "tv", fake) == 1396
|
||||
# The SAME /find result, asked for a movie, reads the (empty) movie_results → None.
|
||||
assert tmdb.guid_to_tmdb_id("tvdb:81189", "movie", fake) is None
|
||||
# A direct tmdb guid for a tv item is just the int (no /find).
|
||||
assert tmdb.guid_to_tmdb_id("tmdb:1396", "tv", fake) == 1396
|
||||
# An unknown provider (only imdb/tvdb are /find sources) is unresolvable.
|
||||
assert tmdb.guid_to_tmdb_id("plex:abc", "tv", fake) is None
|
||||
|
||||
|
||||
# ---- DB-lane: batch enrich + serve --------------------------------------------------------------
|
||||
|
||||
def _lib(db, key="1", kind="movie"):
|
||||
lib = PlexLibrary(plex_key=key, title="L", kind=kind, enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
return lib
|
||||
|
||||
|
||||
def test_enrich_batch_persists_stamps_and_links_people(db):
|
||||
lib = _lib(db)
|
||||
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
|
||||
unmatched = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777")
|
||||
db.add_all([mv, unmatched])
|
||||
db.commit()
|
||||
|
||||
fake = FakeTmdb(payloads={("movie", 238): MOVIE_PAYLOAD}) # 777 has no payload → definitive no-match
|
||||
n = tmdb.enrich_batch(db, fake, 50)
|
||||
assert n == 2 # both processed (one matched, one definitively unmatched)
|
||||
|
||||
db.refresh(mv)
|
||||
assert mv.tmdb_id == 238
|
||||
assert mv.keywords == ["mafia", "crime family"]
|
||||
assert mv.revenue == 245066411
|
||||
assert mv.tmdb_enriched_at is not None
|
||||
|
||||
db.refresh(unmatched)
|
||||
assert unmatched.tmdb_enriched_at is not None # stamped so it isn't re-fetched every run
|
||||
assert unmatched.tmdb_id is None # but nothing stored
|
||||
|
||||
brando = db.execute(
|
||||
PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando")
|
||||
).mappings().first()
|
||||
assert brando is not None and brando["tmdb_person_id"] == 3084
|
||||
|
||||
|
||||
def test_serve_block_none_until_enriched_and_resolves_recs(db):
|
||||
lib = _lib(db)
|
||||
# A recommendation target that IS in our library (guid tmdb:240) → deep-linkable.
|
||||
sequel = PlexItem(rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240")
|
||||
fresh = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
|
||||
db.add_all([sequel, fresh])
|
||||
db.commit()
|
||||
|
||||
assert tmdb.serve_block(db, fresh) is None # not enriched yet
|
||||
|
||||
tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
|
||||
fresh.tmdb_enriched_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
block = tmdb.serve_block(db, fresh)
|
||||
assert block is not None
|
||||
assert block["budget"] == 6000000
|
||||
assert block["keywords"] == ["mafia", "crime family"]
|
||||
recs = {r["tmdb_id"]: r for r in block["recommendations"]}
|
||||
assert recs[240]["in_library"] is True and recs[240]["rating_key"] == "m240"
|
||||
assert recs[999]["in_library"] is False and recs[999]["rating_key"] is None
|
||||
|
||||
|
||||
def test_get_never_leaks_api_key_in_error_messages(monkeypatch):
|
||||
# The api_key rides in the request URL query string, so an error message built from the httpx
|
||||
# exception (which embeds that URL) would leak the secret to logs (enrich_batch logs it). Both the
|
||||
# HTTP-status and the network error paths must report status/path only — never the key.
|
||||
monkeypatch.setattr(
|
||||
tmdb.sysconfig, "get_str", lambda d, k: "SECRETKEY" if k == "tmdb_api_key" else "en-US"
|
||||
)
|
||||
client = tmdb.TmdbClient(None)
|
||||
req = httpx.Request(
|
||||
"GET", "https://api.themoviedb.org/3/movie/1?api_key=SECRETKEY&language=en-US"
|
||||
)
|
||||
|
||||
class _StatusHttp:
|
||||
def get(self, url, params=None):
|
||||
return httpx.Response(401, request=req) # non-404 → raise_for_status → HTTPStatusError
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
client._http = _StatusHttp()
|
||||
with pytest.raises(tmdb.TmdbError) as ei:
|
||||
client._get("/movie/1")
|
||||
assert "SECRETKEY" not in str(ei.value) and "401" in str(ei.value)
|
||||
|
||||
class _NetHttp:
|
||||
def get(self, url, params=None):
|
||||
raise httpx.ConnectError("connect failed", request=req)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
client._http = _NetHttp()
|
||||
with pytest.raises(tmdb.TmdbError) as ei2:
|
||||
client._get("/movie/1")
|
||||
assert "SECRETKEY" not in str(ei2.value)
|
||||
client.close()
|
||||
|
||||
|
||||
def test_run_enrich_noops_without_key(db):
|
||||
# No tmdb_api_key configured (default "") → clean no-op, never an error.
|
||||
out = tmdb.run_enrich(db)
|
||||
assert out["enriched"] == 0 and "skipped" in out
|
||||
|
||||
|
||||
class _RaisingTmdb:
|
||||
"""A client whose fetch raises a transient TmdbError (e.g. a 429), to exercise the retry path."""
|
||||
|
||||
def find(self, external_id, source):
|
||||
return None
|
||||
|
||||
def fetch(self, tmdb_id, media_type):
|
||||
raise tmdb.TmdbError("429 rate limited")
|
||||
|
||||
|
||||
def test_enrich_batch_transient_error_leaves_watermark_null(db):
|
||||
# A transient TmdbError mid-batch must leave the row's tmdb_enriched_at NULL so the next run retries
|
||||
# it (unlike a definitive no-match, which stamps the watermark). The row is NOT counted as processed.
|
||||
lib = _lib(db)
|
||||
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Godfather", guid="tmdb:238")
|
||||
db.add(mv)
|
||||
db.commit()
|
||||
|
||||
n = tmdb.enrich_batch(db, _RaisingTmdb(), 50)
|
||||
assert n == 0 # the transient failure is skipped, not processed
|
||||
db.refresh(mv)
|
||||
assert mv.tmdb_enriched_at is None # left NULL → retried next run
|
||||
assert mv.tmdb_id is None
|
||||
|
||||
|
||||
def test_enrich_batch_skips_shows_without_guid(db):
|
||||
lib = _lib(db, key="2", kind="show")
|
||||
sh = PlexShow(rating_key="s1", library_id=lib.id, title="No Guid Show") # guid None → not selected
|
||||
db.add(sh)
|
||||
db.commit()
|
||||
assert tmdb.enrich_batch(db, FakeTmdb(), 50) == 0
|
||||
db.refresh(sh)
|
||||
assert sh.tmdb_enriched_at is None
|
||||
|
||||
|
||||
def test_resolve_library_guids_scopes_to_given_libraries(db):
|
||||
# The scoped path (person page) must keep a credit that lives in a NON-scoped library (disabled or an
|
||||
# HU-overlay twin) OUT of the result; the unscoped path (detail recs) matches across all libraries;
|
||||
# and an explicit empty list skips that media side entirely (distinct from None = match all).
|
||||
lib_on = _lib(db, key="on", kind="movie")
|
||||
lib_off = _lib(db, key="off", kind="movie")
|
||||
m_on = PlexItem(rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100")
|
||||
m_off = PlexItem(rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200")
|
||||
db.add_all([m_on, m_off])
|
||||
db.commit()
|
||||
entries = [{"tmdb_id": 100, "media_type": "movie"}, {"tmdb_id": 200, "media_type": "movie"}]
|
||||
|
||||
# Scoped to lib_on: the row in lib_off is excluded.
|
||||
movies, shows = tmdb.resolve_library_guids(
|
||||
db, entries, movie_lib_ids=[lib_on.id], show_lib_ids=[]
|
||||
)
|
||||
assert set(movies) == {"tmdb:100"}
|
||||
assert shows == {}
|
||||
|
||||
# Unscoped (None defaults): matches across all libraries.
|
||||
movies_all, _ = tmdb.resolve_library_guids(db, entries)
|
||||
assert set(movies_all) == {"tmdb:100", "tmdb:200"}
|
||||
|
||||
# Empty list for a media side skips its bucket even with candidates present (vs None = match all).
|
||||
_, shows_empty = tmdb.resolve_library_guids(
|
||||
db, [{"tmdb_id": 100, "media_type": "tv"}], show_lib_ids=[]
|
||||
)
|
||||
assert shows_empty == {}
|
||||
|
||||
|
||||
def test_resolve_recs_matches_by_media_type_not_bare_id(db):
|
||||
# TMDB movie ids and tv ids are independent, so tmdb:500 can be BOTH a show (in our library) and a
|
||||
# movie recommendation. The match must respect media_type — a bare-id match would deep-link the movie
|
||||
# rec to the unrelated show (and vice-versa).
|
||||
mlib = _lib(db, key="10", kind="movie")
|
||||
slib = _lib(db, key="11", kind="show")
|
||||
show500 = PlexShow(rating_key="s500", library_id=slib.id, title="Show 500", guid="tmdb:500")
|
||||
movie900 = PlexItem(
|
||||
rating_key="m900", library_id=mlib.id, kind="movie", title="Movie 900", guid="tmdb:900"
|
||||
)
|
||||
db.add_all([show500, movie900])
|
||||
db.commit()
|
||||
|
||||
# A movie rec for id 500 must NOT match show s500; a tv rec for id 900 must NOT match movie m900.
|
||||
miss = {
|
||||
r["tmdb_id"]: r
|
||||
for r in tmdb.resolve_recs(
|
||||
db,
|
||||
[
|
||||
{"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"},
|
||||
{"tmdb_id": 900, "title": "Show 900", "media_type": "tv"},
|
||||
],
|
||||
)
|
||||
}
|
||||
assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None
|
||||
assert miss[900]["in_library"] is False and miss[900]["rating_key"] is None
|
||||
|
||||
# The right media_type DOES resolve to the right row.
|
||||
hit = {
|
||||
r["tmdb_id"]: r
|
||||
for r in tmdb.resolve_recs(
|
||||
db,
|
||||
[
|
||||
{"tmdb_id": 900, "media_type": "movie"},
|
||||
{"tmdb_id": 500, "media_type": "tv"},
|
||||
],
|
||||
)
|
||||
}
|
||||
assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900"
|
||||
assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""ask 4: saved views are per-module (`source`). Feed and Plex views are separate lists, may share a
|
||||
name, and each has its own single default. DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _client(db, user, monkeypatch):
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _user(db):
|
||||
u = User(email="views@example.com")
|
||||
db.add(u)
|
||||
db.commit()
|
||||
return u
|
||||
|
||||
|
||||
def test_views_are_scoped_by_source(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
# Same NAME under two sources is allowed (unique is per user+source now).
|
||||
f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
|
||||
p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
|
||||
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
|
||||
|
||||
feed = c.get("/api/saved-views?source=feed").json()
|
||||
plex = c.get("/api/saved-views?source=plex").json()
|
||||
assert [v["name"] for v in feed] == ["Fresh"]
|
||||
assert [v["name"] for v in plex] == ["Fresh"]
|
||||
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
||||
# A source-less GET keeps the old feed-only behaviour.
|
||||
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_default_is_per_source(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
fid = c.post(
|
||||
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
|
||||
).json()["id"]
|
||||
pid = c.post(
|
||||
"/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}
|
||||
).json()["id"]
|
||||
c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
|
||||
c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
|
||||
# Making the Plex view default must NOT clear the feed view's default (different source).
|
||||
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
|
||||
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
assert a.status_code == 200
|
||||
assert b.status_code == 409 # same (user, source, name)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Regression: live YouTube search ingest must survive the scrape source carrying no publish date.
|
||||
|
||||
`videos.published_at` is NOT NULL (migration 0068, PERF-15). The scrape source exposes only a
|
||||
relative time ("3 years ago"), so its stub has `published_at=None`; the ingest used to pass that
|
||||
None straight through, which the NOT NULL constraint rejected with an IntegrityError. That error
|
||||
is raised OUTSIDE the route's page-fetch try/except, so the whole `/api/search/youtube` request
|
||||
500'd and the UI showed the generic "YouTube search failed." — every scrape search was broken.
|
||||
|
||||
The stub now falls back to now() (a non-null placeholder), and the enrich overwrites it with the
|
||||
video's real snippet date. These DB-lane tests lock both halves: no crash on a date-less stub, and
|
||||
a placeholder never becomes the video's permanent date.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app import sysconfig
|
||||
from app.models import User, Video
|
||||
from app.routes.search import _ingest_candidates
|
||||
|
||||
|
||||
class _FakeYT:
|
||||
"""Offline stand-in for YouTubeClient. Channel enrich is a no-op (the from_search stub survives);
|
||||
video enrich returns a real publishedAt plus a long duration so Shorts classification finalises
|
||||
without the youtube.com/shorts probe (no network in the DB lane)."""
|
||||
|
||||
def __init__(self, enrich: dict[str, dict]):
|
||||
self._enrich = enrich
|
||||
|
||||
def get_channels(self, ids):
|
||||
return []
|
||||
|
||||
def get_videos(self, ids):
|
||||
return [self._enrich[i] for i in ids if i in self._enrich]
|
||||
|
||||
|
||||
def _candidate(vid: str, ch: str) -> dict:
|
||||
# The scrape shape (see search_scrape._video_item): published_at is ALWAYS None.
|
||||
return {
|
||||
"id": vid,
|
||||
"channel_id": ch,
|
||||
"channel_title": f"Chan {ch}",
|
||||
"title": f"Title {vid}",
|
||||
"published_at": None,
|
||||
"thumbnail_url": None,
|
||||
"live_broadcast": "none",
|
||||
}
|
||||
|
||||
|
||||
def _enrich_item(vid: str, published_iso: str) -> dict:
|
||||
# PT30M keeps the video well above shorts_probe_max_seconds so no probe fires.
|
||||
return {
|
||||
"id": vid,
|
||||
"snippet": {"title": f"Title {vid}", "publishedAt": published_iso},
|
||||
"contentDetails": {"duration": "PT30M"},
|
||||
"statistics": {},
|
||||
"status": {},
|
||||
}
|
||||
|
||||
|
||||
def test_scrape_ingest_dateless_stub_ingests_and_takes_real_enrich_date(db):
|
||||
# Small cap so the 1800s enrich duration keeps the shorts step offline.
|
||||
sysconfig.set_value(db, "shorts_probe_max_seconds", 60)
|
||||
user = User(email="searcher@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
candidates = [_candidate("vid_aaaaaaa", "UC_chan_a"), _candidate("vid_bbbbbbb", "UC_chan_a")]
|
||||
enrich = {
|
||||
"vid_aaaaaaa": _enrich_item("vid_aaaaaaa", "2021-02-12T19:00:07Z"),
|
||||
"vid_bbbbbbb": _enrich_item("vid_bbbbbbb", "2023-10-30T17:00:08Z"),
|
||||
}
|
||||
|
||||
kept = _ingest_candidates(db, _FakeYT(enrich), user, candidates)
|
||||
|
||||
# No IntegrityError, and both non-Short/non-live videos are kept in input order.
|
||||
assert kept == ["vid_aaaaaaa", "vid_bbbbbbb"]
|
||||
a = db.get(Video, "vid_aaaaaaa")
|
||||
# The now() placeholder was replaced by the video's real snippet date, not left as "now".
|
||||
assert a.published_at == datetime(2021, 2, 12, 19, 0, 7, tzinfo=timezone.utc)
|
||||
assert a.via_search is True
|
||||
assert a.enriched_at is not None
|
||||
|
||||
|
||||
def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db):
|
||||
# If enrich yields nothing (proxy blip / omitted row), the stub must STILL satisfy the NOT NULL
|
||||
# constraint (a non-null placeholder) — it simply isn't kept (enriched_at stays None).
|
||||
user = User(email="searcher2@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
kept = _ingest_candidates(db, _FakeYT({}), user, [_candidate("vid_ccccccc", "UC_chan_c")])
|
||||
|
||||
assert kept == []
|
||||
c = db.get(Video, "vid_ccccccc")
|
||||
assert c.published_at is not None # placeholder wrote a real value → no crash
|
||||
assert c.enriched_at is None
|
||||
|
||||
|
||||
def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch):
|
||||
# Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422
|
||||
# + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry,
|
||||
# burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds
|
||||
# (one candidate) and _ingest_candidates raises → the endpoint returns 422.
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import require_human
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.routes import search as search_mod
|
||||
|
||||
user = User(email="route422@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(
|
||||
search_mod.sysconfig, "get_str", lambda d, k: "scrape" if k == "search_source" else ""
|
||||
)
|
||||
monkeypatch.setattr(search_mod.sysconfig, "get_int", lambda d, k: 100) # daily cap not reached
|
||||
monkeypatch.setattr(search_mod.quota, "actions_today", lambda d, uid, action: 0)
|
||||
|
||||
class _FakeYTCM:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(search_mod, "YouTubeClient", _FakeYTCM)
|
||||
monkeypatch.setattr(
|
||||
search_mod.search_scrape,
|
||||
"search",
|
||||
lambda term, continuation=None: {
|
||||
"items": [_candidate("vid_route12", "UC_route_a")],
|
||||
"next_page_token": None,
|
||||
},
|
||||
)
|
||||
|
||||
def _boom(db_, yt, user_, candidates):
|
||||
raise RuntimeError("ingest exploded")
|
||||
|
||||
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[require_human] = lambda: user
|
||||
try:
|
||||
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert "YouTube search failed" in resp.json()["detail"]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from app.utils import iso
|
||||
from app.utils import iso, normalize_description
|
||||
|
||||
|
||||
def test_none_maps_to_none():
|
||||
@@ -28,3 +28,30 @@ def test_matches_datetime_isoformat_contract():
|
||||
# hand-roll `x.isoformat() if x else None`.
|
||||
dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc)
|
||||
assert iso(dt) == dt.isoformat()
|
||||
|
||||
|
||||
def test_normalize_description_none_stays_none():
|
||||
assert normalize_description(None) is None
|
||||
|
||||
|
||||
def test_normalize_description_drops_blank_lines():
|
||||
# A YouTube contact block padded with runs of blank lines — the waste we strip before storing.
|
||||
raw = "Contact us\n\n\nEmail: a@b.com\n \nInstagram: @x\n\n\n"
|
||||
assert normalize_description(raw) == "Contact us\nEmail: a@b.com\nInstagram: @x"
|
||||
|
||||
|
||||
def test_normalize_description_right_strips_and_trims():
|
||||
assert normalize_description(" hi there \n\n bye ") == " hi there\n bye"
|
||||
|
||||
|
||||
def test_normalize_description_all_blank_collapses_to_empty():
|
||||
assert normalize_description("\n \n\t\n") == ""
|
||||
|
||||
|
||||
def test_normalize_description_single_line_unchanged():
|
||||
assert normalize_description("just one line") == "just one line"
|
||||
|
||||
|
||||
def test_normalize_description_folds_crlf_line_endings():
|
||||
# splitlines() folds CR/CRLF to \n — a documented, benign line-ending normalization.
|
||||
assert normalize_description("a\r\nb\r\n\r\nc") == "a\nb\nc"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Development
|
||||
|
||||
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in
|
||||
[`backend/app/README.md`](../backend/app/README.md).
|
||||
|
||||
## Run it locally
|
||||
|
||||
The whole stack (Postgres + API + worker) comes up from the localdev compose file:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.localdev.yml up --build
|
||||
```
|
||||
|
||||
Browse at **http://localhost:8080** (Google login works — the OAuth redirect is
|
||||
`http://localhost:8080/auth/callback`). The API runs `alembic upgrade head` on startup, so the schema
|
||||
is always current.
|
||||
|
||||
For a fast frontend edit loop, run the Vite dev server with HMR against that API:
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||
```
|
||||
|
||||
## The gate
|
||||
|
||||
A change is done when all of this is green. CI runs the same checks server-side
|
||||
(`.gitea/workflows/check.yml`), so "main is always green".
|
||||
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
# lint
|
||||
python -m ruff check --config backend/ruff.toml backend scripts
|
||||
|
||||
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
|
||||
# The backend tree is bind-mounted, so it always tests your working files.
|
||||
docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test
|
||||
docker compose -f backend/docker-compose.test.yml down -v
|
||||
```
|
||||
|
||||
The test compose sets `SIFTLODE_TEST_REQUIRE_DB=1`, which turns a would-be silent skip (no DB, or no
|
||||
ffmpeg) into a hard failure — so the gate can't print green while the DB-backed tests were quietly not
|
||||
run. Tests that need a database take the `db` fixture (`backend/tests/conftest.py`); the rest are
|
||||
pure-logic and run without one.
|
||||
|
||||
**Frontend** (from `frontend/`)
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc x3 (app / node / e2e)
|
||||
npm run lint # eslint
|
||||
npm run format:check # prettier
|
||||
npm run knip # unused exports/deps
|
||||
npm run test # vitest
|
||||
```
|
||||
|
||||
## Branching
|
||||
|
||||
Trunk-based: branch off `main`, keep the branch gate-green, and land the whole change back to `main`
|
||||
in one reviewed step. Migrations are Alembic revisions under `backend/alembic/versions`; the API
|
||||
applies them on startup, and the DB-lane rebuilds the schema via `alembic upgrade head`, so a broken
|
||||
migration chain fails the tests.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { clickVisibleChannelLink, openApp } from "./helpers";
|
||||
import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers";
|
||||
|
||||
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
|
||||
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
|
||||
@@ -68,29 +68,22 @@ test.describe("channel view", () => {
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
const feedLive = page.getByTestId("feed-content-includeLive");
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
|
||||
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
|
||||
await feedLive.click();
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expectContentType(page, "includeLive", true);
|
||||
});
|
||||
|
||||
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
|
||||
@@ -99,27 +92,18 @@ test.describe("channel view", () => {
|
||||
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Turn Live/Upcoming ON inside the channel view.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await page.getByTestId("feed-content-includeLive").click();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
|
||||
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
});
|
||||
|
||||
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
|
||||
@@ -127,7 +111,7 @@ test.describe("channel view", () => {
|
||||
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
const title = page.getByTestId("channel-title");
|
||||
await expect(count).toBeVisible();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
@@ -8,15 +8,18 @@ test.describe("feed", () => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
await expect(count).toHaveText(/\d/);
|
||||
const baseline = await count.innerText();
|
||||
|
||||
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
|
||||
// Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state
|
||||
// pill summarises the current pick).
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
|
||||
|
||||
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
|
||||
// Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick).
|
||||
// The seed guarantees exactly one, a real deterministic change.
|
||||
await page.getByTestId("feed-show").click();
|
||||
await page.getByTestId("feed-show-watched").click();
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
|
||||
await expect(count).not.toHaveText(baseline);
|
||||
@@ -24,7 +27,7 @@ test.describe("feed", () => {
|
||||
// Remove the chip → default show restored, chips gone.
|
||||
await page.getByTestId("active-filter-chip").click();
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
|
||||
});
|
||||
|
||||
test("scrolling virtualizes in fresh cards", async ({ page }) => {
|
||||
@@ -52,7 +55,7 @@ test.describe("feed", () => {
|
||||
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
|
||||
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
await expect(count).toBeVisible();
|
||||
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
|
||||
|
||||
@@ -73,6 +76,7 @@ test.describe("feed", () => {
|
||||
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||
|
||||
// Switch the show filter → a different result set → back to the top.
|
||||
await page.getByTestId("feed-show").click();
|
||||
await page.getByTestId("feed-show-all").click();
|
||||
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,30 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown.
|
||||
* The options only exist while the dropdown is open, so this opens it, checks the option's
|
||||
* `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it
|
||||
* (Escape) so the floating panel can't block a later click.
|
||||
*/
|
||||
export async function expectContentType(page: Page, value: string, on: boolean): Promise<void> {
|
||||
await page.getByTestId("feed-content").click();
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute(
|
||||
"aria-checked",
|
||||
on ? "true" : "false",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */
|
||||
export async function toggleContentType(page: Page, value: string): Promise<void> {
|
||||
await page.getByTestId("feed-content").click();
|
||||
await page.getByTestId(`feed-content-${value}`).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
|
||||
export async function openFirstVideo(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
+43
-52
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
import {
|
||||
loadLayout,
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type PanelLayout,
|
||||
} from "./lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -90,37 +91,20 @@ export default function App() {
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.filterCollapsed, true)),
|
||||
);
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
||||
);
|
||||
function setFilterCollapsed(next: boolean) {
|
||||
setFilterCollapsedState(next);
|
||||
writeAccount(LS.filterCollapsed, next);
|
||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
||||
}
|
||||
function setPlaylistsCollapsed(next: boolean) {
|
||||
setPlaylistsCollapsedState(next);
|
||||
writeAccount(LS.playlistsCollapsed, next);
|
||||
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
||||
}
|
||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (hasFilterParams(params) || params.has("page")) {
|
||||
// A share link's filters are held in state; they're persisted to the account's key by the
|
||||
// post-login effect (which knows the account id). Here we only clean the address bar.
|
||||
stripUrlParams();
|
||||
}
|
||||
}, []);
|
||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
||||
@@ -152,11 +136,24 @@ export default function App() {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
|
||||
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
|
||||
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
||||
useEffect(() => {
|
||||
if (meQuery.data) stripUrlParams();
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText =
|
||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
||||
document.body.appendChild(probe);
|
||||
const w = probe.offsetWidth - probe.clientWidth;
|
||||
document.body.removeChild(probe);
|
||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
||||
}, []);
|
||||
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
useEffect(() => {
|
||||
if (meQuery.data) clearParams("access", "verify", "reset");
|
||||
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
@@ -188,7 +185,7 @@ export default function App() {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("link");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||
@@ -197,12 +194,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
stripUrlParams();
|
||||
clearParams("oauth");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
useEffect(() => {
|
||||
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
|
||||
@@ -210,12 +207,13 @@ export default function App() {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
stripUrlParams();
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
||||
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
||||
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
@@ -227,14 +225,6 @@ export default function App() {
|
||||
saveLayoutLocal(p, l);
|
||||
}
|
||||
});
|
||||
if (typeof prefs.filterCollapsed === "boolean") {
|
||||
setFilterCollapsedState(prefs.filterCollapsed);
|
||||
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
||||
}
|
||||
if (typeof prefs.playlistsCollapsed === "boolean") {
|
||||
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
|
||||
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
|
||||
}
|
||||
const lang = prefs.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||
@@ -323,7 +313,7 @@ export default function App() {
|
||||
const me = meQuery.data!;
|
||||
const pageGate = moduleDef(page).gate;
|
||||
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
||||
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
|
||||
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
|
||||
return (
|
||||
@@ -389,6 +379,7 @@ export default function App() {
|
||||
) : (
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
// Channels: the registry disables the top fade for its sticky-header table, but the card
|
||||
// layout has no sticky header, so re-enable it there.
|
||||
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
|
||||
|
||||
@@ -28,6 +28,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
offset: 6,
|
||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
||||
hoverDelay: { open: 0, close: 160 },
|
||||
// Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a
|
||||
// long About never runs its top off-screen — at high browser zoom the short viewport made a fixed
|
||||
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
@@ -51,7 +55,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-[480px] max-h-112 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
className="glass w-[480px] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
|
||||
@@ -1,51 +1,258 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
// open the panel just to see what you'd filtered by.
|
||||
//
|
||||
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
|
||||
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
|
||||
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
|
||||
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
|
||||
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
|
||||
// back into the measurement and oscillate).
|
||||
|
||||
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
|
||||
// the "fit some + N more" treatment used on wider lanes.
|
||||
const NARROW_PX = 420;
|
||||
|
||||
const chipCls =
|
||||
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
||||
|
||||
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className={chipCls}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
|
||||
function OverflowPopover({
|
||||
label,
|
||||
ariaLabel,
|
||||
chips,
|
||||
onClearAll,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
/** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
|
||||
* since the visible label can be a bare "+3". Defaults to `label`. */
|
||||
ariaLabel?: string;
|
||||
chips: ActiveFilter[];
|
||||
onClearAll: () => void;
|
||||
icon?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const name = ariaLabel ?? label;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={name}
|
||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
||||
open ? "border-accent text-accent" : "hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
||||
{label}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={name}
|
||||
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{chips.map((f) => (
|
||||
<PopoverItem
|
||||
key={f.key}
|
||||
role="menuitem"
|
||||
onSelect={f.remove}
|
||||
label={f.label}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
>
|
||||
<span className="flex-1 truncate">{f.label}</span>
|
||||
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={() => {
|
||||
onClearAll();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActiveFilterChips({
|
||||
filters,
|
||||
onClearAll,
|
||||
className,
|
||||
}: {
|
||||
filters: ActiveFilter[];
|
||||
onClearAll: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (filters.length === 0) return null;
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const labelRef = useRef<HTMLSpanElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
mode: "all",
|
||||
visible: filters.length,
|
||||
});
|
||||
|
||||
const n = filters.length;
|
||||
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const lane = laneRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
if (!lane || !labelEl || n === 0) return;
|
||||
|
||||
const measure = () => {
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const chips = chipRefs.current.slice(0, n);
|
||||
const plusEl = plusRef.current;
|
||||
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
|
||||
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
|
||||
const lastChip = chips[n - 1];
|
||||
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
|
||||
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
|
||||
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
|
||||
setLayout(
|
||||
decideChipLayout({
|
||||
n,
|
||||
labelRight: rightOf(labelEl),
|
||||
chipRights: chips.map((c) => rightOf(c)),
|
||||
plusW: plusEl?.offsetWidth ?? 0,
|
||||
clearW,
|
||||
avail: lane.clientWidth,
|
||||
gap,
|
||||
narrowPx: NARROW_PX,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(lane);
|
||||
return () => ro.disconnect();
|
||||
}, [n, chipsKey]);
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
const hiddenCount = n - layout.visible;
|
||||
const overflowChips = filters.slice(layout.visible);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className="flex flex-wrap items-center gap-1.5 pb-3"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
<span className="text-2xs uppercase tracking-wider text-muted mr-0.5">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
data-testid="active-filter-chip"
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
and without test ids so it never doubles the visible chip count. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
|
||||
>
|
||||
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f, i) => (
|
||||
<span
|
||||
key={f.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className={chipCls}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
</span>
|
||||
))}
|
||||
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
||||
+{n}
|
||||
</span>
|
||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visible lane. */}
|
||||
{layout.mode === "count" ? (
|
||||
<OverflowPopover
|
||||
label={t("feed.chips.count", { count: n })}
|
||||
chips={filters}
|
||||
onClearAll={onClearAll}
|
||||
icon
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.slice(0, layout.visible).map((f) => (
|
||||
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
|
||||
))}
|
||||
{layout.mode === "some" && hiddenCount > 0 && (
|
||||
<OverflowPopover
|
||||
label={`+${hiddenCount}`}
|
||||
ariaLabel={t("feed.chips.moreCount", { count: hiddenCount })}
|
||||
chips={overflowChips}
|
||||
onClearAll={onClearAll}
|
||||
/>
|
||||
)}
|
||||
{layout.mode === "all" && n > 1 && (
|
||||
<button
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function AdminUsers() {
|
||||
<>
|
||||
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
||||
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Gates the shared PopoverItem ARIA contract: aria-checked for menuitemradio/menuitemcheckbox and
|
||||
// aria-selected for option are rendered as explicit attributes AFTER the floating-ui item props (so they
|
||||
// win over getItemProps' single-select guess). Six live menus (LanguageSwitcher, ViewSwitcher,
|
||||
// AddToPlaylist, ChannelTags, tableHeader, plexDetailUi) depend on this; without a test, a regression in
|
||||
// the shared path would silently drop the checked indicator for screen-reader users. Floating-ui recipe:
|
||||
// stub the two observers autoUpdate wires, wrap in a FloatingTree, query the hidden surface.
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Menu() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, mode: "menu" });
|
||||
return (
|
||||
<PopoverSurface popover={pop} ariaLabel="Choices">
|
||||
<PopoverItem role="menuitemradio" checked onSelect={() => {}}>
|
||||
Newest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemradio" checked={false} onSelect={() => {}}>
|
||||
Oldest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemcheckbox" checked onSelect={() => {}}>
|
||||
Show shorts
|
||||
</PopoverItem>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PopoverItem ARIA contract", () => {
|
||||
it("exposes aria-checked on menuitemradio and menuitemcheckbox items", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<Menu />
|
||||
</FloatingTree>,
|
||||
);
|
||||
const radios = screen.getAllByRole("menuitemradio", { hidden: true });
|
||||
expect(radios.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]);
|
||||
const checkbox = screen.getByRole("menuitemcheckbox", { hidden: true });
|
||||
expect(checkbox.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -61,14 +62,29 @@ import { useFullscreenElement } from "../lib/fullscreen";
|
||||
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
|
||||
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
|
||||
// typeahead act on the text, not the list — see ChannelTags.
|
||||
//
|
||||
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
|
||||
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
|
||||
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
|
||||
// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick;
|
||||
// - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused;
|
||||
// arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the
|
||||
// input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus).
|
||||
// Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the
|
||||
// reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface
|
||||
// gets role=listbox, and each <PopoverItem role="option"> gets its option id (so activedescendant has a
|
||||
// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference")
|
||||
// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input
|
||||
// field). The universal <Combobox> component wraps all of this — call sites use it, not this mode raw.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
@@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
const isCombobox = mode === "combobox";
|
||||
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
|
||||
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
|
||||
const isNav = mode === "menu" || mode === "listbox";
|
||||
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
|
||||
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
|
||||
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
|
||||
const roves = isNav || isCombobox;
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
@@ -131,7 +152,27 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
// Cap the positioned element, AND publish the available space as a custom property so
|
||||
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
|
||||
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
|
||||
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
|
||||
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
|
||||
// height, spilling its top off-screen (the whole point of fitHeight).
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
|
||||
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
|
||||
// field box (the caller mounts it via setPositionReference), not the bare input.
|
||||
...(isCombobox
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
@@ -149,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
}
|
||||
}, [opts.open]);
|
||||
|
||||
const click = useClick(context, { enabled: !isHover });
|
||||
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
move: false,
|
||||
@@ -163,9 +206,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
|
||||
const role = useRole(context, {
|
||||
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
|
||||
});
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: isNav,
|
||||
enabled: roves,
|
||||
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
|
||||
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
|
||||
// input). A menu/listbox uses real roving focus instead.
|
||||
virtual: isCombobox,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
|
||||
@@ -181,12 +234,15 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all).
|
||||
focusItemOnOpen: true,
|
||||
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
|
||||
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
|
||||
// Enter to the hovered item. Turned off together with typeahead for such menus.
|
||||
focusItemOnHover: !opts.hostsInput,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all). A
|
||||
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
|
||||
// option; auto-highlighting item 0 on open would fight the typed query.
|
||||
focusItemOnOpen: isCombobox ? false : true,
|
||||
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
|
||||
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
|
||||
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
|
||||
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
|
||||
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
@@ -215,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
@@ -238,6 +296,9 @@ interface PopoverListContextValue {
|
||||
highlightIndex: number | null;
|
||||
setHoverIndex: (index: number | null) => void;
|
||||
getItemProps: AnchoredPopover["getItemProps"];
|
||||
/** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a <PopoverItem> never
|
||||
* takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */
|
||||
virtual: boolean;
|
||||
}
|
||||
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
|
||||
|
||||
@@ -295,6 +356,12 @@ export function PopoverItem({
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||
const ctx = useContext(PopoverListContext);
|
||||
const virtual = ctx?.virtual ?? false;
|
||||
// A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant
|
||||
// is set to the active option's `.id` when the arrow handler reads it, so the id must already be there
|
||||
// (Floating UI's own on-activation id assignment races the re-render and lands empty through our
|
||||
// indirection). A menu/listbox item uses roving DOM focus instead and needs no id.
|
||||
const generatedId = useId();
|
||||
const textLabel = label ?? (typeof children === "string" ? children : undefined);
|
||||
const { ref, index } = useListItem({ label: textLabel });
|
||||
// Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight
|
||||
@@ -302,12 +369,12 @@ export function PopoverItem({
|
||||
const focusActive = ctx?.activeIndex === index;
|
||||
const visualActive = ctx?.highlightIndex === index;
|
||||
|
||||
const ariaProps =
|
||||
role === "menuitemradio" || role === "menuitemcheckbox"
|
||||
? { role, "aria-checked": !!checked }
|
||||
: role === "option"
|
||||
? { role, "aria-selected": !!selected }
|
||||
: { role };
|
||||
// The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it
|
||||
// wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the
|
||||
// single selectedIndex, which is always false for a MULTI-select — our per-value truth must override.
|
||||
const ariaChecked =
|
||||
role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined;
|
||||
const ariaSelected = role === "option" ? !!selected : undefined;
|
||||
|
||||
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
|
||||
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
|
||||
@@ -325,19 +392,37 @@ export function PopoverItem({
|
||||
rest.onMouseMove?.(e);
|
||||
if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index);
|
||||
};
|
||||
// In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list
|
||||
// and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects.
|
||||
const mergedMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
rest.onMouseDown?.(e);
|
||||
if (virtual) e.preventDefault();
|
||||
};
|
||||
const handlers = ctx
|
||||
? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove })
|
||||
: { ...rest, onClick: mergedClick };
|
||||
? ctx.getItemProps({
|
||||
...rest,
|
||||
onClick: mergedClick,
|
||||
onMouseMove: mergedMouseMove,
|
||||
onMouseDown: mergedMouseDown,
|
||||
})
|
||||
: { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...ariaProps}
|
||||
role={role}
|
||||
{...handlers}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
|
||||
// and disabled/className/data-active are the platform's to set.
|
||||
tabIndex={focusActive ? 0 : -1}
|
||||
// A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
|
||||
// id Floating UI's getItemProps would inject on activation.
|
||||
id={virtual ? generatedId : undefined}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override, the
|
||||
// checked/selected truth must beat getItemProps' single-select guess, and disabled/className/
|
||||
// data-active are the platform's to set. A virtual (combobox) item never holds DOM focus, so its
|
||||
// tabindex is always -1; navigation is by aria-activedescendant.
|
||||
aria-checked={ariaChecked}
|
||||
aria-selected={ariaSelected}
|
||||
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
|
||||
data-active={visualActive ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={`outline-hidden ${
|
||||
@@ -402,6 +487,8 @@ function PopoverSurfaceInner({
|
||||
isPositioned,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
selectedIndex,
|
||||
activeIndex,
|
||||
highlightIndex,
|
||||
@@ -434,9 +521,10 @@ function PopoverSurfaceInner({
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// Everything but a passive hover card manages focus by default (trap into the surface, return to
|
||||
// the trigger on close).
|
||||
const managed = focusManaged ?? mode !== "hover";
|
||||
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
|
||||
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
|
||||
// is focus-managed.
|
||||
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
@@ -470,11 +558,11 @@ function PopoverSurfaceInner({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
|
||||
// <PopoverItem>s can self-register and rove.
|
||||
const positioned = isNav ? (
|
||||
// Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
|
||||
// nested <PopoverItem>s can self-register and rove/aria-activedescendant.
|
||||
const positioned = roves ? (
|
||||
<PopoverListContext.Provider
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
|
||||
>
|
||||
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
||||
{surface}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
|
||||
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
|
||||
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
|
||||
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import { Carousel } from "./Carousel";
|
||||
|
||||
beforeAll(() => {
|
||||
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function setMetrics(
|
||||
el: HTMLElement,
|
||||
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
|
||||
) {
|
||||
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
|
||||
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
|
||||
Object.defineProperty(el, "scrollLeft", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: m.scrollLeft,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Carousel", () => {
|
||||
it("renders children and an optional header with a see-all action", () => {
|
||||
const onSeeAll = vi.fn();
|
||||
render(
|
||||
<Carousel title="Recently added" onSeeAll={onSeeAll}>
|
||||
<div>card-a</div>
|
||||
<div>card-b</div>
|
||||
</Carousel>,
|
||||
);
|
||||
expect(screen.getByText("card-a")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
|
||||
expect(onSeeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits the header when no title is given and names the scroll region", () => {
|
||||
render(
|
||||
<Carousel ariaLabel="Cast">
|
||||
<div>x</div>
|
||||
</Carousel>,
|
||||
);
|
||||
expect(screen.queryByRole("heading")).toBeNull();
|
||||
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the clipped edge's button and pages the scroller on click", () => {
|
||||
render(
|
||||
<Carousel ariaLabel="rail">
|
||||
<div>x</div>
|
||||
</Carousel>,
|
||||
);
|
||||
const scroller = screen.getByRole("group", { name: "rail" });
|
||||
const scrollBy = vi.fn();
|
||||
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
|
||||
|
||||
// At the left boundary with more content to the right → only the right button.
|
||||
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
|
||||
fireEvent.scroll(scroller);
|
||||
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
|
||||
// Scrolled into the middle → both edges clipped → both buttons.
|
||||
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
|
||||
fireEvent.scroll(scroller);
|
||||
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../lib/useScrollEdges";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
|
||||
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
|
||||
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
|
||||
|
||||
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
|
||||
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
|
||||
function scrollBehavior(): ScrollBehavior {
|
||||
const reduce =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
return reduce ? "auto" : "smooth";
|
||||
}
|
||||
|
||||
// Horizontal scroll-affordance: whether the scroller is clipped on the left / right, so the rail shows
|
||||
// a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core
|
||||
// (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the
|
||||
// element itself so the paging buttons can scroll it.
|
||||
function useHScroll(): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
el: HTMLElement | null;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
} {
|
||||
const { ref, el, start, end } = useScrollEdges("x");
|
||||
return { ref, el, left: start, right: end };
|
||||
}
|
||||
|
||||
const FADE_PX = 28;
|
||||
|
||||
export function Carousel({
|
||||
title,
|
||||
onSeeAll,
|
||||
seeAllLabel,
|
||||
ariaLabel,
|
||||
gap = "gap-3",
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
|
||||
title?: ReactNode;
|
||||
/** When set, a "see all" affordance appears in the header (needs `title`). */
|
||||
onSeeAll?: () => void;
|
||||
seeAllLabel?: string;
|
||||
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
|
||||
ariaLabel?: string;
|
||||
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
|
||||
gap?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { ref, el, left, right } = useHScroll();
|
||||
|
||||
const page = useCallback(
|
||||
(dir: -1 | 1) => {
|
||||
if (!el) return;
|
||||
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
|
||||
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
|
||||
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
|
||||
},
|
||||
[el],
|
||||
);
|
||||
|
||||
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
|
||||
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
|
||||
right ? FADE_PX : 0
|
||||
}px), transparent)`;
|
||||
|
||||
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
||||
|
||||
const arrowBtn =
|
||||
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
|
||||
return (
|
||||
<div className={clsx("group/car relative", className)}>
|
||||
{title != null && (
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<button
|
||||
onClick={onSeeAll}
|
||||
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
||||
>
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{left && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => page(-1)}
|
||||
aria-label={t("common.scrollLeft")}
|
||||
className={clsx(arrowBtn, "left-1")}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
|
||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{right && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => page(1)}
|
||||
aria-label={t("common.scrollRight")}
|
||||
className={clsx(arrowBtn, "right-1")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export default function ChannelDiscovery({
|
||||
<>
|
||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
@@ -231,7 +231,7 @@ export default function ChannelPage({
|
||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "all",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { inputCls } from "./ui/form";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
@@ -22,86 +19,29 @@ export default function ChannelPicker({
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
|
||||
const fade = useScrollFade();
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: api.channels,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = channelsQuery.data ?? [];
|
||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
||||
const picked = filters.channelIds;
|
||||
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
||||
const toggle = (id: string) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
||||
});
|
||||
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
|
||||
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
|
||||
// narrowing ever drops it from the visible rows.
|
||||
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
|
||||
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
||||
{picked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-2">
|
||||
{picked.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
||||
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{nameOf(id)}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
aria-label={t("sidebar.channelSearch")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
|
||||
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
|
||||
but does drop the rows that explain it. */}
|
||||
{!q && all.length > 0 && (
|
||||
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
|
||||
)}
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted">
|
||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
||||
</div>
|
||||
) : (
|
||||
matches.map((c) => {
|
||||
const on = picked.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggle(c.id)}
|
||||
aria-pressed={on}
|
||||
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
||||
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
||||
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
||||
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Combobox
|
||||
label={t("sidebar.channelSearch")}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
options={options}
|
||||
selected={filters.channelIds}
|
||||
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||
loading={channelsQuery.isLoading}
|
||||
emptyText={t("sidebar.noChannelMatch")}
|
||||
selectedLabels={selectedLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers
|
||||
// chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap,
|
||||
// single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded,
|
||||
// and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the
|
||||
// floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree,
|
||||
// and query the hidden surface with `{ hidden: true }` since jsdom never positions it.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import Combobox from "./Combobox";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "Drama", count: 120 },
|
||||
{ value: "Action", count: 90 },
|
||||
{ value: "Comedy", count: 60 },
|
||||
];
|
||||
|
||||
function wrap(ui: React.ReactElement) {
|
||||
return render(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("Combobox", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
expect(screen.getByText("Drama")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.remove" }));
|
||||
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
|
||||
});
|
||||
|
||||
it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
expect(input.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||
fireEvent.focus(input);
|
||||
expect(input.getAttribute("aria-expanded")).toBe("true");
|
||||
// aria-controls now points at the listbox surface.
|
||||
const controls = input.getAttribute("aria-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
|
||||
});
|
||||
|
||||
it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
|
||||
// Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
|
||||
// onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
|
||||
// always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
|
||||
// typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
const field = input.parentElement as HTMLElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
fireEvent.mouseDown(field); // press the field padding, not the input itself
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps the option list unmounted until the field is focused", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3);
|
||||
});
|
||||
|
||||
it("filters the options by the typed query", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const field = screen.getByRole("combobox");
|
||||
fireEvent.focus(field);
|
||||
fireEvent.change(field, { target: { value: "com" } });
|
||||
const items = screen.getAllByRole("option", { hidden: true });
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]?.textContent).toContain("Comedy");
|
||||
});
|
||||
|
||||
it("marks a selected option aria-selected and toggles another on", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const drama = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Drama"));
|
||||
expect(drama?.getAttribute("aria-selected")).toBe("true");
|
||||
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
|
||||
});
|
||||
|
||||
it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
// Virtual navigation: the input keeps DOM focus, the active option is referenced by id.
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.getAttribute("aria-activedescendant")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("removes the last chip on Backspace in an empty field", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Genre"
|
||||
options={OPTIONS}
|
||||
selected={["Drama", "Action"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
|
||||
});
|
||||
|
||||
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
|
||||
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
|
||||
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
|
||||
wrap(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50);
|
||||
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
|
||||
});
|
||||
|
||||
it("Enter commits the pointer-highlighted option, not the arrow-active one", () => {
|
||||
// The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare
|
||||
// keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown.
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama)
|
||||
const action = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Action"))!;
|
||||
fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row)
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama
|
||||
});
|
||||
|
||||
it("Enter commits a hover-highlighted option even with no prior arrow key", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
const comedy = screen
|
||||
.getAllByRole("option", { hidden: true })
|
||||
.find((el) => el.textContent?.includes("Comedy"))!;
|
||||
fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op
|
||||
});
|
||||
|
||||
it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }];
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
clientFilter={false}
|
||||
options={serverOpts}
|
||||
selected={["stored-id"]}
|
||||
selectedLabels={{ "stored-id": "Stored Collection" }}
|
||||
query="har"
|
||||
onQueryChange={onQueryChange}
|
||||
onOpenChange={onOpenChange}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
// The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels.
|
||||
expect(screen.getByText("Stored Collection")).toBeTruthy();
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.focus(input);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
fireEvent.change(input, { target: { value: "harr" } });
|
||||
expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out
|
||||
// clientFilter=false: the single server option renders verbatim (not re-filtered against "har").
|
||||
const opts = screen.getAllByRole("option", { hidden: true });
|
||||
expect(opts.length).toBe(1);
|
||||
expect(opts[0]?.textContent).toContain("Harry Potter");
|
||||
fireEvent.click(screen.getByText("Harry Potter"));
|
||||
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
|
||||
});
|
||||
|
||||
it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
|
||||
// The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
|
||||
// only at >= 2, and clicking 'all' reports the mode change out.
|
||||
const onMatchModeChange = vi.fn();
|
||||
const props = {
|
||||
label: "Topic",
|
||||
options: OPTIONS,
|
||||
onChange: vi.fn(),
|
||||
matchMode: "or" as const,
|
||||
onMatchModeChange,
|
||||
};
|
||||
// One selection: no footer.
|
||||
const { rerender } = wrap(<Combobox {...props} selected={["Drama"]} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.queryByText("sidebar.all")).toBeNull();
|
||||
|
||||
// Two selections: footer appears with an any/all segment.
|
||||
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const all = screen.getByText("sidebar.all");
|
||||
expect(all).toBeTruthy();
|
||||
fireEvent.click(all);
|
||||
expect(onMatchModeChange).toHaveBeenCalledWith("and");
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
options={OPTIONS}
|
||||
selected={["Drama"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
fireEvent.click(screen.getByText("Action"));
|
||||
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
|
||||
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
|
||||
// It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the E5
|
||||
// Plex filters (genre / age-rating / studio, multi-select) and the collection picker (single-select,
|
||||
// server-searched) are both instances, and the header search typeahead can move onto it later.
|
||||
//
|
||||
// All the fiddly parts live in the AnchoredPopover `combobox` mode, NOT here: floating-ui positioning,
|
||||
// the paint-before-position + transform-clobber flash guards, the topmost-only Escape layer, and the
|
||||
// FULL combobox ARIA tree (input role=combobox + aria-expanded/controls/activedescendant, listbox
|
||||
// surface, option ids, virtual ↑/↓ arrow-nav that keeps the caret in the field). This component only
|
||||
// adds the field chrome (chips + input + chevron), the query/selection state, and the big-vocabulary
|
||||
// render cap. That is what makes it accessible where the old hand-rolled MultiSelectChips was not.
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
/** Display text; defaults to `value`. */
|
||||
label?: string;
|
||||
/** A facet count shown at the row's end. */
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ComboboxProps {
|
||||
/** Accessible name for the input (the section heading usually names it visibly too). */
|
||||
label: string;
|
||||
options: readonly ComboboxOption[];
|
||||
/** Selected values. Single-select carries 0 or 1. */
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** Several values at once (chips accumulate) vs one (picking replaces + closes). Default true. */
|
||||
multiple?: boolean;
|
||||
/** Notified when the list opens/closes — a server-searched caller gates its fetch on `open`. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
placeholder?: string;
|
||||
/** Controlled query text — pass with `onQueryChange` for a server-searched (async) vocabulary; omit
|
||||
* to let the combobox own the query state and filter `options` on the client. */
|
||||
query?: string;
|
||||
onQueryChange?: (q: string) => void;
|
||||
/** Filter `options` by the query on the client (default). Set false when the caller already narrows
|
||||
* the list server-side (then `options` arrives pre-filtered). */
|
||||
clientFilter?: boolean;
|
||||
loading?: boolean;
|
||||
/** Shown when there are no options to list. */
|
||||
emptyText?: string;
|
||||
/** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */
|
||||
maxVisible?: number;
|
||||
/** Custom row body (icon + label + count …); defaults to a checkbox + label + count. */
|
||||
renderOption?: (option: ComboboxOption, isSelected: boolean) => ReactNode;
|
||||
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
|
||||
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
|
||||
selectedLabels?: Record<string, string>;
|
||||
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
|
||||
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
|
||||
* only once 2+ values are selected (below that, any and all are the same). */
|
||||
matchMode?: "or" | "and";
|
||||
onMatchModeChange?: (mode: "or" | "and") => void;
|
||||
}
|
||||
|
||||
export default function Combobox({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
multiple = true,
|
||||
onOpenChange,
|
||||
placeholder,
|
||||
query: queryProp,
|
||||
onQueryChange,
|
||||
clientFilter = true,
|
||||
loading = false,
|
||||
emptyText,
|
||||
maxVisible = 50,
|
||||
renderOption,
|
||||
selectedLabels,
|
||||
matchMode,
|
||||
onMatchModeChange,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// The query is controlled when the caller drives it (async search), else owned here.
|
||||
const [queryState, setQueryState] = useState("");
|
||||
const query = queryProp ?? queryState;
|
||||
const setQuery = (q: string) => {
|
||||
if (queryProp === undefined) setQueryState(q);
|
||||
onQueryChange?.(q);
|
||||
};
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fade = useScrollFade();
|
||||
|
||||
// Remember each value's display label as options flow through, so a selected chip can name itself
|
||||
// even after the option leaves the query-narrowed list (async single-select). Caller-supplied
|
||||
// `selectedLabels` win (they survive a page reload where nothing has been fetched yet).
|
||||
const labelCache = useRef<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label;
|
||||
}, [options]);
|
||||
const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v;
|
||||
|
||||
const setOpenAnd = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
|
||||
// The input is the combobox reference for floating-ui AND our own focus target (the field's
|
||||
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
|
||||
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
|
||||
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
|
||||
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
|
||||
// every render.
|
||||
const setInputRef = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
pop.refs.setReference(node);
|
||||
inputRef.current = node;
|
||||
},
|
||||
[pop.refs],
|
||||
);
|
||||
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
|
||||
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
|
||||
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
|
||||
// would make Enter a no-op over a highlighted row.
|
||||
const { activeIndex, highlightIndex } = pop;
|
||||
|
||||
// Clear the typed query when the list closes.
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!clientFilter) return options;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
|
||||
}, [options, query, clientFilter]);
|
||||
|
||||
// Options arrive count-desc (facet order), so the capped default view is the most-common values; a
|
||||
// footer flags how many more are reachable through the search.
|
||||
const visible = filtered.slice(0, maxVisible);
|
||||
const hiddenCount = filtered.length - visible.length;
|
||||
|
||||
const commit = (value: string) => {
|
||||
if (multiple) {
|
||||
onChange(
|
||||
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
|
||||
);
|
||||
// Keep the field focused so the user can keep picking / typing; the list stays open.
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
|
||||
// immediately re-open the list (now that the merged ref makes focus() actually work).
|
||||
onChange([value]);
|
||||
setOpenAnd(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole
|
||||
box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */}
|
||||
<div
|
||||
ref={pop.refs.setPositionReference}
|
||||
onMouseDown={(e) => {
|
||||
// A press anywhere but the input (padding / a chip gap) focuses the field and opens the list —
|
||||
// focus drives "open", so a caret click never toggles it shut.
|
||||
if (e.target !== inputRef.current) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpenAnd(true);
|
||||
}
|
||||
}}
|
||||
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
|
||||
>
|
||||
{selected.map((v) => (
|
||||
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
|
||||
<span className="max-w-36 truncate">{labelFor(v)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
// Keep the box's focus-the-field handler from firing when removing a chip.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => onChange(selected.filter((x) => x !== v))}
|
||||
aria-label={t("common.remove", { defaultValue: "Remove" })}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={setInputRef}
|
||||
{...pop.getReferenceProps({
|
||||
onChange: (e) => {
|
||||
setQuery((e.target as HTMLInputElement).value);
|
||||
setOpenAnd(true);
|
||||
},
|
||||
onFocus: () => setOpenAnd(true),
|
||||
onKeyDown: (e) => {
|
||||
const idx = highlightIndex ?? activeIndex;
|
||||
if (e.key === "Enter" && open && idx != null && visible[idx]) {
|
||||
e.preventDefault(); // select the highlighted option, don't submit a surrounding form
|
||||
if (!visible[idx].disabled) commit(visible[idx].value);
|
||||
} else if (e.key === "Backspace" && query === "" && selected.length > 0) {
|
||||
// Backspace on an empty field removes the last chip (react-select behaviour).
|
||||
onChange(selected.slice(0, -1));
|
||||
}
|
||||
},
|
||||
})}
|
||||
value={query}
|
||||
aria-label={label}
|
||||
placeholder={
|
||||
selected.length === 0
|
||||
? (placeholder ?? t("common.search", { defaultValue: "Search" }))
|
||||
: ""
|
||||
}
|
||||
// min-w-8 (not -16): flex line-packing sizes the input at its MIN-width, so a wide 64px floor
|
||||
// made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping
|
||||
// the chevron to a second line while the (empty) input filled the first. A small floor keeps the
|
||||
// chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left.
|
||||
className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
|
||||
/>
|
||||
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{loading && filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{t("plex.loading", { defaultValue: "Loading…" })}
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="option"
|
||||
selected={on}
|
||||
disabled={o.disabled}
|
||||
onSelect={() => commit(o.value)}
|
||||
label={o.label ?? o.value}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{renderOption ? (
|
||||
renderOption(o, on)
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.label ?? o.value}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||||
{o.count}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||
{t("plex.filter.moreRefine", {
|
||||
count: hiddenCount,
|
||||
defaultValue: "{{count}} more — refine with search",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
|
||||
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
|
||||
open in the combobox's focus model. */}
|
||||
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["or", "and"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={matchMode === m}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onMatchModeChange(m)}
|
||||
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
|
||||
matchMode === m
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
|
||||
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
|
||||
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
|
||||
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
|
||||
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
|
||||
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
|
||||
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
|
||||
|
||||
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
|
||||
function parseDay(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
export default function DateFilterSelect({
|
||||
filters,
|
||||
setFilters,
|
||||
presets,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
presets: readonly DatePreset[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
|
||||
|
||||
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
|
||||
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
|
||||
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
|
||||
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
|
||||
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
|
||||
// one stale "Custom range" frame between the external clear and this reset.
|
||||
useLayoutEffect(() => {
|
||||
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const key = deriveDateKey(filters, customOpen, presets);
|
||||
|
||||
const fmt = (s: string) => {
|
||||
const d = parseDay(s);
|
||||
return d
|
||||
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
|
||||
: s;
|
||||
};
|
||||
const rangeSummary = () => {
|
||||
const { dateFrom: f, dateTo: to } = filters;
|
||||
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
|
||||
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
|
||||
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
|
||||
return t("sidebar.date.customRange");
|
||||
};
|
||||
const summary =
|
||||
key === ""
|
||||
? t("sidebar.date.anyTime")
|
||||
: key === "custom"
|
||||
? rangeSummary()
|
||||
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
|
||||
|
||||
const options = [
|
||||
{ value: "any", label: t("sidebar.date.anyTime") },
|
||||
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
|
||||
{ value: "custom", label: t("sidebar.date.customRange") },
|
||||
];
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: (next) => {
|
||||
setOpen(next);
|
||||
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
|
||||
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
},
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const pick = (value: string) => {
|
||||
const change = selectDateChip(value, filters);
|
||||
setCustomOpen(change.customOpen);
|
||||
setFilters(change.filters);
|
||||
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
|
||||
};
|
||||
|
||||
const dateInputCls =
|
||||
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||
{options.map((o) => {
|
||||
const on = o.value === key || (o.value === "any" && key === "");
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => pick(o.value)}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
|
||||
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
|
||||
{key === "custom" && (
|
||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.from")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.to")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||
className="text-2xs text-muted hover:text-accent self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Ban,
|
||||
CalendarClock,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -19,11 +16,9 @@ import {
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Tabs from "./Tabs";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Modal from "./Modal";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
// used (the video editor in particular is heavy — filmstrip + scrubber).
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
@@ -35,726 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
queued: "text-muted",
|
||||
running: "text-accent",
|
||||
paused: "text-warn",
|
||||
done: "text-success",
|
||||
error: "text-danger",
|
||||
canceled: "text-muted",
|
||||
};
|
||||
|
||||
// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse).
|
||||
// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar.
|
||||
const DOWNLOAD_PHASES = ["video", "audio", "editing"];
|
||||
const PHASE_KEYS = [
|
||||
"video",
|
||||
"audio",
|
||||
"editing",
|
||||
"merging",
|
||||
"audio_extract",
|
||||
"thumbnail",
|
||||
"sponsorblock",
|
||||
"metadata",
|
||||
"processing",
|
||||
];
|
||||
|
||||
function StatusPill({ job }: { job: DownloadJob }) {
|
||||
const { t } = useTranslation();
|
||||
// While downloading, show the concrete phase (video / audio / merging) so the user isn't
|
||||
// confused by the bar resetting between the separate video and audio streams.
|
||||
if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) {
|
||||
return (
|
||||
<span className="text-xs font-medium text-accent">{t(`downloads.phase.${job.phase}`)}</span>
|
||||
);
|
||||
}
|
||||
const key = job.expired ? "expired" : job.status;
|
||||
return (
|
||||
<span className={clsx("text-xs font-medium", STATUS_CLS[job.status] ?? "text-muted")}>
|
||||
{t(`downloads.status.${key}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumb({ job }: { job: DownloadJob }) {
|
||||
// Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources.
|
||||
const primary = job.asset?.thumbnail_url || job.poster_url;
|
||||
// A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a
|
||||
// broken image. On load failure fall back to our always-present self-hosted poster.
|
||||
const [src, setSrc] = useState(primary);
|
||||
useEffect(() => setSrc(primary), [primary]);
|
||||
return (
|
||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function jobTitle(job: DownloadJob): string {
|
||||
return job.display_name || job.asset?.title || job.source_ref;
|
||||
}
|
||||
|
||||
// The channel to show under the title: a user override wins over the auto-extracted asset value.
|
||||
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
|
||||
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
|
||||
return {
|
||||
name: job.display_uploader || job.asset?.uploader || job.source_ref,
|
||||
url: job.display_uploader_url || job.asset?.uploader_url || null,
|
||||
};
|
||||
}
|
||||
|
||||
function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||
danger ? "hover:text-danger" : "hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
|
||||
function displayUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.host + u.pathname + u.search).replace(/\/$/, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// "Downloaded from" reference: opens the original page in a new tab, or copies the link.
|
||||
function SourceRef({ url }: { url: string }) {
|
||||
const { t } = useTranslation();
|
||||
const copy = async () => {
|
||||
if (await copyText(url)) {
|
||||
notify({ level: "success", message: t("downloads.source.copied") });
|
||||
} else {
|
||||
notify({ level: "error", message: t("downloads.source.copyFailed") });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||
<Link2 className="w-3 h-3 shrink-0" />
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${t("downloads.source.open")} — ${url}`}
|
||||
className="truncate hover:text-accent hover:underline"
|
||||
>
|
||||
{displayUrl(url)}
|
||||
</a>
|
||||
<button
|
||||
onClick={copy}
|
||||
aria-label={t("downloads.source.copy")}
|
||||
title={t("downloads.source.copy")}
|
||||
className="shrink-0 p-0.5 rounded-sm hover:bg-surface hover:text-fg transition"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
|
||||
function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
const { name, url } = jobChannel(job);
|
||||
if (url) {
|
||||
return (
|
||||
<div className="text-xs mt-0.5 truncate">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={url}
|
||||
className="text-muted hover:text-accent hover:underline"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
|
||||
}
|
||||
|
||||
function DownloadRow({
|
||||
job,
|
||||
onShowError,
|
||||
children,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onShowError?: (job: DownloadJob) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const running = job.status === "running";
|
||||
return (
|
||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
{job.job_kind === "edit" && (
|
||||
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-3xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
|
||||
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{jobTitle(job)}</span>
|
||||
</div>
|
||||
<ChannelLine job={job} />
|
||||
<div className="flex items-center gap-2 mt-1 text-xs">
|
||||
<StatusPill job={job} />
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
|
||||
) : null}
|
||||
{job.asset?.duration_s ? (
|
||||
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
|
||||
) : null}
|
||||
{job.status === "done" && job.created_at ? (
|
||||
<span
|
||||
className="text-muted inline-flex items-center gap-1"
|
||||
title={new Date(job.created_at).toLocaleString()}
|
||||
>
|
||||
· <CalendarClock className="w-3 h-3" /> {relativeTime(job.created_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
<button
|
||||
onClick={() => onShowError?.(job)}
|
||||
className="text-danger hover:opacity-80 inline-flex items-center gap-1 truncate"
|
||||
title={t("downloads.errors.fail.title")}
|
||||
>
|
||||
· {t(`downloads.errors.fail.cat.${job.error_code || "unknown"}.short`)}
|
||||
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{job.source_url && <SourceRef url={job.source_url} />}
|
||||
{job.extra_links?.map((url) => (
|
||||
<SourceRef key={url} url={url} />
|
||||
))}
|
||||
{running && (
|
||||
<div className="mt-1.5">
|
||||
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
|
||||
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div className="h-full w-full bg-accent/60 animate-pulse" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-2xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-0.5 shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Rename + Share small modals ------------------------------------------------------------
|
||||
|
||||
// Edit a download's display metadata: title, channel (name + link), and extra reference links.
|
||||
// All are display-only overrides — the on-disk file is never renamed.
|
||||
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(job.display_name || job.asset?.title || "");
|
||||
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
|
||||
const [channelUrl, setChannelUrl] = useState(
|
||||
job.display_uploader_url || job.asset?.uploader_url || "",
|
||||
);
|
||||
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
|
||||
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
|
||||
|
||||
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
|
||||
const addLink = () => setLinks((ls) => [...ls, ""]);
|
||||
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.updateDownloadMeta(job.id, {
|
||||
display_name: name.trim(),
|
||||
display_uploader: channel.trim(),
|
||||
display_uploader_url: channelUrl.trim(),
|
||||
extra_links: links.map((l) => l.trim()).filter(Boolean),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.edit.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputCls}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
|
||||
<input
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
|
||||
<input
|
||||
value={channelUrl}
|
||||
onChange={(e) => setChannelUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
|
||||
<div className="space-y-2">
|
||||
{links.map((l, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
value={l}
|
||||
onChange={(e) => setLink(i, e.target.value)}
|
||||
placeholder="https://…"
|
||||
className={inputCls}
|
||||
/>
|
||||
<IconBtn
|
||||
onClick={() => removeLink(i)}
|
||||
title={t("downloads.edit.removeLink")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addLink}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name.trim() || save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.edit.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Error detail modal ---------------------------------------------------------------------
|
||||
|
||||
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
|
||||
// where a retry can't help — the queue row still keeps its own retry control regardless.
|
||||
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
|
||||
|
||||
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
|
||||
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.
|
||||
function ErrorModal({
|
||||
job,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const code = job.error_code || "unknown";
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const retryable = RETRYABLE_ERRORS.includes(code);
|
||||
return (
|
||||
<Modal title={t("downloads.errors.fail.title")} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">{t(`downloads.errors.fail.cat.${code}.short`)}</div>
|
||||
<p className="text-sm text-muted">{t(`downloads.errors.fail.cat.${code}.body`)}</p>
|
||||
{job.error && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
className="text-xs text-muted hover:text-fg transition"
|
||||
>
|
||||
{t("downloads.errors.fail.detailsToggle")}
|
||||
</button>
|
||||
{showRaw && (
|
||||
<pre className="mt-1.5 text-xs bg-surface rounded-lg p-2.5 overflow-x-auto whitespace-pre-wrap wrap-break-word max-h-40 text-muted">
|
||||
{job.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
{retryable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onRetry();
|
||||
onClose();
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary transition"
|
||||
>
|
||||
{t("downloads.actions.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Usage bar ------------------------------------------------------------------------------
|
||||
|
||||
function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
|
||||
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
|
||||
const u = usage.data;
|
||||
if (!u) return null;
|
||||
const pct =
|
||||
u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
|
||||
return (
|
||||
<div className="rounded-xl glass-card p-3 mb-4">
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||
<span className="text-muted">
|
||||
{formatBytes(u.footprint_bytes)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||
{u.unlimited
|
||||
? t("downloads.usage.unlimited")
|
||||
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||
</span>
|
||||
</div>
|
||||
{!u.unlimited && (
|
||||
<div className="h-2 rounded-full bg-surface overflow-hidden">
|
||||
<div
|
||||
className={clsx("h-full transition-all", pct > 90 ? "bg-danger" : "bg-accent")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin system tab -----------------------------------------------------------------------
|
||||
|
||||
function QuotaModal({
|
||||
userId,
|
||||
email,
|
||||
onClose,
|
||||
}: {
|
||||
userId: number;
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: qk.dlQuota(userId),
|
||||
queryFn: () => api.adminGetDownloadQuota(userId),
|
||||
});
|
||||
const [form, setForm] = useState<{
|
||||
gb: number;
|
||||
jobs: number;
|
||||
conc: number;
|
||||
unlimited: boolean;
|
||||
} | null>(null);
|
||||
const cur = q.data;
|
||||
const state =
|
||||
form ??
|
||||
(cur
|
||||
? {
|
||||
gb: Math.round((cur.max_bytes / GiB) * 10) / 10,
|
||||
jobs: cur.max_jobs,
|
||||
conc: cur.max_concurrent,
|
||||
unlimited: cur.unlimited,
|
||||
}
|
||||
: null);
|
||||
const done = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
|
||||
qc.invalidateQueries({ queryKey: qk.adminStorage() });
|
||||
onClose();
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
api.adminSetDownloadQuota(userId, {
|
||||
max_bytes: Math.round(state!.gb * GiB),
|
||||
max_jobs: state!.jobs,
|
||||
max_concurrent: state!.conc,
|
||||
unlimited: state!.unlimited,
|
||||
}),
|
||||
onSuccess: done,
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: done,
|
||||
});
|
||||
if (!state) return null;
|
||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||
return (
|
||||
<Modal title={t("downloads.admin.quotaTitle", { email })} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxBytes")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.gb}
|
||||
onChange={(e) => upd({ gb: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
disabled={state.unlimited}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxJobs")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.jobs}
|
||||
onChange={(e) => upd({ jobs: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("downloads.admin.maxConcurrent")}
|
||||
<input
|
||||
type="number"
|
||||
value={state.conc}
|
||||
onChange={(e) => upd({ conc: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.unlimited}
|
||||
onChange={(e) => upd({ unlimited: e.target.checked })}
|
||||
/>
|
||||
{t("downloads.admin.unlimited")}
|
||||
</label>
|
||||
<div className="flex justify-between pt-1">
|
||||
<button
|
||||
onClick={() => reset.mutate()}
|
||||
className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
{t("downloads.admin.reset")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium btn-primary disabled:opacity-50 transition"
|
||||
>
|
||||
{t("downloads.admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
|
||||
// footprint list below only shows users who already have files).
|
||||
function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
const list = (users.data ?? []).filter((u) => !u.is_demo);
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
const base = s
|
||||
? list.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
|
||||
)
|
||||
: list;
|
||||
return base.slice(0, 8);
|
||||
}, [q, list]);
|
||||
return (
|
||||
<div className="relative w-full sm:w-72">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder={t("downloads.admin.quotaPickPlaceholder")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
|
||||
{filtered.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => {
|
||||
onPick({ id: u.id, email: u.email });
|
||||
setQ("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
|
||||
>
|
||||
<span className="truncate">{u.display_name || u.email}</span>
|
||||
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSystem() {
|
||||
const { t } = useTranslation();
|
||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||
const s = storage.data;
|
||||
return (
|
||||
<div>
|
||||
{s && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
{[
|
||||
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||
[
|
||||
t("downloads.admin.cap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className="rounded-xl glass-card p-3">
|
||||
<div className="text-xs text-muted">{label}</div>
|
||||
<div className="text-lg font-semibold mt-0.5">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-2 flex-wrap">
|
||||
<div className="text-sm font-medium">{t("downloads.admin.perUser")}</div>
|
||||
{/* Set a quota for any user, regardless of whether they've downloaded anything. */}
|
||||
<QuotaUserPicker onPick={setQuotaFor} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{s.per_user.map((u) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||
<button
|
||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||
className="p-1 rounded-sm hover:bg-surface text-muted hover:text-fg"
|
||||
title={t("downloads.admin.editQuota")}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{s.per_user.length === 0 && (
|
||||
<div className="text-xs text-muted px-3 py-2">{t("downloads.admin.noFootprint")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium mb-2">{t("downloads.tabs.system")}</div>
|
||||
<div className="space-y-1.5">
|
||||
{(jobs.data ?? []).map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="flex items-center gap-3 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{jobTitle(j)}</span>
|
||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||
<StatusPill job={j} />
|
||||
<span className="text-muted w-16 text-right">
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{jobs.data && jobs.data.length === 0 && (
|
||||
<div className="text-sm text-muted py-6 text-center">{t("downloads.empty.admin")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quotaFor && (
|
||||
<QuotaModal userId={quotaFor.id} email={quotaFor.email} onClose={() => setQuotaFor(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main -----------------------------------------------------------------------------------
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
@@ -856,7 +135,7 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||
<button
|
||||
|
||||
+124
-109
@@ -13,6 +13,7 @@ import {
|
||||
Grid3x3,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
@@ -36,11 +37,13 @@ import { type SortOption } from "../lib/columnSort";
|
||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import ItemCount from "./ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -567,124 +570,109 @@ export default function Feed({
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
const sortView = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
labelClassName="hidden xl:inline"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<div className="pb-3">
|
||||
<div className="pb-1.5">
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("feed.showLabel")}
|
||||
options={SHOW_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: t("sidebar.show." + id),
|
||||
testId: `feed-show-${id}`,
|
||||
}))}
|
||||
value={filters.show}
|
||||
onChange={(show) => setFilters({ ...filters, show })}
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount count={summaryQuery.data?.count} />
|
||||
{/* Watch state is single-select (always exactly one); content types are an independent
|
||||
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
|
||||
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
|
||||
onChange={(next) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
}}
|
||||
/>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
|
||||
filterChipClass, but not a single-select group. */}
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
|
||||
{CONTENT.map((c) => {
|
||||
const on = filters[c.key];
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
data-testid={`feed-content-${c.key}`}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||
only offer a near-empty, confusing slice — hide it here. */}
|
||||
{!channelScoped && (
|
||||
<>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<span>{t("feed.source.label")}</span>
|
||||
<select
|
||||
value={filters.librarySource ?? "organic"}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: e.target.value as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
title={t("feed.source.tip")}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
<option value="organic">{t("feed.source.organic")}</option>
|
||||
<option value="all">{t("feed.source.all")}</option>
|
||||
<option value="search">{t("feed.source.only")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||
{summaryQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: summaryQuery.data.count,
|
||||
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||
})
|
||||
: " "}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
) : undefined
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
|
||||
// only earns its place once the window is wide.
|
||||
labelClassName="hidden xl:inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
@@ -692,19 +680,46 @@ export default function Feed({
|
||||
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
|
||||
applied" when it's just the page being itself. */}
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
||||
{toolbar}
|
||||
{!channelScoped && (
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
/>
|
||||
)}
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
|
||||
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
||||
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
|
||||
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
|
||||
Library. Point there rather than leaving a bare "no matches". */}
|
||||
{filters.channelIds.length > 0 && filters.scope === "my" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">{t("feed.channelScopeHint")}</p>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Library className="w-4 h-4" />
|
||||
{t("feed.viewInLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && !isDemo && (
|
||||
<button
|
||||
onClick={() => onYtSearch(filters.q.trim())}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
|
||||
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
|
||||
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadAccountFilters } from "./FeedFiltersProvider";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
|
||||
const ID = 7;
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||
const f = loadAccountFilters(ID);
|
||||
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.defaultViewFilters, ID)!,
|
||||
JSON.stringify({ tagMode: "and" }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
it("preserves an already-migrated per-category tagModes blob", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.filters, ID)!,
|
||||
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({
|
||||
language: "and",
|
||||
topic: "or",
|
||||
other: "and",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,17 @@ import {
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
||||
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
||||
import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
} from "../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
|
||||
@@ -30,7 +38,7 @@ import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage"
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
@@ -47,7 +55,7 @@ const FILTERS_KEY = LS.filters;
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
// post-login effect loads the real ones once the id arrives.
|
||||
function loadAccountFilters(id: number | null): FeedFilters {
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||
@@ -60,10 +68,19 @@ function loadAccountFilters(id: number | null): FeedFilters {
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
|
||||
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
|
||||
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
|
||||
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
|
||||
if (def) return { ...DEFAULT_FILTERS, ...def };
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
@@ -90,8 +107,8 @@ export function useFeedFiltersActions(): FeedFiltersActions {
|
||||
|
||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
||||
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
|
||||
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||
const [initialHadUrlFilters] = useState(() =>
|
||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
@@ -108,6 +125,14 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
||||
if (key) writeJSON(key, next);
|
||||
}, []);
|
||||
|
||||
// Mirror the feed filters into the address bar so every feed view is bookmarkable. Gated on
|
||||
// page===feed: on other pages the filter keys are cleared from the URL (localStorage still holds
|
||||
// them; they re-mirror on return to the feed). The channel detail page keeps its own isolated
|
||||
// filters, so those never reach here. useUrlMirror preserves the nav params (?page/?chan/?v/?pl) the
|
||||
// other providers own.
|
||||
const { page } = useNavigation();
|
||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||
|
||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
||||
// default saved view) must never carry over. A share link's filters (captured at mount) win and
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
|
||||
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
|
||||
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
|
||||
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
|
||||
export default function FeedScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("header.scope.label")}
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,15 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { parseFeedView, type FeedView } from "../lib/feedView";
|
||||
import { LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { LS } from "../lib/storage";
|
||||
import { usePref } from "../lib/usePref";
|
||||
|
||||
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||
//
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
|
||||
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
|
||||
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
|
||||
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||
|
||||
interface FeedViewActions {
|
||||
setView: (v: FeedView) => void;
|
||||
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
|
||||
}
|
||||
|
||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setViewState] = useState<FeedView>(() =>
|
||||
parseFeedView(readAccount(LS.feedView, null)),
|
||||
);
|
||||
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
|
||||
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
|
||||
const setView = useCallback((next: FeedView) => {
|
||||
setViewState(next);
|
||||
writeAccount(LS.feedView, next);
|
||||
api.savePrefs({ view: next }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
|
||||
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
|
||||
// "grid" / "list" values forward.
|
||||
useEffect(() => {
|
||||
const prefs = me?.preferences;
|
||||
if (!prefs) return;
|
||||
const adopted = parseFeedView(prefs.view);
|
||||
setViewState(adopted);
|
||||
writeAccount(LS.feedView, adopted);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||
localKey: LS.feedView,
|
||||
parse: parseFeedView,
|
||||
});
|
||||
|
||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 below the
|
||||
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
|
||||
// `children` defaults to nothing for the rails that need none.
|
||||
export default function FilterRail({
|
||||
kind,
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
actions,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
layout,
|
||||
setLayout,
|
||||
items,
|
||||
collapsible = true,
|
||||
iconExpanded,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
kind: PanelId;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
count?: number;
|
||||
actions?: ReactNode;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
items: PanelItem[];
|
||||
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
|
||||
collapsible?: boolean;
|
||||
iconExpanded?: ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
titleActionLabel?: string;
|
||||
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}
|
||||
iconExpanded={iconExpanded}
|
||||
onTitleClick={onTitleClick}
|
||||
titleActionLabel={titleActionLabel}
|
||||
>
|
||||
<PanelGroups
|
||||
items={items}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
editing={editing}
|
||||
collapsible={collapsible}
|
||||
/>
|
||||
{children}
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the labeled-pill FilterSelect. Labels are plain props (no i18n). Built on the floating-ui
|
||||
// AnchoredPopover platform, so we stub the two observers autoUpdate wires and wrap in a FloatingTree.
|
||||
// floating-ui never positions in jsdom → the surface stays visibility:hidden, so the menuitem role
|
||||
// queries pass `{ hidden: true }`. Single-select rows are `menuitemradio`, multi-select `menuitemcheckbox`
|
||||
// (a menu surface with checkable items, not a listbox of orphaned options).
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import FilterSelect, { type FilterSelectOption } from "./FilterSelect";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const STATUS: FilterSelectOption[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "new", label: "New" },
|
||||
];
|
||||
const TYPES: FilterSelectOption[] = [
|
||||
{ value: "n", label: "Normal" },
|
||||
{ value: "s", label: "Shorts" },
|
||||
];
|
||||
const tree = (ui: React.ReactNode) => render(<FloatingTree>{ui}</FloatingTree>);
|
||||
|
||||
describe("FilterSelect", () => {
|
||||
it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => {
|
||||
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={vi.fn()} />);
|
||||
const trigger = screen.getByRole("button", { name: /^Status/ });
|
||||
expect(trigger.textContent).toContain("Status:");
|
||||
expect(trigger.textContent).toContain("All");
|
||||
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("single: picking a row replaces the selection and closes", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
|
||||
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
|
||||
expect(rows.map((o) => o.textContent)).toEqual(["All", "New"]);
|
||||
expect(rows[0]!.getAttribute("aria-checked")).toBe("true"); // "All" is the current selection
|
||||
fireEvent.click(rows[1]!); // New
|
||||
expect(onChange).toHaveBeenCalledWith(["new"]);
|
||||
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("multiple: toggling a row adds it and keeps the panel open", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(
|
||||
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
|
||||
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[1]!); // Shorts
|
||||
expect(onChange).toHaveBeenCalledWith(["n", "s"]);
|
||||
expect(screen.queryAllByRole("menuitemcheckbox", { hidden: true }).length).toBeGreaterThan(0); // still open
|
||||
});
|
||||
|
||||
it("multiple + requireOne: the last remaining selection can't be turned off", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(
|
||||
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
|
||||
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[0]!); // Normal — the only one selected
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multiple: the trigger summarises extra picks as `First +N`", () => {
|
||||
tree(
|
||||
<FilterSelect
|
||||
label="Type"
|
||||
multiple
|
||||
options={TYPES}
|
||||
selected={["n", "s"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("Normal +1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
|
||||
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
|
||||
// with one pill per filter group, in two flavours:
|
||||
// - single (default): picking a row replaces the choice and closes — always exactly one active.
|
||||
// - multiple: rows toggle independently and the panel stays open; `requireOne` blocks clearing the last.
|
||||
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
|
||||
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
|
||||
|
||||
export interface FilterSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** A facet count shown at the row's end. */
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterSelectProps {
|
||||
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
|
||||
label: string;
|
||||
options: readonly FilterSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** Toggle a subset (default false = pick exactly one). */
|
||||
multiple?: boolean;
|
||||
/** Multi only: keep at least one selected — the last active row can't be turned off. Default true. */
|
||||
requireOne?: boolean;
|
||||
/** Test hook: the trigger gets `data-testid={testId}`; each option gets `${testId}-${value}`. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export default function FilterSelect({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
multiple = false,
|
||||
requireOne = true,
|
||||
testId,
|
||||
}: FilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
||||
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
|
||||
const chosen = options.filter((o) => selected.includes(o.value));
|
||||
const first = chosen[0];
|
||||
const summary = multiple
|
||||
? first
|
||||
? `${first.label}${chosen.length > 1 ? ` +${chosen.length - 1}` : ""}`
|
||||
: "—"
|
||||
: labelOf(selected[0] ?? options[0]?.value ?? "");
|
||||
|
||||
const commit = (value: string) => {
|
||||
if (!multiple) {
|
||||
onChange([value]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const has = selected.includes(value);
|
||||
if (has) {
|
||||
if (requireOne && selected.length === 1) return; // don't let the last one go
|
||||
onChange(selected.filter((x) => x !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
// Carry the current value in the accessible name (not just the group label), matching
|
||||
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
|
||||
aria-label={`${label}: ${summary}`}
|
||||
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted">{label}:</span>
|
||||
<span className="text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
|
||||
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
|
||||
menu (see the mode above), never a bare listbox with orphaned options. */}
|
||||
<div role="group" aria-label={label}>
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role={multiple ? "menuitemcheckbox" : "menuitemradio"}
|
||||
checked={on}
|
||||
disabled={o.disabled}
|
||||
onSelect={() => commit(o.value)}
|
||||
label={o.label}
|
||||
data-testid={testId ? `${testId}-${o.value}` : undefined}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{multiple ? (
|
||||
<span
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
) : (
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,19 @@ import { forwardRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Youtube } from "lucide-react";
|
||||
import type { Me } from "../lib/api";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
import FeedScopeToggle from "./FeedScopeToggle";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||
// sync chip at the right end.
|
||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
||||
// has one, and the per-user sync chip at the right end.
|
||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
||||
// owns both the header slot and the padded content wrapper (E5 S7).
|
||||
@@ -30,7 +29,6 @@ const Header = forwardRef<
|
||||
>(function Header({ me, onYtSearch }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// Each module's search term lives in its own provider (shared with that module's rail/content):
|
||||
@@ -40,12 +38,6 @@ const Header = forwardRef<
|
||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||
const { search: channelsSearch } = useChannels();
|
||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
||||
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||
const order = moduleOrder(me);
|
||||
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
|
||||
const multiModule = order.length > 1;
|
||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
@@ -99,20 +91,23 @@ const Header = forwardRef<
|
||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||
<div
|
||||
ref={ref}
|
||||
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
|
||||
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
|
||||
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
|
||||
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
|
||||
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
|
||||
// once the column is wider than the box's max-w — not hugged to the left edge.
|
||||
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
|
||||
>
|
||||
<ModuleName
|
||||
label={t(moduleLabelKey[page])}
|
||||
labels={moduleLabels}
|
||||
onPrev={() => setPage(stepModule(me, page, -1))}
|
||||
onNext={() => setPage(stepModule(me, page, 1))}
|
||||
canPrev={multiModule}
|
||||
canNext={multiModule}
|
||||
/>
|
||||
{search}
|
||||
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
|
||||
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
||||
{page === "feed" && <FeedScopeToggle />}
|
||||
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
|
||||
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
|
||||
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
|
||||
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
|
||||
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
||||
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
|
||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
||||
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
||||
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the fixed-width count pill. Uses the REAL i18n instance (not a key-passthrough) so the
|
||||
// plural selection (itemCount_one/_other) and the "1M+" overflow interpolation are actually exercised —
|
||||
// those are the two branches worth guarding. English is forced so the singular/plural strings are stable.
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import "../i18n"; // side-effect: initialise the shared i18next instance
|
||||
import i18n from "i18next";
|
||||
import ItemCount from "./ItemCount";
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const text = () => screen.getByTestId("item-count").textContent;
|
||||
|
||||
describe("ItemCount", () => {
|
||||
it("uses the singular string at 1 and the plural above it", () => {
|
||||
render(<ItemCount count={1} />);
|
||||
expect(text()).toBe("1 item");
|
||||
});
|
||||
|
||||
it("pluralises and thousands-separates a normal count", () => {
|
||||
render(<ItemCount count={192045} />);
|
||||
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
|
||||
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
|
||||
expect(text()).toBe(`${(192045).toLocaleString()} items`);
|
||||
});
|
||||
|
||||
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
|
||||
render(<ItemCount count={1_000_000} />);
|
||||
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
|
||||
(1_000_000).toLocaleString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
|
||||
render(<ItemCount />);
|
||||
expect(text()).toBe(""); // the Layers icon carries no text
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
|
||||
// the pill shows "1M+" and the tooltip carries the precise number.
|
||||
const OVERFLOW_AT = 1_000_000;
|
||||
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
|
||||
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
|
||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||
// so any surface can reuse it for whatever it counts.
|
||||
export default function ItemCount({ count }: { count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
||||
// show the icon until the number arrives.
|
||||
const label =
|
||||
count == null
|
||||
? ""
|
||||
: t("common.itemCount", {
|
||||
count,
|
||||
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
|
||||
});
|
||||
return (
|
||||
<span
|
||||
data-testid="item-count"
|
||||
title={count?.toLocaleString()}
|
||||
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user