Compare commits
89
Commits
ea7bf593cb
...
v0.68.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa176a3e6a | ||
|
|
e8771f4d6b | ||
|
|
94dbe4b228 | ||
|
|
0ed4ac6958 | ||
|
|
8b8b0ca5ab | ||
|
|
6116ad4bf4 | ||
|
|
943143ef45 | ||
|
|
21a3aae700 | ||
|
|
6a35e83ded | ||
|
|
8c08ea3133 | ||
|
|
3af3799b25 | ||
|
|
aff5a9922c | ||
|
|
cace9a004d | ||
|
|
bfc104a9b4 | ||
|
|
0340d0981c | ||
|
|
18edbecb1e | ||
|
|
8ffa65a9da | ||
|
|
f54dd98129 | ||
|
|
44d4e4876d | ||
|
|
e4ac061721 | ||
|
|
54ba92bb05 | ||
|
|
472cefed55 | ||
|
|
e764ca4568 | ||
|
|
296af2a498 | ||
|
|
66c00327b1 | ||
|
|
31879b01f4 | ||
|
|
15a59f4695 | ||
|
|
0653203d81 | ||
|
|
4ce0a702f4 | ||
|
|
9845c8d0f7 | ||
|
|
7c431d73fb | ||
|
|
67d32fecd6 | ||
|
|
f39c5bb25c | ||
|
|
367bcebc9f | ||
|
|
429fbde866 | ||
|
|
4c013595b5 | ||
|
|
b605c28b32 | ||
|
|
cee0bc95b6 | ||
|
|
5c6172754a | ||
|
|
3575237382 | ||
|
|
14913a6447 | ||
|
|
9fb4db2546 | ||
|
|
2bfe7d53eb | ||
|
|
4169cfe4ff | ||
|
|
030ba7297a | ||
|
|
c0cda545e0 | ||
|
|
2ab188d873 | ||
|
|
e7b5b8e87f | ||
|
|
5f09259bbf | ||
|
|
c361fc2581 | ||
|
|
81827fcf28 | ||
|
|
4e97fc1a84 | ||
|
|
02a2102523 | ||
|
|
704c6c35d4 | ||
|
|
c959859d29 | ||
|
|
072d8fc88f | ||
|
|
87ef075ea7 | ||
|
|
8b8c077a29 | ||
|
|
6465f0ce75 | ||
|
|
ee356f8a1f | ||
|
|
588a1a82a1 | ||
|
|
80ea1918e3 | ||
|
|
83326ecf53 | ||
|
|
1d6c5f051e | ||
|
|
a702e72973 | ||
|
|
d0f5559c49 | ||
|
|
5134ba5433 | ||
|
|
c51df4e0b9 | ||
|
|
f9681deecf | ||
|
|
73a58674cf | ||
|
|
e008c48284 | ||
|
|
f8df504d18 | ||
|
|
59d50615c4 | ||
|
|
ecbf5737b6 | ||
|
|
f38833f104 | ||
|
|
9105c4db3d | ||
|
|
ee2bd81254 | ||
|
|
48285a715b | ||
|
|
e3ee1baa94 | ||
|
|
a7b4b2ed79 | ||
|
|
7dc9f712a0 | ||
|
|
0842babc77 | ||
|
|
5720356191 | ||
|
|
2cc77757d9 | ||
|
|
a342e15e05 | ||
|
|
be738d3791 | ||
|
|
95aa57a2ff | ||
|
|
b4dae04631 | ||
|
|
7ee0e9cd02 |
@@ -0,0 +1,69 @@
|
|||||||
|
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
|
||||||
|
# independent of any one machine. Two parallel jobs match the two local lanes:
|
||||||
|
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
|
||||||
|
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
|
||||||
|
#
|
||||||
|
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
|
||||||
|
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
|
||||||
|
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
|
||||||
|
# requirements, Postgres 16, and ffmpeg, and by setting SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that
|
||||||
|
# failed to come up is a hard error, never a silent skip. node 22 matches the prod build image
|
||||||
|
# (Dockerfile `node:22-alpine`).
|
||||||
|
name: gate
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:22-bookworm
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run format:check
|
||||||
|
- run: npm run knip
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# A combined python+node image: python 3.12 (matches the prod Dockerfile) for the tests, and node
|
||||||
|
# so the JS actions (checkout) run. actions/setup-python is NOT usable here — the runner can't reach
|
||||||
|
# GitHub's python-versions download, so it must be baked into the image.
|
||||||
|
container: nikolaik/python-nodejs:python3.12-nodejs20
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: siftlode
|
||||||
|
POSTGRES_PASSWORD: siftlode
|
||||||
|
POSTGRES_DB: siftlode_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U siftlode -d siftlode_test"
|
||||||
|
--health-interval 2s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 30
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@postgres:5432/siftlode_test
|
||||||
|
SIFTLODE_TEST_REQUIRE_DB: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# ffmpeg for the poster/thumbnail tests; postgresql-client for the readiness wait below.
|
||||||
|
- run: apt-get update && apt-get install -y --no-install-recommends ffmpeg postgresql-client
|
||||||
|
- name: Wait for Postgres
|
||||||
|
run: until pg_isready -h postgres -U siftlode -d siftlode_test; do sleep 1; done
|
||||||
|
- run: pip install --no-cache-dir -r backend/requirements.lock -r backend/requirements-dev.txt
|
||||||
|
- name: ruff
|
||||||
|
run: python -m ruff check --config backend/ruff.toml backend scripts
|
||||||
|
- name: pytest
|
||||||
|
run: python -m pytest
|
||||||
|
working-directory: backend
|
||||||
|
# Dependency CVE audit over the pinned lock. Non-blocking for now (informational) — a real CVE
|
||||||
|
# shows up in the run without failing the gate; tighten to a hard fail once the noise is known.
|
||||||
|
- name: pip-audit (advisory)
|
||||||
|
run: pip install --no-cache-dir pip-audit && pip-audit -r backend/requirements.lock
|
||||||
|
continue-on-error: true
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# CLAUDE.md — working agreement for AI agents on Siftlode
|
||||||
|
|
||||||
|
This repository is **fully AI-authored** — every line was written by an AI agent, and it continues to
|
||||||
|
be developed that way. This file is the rules of the road: read it before you touch anything. It is
|
||||||
|
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
|
||||||
|
worse than no rule — if you change how the code works, change this file in the same commit.
|
||||||
|
|
||||||
|
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
|
||||||
|
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
|
||||||
|
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
|
||||||
|
provider.
|
||||||
|
|
||||||
|
## The gate — never merge or ship red
|
||||||
|
|
||||||
|
The local gate is **ruff + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest +
|
||||||
|
pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||||
|
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||||
|
|
||||||
|
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||||
|
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` and `pytest` (the DB-lane needs Postgres).
|
||||||
|
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- **Trunk model.** One long-lived branch, `main`. Branch off it (`feature/`, `bug/`, `improvement/`),
|
||||||
|
keep commits logical and Conventional-Commit-styled.
|
||||||
|
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||||
|
being AI-written is the intended, visible exception; commits are not.)
|
||||||
|
- **Release is a deliberate, user-owned step** (`siftlode release` from the feature branch: merge to
|
||||||
|
`main` → publish → deploy → verify `/api/version`). Do not self-ship; bring work to a green,
|
||||||
|
committed state and stop for the user's go-ahead.
|
||||||
|
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||||
|
should assume or leak them.
|
||||||
|
|
||||||
|
## Architecture shape (don't fight it)
|
||||||
|
|
||||||
|
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||||
|
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
|
||||||
|
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
|
||||||
|
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
|
||||||
|
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
|
||||||
|
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
|
||||||
|
slices over `core.req<T>`.
|
||||||
|
|
||||||
|
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
|
||||||
|
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
|
||||||
|
|
||||||
|
## Canonical patterns — when you have a choice, pick THIS
|
||||||
|
|
||||||
|
| Area | Use | Not |
|
||||||
|
|---|---|---|
|
||||||
|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||||
|
| Request body | a Pydantic model | `payload: dict` |
|
||||||
|
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||||
|
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||||
|
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||||
|
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
|
||||||
|
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
|
||||||
|
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
|
||||||
|
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
|
||||||
|
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||||
|
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||||
|
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||||
|
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||||
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
|
|
||||||
|
## House rules that bite (learned from real bugs)
|
||||||
|
|
||||||
|
- **A rule stated in a comment must also be enforced in code.** The worst bugs here came from a comment
|
||||||
|
and the code/migration drifting apart (a `cancelled`≠`canceled` literal killed a cancel path). Put the
|
||||||
|
invariant in an enum/constant/shared function, not in prose.
|
||||||
|
- **Fix a principle everywhere, in one sweep.** If you correct one owner/commit/serialization site,
|
||||||
|
`grep` for the siblings and fix them too — asymmetric fixes are a recurring smell here.
|
||||||
|
- **Honest states.** An error must not render as an empty state; a swallowed failure must not look like
|
||||||
|
"no results". Reads degrade honestly; writes are conditional (`UPDATE … WHERE status='running'`).
|
||||||
|
- **i18n parity.** HU/EN, zero key drift. Every user-facing string is translated.
|
||||||
|
- **Comment the "why", keep it current.** This codebase uses comments as its architecture doc; a stale
|
||||||
|
"why" is a liability, so update it when the behavior changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*A fuller written-conventions doc (`docs/development.md`) is planned; once it lands, this file slims to a
|
||||||
|
pointer plus the gate/workflow/no-attribution rules. Until then, the table above is the source of truth.*
|
||||||
+6
-3
@@ -10,7 +10,7 @@ ARG GIT_SHA=unknown
|
|||||||
ARG BUILD_DATE=
|
ARG BUILD_DATE=
|
||||||
|
|
||||||
# Stage 1: build the frontend SPA
|
# Stage 1: build the frontend SPA
|
||||||
FROM node:20-alpine AS frontend
|
FROM node:22-alpine AS frontend
|
||||||
WORKDIR /fe
|
WORKDIR /fe
|
||||||
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
@@ -57,9 +57,12 @@ RUN apt-get update \
|
|||||||
&& rm /tmp/deno.zip \
|
&& rm /tmp/deno.zip \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
|
||||||
|
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
|
||||||
|
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
|
||||||
|
COPY backend/requirements.lock .
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install --upgrade pip && pip install -r requirements.txt
|
pip install --upgrade pip && pip install -r requirements.lock
|
||||||
|
|
||||||
COPY --chown=appuser backend/ .
|
COPY --chown=appuser backend/ .
|
||||||
# Read at runtime by config.py as the app_version (a committed single source of truth).
|
# Read at runtime by config.py as the app_version (a committed single source of truth).
|
||||||
|
|||||||
+10
-2
@@ -8,5 +8,13 @@
|
|||||||
# docker run --rm -v "<repo>/backend:/app" -w /app siftlode-test:local python -m pytest
|
# docker run --rm -v "<repo>/backend:/app" -w /app siftlode-test:local python -m pytest
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt requirements-dev.txt ./
|
# ffmpeg so the thumbnail/poster tests (needs_ffmpeg) actually run in the gate instead of skipping
|
||||||
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
|
# silently — the runtime image already ships ffmpeg, so the test image should too (see conftest's
|
||||||
|
# SIFTLODE_TEST_REQUIRE_DB, which now also refuses to run if ffmpeg is missing).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
# Install the SAME pinned lock as the prod image (+ the test-only tooling), so the tests run against
|
||||||
|
# the exact dependency versions prod ships.
|
||||||
|
COPY requirements.lock requirements-dev.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.lock -r requirements-dev.txt
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""plex library HU-overlay columns (E5 S5a)
|
||||||
|
|
||||||
|
Adds `metadata_lang` (default 'en') and a self-referential `overlay_of` to plex_libraries. An
|
||||||
|
OVERLAY library is the Hungarian-metadata twin of another section: it is never browsed on its own —
|
||||||
|
its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap:
|
||||||
|
metadata-only, no table rewrite.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0063_plex_library_overlay"
|
||||||
|
down_revision: Union[str, None] = "0062_drop_redundant_idx"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"plex_libraries",
|
||||||
|
sa.Column("metadata_lang", sa.String(8), nullable=False, server_default="en"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"plex_libraries",
|
||||||
|
sa.Column(
|
||||||
|
"overlay_of",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("plex_libraries.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_libraries_overlay_of", "plex_libraries", ["overlay_of"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_libraries_overlay_of", table_name="plex_libraries")
|
||||||
|
op.drop_column("plex_libraries", "overlay_of")
|
||||||
|
op.drop_column("plex_libraries", "metadata_lang")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""plex HU-overlay item/show columns + guid (E5 S5a)
|
||||||
|
|
||||||
|
On plex_items and plex_shows: `guid` (canonical external id, the EN<->HU pairing key), the Hungarian
|
||||||
|
twin fields `title_hu`/`summary_hu`/`genres_hu`, and a STORED generated `search_vector_hu` mirroring
|
||||||
|
the EN vector (title_hu A / people_text B / left(summary_hu,1000) C) + a GIN index. people_text is
|
||||||
|
language-neutral, reused for both vectors.
|
||||||
|
|
||||||
|
Run BEFORE the first HU overlay sync: while the *_hu columns are all NULL the generated-column build
|
||||||
|
is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated
|
||||||
|
column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0064_plex_hu_overlay_cols"
|
||||||
|
down_revision: Union[str, None] = "0063_plex_library_overlay"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_TABLES = ("plex_items", "plex_shows")
|
||||||
|
|
||||||
|
# Mirror of the EN search_vector (models.py) but over the HU twin columns; people_text is shared.
|
||||||
|
_VECTOR_HU = (
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for t in _TABLES:
|
||||||
|
op.add_column(t, sa.Column("guid", sa.String(64), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("title_hu", sa.String(512), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("summary_hu", sa.Text(), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("genres_hu", postgresql.JSONB(), nullable=True))
|
||||||
|
op.create_index(f"ix_{t}_guid", t, ["guid"])
|
||||||
|
op.create_index(f"ix_{t}_genres_hu_gin", t, ["genres_hu"], postgresql_using="gin")
|
||||||
|
# Generated column can't be expressed via add_column — raw DDL, matching the EN idiom (0044).
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE {t} ADD COLUMN search_vector_hu tsvector "
|
||||||
|
f"GENERATED ALWAYS AS ({_VECTOR_HU}) STORED"
|
||||||
|
)
|
||||||
|
op.execute(f"CREATE INDEX ix_{t}_search_vector_hu ON {t} USING gin (search_vector_hu)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for t in _TABLES:
|
||||||
|
op.execute(f"DROP INDEX IF EXISTS ix_{t}_search_vector_hu")
|
||||||
|
op.execute(f"ALTER TABLE {t} DROP COLUMN IF EXISTS search_vector_hu")
|
||||||
|
op.drop_index(f"ix_{t}_genres_hu_gin", table_name=t)
|
||||||
|
op.drop_index(f"ix_{t}_guid", table_name=t)
|
||||||
|
op.drop_column(t, "genres_hu")
|
||||||
|
op.drop_column(t, "summary_hu")
|
||||||
|
op.drop_column(t, "title_hu")
|
||||||
|
op.drop_column(t, "guid")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""plex rich-detail item/show columns (E5 S5b)
|
||||||
|
|
||||||
|
On plex_items and plex_shows: the fields that were previously read LIVE from plex.metadata() on every
|
||||||
|
info-page view — `imdb_rating` (the IMDb-specific score, distinct from `rating` = audienceRating),
|
||||||
|
`imdb_id` (tt…), `tagline`, and `full_cast` (ordered [{name, role, thumb}]). Persisting them lets the
|
||||||
|
detail endpoints serve from the DB row instead of a live Plex call (outage-resilient, and the
|
||||||
|
precondition for S6's pure-DB language-aware serving).
|
||||||
|
|
||||||
|
These are filled by the same rich backfill that already fills the full cast (`_enrich_people_batch`),
|
||||||
|
so the data step below resets `people_enriched_at` to NULL on movie/show rows: the backfill re-fetches
|
||||||
|
each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table
|
||||||
|
rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0065_plex_detail_fields"
|
||||||
|
down_revision: Union[str, None] = "0064_plex_hu_overlay_cols"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_TABLES = ("plex_items", "plex_shows")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for t in _TABLES:
|
||||||
|
op.add_column(t, sa.Column("imdb_rating", sa.Float(), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("imdb_id", sa.String(16), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("tagline", sa.Text(), nullable=True))
|
||||||
|
op.add_column(t, sa.Column("full_cast", postgresql.JSONB(), nullable=True))
|
||||||
|
# Force a one-time re-enrich so already-enriched rows gain the new columns (episodes are never
|
||||||
|
# enriched, so their NULL people_enriched_at is unaffected; they keep serving detail live).
|
||||||
|
op.execute("UPDATE plex_items SET people_enriched_at = NULL WHERE kind = 'movie'")
|
||||||
|
op.execute("UPDATE plex_shows SET people_enriched_at = NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for t in _TABLES:
|
||||||
|
op.drop_column(t, "full_cast")
|
||||||
|
op.drop_column(t, "tagline")
|
||||||
|
op.drop_column(t, "imdb_id")
|
||||||
|
op.drop_column(t, "imdb_rating")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""notifications.created_at NOT NULL (align schema to the model)
|
||||||
|
|
||||||
|
The model has always declared `Notification.created_at: Mapped[datetime]` (NOT NULL) with
|
||||||
|
`server_default=func.now()`, but migration 0017 created the column without `nullable=False`, so the live
|
||||||
|
schema allowed NULL — a model/schema drift the new migration drift-test (E1 S2) surfaced. The
|
||||||
|
server_default has filled every row since day one, so no NULLs exist; enforcing NOT NULL just makes the
|
||||||
|
schema match the contract the model already promises. Pure DDL, reversible.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0066_notif_created_at_notnull"
|
||||||
|
down_revision: Union[str, None] = "0065_plex_detail_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column("notifications", "created_at", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column("notifications", "created_at", nullable=True)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""feed sort indexes: published_at DESC NULLS LAST composite(s) for the default feed (PERF-1)
|
||||||
|
|
||||||
|
The feed's default `newest` sort is `published_at DESC NULLS LAST, id ASC` (feedquery.order_by), but the
|
||||||
|
only published_at index was a plain ASC btree (`ix_videos_published_at`). A btree read backwards satisfies
|
||||||
|
`DESC NULLS FIRST`, not the `DESC NULLS LAST` the query asks for, so the planner could not use it for
|
||||||
|
ordering — every feed page fell back to a full `videos` scan + top-N sort. Measured on a prod-sized reseed
|
||||||
|
(257k videos): the default page went from ~44.8k shared buffers (dev planner) / ~169k (prod planner, the
|
||||||
|
per-channel nested loop that is the likely "prod feed is slow" root cause) down to ~624 buffers with the
|
||||||
|
matching index.
|
||||||
|
|
||||||
|
This adds two indexes in the exact feed order:
|
||||||
|
- ix_videos_published_at_desc (published_at DESC NULLS LAST, id ASC) — the global feed / Library sort.
|
||||||
|
- ix_videos_channel_pub_desc (channel_id, published_at DESC NULLS LAST, id ASC) — the per-channel feed
|
||||||
|
(channel page, channel_ids filter). It leads with channel_id, so it is a strict superset of the old
|
||||||
|
single-column `ix_videos_channel_id` (FK cascade, sync upsert, autotag, channel-detail all still ride
|
||||||
|
it) — that redundant index is dropped.
|
||||||
|
|
||||||
|
The plain ASC `ix_videos_published_at` stays: it already serves the `oldest` sort (ASC NULLS LAST = btree
|
||||||
|
default) as an Index Scan.
|
||||||
|
|
||||||
|
CONCURRENTLY (in alembic's autocommit_block, since it can't run inside a transaction) so a live prod builds
|
||||||
|
these without blocking reads/writes; downtime ~0. IF (NOT) EXISTS keeps the drift test's
|
||||||
|
downgrade->re-upgrade round-trip idempotent.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0067_feed_sort_indexes"
|
||||||
|
down_revision: Union[str, None] = "0066_notif_created_at_notnull"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_published_at_desc "
|
||||||
|
"ON videos (published_at DESC NULLS LAST, id ASC)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_pub_desc "
|
||||||
|
"ON videos (channel_id, published_at DESC NULLS LAST, id ASC)"
|
||||||
|
)
|
||||||
|
# Redundant now — ix_videos_channel_pub_desc leads with channel_id (a strict superset).
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_channel_id ON videos (channel_id)"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_channel_pub_desc")
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_published_at_desc")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""videos.published_at NOT NULL DEFAULT now() (PERF-15)
|
||||||
|
|
||||||
|
Every video in practice carries a publish date (0 NULLs in the 257k-row prod-sized reseed), but the
|
||||||
|
column was nullable, which forced the feed's keyset predicate into a non-sargable
|
||||||
|
`... OR published_at IS NULL` disjunction — blocking the ix_videos_published_at_desc range so deep
|
||||||
|
`newest` pages scanned from the top of the index and filtered (51.8k buffers). Making it NOT NULL lets
|
||||||
|
the keyset drop that disjunction and take a `published_at <=` range bound (deep page -> ~0.5k buffers).
|
||||||
|
|
||||||
|
server_default now() is the safety net for the rare date-less ingest path (the enrich overwrites it with
|
||||||
|
the real date); the app-side ingest paths also fall back to now() so they never write an explicit NULL.
|
||||||
|
|
||||||
|
Note: `SET NOT NULL` validates with a brief ACCESS EXCLUSIVE lock + one table scan (it cannot run
|
||||||
|
CONCURRENTLY). On the prod row count (~235k) that is sub-second; still, it is a short write-blocking lock
|
||||||
|
rather than the ~0 of 0067's CONCURRENTLY index build.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0068_published_at_not_null"
|
||||||
|
down_revision: Union[str, None] = "0067_feed_sort_indexes"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Backfill any stray NULLs first so SET NOT NULL can't fail (0 in prod, but be safe).
|
||||||
|
op.execute("UPDATE videos SET published_at = now() WHERE published_at IS NULL")
|
||||||
|
op.alter_column(
|
||||||
|
"videos", "published_at", nullable=False, server_default=sa.text("now()")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column("videos", "published_at", nullable=True, server_default=None)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""scheduler-job partial indexes + boolean-index cleanup (PERF-17/18/3)
|
||||||
|
|
||||||
|
Two scheduler jobs full-seq-scanned the 257k-row videos table every run:
|
||||||
|
- PERF-17: enrich_pending (`WHERE enriched_at IS NULL LIMIT N`, every few minutes) — 44.6k buffers /
|
||||||
|
run even when nothing is pending. A partial index over just the pending rows makes it a 1-buffer
|
||||||
|
(empty) index scan.
|
||||||
|
- PERF-18: maintenance revalidate (`WHERE unavailable_since IS NULL ORDER BY last_checked_at NULLS
|
||||||
|
FIRST LIMIT N`, ~300 pages/run) — 44.7k buffers/page; ix_videos_last_checked_at had idx_scan=0. A
|
||||||
|
partial index in that exact order serves it as an index scan (~36 buffers/page) and replaces the
|
||||||
|
unused plain one.
|
||||||
|
|
||||||
|
PERF-3: the videos table also carried plain btrees on low-cardinality boolean columns the planner never
|
||||||
|
picks (a `col IS false` majority predicate can't ride a btree). Drop the never-useful ones (is_short,
|
||||||
|
via_explore) and slim the rest to partial indexes over the small/shrinking subset the job queries target
|
||||||
|
(via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — less write amplification on the
|
||||||
|
biggest table, same lookups. live_status is left alone (a separate finding).
|
||||||
|
|
||||||
|
All CONCURRENTLY (autocommit_block) — downtime ~0; IF (NOT) EXISTS keeps the drift test's
|
||||||
|
downgrade->re-upgrade idempotent.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0069_job_index_hygiene"
|
||||||
|
down_revision: Union[str, None] = "0068_published_at_not_null"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
# PERF-17: enrich-pending partial (empty when nothing is pending -> 1-buffer scan).
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_enrich_pending "
|
||||||
|
"ON videos (id) WHERE enriched_at IS NULL"
|
||||||
|
)
|
||||||
|
# PERF-18: maintenance revalidate order, as a partial in the exact WHERE + ORDER BY shape.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_recheck_order "
|
||||||
|
"ON videos (last_checked_at ASC NULLS FIRST) WHERE unavailable_since IS NULL"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_last_checked_at")
|
||||||
|
# PERF-3: drop the never-picked boolean btrees; slim the rest to partials over the target subset.
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_is_short")
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_explore")
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_search")
|
||||||
|
# Predicate MUST match the query's `.is_(True)`/`.is_(False)` form (SQL `IS TRUE`/`IS FALSE`):
|
||||||
|
# Postgres's partial-index prover does NOT match a `col IS TRUE` clause to a bare `col` predicate,
|
||||||
|
# so `WHERE via_search` / `WHERE NOT shorts_probed` would leave the queries seq-scanning.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_search "
|
||||||
|
"ON videos (id) WHERE via_search IS TRUE"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shorts_probed")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_shorts_probed "
|
||||||
|
"ON videos (id) WHERE shorts_probed IS FALSE"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_unavailable_since")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_unavailable_since "
|
||||||
|
"ON videos (unavailable_since) WHERE unavailable_since IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_enrich_pending")
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_recheck_order")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_last_checked_at "
|
||||||
|
"ON videos (last_checked_at)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_is_short ON videos (is_short)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_explore ON videos (via_explore)"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_via_search")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_via_search ON videos (via_search)"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_shorts_probed")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_shorts_probed ON videos (shorts_probed)"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_videos_unavailable_since")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_videos_unavailable_since "
|
||||||
|
"ON videos (unavailable_since)"
|
||||||
|
)
|
||||||
+4
-3
@@ -11,7 +11,7 @@ from app import sysconfig
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
from app.models import Invite, User
|
||||||
from app.security import decrypt
|
from app.security import decrypt_optional
|
||||||
|
|
||||||
PASSWORD_MIN_LEN = 10
|
PASSWORD_MIN_LEN = 10
|
||||||
|
|
||||||
@@ -122,9 +122,10 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
|||||||
user_id = user.id
|
user_id = user.id
|
||||||
tok = user.token
|
tok = user.token
|
||||||
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
||||||
# ciphertext, so a token row with no refresh token silently failed to revoke).
|
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
||||||
|
# TOKEN_ENCRYPTION_KEY mismatch can't 500 the purge — a best-effort revoke degrades to "skip". (B-core-2)
|
||||||
google_token = (
|
google_token = (
|
||||||
(decrypt(tok.refresh_token_enc) or decrypt(tok.access_token)) if tok else None
|
(decrypt_optional(tok.refresh_token_enc) or decrypt_optional(tok.access_token)) if tok else None
|
||||||
)
|
)
|
||||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||||
db.execute(delete(User).where(User.id == user_id))
|
db.execute(delete(User).where(User.id == user_id))
|
||||||
|
|||||||
@@ -221,6 +221,10 @@ class Settings(BaseSettings):
|
|||||||
plex_path_map: str = ""
|
plex_path_map: str = ""
|
||||||
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
|
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
|
||||||
plex_libraries: str = ""
|
plex_libraries: str = ""
|
||||||
|
# HU-overlay mapping (E5 S5): comma-separated `huSectionKey:enSectionKey` pairs. Each named HU
|
||||||
|
# section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN
|
||||||
|
# twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1".
|
||||||
|
plex_overlay_map: str = ""
|
||||||
# Stable client identifier Siftlode presents to Plex (X-Plex-Client-Identifier).
|
# Stable client identifier Siftlode presents to Plex (X-Plex-Client-Identifier).
|
||||||
plex_client_id: str = "siftlode-server"
|
plex_client_id: str = "siftlode-server"
|
||||||
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
# How often the catalog sync job mirrors Plex metadata, in minutes.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ the worker live in `worker.py` / the routes; this module owns enqueue + asset bo
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ from app.config import settings
|
|||||||
from app.downloads import adapters
|
from app.downloads import adapters
|
||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import formats, quota, storage
|
from app.downloads import formats, quota, storage
|
||||||
|
from app.downloads.status import HOLDING, JobStatus
|
||||||
from app.models import Channel, DownloadJob, MediaAsset, Video
|
from app.models import Channel, DownloadJob, MediaAsset, Video
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +224,35 @@ def enqueue_edit(
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
|
||||||
|
"""Write terminal `fields` to a job in ONE atomic ``UPDATE ... WHERE status='running'`` and report
|
||||||
|
whether it fired. This is the single owner of the invariant "a finishing worker child must never
|
||||||
|
resurrect a job a route just stopped": the atomic WHERE closes the read-then-write race an
|
||||||
|
``if job.status == 'running'`` guard leaves open, so a concurrent cancel/pause can no longer be
|
||||||
|
overwritten with done/error (which would re-open the double-release → file-delete-under-live-holder
|
||||||
|
class, since `done`/`error` hold the asset but `canceled` does not — see release_asset).
|
||||||
|
|
||||||
|
Runs in the CALLER's session and does NOT commit — the caller commits it together with its asset
|
||||||
|
writes in one transaction. It flushes first so any pending asset write takes its row lock BEFORE
|
||||||
|
this takes the job's, matching the cancel route's asset-then-job order (no deadlock). `fields` are
|
||||||
|
mapped-column kwargs (never user input). Returns True when the row was still running and got
|
||||||
|
updated, False when a route had already moved it out of 'running'."""
|
||||||
|
if not fields:
|
||||||
|
return False
|
||||||
|
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
|
||||||
|
# A Core UPDATE with the guard as a WHERE — same atomic "only while still running" write as the
|
||||||
|
# rest of the codebase's 2.0 statements, no hand-built SQL string. synchronize_session=False: this
|
||||||
|
# is a terminal write, so we don't pay to sync the (about-to-be-discarded) ORM identity map. Bound
|
||||||
|
# StrEnum values bind as their plain str literal (StrEnum is a str subclass).
|
||||||
|
result = db.execute(
|
||||||
|
update(DownloadJob)
|
||||||
|
.where(DownloadJob.id == job_id, DownloadJob.status == JobStatus.RUNNING.value)
|
||||||
|
.values(**fields)
|
||||||
|
.execution_options(synchronize_session=False)
|
||||||
|
)
|
||||||
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
def release_asset(db: Session, job: DownloadJob) -> None:
|
def release_asset(db: Session, job: DownloadJob) -> None:
|
||||||
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
"""Drop this job's hold on its asset when it leaves a holding state. Once NO job holds the
|
||||||
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
asset anymore, delete the file + row immediately — a deleted download should free its disk,
|
||||||
@@ -238,7 +268,7 @@ def release_asset(db: Session, job: DownloadJob) -> None:
|
|||||||
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
+1 it, and deleting it under that would FK-null the new job's asset (a lost update on ref_count).
|
||||||
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
A ready asset is still freed at ref==0 (its file is the point); an orphaned errored row is cheap
|
||||||
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
|
(no file) and gets reused+reset by the next enqueue, or reclaimed by a later GC pass."""
|
||||||
if not job.asset_id or job.status not in ("queued", "running", "paused", "done", "error"):
|
if not job.asset_id or job.status not in HOLDING:
|
||||||
return
|
return
|
||||||
asset = db.get(MediaAsset, job.asset_id)
|
asset = db.get(MediaAsset, job.asset_id)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Canonical download-job status values — the ONE source of truth for the lifecycle literals.
|
||||||
|
|
||||||
|
A job's status is written from several places: the worker (claim → running, terminal done/error,
|
||||||
|
requeue), the routes (pause/cancel/redownload/delete), the service (enqueue), and the GC. Keeping the
|
||||||
|
literals here is a data-safety measure, not tidiness. A British/American drift once silently broke
|
||||||
|
cancellation: the supervisor compared the live status against ``"cancelled"`` while the cancel route
|
||||||
|
wrote ``"canceled"`` (models.py), so the supervisor's "a route stopped it → kill the child, free the
|
||||||
|
asset" branch was DEAD. A canceled job's child then ran to completion and wrote ``"done"``
|
||||||
|
unconditionally over the route's ``"canceled"``; because ``done`` counts as a holding state but
|
||||||
|
``canceled`` does not (see ``service.release_asset``), a later delete released the asset a SECOND time,
|
||||||
|
dropping ``ref_count`` below the live holder count and deleting a file still in use. Import these names
|
||||||
|
instead of typing the strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
PAUSED = "paused"
|
||||||
|
DONE = "done"
|
||||||
|
ERROR = "error"
|
||||||
|
CANCELED = "canceled"
|
||||||
|
|
||||||
|
|
||||||
|
# A job "holds" its media asset (the +1 on ``ref_count`` from enqueue) while in any of these states;
|
||||||
|
# ``release_asset`` on a status OUTSIDE this set is a no-op. ``CANCELED`` is deliberately excluded: the
|
||||||
|
# cancel route releases once while the job is still holding, then sets ``CANCELED``, so a later delete
|
||||||
|
# does not double-release. This invariant only holds while a finishing child cannot flip ``CANCELED``
|
||||||
|
# back into the set — hence the worker's terminal writes are conditional on the row still being RUNNING.
|
||||||
|
HOLDING = frozenset(
|
||||||
|
{JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE, JobStatus.ERROR}
|
||||||
|
)
|
||||||
|
|
||||||
|
# States a route sets to STOP a running job. The worker's supervisor must step in (kill the child, free
|
||||||
|
# the asset, leave the route's status intact) when it observes one of these on a job it is running.
|
||||||
|
STOPPED_BY_ROUTE = frozenset({JobStatus.PAUSED, JobStatus.CANCELED})
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
"""Feed read-model: the pure query-building and row-serialization layer behind the feed.
|
||||||
|
|
||||||
|
Extracted from `routes/feed.py` so the read-model has one home instead of living inside the
|
||||||
|
router module — the feed route handlers and the playlist-detail query share these helpers (the
|
||||||
|
projection, the filter builder, and keyset paging). Nothing here defines a route or reaches for
|
||||||
|
request state; it's all `(db, user, params) -> query`, and a malformed cursor raises a plain
|
||||||
|
`ValueError` for the route handler to translate (the tsquery sanitizer lives in `app.utils`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
LIVE_OR_UPCOMING,
|
||||||
|
BlockedChannel,
|
||||||
|
Channel,
|
||||||
|
ChannelTag,
|
||||||
|
ExploredChannel,
|
||||||
|
Playlist,
|
||||||
|
PlaylistItem,
|
||||||
|
SearchFind,
|
||||||
|
Subscription,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
VideoState,
|
||||||
|
)
|
||||||
|
from app.utils import iso, to_tsquery_str
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
|
if handle and handle.startswith("@"):
|
||||||
|
return f"https://www.youtube.com/{handle}"
|
||||||
|
return f"https://www.youtube.com/channel/{channel_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_expr(user: User):
|
||||||
|
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
|
||||||
|
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
|
||||||
|
return (
|
||||||
|
select(1)
|
||||||
|
.select_from(PlaylistItem)
|
||||||
|
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||||
|
.where(
|
||||||
|
Playlist.user_id == user.id,
|
||||||
|
Playlist.kind == "watch_later",
|
||||||
|
PlaylistItem.video_id == Video.id,
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
.label("saved")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def feed_columns(user: User, state) -> list:
|
||||||
|
"""The shared feed-row projection (joined Channel fields + per-user watch state +
|
||||||
|
`saved`), consumed by both the feed query and the playlist-detail query so a new column
|
||||||
|
is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined)."""
|
||||||
|
return [
|
||||||
|
Video.id,
|
||||||
|
Video.title,
|
||||||
|
Video.channel_id,
|
||||||
|
Channel.title.label("channel_title"),
|
||||||
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
|
Video.published_at,
|
||||||
|
Video.thumbnail_url,
|
||||||
|
Video.duration_seconds,
|
||||||
|
Video.view_count,
|
||||||
|
Video.is_short,
|
||||||
|
Video.live_status,
|
||||||
|
func.coalesce(state.status, "new").label("status"),
|
||||||
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||||
|
_saved_expr(user),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize(row) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"title": row.title,
|
||||||
|
"channel_id": row.channel_id,
|
||||||
|
"channel_title": row.channel_title,
|
||||||
|
"channel_thumbnail": row.channel_thumbnail,
|
||||||
|
"channel_url": _channel_url(row.channel_id, row.channel_handle),
|
||||||
|
"published_at": iso(row.published_at),
|
||||||
|
"thumbnail_url": row.thumbnail_url,
|
||||||
|
"duration_seconds": row.duration_seconds,
|
||||||
|
"view_count": row.view_count,
|
||||||
|
"is_short": row.is_short,
|
||||||
|
"live_status": row.live_status,
|
||||||
|
"status": row.status or "new",
|
||||||
|
"position_seconds": row.position_seconds or 0,
|
||||||
|
"saved": bool(getattr(row, "saved", False)),
|
||||||
|
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||||
|
"""Group tag ids by their category (language/topic/other). The single source for the category split
|
||||||
|
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||||
|
two can't disagree on which selected tag belongs to which category."""
|
||||||
|
if not tag_ids:
|
||||||
|
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
|
||||||
|
out: dict[str, list[int]] = {}
|
||||||
|
for tid, cat in db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids))).all():
|
||||||
|
out.setdefault(cat, []).append(tid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def filtered_query(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
tags: list[int],
|
||||||
|
tag_mode: str,
|
||||||
|
channel_ids: list[str],
|
||||||
|
q: str | None,
|
||||||
|
min_duration: int | None,
|
||||||
|
max_duration: int | None,
|
||||||
|
max_age_days: int | None,
|
||||||
|
published_after: date | None,
|
||||||
|
published_before: date | None,
|
||||||
|
show_normal: bool,
|
||||||
|
include_shorts: bool,
|
||||||
|
include_live: bool,
|
||||||
|
show: str,
|
||||||
|
scope: str = "my",
|
||||||
|
library_source: str = "organic",
|
||||||
|
exclude_tag_category: str | None = None,
|
||||||
|
) -> tuple[Select, object]:
|
||||||
|
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
||||||
|
Returns the column-bearing select plus the priority/relevance rank expression for sorting.
|
||||||
|
|
||||||
|
`scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions.
|
||||||
|
`scope="all"` shows every video in the shared catalog (any user's ingested channels);
|
||||||
|
the subscription is then only LEFT-joined so per-channel priority still resolves for
|
||||||
|
channels the user happens to be subscribed to. Per-user watch state stays private in
|
||||||
|
either mode via the VideoState outer join."""
|
||||||
|
state = aliased(VideoState)
|
||||||
|
status_expr = func.coalesce(state.status, "new")
|
||||||
|
|
||||||
|
query = select(*feed_columns(user, state)).join(
|
||||||
|
Channel, Channel.id == Video.channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if scope == "all":
|
||||||
|
# Whole shared catalog; subscription is optional (only for priority sort).
|
||||||
|
query = query.outerjoin(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
Subscription.user_id == user.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# "Mine": the user's own videos = their non-hidden subscriptions OR videos they
|
||||||
|
# surfaced via their own live YouTube search. Both are LEFT-joined so the Source
|
||||||
|
# filter below can include either or both, and the subscription row stays available
|
||||||
|
# for the priority sort.
|
||||||
|
query = query.outerjoin(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
Subscription.user_id == user.id,
|
||||||
|
Subscription.hidden.is_(False),
|
||||||
|
),
|
||||||
|
).outerjoin(
|
||||||
|
SearchFind,
|
||||||
|
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The state-gated views (watched/hidden/in_progress) only ever return videos that HAVE a
|
||||||
|
# video_states row, so an INNER join lets the planner drive from the user's own small video_states
|
||||||
|
# set (~100 rows) instead of scanning all videos and outer-joining (PERF-16). status is NOT NULL on
|
||||||
|
# a present row (server_default 'new'), so the raw-column filters below match the coalesce form
|
||||||
|
# exactly. 'unwatched'/'all' include row-less 'new' videos, so they keep the outer join.
|
||||||
|
state_required = show in ("watched", "hidden", "in_progress")
|
||||||
|
join_state = query.join if state_required else query.outerjoin
|
||||||
|
query = join_state(
|
||||||
|
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
|
||||||
|
# abandoned). They're either deleted after a grace period or unhidden if they recover;
|
||||||
|
# either way they shouldn't show in any feed view meanwhile.
|
||||||
|
query = query.where(Video.unavailable_since.is_(None))
|
||||||
|
|
||||||
|
# Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone
|
||||||
|
# opened an un-subscribed channel's page) is visible only to users who explored or
|
||||||
|
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
|
||||||
|
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
||||||
|
# asking for scope=all + channel_ids (the channel is in this accessible set).
|
||||||
|
accessible_explore = (
|
||||||
|
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
||||||
|
).union(
|
||||||
|
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
||||||
|
)
|
||||||
|
query = query.where(
|
||||||
|
or_(
|
||||||
|
Video.via_explore.is_(False),
|
||||||
|
Video.channel_id.in_(accessible_explore),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-user channel blocklist: never show this user videos from a channel they blocked
|
||||||
|
# (applies to feed, Library, explore, and the channel page alike).
|
||||||
|
query = query.where(
|
||||||
|
Video.channel_id.notin_(
|
||||||
|
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
||||||
|
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
||||||
|
# "organic" = your subscriptions, "search" = videos you found via your own search,
|
||||||
|
# "all" = both. Default "organic" keeps the Mine feed = subscriptions only.
|
||||||
|
if scope == "all":
|
||||||
|
if library_source == "organic":
|
||||||
|
query = query.where(Video.via_search.is_(False))
|
||||||
|
elif library_source == "search":
|
||||||
|
query = query.where(Video.via_search.is_(True))
|
||||||
|
# "all" → both organic and search-discovered videos
|
||||||
|
else:
|
||||||
|
subscribed = Subscription.channel_id.isnot(None)
|
||||||
|
searched = SearchFind.video_id.isnot(None)
|
||||||
|
if library_source == "search":
|
||||||
|
query = query.where(searched)
|
||||||
|
elif library_source == "all":
|
||||||
|
query = query.where(or_(subscribed, searched))
|
||||||
|
else: # "organic" (default): subscriptions only
|
||||||
|
query = query.where(subscribed)
|
||||||
|
|
||||||
|
if channel_ids:
|
||||||
|
# OR across the picked channels; every other filter still applies on top.
|
||||||
|
query = query.where(Video.channel_id.in_(channel_ids))
|
||||||
|
if min_duration is not None:
|
||||||
|
query = query.where(Video.duration_seconds >= min_duration)
|
||||||
|
if max_duration is not None:
|
||||||
|
query = query.where(Video.duration_seconds <= max_duration)
|
||||||
|
if max_age_days is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
||||||
|
query = query.where(
|
||||||
|
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||||
|
)
|
||||||
|
if published_after is not None:
|
||||||
|
start = datetime.combine(
|
||||||
|
published_after, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
query = query.where(Video.published_at >= start)
|
||||||
|
if published_before is not None:
|
||||||
|
end = datetime.combine(
|
||||||
|
published_before, datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
) + timedelta(days=1)
|
||||||
|
query = query.where(Video.published_at < end)
|
||||||
|
# Full-text relevance search over the weighted search document (title > creator keywords +
|
||||||
|
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like
|
||||||
|
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
|
||||||
|
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so
|
||||||
|
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
|
||||||
|
rank_expr = None
|
||||||
|
if q:
|
||||||
|
# Resolve the channels whose NAME matches up front (there are only a few hundred channels, so
|
||||||
|
# this is a tiny standalone scan) and match videos by channel_id. ORing an unindexed
|
||||||
|
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
|
||||||
|
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
|
||||||
|
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
|
||||||
|
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone.
|
||||||
|
like = func.unaccent(f"%{q}%")
|
||||||
|
matching_channel_ids = (
|
||||||
|
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
channel_match = (
|
||||||
|
Video.channel_id.in_(matching_channel_ids) if matching_channel_ids else false()
|
||||||
|
)
|
||||||
|
ts_str = to_tsquery_str(q)
|
||||||
|
if ts_str:
|
||||||
|
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
||||||
|
query = query.where(or_(Video.search_vector.op("@@")(tsq), channel_match))
|
||||||
|
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
|
||||||
|
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
|
||||||
|
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
|
||||||
|
# published_at then id.
|
||||||
|
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
|
||||||
|
else:
|
||||||
|
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
|
||||||
|
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
|
||||||
|
# test_feed_summary_matches_count_plus_facets).
|
||||||
|
by_category = tags_by_category(db, tags)
|
||||||
|
# Facet counting drops the category being counted so its own chips don't zero each
|
||||||
|
# other out (standard drill-down faceting: a category's count ignores its own
|
||||||
|
# selections but still honours the other categories' filters).
|
||||||
|
if exclude_tag_category:
|
||||||
|
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:
|
||||||
|
sub = (
|
||||||
|
select(ChannelTag.channel_id)
|
||||||
|
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
|
.group_by(ChannelTag.channel_id)
|
||||||
|
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sub = select(ChannelTag.channel_id).where(
|
||||||
|
ChannelTag.tag_id.in_(ids), visible
|
||||||
|
)
|
||||||
|
query = query.where(Video.channel_id.in_(sub))
|
||||||
|
|
||||||
|
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||||
|
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||||
|
explicit_view = show in ("watched", "hidden")
|
||||||
|
if not explicit_view:
|
||||||
|
type_clauses = []
|
||||||
|
if show_normal:
|
||||||
|
type_clauses.append(
|
||||||
|
and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING))
|
||||||
|
)
|
||||||
|
if include_shorts:
|
||||||
|
type_clauses.append(Video.is_short.is_(True))
|
||||||
|
if include_live:
|
||||||
|
type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING))
|
||||||
|
query = query.where(or_(*type_clauses) if type_clauses else false())
|
||||||
|
|
||||||
|
if show == "unwatched":
|
||||||
|
query = query.where(status_expr.notin_(("watched", "hidden")))
|
||||||
|
elif show == "in_progress":
|
||||||
|
# Started but not finished: a stored resume position, not yet watched/hidden. INNER-joined
|
||||||
|
# (state_required) so the raw state columns are safe and the planner can use video_states.
|
||||||
|
query = query.where(
|
||||||
|
and_(state.position_seconds > 0, state.status.notin_(("watched", "hidden")))
|
||||||
|
)
|
||||||
|
elif show == "watched":
|
||||||
|
query = query.where(state.status == "watched")
|
||||||
|
elif show == "hidden":
|
||||||
|
query = query.where(state.status == "hidden")
|
||||||
|
else: # all
|
||||||
|
query = query.where(status_expr != "hidden")
|
||||||
|
|
||||||
|
return query, rank_expr
|
||||||
|
|
||||||
|
|
||||||
|
def feed_summary(db: Session, user: User, **params) -> dict:
|
||||||
|
"""Single-pass replacement for /feed/count + 3x /facets (PERF-13): the sidebar's total video count
|
||||||
|
and every tag's distinct-channel facet count, derived in Python from ONE grouped query instead of
|
||||||
|
four full passes over the subscribed set on every filter change.
|
||||||
|
|
||||||
|
The one query is the base filter with the TAG predicate removed, grouped by channel: it yields every
|
||||||
|
channel that has a matching video, with its video count. From that ~few-hundred-row map we compute:
|
||||||
|
- count = sum of the video counts of the channels that pass the SELECTED tag filter;
|
||||||
|
- facets = per tag, the number of distinct channels that carry it AND pass the drill-down filter
|
||||||
|
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
||||||
|
old /facets exclude_tag_category logic exactly).
|
||||||
|
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
||||||
|
within, topic-AND requires ALL selected topics)."""
|
||||||
|
tag_mode = params.get("tag_mode", "or")
|
||||||
|
|
||||||
|
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||||
|
# is present to facet over.
|
||||||
|
base, _rank = filtered_query(db, user, **{**params, "tags": []})
|
||||||
|
ch_col = base.with_only_columns(Video.channel_id).subquery()
|
||||||
|
channel_counts: dict[str, int] = dict(
|
||||||
|
db.execute(select(ch_col.c.channel_id, func.count()).group_by(ch_col.c.channel_id)).all()
|
||||||
|
)
|
||||||
|
if not channel_counts:
|
||||||
|
return {"count": 0, "facets": {}}
|
||||||
|
|
||||||
|
# channel -> its visible tag ids, plus each tag's category (for grouping the facet output).
|
||||||
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
|
channel_tags: dict[str, set[int]] = {}
|
||||||
|
tag_category: dict[int, str] = {}
|
||||||
|
for cid, tid, cat in db.execute(
|
||||||
|
select(ChannelTag.channel_id, ChannelTag.tag_id, Tag.category)
|
||||||
|
.join(Tag, Tag.id == ChannelTag.tag_id)
|
||||||
|
.where(visible, ChannelTag.channel_id.in_(channel_counts.keys()))
|
||||||
|
).all():
|
||||||
|
channel_tags.setdefault(cid, set()).add(tid)
|
||||||
|
tag_category[tid] = cat
|
||||||
|
|
||||||
|
# Selected tags grouped by category (read from Tag via the shared helper, so a selected tag that no
|
||||||
|
# base channel carries still filters everything out for its category — matching the SQL `IN (empty)`
|
||||||
|
# behaviour).
|
||||||
|
selected_by_cat: dict[str, set[int]] = {
|
||||||
|
cat: set(ids) for cat, ids in tags_by_category(db, params.get("tags") or []).items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
|
||||||
|
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
|
||||||
|
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
|
||||||
|
for cat, sel in selected_by_cat.items():
|
||||||
|
if cat == exclude_cat:
|
||||||
|
continue
|
||||||
|
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
|
||||||
|
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
|
||||||
|
return False
|
||||||
|
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
count = sum(
|
||||||
|
n for cid, n in channel_counts.items() if passes(channel_tags.get(cid, set()), None)
|
||||||
|
)
|
||||||
|
|
||||||
|
facets: dict[int, int] = {}
|
||||||
|
for category in ("language", "topic", "other"):
|
||||||
|
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||||
|
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||||
|
exclude = None if (category == "topic" and tag_mode == "and") else category
|
||||||
|
for cid, ch_tags in channel_tags.items():
|
||||||
|
if not passes(ch_tags, exclude):
|
||||||
|
continue
|
||||||
|
for tid in ch_tags:
|
||||||
|
if tag_category.get(tid) == category:
|
||||||
|
facets[tid] = facets.get(tid, 0) + 1
|
||||||
|
return {"count": count, "facets": facets}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||||
|
#
|
||||||
|
# Each sort is described as an ordered list of key columns (expression + direction
|
||||||
|
# + whether it can be NULL + a JSON round-trip kind), always with Video.id as the
|
||||||
|
# final, unique tiebreaker. This drives BOTH the ORDER BY and the keyset WHERE so
|
||||||
|
# they can never drift apart — the prerequisite for correct cursor paging. Keyset
|
||||||
|
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
|
||||||
|
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
||||||
|
# (OFFSET would shift and duplicate/skip rows).
|
||||||
|
def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
||||||
|
pub = {"expr": Video.published_at, "asc": False, "nullable": False, "kind": "dt"}
|
||||||
|
table: dict[str, list[dict]] = {
|
||||||
|
"newest": [pub],
|
||||||
|
"oldest": [{**pub, "asc": True}],
|
||||||
|
"views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
|
||||||
|
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
|
||||||
|
"duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}],
|
||||||
|
"duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}],
|
||||||
|
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
|
||||||
|
"title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}],
|
||||||
|
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}],
|
||||||
|
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}],
|
||||||
|
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
||||||
|
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
||||||
|
"priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub],
|
||||||
|
"priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub],
|
||||||
|
# Deterministic per-seed shuffle: the same seed yields the same total order, so
|
||||||
|
# the cursor stays valid across pages of one shuffled run.
|
||||||
|
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
|
||||||
|
}
|
||||||
|
# Relevance (search-result ranking) only exists when there's a query to rank against;
|
||||||
|
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
|
||||||
|
if rank_expr is not None:
|
||||||
|
table["relevance"] = [
|
||||||
|
{"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"},
|
||||||
|
pub,
|
||||||
|
]
|
||||||
|
return table.get(sort) or table["newest"]
|
||||||
|
|
||||||
|
|
||||||
|
def order_by(keys: list[dict]):
|
||||||
|
cols = []
|
||||||
|
for k in keys:
|
||||||
|
c = k["expr"].asc() if k["asc"] else k["expr"].desc()
|
||||||
|
cols.append(c.nulls_last() if k["nullable"] else c)
|
||||||
|
cols.append(Video.id.asc()) # unique final tiebreaker
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def keyset_predicate(keys: list[dict], cursor_id: str):
|
||||||
|
"""WHERE clause selecting rows strictly AFTER the cursor in the sort order.
|
||||||
|
|
||||||
|
Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL
|
||||||
|
sorts after every non-NULL value, so a cursor sitting on a non-NULL value must
|
||||||
|
also pull in the NULL group, and equality treats NULL == NULL.
|
||||||
|
|
||||||
|
Built recursively so that for a NON-nullable leading key we can prepend a non-strict
|
||||||
|
range bound (`col <= v` for desc / `col >= v` for asc) as an AND conjunct — logically
|
||||||
|
a no-op over the OR of strict-after branches, but it lets the planner bound the index
|
||||||
|
scan instead of scanning from the top and filtering every earlier row (PERF-15: deep
|
||||||
|
`newest` pages went 51.8k -> ~0.5k buffers). A NULLABLE key can't take that bound (the
|
||||||
|
NULL group sorts after every value and `col <= v` would drop it), so it keeps the pure
|
||||||
|
NULL-aware OR form."""
|
||||||
|
def eq(k):
|
||||||
|
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
||||||
|
|
||||||
|
def after(k):
|
||||||
|
if k["value"] is None:
|
||||||
|
return false() # nulls are last; nothing sorts strictly after them
|
||||||
|
cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"]
|
||||||
|
return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp
|
||||||
|
|
||||||
|
def rec(i: int):
|
||||||
|
if i == len(keys):
|
||||||
|
return Video.id > cursor_id # all keys tied -> break by the unique id
|
||||||
|
k = keys[i]
|
||||||
|
tail = or_(after(k), and_(eq(k), rec(i + 1)))
|
||||||
|
if not k["nullable"] and k["value"] is not None:
|
||||||
|
bound = k["expr"] >= k["value"] if k["asc"] else k["expr"] <= k["value"]
|
||||||
|
return and_(bound, tail)
|
||||||
|
return tail
|
||||||
|
|
||||||
|
return rec(0)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(keys: list[dict], row) -> str:
|
||||||
|
vals = []
|
||||||
|
for i, k in enumerate(keys):
|
||||||
|
v = getattr(row, f"_k{i}")
|
||||||
|
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
|
||||||
|
payload = json.dumps({"k": vals, "id": row.id})
|
||||||
|
return base64.urlsafe_b64encode(payload.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
||||||
|
raw = payload["k"]
|
||||||
|
if len(raw) != len(keys):
|
||||||
|
raise ValueError("key arity mismatch")
|
||||||
|
vals = [
|
||||||
|
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
|
||||||
|
for k, v in zip(keys, raw)
|
||||||
|
]
|
||||||
|
return vals, payload["id"]
|
||||||
|
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
|
||||||
|
# Framework-free: the route handler translates this into an HTTP 400 so the
|
||||||
|
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
|
||||||
|
raise ValueError("Invalid feed cursor") from exc
|
||||||
@@ -160,6 +160,32 @@ async def static_cache_headers(request, call_next):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning:
|
||||||
|
# nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to
|
||||||
|
# external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be
|
||||||
|
# iframed anywhere (we never embed our own pages; we embed OTHERS, which this does not affect), and
|
||||||
|
# Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates
|
||||||
|
# the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and
|
||||||
|
# suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
|
||||||
|
# (script/style/connect sources) is deferred — the SPA's inline styles + the YouTube/Plex embeds make
|
||||||
|
# it real work — and HSTS belongs on the TLS-terminating edge (the Caddy front door), not here.
|
||||||
|
_SECURITY_HEADERS = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def security_headers(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
for name, value in _SECURITY_HEADERS.items():
|
||||||
|
response.headers.setdefault(name, value)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(auth_routes.router)
|
app.include_router(auth_routes.router)
|
||||||
app.include_router(sync.router)
|
app.include_router(sync.router)
|
||||||
|
|||||||
+80
-9
@@ -251,14 +251,23 @@ class Video(Base, TimestampMixin):
|
|||||||
__tablename__ = "videos"
|
__tablename__ = "videos"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
|
||||||
|
# No standalone channel_id index: the composite `ix_videos_channel_pub_desc`
|
||||||
|
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
|
||||||
|
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
|
||||||
|
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
|
||||||
channel_id: Mapped[str] = mapped_column(
|
channel_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("channels.id", ondelete="CASCADE"), index=True
|
ForeignKey("channels.id", ondelete="CASCADE")
|
||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
|
||||||
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(
|
# NOT NULL (migration 0068): in reality every video carries a publish date, and a nullable key
|
||||||
DateTime(timezone=True), index=True
|
# forced the feed's keyset predicate into a non-sargable `... OR published_at IS NULL` disjunction
|
||||||
|
# that blocked the ix_videos_published_at_desc range (deep pages scanned from the top and filtered —
|
||||||
|
# PERF-15). server_default now() is the safety net for the rare date-less ingest (the enrich fills
|
||||||
|
# the real date); the ix_videos_published_at index stays for the `oldest` sort.
|
||||||
|
published_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -276,18 +285,22 @@ class Video(Base, TimestampMixin):
|
|||||||
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
|
||||||
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
|
||||||
search_terms: Mapped[str | None] = mapped_column(Text)
|
search_terms: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# No btree here or on the flag columns below: migration 0069 (PERF-3) dropped the never-picked
|
||||||
|
# boolean btrees and replaced the useful ones with partial indexes over the target subset
|
||||||
|
# (via_search=true, NOT shorts_probed, unavailable_since IS NOT NULL) — migration-defined, like the
|
||||||
|
# search_vector GIN.
|
||||||
is_short: Mapped[bool] = mapped_column(
|
is_short: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
shorts_probed: Mapped[bool] = mapped_column(
|
shorts_probed: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
# True when this video first entered the catalog via a live YouTube search (not an
|
# True when this video first entered the catalog via a live YouTube search (not an
|
||||||
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
# organic channel/playlist sync). Used to hide search-discovered "noise" from the
|
||||||
# Library by default; left False by the organic ingest paths.
|
# Library by default; left False by the organic ingest paths.
|
||||||
via_search: Mapped[bool] = mapped_column(
|
via_search: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
# True when this video entered the catalog because a user EXPLORED its (un-subscribed)
|
||||||
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
# channel. Like via_search it's hidden from the shared Library by default, but it's
|
||||||
@@ -295,7 +308,7 @@ class Video(Base, TimestampMixin):
|
|||||||
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
# explored or subscribe to its channel (a gate in the feed query), so one user's
|
||||||
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
# curiosity never floods everyone else's catalog. Left False by the organic ingest paths.
|
||||||
via_explore: Mapped[bool] = mapped_column(
|
via_explore: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
# none | live | upcoming | premiere | was_live
|
# none | live | upcoming | premiere | was_live
|
||||||
live_status: Mapped[str] = mapped_column(
|
live_status: Mapped[str] = mapped_column(
|
||||||
@@ -311,11 +324,14 @@ class Video(Base, TimestampMixin):
|
|||||||
# (least-recently-checked first); unavailable_since marks a video as currently
|
# (least-recently-checked first); unavailable_since marks a video as currently
|
||||||
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
# unplayable (hidden from the feed immediately, hard-deleted after a grace period);
|
||||||
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
# unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live).
|
||||||
|
# last_checked_at: no plain btree — migration 0069 (PERF-18) replaced it with the partial
|
||||||
|
# ix_videos_recheck_order (last_checked_at NULLS FIRST WHERE unavailable_since IS NULL) that the
|
||||||
|
# revalidate job's exact ORDER BY rides; unavailable_since is a partial WHERE IS NOT NULL (PERF-3).
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True)
|
||||||
)
|
)
|
||||||
unavailable_since: Mapped[datetime | None] = mapped_column(
|
unavailable_since: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True)
|
||||||
)
|
)
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
|
|
||||||
@@ -995,6 +1011,13 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# HU-overlay support (E5 S5). `metadata_lang` tags the section's metadata language. An OVERLAY
|
||||||
|
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
|
||||||
|
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
|
||||||
|
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
|
||||||
|
overlay_of: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
@@ -1007,6 +1030,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
|
||||||
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
|
||||||
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||||
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1015,6 +1039,9 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
library_id: Mapped[int] = mapped_column(
|
library_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
|
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
|
||||||
|
# twin during sync (E5 S5). Indexed for the guid->row overlay match.
|
||||||
|
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
title: Mapped[str] = mapped_column(String(512))
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
summary: Mapped[str | None] = mapped_column(Text)
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
year: Mapped[int | None] = mapped_column(Integer)
|
year: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -1037,6 +1064,17 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
||||||
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||||
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
|
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
|
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}]
|
||||||
|
# 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))
|
||||||
|
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||||
|
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
search_vector: Mapped[object | None] = mapped_column(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
Computed(
|
Computed(
|
||||||
@@ -1046,6 +1084,15 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
persisted=True,
|
persisted=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexSeason(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
@@ -1077,6 +1124,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||||
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
@@ -1085,6 +1133,9 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
|
||||||
|
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
|
||||||
|
# sync (E5 S5); indexed for the guid->row match. Episodes carry it too but it's unused for them.
|
||||||
|
guid: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
show_id: Mapped[int | None] = mapped_column(
|
show_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
@@ -1130,6 +1181,17 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
people_text: Mapped[str | None] = mapped_column(Text)
|
people_text: Mapped[str | None] = mapped_column(Text)
|
||||||
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
|
||||||
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so
|
||||||
|
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
|
||||||
|
imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating)
|
||||||
|
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}]
|
||||||
|
# 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))
|
||||||
|
summary_hu: Mapped[str | None] = mapped_column(Text)
|
||||||
|
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
|
||||||
search_vector: Mapped[object | None] = mapped_column(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
Computed(
|
Computed(
|
||||||
@@ -1139,6 +1201,15 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||||||
persisted=True,
|
persisted=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
search_vector_hu: Mapped[object | None] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ from app.models import Notification
|
|||||||
# rows are never pruned (the user hasn't seen them yet).
|
# rows are never pruned (the user hasn't seen them yet).
|
||||||
READ_SOFT_CAP = 200
|
READ_SOFT_CAP = 200
|
||||||
|
|
||||||
|
# Hard limit of the Notification.title column, read straight from the model so the clamp
|
||||||
|
# can never drift from the actual column width (a later migration is the single source of
|
||||||
|
# truth). Titles are only an English fallback — the client renders the translated message
|
||||||
|
# from `data` — so clamping an over-long one (e.g. a FB/IG reel whose "title" is the whole
|
||||||
|
# caption, hundreds of chars) is harmless to the UI and prevents a StringDataRightTruncation
|
||||||
|
# that would abort the caller's whole transaction (the batched download_gc run in particular).
|
||||||
|
TITLE_MAX_LEN = Notification.__table__.c.title.type.length
|
||||||
|
|
||||||
|
|
||||||
def create_notification(
|
def create_notification(
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -27,6 +35,8 @@ def create_notification(
|
|||||||
) -> Notification:
|
) -> Notification:
|
||||||
"""Insert one notification for a user. Callers that batch many inserts in a single
|
"""Insert one notification for a user. Callers that batch many inserts in a single
|
||||||
transaction can pass commit=False and commit once themselves."""
|
transaction can pass commit=False and commit once themselves."""
|
||||||
|
if title is not None and len(title) > TITLE_MAX_LEN:
|
||||||
|
title = title[: TITLE_MAX_LEN - 1] + "…" # clamp to the column limit, add an ellipsis
|
||||||
notif = Notification(
|
notif = Notification(
|
||||||
user_id=user_id, type=type, title=title, body=body, data=data
|
user_id=user_id, type=type, title=title, body=body, data=data
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Plex list/leaf card serializers: the small `model -> dict` projections the library grid,
|
||||||
|
playlists, and detail views render. Extracted from routes/plex/browse.py so the row-shaping
|
||||||
|
lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the
|
||||||
|
router. Pure functions — no DB, no request state."""
|
||||||
|
|
||||||
|
from app.models import PlexItem, PlexShow, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
def movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": it.rating_key,
|
||||||
|
"type": "movie",
|
||||||
|
"title": it.title,
|
||||||
|
"year": it.year,
|
||||||
|
"duration_seconds": it.duration_s,
|
||||||
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
|
"playable": it.playable,
|
||||||
|
"status": st.status if st else "new",
|
||||||
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def show_card(sh: PlexShow, status: str = "new") -> dict:
|
||||||
|
return {
|
||||||
|
"id": sh.rating_key,
|
||||||
|
"type": "show",
|
||||||
|
"title": sh.title,
|
||||||
|
"year": sh.year,
|
||||||
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
|
"season_count": sh.child_count,
|
||||||
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": e.rating_key,
|
||||||
|
"type": "episode",
|
||||||
|
"title": e.title,
|
||||||
|
"summary": e.summary,
|
||||||
|
"season_number": e.season_number,
|
||||||
|
"episode_number": e.episode_number,
|
||||||
|
"duration_seconds": e.duration_s,
|
||||||
|
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||||
|
"playable": e.playable,
|
||||||
|
"status": st.status if st else "new",
|
||||||
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def leaf_card(
|
||||||
|
it: PlexItem,
|
||||||
|
st: PlexState | None,
|
||||||
|
show_title: str | None = None,
|
||||||
|
show_rk: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
|
||||||
|
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
|
||||||
|
if it.kind == "episode":
|
||||||
|
card = episode_card(it, st)
|
||||||
|
card["show_title"] = show_title
|
||||||
|
card["show_id"] = show_rk
|
||||||
|
return card
|
||||||
|
return movie_card(it, st)
|
||||||
@@ -85,6 +85,10 @@ class PlexClient:
|
|||||||
f"/library/sections/{key}/all",
|
f"/library/sections/{key}/all",
|
||||||
params={
|
params={
|
||||||
"type": item_type,
|
"type": item_type,
|
||||||
|
# includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap
|
||||||
|
# listing, so the sync can capture the canonical guid without a per-item rich fetch —
|
||||||
|
# the pairing key for the HU overlay (E5 S5).
|
||||||
|
"includeGuids": 1,
|
||||||
"X-Plex-Container-Start": start,
|
"X-Plex-Container-Start": start,
|
||||||
"X-Plex-Container-Size": size,
|
"X-Plex-Container-Size": size,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
|
||||||
|
the detail endpoints serve from the DB row (E5 S5b).
|
||||||
|
|
||||||
|
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
|
||||||
|
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
||||||
|
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
||||||
|
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||||
|
|
||||||
|
from app.plex import people
|
||||||
|
|
||||||
|
|
||||||
|
def rich_detail(meta: dict) -> dict:
|
||||||
|
"""Pull the info-page extras out of a full Plex metadata dict: an IMDb score + link, content
|
||||||
|
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
|
||||||
|
missing field is just omitted."""
|
||||||
|
# Rating: IMDb-ONLY — the explicit `imdb://` entry in the Rating array, or NULL. We deliberately do
|
||||||
|
# NOT fall back to audienceRating/rating here: that is a Plex audience score, not an IMDb score, and
|
||||||
|
# labelling it "imdb_rating" mislabels it. The audience score is served separately (item/show detail
|
||||||
|
# expose the `rating` column) so the UI can show it when there's no IMDb score.
|
||||||
|
imdb_rating = None
|
||||||
|
for r in meta.get("Rating") or []:
|
||||||
|
if str(r.get("image") or "").startswith("imdb://"):
|
||||||
|
try:
|
||||||
|
imdb_rating = round(float(r.get("value")), 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
# IMDb id (tt…) from the Guid list → a clickable link.
|
||||||
|
imdb_id = None
|
||||||
|
for g in meta.get("Guid") or []:
|
||||||
|
gid = str(g.get("id") or "")
|
||||||
|
if gid.startswith("imdb://"):
|
||||||
|
imdb_id = gid.split("imdb://", 1)[1]
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"imdb_rating": imdb_rating,
|
||||||
|
"imdb_id": imdb_id,
|
||||||
|
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||||
|
"content_rating": meta.get("contentRating"),
|
||||||
|
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||||
|
"directors": [
|
||||||
|
d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")
|
||||||
|
][:4],
|
||||||
|
"studio": meta.get("studio"),
|
||||||
|
"tagline": meta.get("tagline"),
|
||||||
|
"cast": people.cast_from_meta(meta),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_rich_detail(row, meta: dict) -> None:
|
||||||
|
"""Persist the rich-ONLY detail fields onto a movie/show row so the info page serves them from the
|
||||||
|
DB instead of a live Plex call. Call wherever a rich metadata dict is fetched (the cast backfill +
|
||||||
|
the info-page lazy enrich). Empty cast persists as SQL NULL (none_as_null), so an EN fallback and
|
||||||
|
the `full_cast IS NULL` "not yet enriched" signal stay meaningful."""
|
||||||
|
d = rich_detail(meta)
|
||||||
|
row.imdb_rating = d["imdb_rating"]
|
||||||
|
row.imdb_id = d["imdb_id"]
|
||||||
|
row.tagline = d["tagline"]
|
||||||
|
row.full_cast = d["cast"] or None
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||||
from app.plex.cache import CACHE_DIR
|
from app.plex.cache import CACHE_DIR
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
||||||
@@ -24,16 +25,16 @@ SIZES = {"thumb": (400, 600), "art": (1280, 720)}
|
|||||||
def key_for(db: Session, rating_key: str, variant: str) -> str | None:
|
def key_for(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
||||||
are proxyable — this is not an open image proxy."""
|
are proxyable — this is not an open image proxy."""
|
||||||
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
it = by_rating_key(db, PlexItem, rating_key)
|
||||||
if it is not None:
|
if it is not None:
|
||||||
return it.art_key if variant == "art" else it.thumb_key
|
return it.art_key if variant == "art" else it.thumb_key
|
||||||
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is not None:
|
if sh is not None:
|
||||||
return sh.art_key if variant == "art" else sh.thumb_key
|
return sh.art_key if variant == "art" else sh.thumb_key
|
||||||
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
se = by_rating_key(db, PlexSeason, rating_key)
|
||||||
if se is not None:
|
if se is not None:
|
||||||
return se.thumb_key
|
return se.thumb_key
|
||||||
col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is not None:
|
if col is not None:
|
||||||
return col.thumb_key
|
return col.thumb_key
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place."""
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
_M = TypeVar("_M")
|
||||||
|
|
||||||
|
|
||||||
|
def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None:
|
||||||
|
"""The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex
|
||||||
|
tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call
|
||||||
|
sites keep their `Model | None` static type."""
|
||||||
|
return db.execute(
|
||||||
|
select(model).where(model.rating_key == str(rating_key))
|
||||||
|
).scalars().first()
|
||||||
@@ -5,7 +5,7 @@ re-sync (plex.sync), so the two paths never diverge."""
|
|||||||
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -55,7 +55,10 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
|
|||||||
dirty = True
|
dirty = True
|
||||||
# Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a
|
# Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a
|
||||||
# whitelisted photo still get a row so a card can show a placeholder).
|
# whitelisted photo still get a row so a card can show a placeholder).
|
||||||
existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))}
|
existing = {
|
||||||
|
p.name: p
|
||||||
|
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
|
||||||
|
}
|
||||||
missing: dict[str, str | None] = {}
|
missing: dict[str, str | None] = {}
|
||||||
for c in cast:
|
for c in cast:
|
||||||
nm, th = c.get("name"), c.get("thumb")
|
nm, th = c.get("name"), c.get("thumb")
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Plex library query-shaping: the full-text field-scope rechecks and the sidebar metadata
|
||||||
|
filters that /library and /facets share. Extracted from routes/plex/browse.py so the query
|
||||||
|
builder has one home (and so the HU-overlay column selection lands here, not in the router).
|
||||||
|
Pure query fragments — every function returns SQLAlchemy expressions or a params dict."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, type_coerce
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
TS_CONFIG = "public.unaccent_simple"
|
||||||
|
|
||||||
|
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||||||
|
#
|
||||||
|
# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
|
||||||
|
# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin.
|
||||||
|
# Two flavours of picker, on purpose:
|
||||||
|
# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with
|
||||||
|
# no HU metadata still shows (and filters on) its English value. (The detail endpoints serve the
|
||||||
|
# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.)
|
||||||
|
# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the
|
||||||
|
# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN
|
||||||
|
# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2
|
||||||
|
# invariant). `people_text` is language-neutral and shared by both vectors.
|
||||||
|
|
||||||
|
|
||||||
|
def title_col(model, lang: str):
|
||||||
|
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
|
||||||
|
|
||||||
|
|
||||||
|
def genres_col(model, lang: str):
|
||||||
|
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
|
||||||
|
if lang == "hu":
|
||||||
|
return type_coerce(func.coalesce(model.genres_hu, model.genres), JSONB)
|
||||||
|
return model.genres
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
return model.search_vector_hu if lang == "hu" else model.search_vector
|
||||||
|
|
||||||
|
|
||||||
|
def vector_match(model, lang: str, tsq):
|
||||||
|
"""The full-text WHERE clause. In HU, match EITHER vector so a row with no HU twin stays findable
|
||||||
|
by its English title (search_vector_hu has no title tokens for such a row); EN matches only the EN
|
||||||
|
vector. Ranking still uses `vector_col` (the HU vector in HU mode)."""
|
||||||
|
if lang == "hu":
|
||||||
|
return or_(
|
||||||
|
model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq)
|
||||||
|
)
|
||||||
|
return model.search_vector.op("@@")(tsq)
|
||||||
|
|
||||||
|
|
||||||
|
# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE
|
||||||
|
# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to
|
||||||
|
# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column
|
||||||
|
# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions
|
||||||
|
# mirror the `search_vector` Computed definition (same `TS_CONFIG`, same `left(summary, 1000)`
|
||||||
|
# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled
|
||||||
|
# separately in the query body (it targets episode rows, not the movie/show grid).
|
||||||
|
FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
||||||
|
|
||||||
|
|
||||||
|
def _title_tsv(col):
|
||||||
|
return func.to_tsvector(TS_CONFIG, func.coalesce(col, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_tsv(col):
|
||||||
|
return func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def field_recheck(model, field: str, tsq, lang: str = "en"):
|
||||||
|
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.
|
||||||
|
Each per-field tsvector mirrors the generated `search_vector[_hu]` definition (same TS_CONFIG,
|
||||||
|
same `left(summary, 1000)`). In HU, title/summary re-check BOTH the HU and EN columns so a scoped
|
||||||
|
hit stays a strict subset of `vector_match`'s HU union; `people` is language-neutral."""
|
||||||
|
hu = lang == "hu"
|
||||||
|
if field == "title":
|
||||||
|
cols = [model.title_hu, model.title] if hu else [model.title]
|
||||||
|
return or_(*[_title_tsv(c).op("@@")(tsq) for c in cols])
|
||||||
|
if field == "people":
|
||||||
|
return func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, "")).op("@@")(tsq)
|
||||||
|
if field == "summary":
|
||||||
|
cols = [model.summary_hu, model.summary] if hu else [model.summary]
|
||||||
|
return or_(*[_summary_tsv(c).op("@@")(tsq) for c in cols])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def split_csv(v: str | None) -> list[str]:
|
||||||
|
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
||||||
|
|
||||||
|
|
||||||
|
_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365}
|
||||||
|
|
||||||
|
|
||||||
|
def added_cutoff(within: str | None) -> datetime | None:
|
||||||
|
days = _ADDED_WINDOWS.get(within or "")
|
||||||
|
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
||||||
|
|
||||||
|
|
||||||
|
def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
|
||||||
|
"""The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
|
||||||
|
`p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
|
||||||
|
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets.
|
||||||
|
Genre chips are language-specific strings, so they filter the active-language genre column."""
|
||||||
|
conds: list = []
|
||||||
|
gsel = split_csv(p.get("genres"))
|
||||||
|
if gsel:
|
||||||
|
gcol = genres_col(model, lang)
|
||||||
|
gc = [gcol.contains([g]) for g in gsel]
|
||||||
|
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
||||||
|
crs = split_csv(p.get("content_ratings"))
|
||||||
|
if crs:
|
||||||
|
conds.append(model.content_rating.in_(crs))
|
||||||
|
if p.get("year_min") is not None:
|
||||||
|
conds.append(model.year >= p["year_min"])
|
||||||
|
if p.get("year_max") is not None:
|
||||||
|
conds.append(model.year <= p["year_max"])
|
||||||
|
if p.get("rating_min") is not None:
|
||||||
|
# Filter by the SAME score the UI shows on the badge (IMDb preferred, audience fallback), so a
|
||||||
|
# ratingMin picked from a displayed IMDb score narrows the right column instead of the raw
|
||||||
|
# audience `rating`. Both are 0–10, so coalesce is scale-consistent.
|
||||||
|
conds.append(func.coalesce(model.imdb_rating, model.rating) >= p["rating_min"])
|
||||||
|
cutoff = added_cutoff(p.get("added_within"))
|
||||||
|
if cutoff is not None:
|
||||||
|
conds.append(model.added_at >= cutoff)
|
||||||
|
for d in split_csv(p.get("directors")): # AND
|
||||||
|
conds.append(model.directors.contains([d]))
|
||||||
|
alist = split_csv(p.get("actors"))
|
||||||
|
if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode
|
||||||
|
ac = [model.cast_names.contains([a]) for a in alist]
|
||||||
|
conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac))
|
||||||
|
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"]]))
|
||||||
|
return conds
|
||||||
|
|
||||||
|
|
||||||
|
def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||||||
|
conds = meta_filter_conds(model, p, lang)
|
||||||
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LibraryFilters:
|
||||||
|
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
|
||||||
|
``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included
|
||||||
|
for /facets' wsq but ignored by `meta_filter_conds` (both callers apply duration separately)."""
|
||||||
|
|
||||||
|
genres: str | None = None
|
||||||
|
genre_mode: str = "any"
|
||||||
|
content_ratings: str | None = None
|
||||||
|
year_min: int | None = None
|
||||||
|
year_max: int | None = None
|
||||||
|
rating_min: float | None = None
|
||||||
|
duration_min: int | None = None
|
||||||
|
duration_max: int | None = None
|
||||||
|
added_within: str | None = None
|
||||||
|
directors: str | None = None
|
||||||
|
actors: str | None = None
|
||||||
|
actor_mode: str = (
|
||||||
|
"any" # how multiple actors combine: any (OR, default) | all (AND)
|
||||||
|
)
|
||||||
|
studios: str | None = None
|
||||||
|
collection: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"genres": self.genres,
|
||||||
|
"genre_mode": self.genre_mode,
|
||||||
|
"content_ratings": self.content_ratings,
|
||||||
|
"year_min": self.year_min,
|
||||||
|
"year_max": self.year_max,
|
||||||
|
"rating_min": self.rating_min,
|
||||||
|
"duration_min": self.duration_min,
|
||||||
|
"duration_max": self.duration_max,
|
||||||
|
"added_within": self.added_within,
|
||||||
|
"directors": self.directors,
|
||||||
|
"actors": self.actors,
|
||||||
|
"actor_mode": self.actor_mode,
|
||||||
|
"studios": self.studios,
|
||||||
|
"collection": self.collection,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def like_escape(s: str) -> str:
|
||||||
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
|
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import and_, case, func, or_
|
from sqlalchemy import and_, case, func, or_, select
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.models import PlexItem, PlexState
|
from app.models import PlexItem, PlexState
|
||||||
@@ -47,7 +47,7 @@ def agg_subquery(db: Session, user_id: int):
|
|||||||
grid by aggregate watch-state and (b) compute each card's badge."""
|
grid by aggregate watch-state and (b) compute each card's badge."""
|
||||||
ps = aliased(PlexState)
|
ps = aliased(PlexState)
|
||||||
return (
|
return (
|
||||||
db.query(
|
select(
|
||||||
PlexItem.show_id.label("show_id"),
|
PlexItem.show_id.label("show_id"),
|
||||||
func.count(PlexItem.id).label("total"),
|
func.count(PlexItem.id).label("total"),
|
||||||
func.count(case((ps.status == "watched", 1))).label("watched"),
|
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||||
@@ -64,7 +64,7 @@ def agg_subquery(db: Session, user_id: int):
|
|||||||
).label("inprog"),
|
).label("inprog"),
|
||||||
)
|
)
|
||||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||||
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
.where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||||
.group_by(PlexItem.show_id)
|
.group_by(PlexItem.show_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,5 +72,7 @@ def agg_subquery(db: Session, user_id: int):
|
|||||||
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||||
if not show_ids:
|
if not show_ids:
|
||||||
return {}
|
return {}
|
||||||
rows = agg_subquery(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
|
rows = db.execute(
|
||||||
|
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}
|
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||||
|
|||||||
+205
-29
@@ -12,12 +12,14 @@ Cast + intro/credit markers are fetched lazily on the item-detail/player path (P
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||||
from app.plex import people
|
from app.plex import detail, people
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
||||||
@@ -80,6 +82,26 @@ def _rating(meta: dict) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Provider preference for the canonical guid (a stable id shared across a title's language twins).
|
||||||
|
_GUID_PREFERENCE = ("tmdb", "imdb", "tvdb")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_guid(meta: dict) -> str | None:
|
||||||
|
"""The canonical external id used to pair an EN title with its HU-overlay twin (E5 S5). Reads the
|
||||||
|
`Guid` array (present only with includeGuids=1 in the listing), preferring tmdb > imdb > tvdb,
|
||||||
|
normalised to '<provider>:<id>' (e.g. 'tmdb:12345'). None when no known provider id is present."""
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
for g in meta.get("Guid") or []:
|
||||||
|
raw = str(g.get("id") or "")
|
||||||
|
for prov in _GUID_PREFERENCE:
|
||||||
|
if raw.startswith(f"{prov}://"):
|
||||||
|
found.setdefault(prov, f"{prov}:{raw.split('://', 1)[1]}")
|
||||||
|
for prov in _GUID_PREFERENCE:
|
||||||
|
if prov in found:
|
||||||
|
return found[prov]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _media_facts(meta: dict) -> dict:
|
def _media_facts(meta: dict) -> dict:
|
||||||
media = meta.get("Media") or []
|
media = meta.get("Media") or []
|
||||||
if not media:
|
if not media:
|
||||||
@@ -108,21 +130,48 @@ def _enabled_section_keys(db: Session) -> set[str] | None:
|
|||||||
return keys or None
|
return keys or None
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_map(db: Session) -> dict[str, str]:
|
||||||
|
"""The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map`
|
||||||
|
('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin.
|
||||||
|
Malformed pairs are skipped."""
|
||||||
|
raw = sysconfig.get_str(db, "plex_overlay_map")
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for pair in raw.split(","):
|
||||||
|
hu, sep, en = pair.partition(":")
|
||||||
|
hu, en = hu.strip(), en.strip()
|
||||||
|
if sep and hu and en:
|
||||||
|
out[hu] = en
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def sync(db: Session) -> dict:
|
def sync(db: Session) -> dict:
|
||||||
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
|
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
return {"skipped": "disabled"}
|
return {"skipped": "disabled"}
|
||||||
stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, "collections": 0}
|
stats = {
|
||||||
|
"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0,
|
||||||
|
"collections": 0, "overlaid": 0,
|
||||||
|
}
|
||||||
wanted = _enabled_section_keys(db)
|
wanted = _enabled_section_keys(db)
|
||||||
|
overlay_map = _overlay_map(db) # {huKey: enKey}
|
||||||
|
overlay_keys = set(overlay_map)
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
for s in plex.sections():
|
sections = {
|
||||||
if s.get("type") not in ("movie", "show"):
|
str(s.get("key")): s
|
||||||
|
for s in plex.sections()
|
||||||
|
if s.get("type") in ("movie", "show")
|
||||||
|
}
|
||||||
|
libs_by_key: dict[str, PlexLibrary] = {}
|
||||||
|
# Pass 1 — browsable (non-overlay) sections. HU-overlay sections are handled in pass 2 and
|
||||||
|
# are NEVER mirrored as browsable libraries of their own.
|
||||||
|
for key, s in sections.items():
|
||||||
|
if key in overlay_keys:
|
||||||
continue
|
continue
|
||||||
key = str(s.get("key"))
|
|
||||||
if wanted is not None and key not in wanted:
|
if wanted is not None and key not in wanted:
|
||||||
continue
|
continue
|
||||||
lib = _upsert_library(db, key, s.get("title") or key, s.get("type"))
|
lib = _upsert_library(db, key, s.get("title") or key, s.get("type"))
|
||||||
|
libs_by_key[key] = lib
|
||||||
stats["libraries"] += 1
|
stats["libraries"] += 1
|
||||||
if s.get("type") == "movie":
|
if s.get("type") == "movie":
|
||||||
_sync_movies(db, plex, lib, stats)
|
_sync_movies(db, plex, lib, stats)
|
||||||
@@ -133,6 +182,24 @@ def sync(db: Session) -> dict:
|
|||||||
# libraries already mirrored (idempotent — the next run reconciles the rest), instead
|
# libraries already mirrored (idempotent — the next run reconciles the rest), instead
|
||||||
# of the old single end-of-loop commit that a late failure rolled back entirely.
|
# of the old single end-of-loop commit that a late failure rolled back entirely.
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# Pass 2 — HU overlays. The EN twins are now synced (with guids), so write each overlay
|
||||||
|
# section's Hungarian metadata onto the matched EN rows. Requires the EN section to have been
|
||||||
|
# synced in pass 1 (in the allowlist / all) and to share the overlay's kind.
|
||||||
|
for hu_key, en_key in overlay_map.items():
|
||||||
|
s = sections.get(hu_key)
|
||||||
|
en_lib = libs_by_key.get(en_key)
|
||||||
|
if s is None or en_lib is None or s.get("type") != en_lib.kind:
|
||||||
|
log.warning(
|
||||||
|
"Plex overlay skipped: hu=%s en=%s (missing section/EN lib or kind mismatch)",
|
||||||
|
hu_key, en_key,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
lib = _upsert_library(db, hu_key, s.get("title") or hu_key, s.get("type"))
|
||||||
|
lib.metadata_lang = "hu"
|
||||||
|
lib.overlay_of = en_lib.id
|
||||||
|
db.flush()
|
||||||
|
_sync_overlay(db, plex, lib, en_lib.id, stats)
|
||||||
|
db.commit()
|
||||||
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
|
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
|
||||||
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
|
||||||
try:
|
try:
|
||||||
@@ -165,20 +232,20 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
|
|||||||
shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on
|
shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on
|
||||||
a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost.
|
a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost.
|
||||||
Returns how many items were enriched this run."""
|
Returns how many items were enriched this run."""
|
||||||
movies = (
|
movies = db.execute(
|
||||||
db.query(PlexItem)
|
select(PlexItem)
|
||||||
.filter(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
|
.where(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
|
||||||
.order_by(PlexItem.id)
|
.order_by(PlexItem.id)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
).scalars().all()
|
||||||
)
|
|
||||||
remaining = limit - len(movies)
|
remaining = limit - len(movies)
|
||||||
shows = (
|
shows = (
|
||||||
db.query(PlexShow)
|
db.execute(
|
||||||
.filter(PlexShow.people_enriched_at.is_(None))
|
select(PlexShow)
|
||||||
.order_by(PlexShow.id)
|
.where(PlexShow.people_enriched_at.is_(None))
|
||||||
.limit(remaining)
|
.order_by(PlexShow.id)
|
||||||
.all()
|
.limit(remaining)
|
||||||
|
).scalars().all()
|
||||||
if remaining > 0
|
if remaining > 0
|
||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
@@ -191,6 +258,7 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
|
|||||||
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
|
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
|
||||||
continue # leave people_enriched_at NULL so it's retried on a later run
|
continue # leave people_enriched_at NULL so it's retried on a later run
|
||||||
people.enrich_row_people(db, row, people.cast_from_meta(meta))
|
people.enrich_row_people(db, row, people.cast_from_meta(meta))
|
||||||
|
detail.apply_rich_detail(row, meta) # imdb/tagline/full_cast → served from the row, not live
|
||||||
row.people_enriched_at = now
|
row.people_enriched_at = now
|
||||||
n += 1
|
n += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -198,7 +266,9 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
|
|||||||
|
|
||||||
|
|
||||||
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
|
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=key).first()
|
lib = db.execute(
|
||||||
|
select(PlexLibrary).where(PlexLibrary.plex_key == key)
|
||||||
|
).scalars().first()
|
||||||
if lib is None:
|
if lib is None:
|
||||||
lib = PlexLibrary(plex_key=key)
|
lib = PlexLibrary(plex_key=key)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -224,6 +294,11 @@ def _paginate(plex: PlexClient, key: str, item_type: int):
|
|||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this
|
||||||
|
# can only have come from the rich enrich, never the listing.
|
||||||
|
_LISTING_CAST_CAP = 20
|
||||||
|
|
||||||
|
|
||||||
def _apply_facet_fields(row, meta: dict) -> None:
|
def _apply_facet_fields(row, meta: dict) -> None:
|
||||||
"""Set the filterable/orderable metadata columns shared by plex_items + plex_shows (identical
|
"""Set the filterable/orderable metadata columns shared by plex_items + plex_shows (identical
|
||||||
column names) from the cheap section listing: rating, content_rating, studio, release date,
|
column names) from the cheap section listing: rating, content_rating, studio, release date,
|
||||||
@@ -234,7 +309,20 @@ def _apply_facet_fields(row, meta: dict) -> None:
|
|||||||
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
|
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
|
||||||
row.genres = _tags(meta, "Genre")
|
row.genres = _tags(meta, "Genre")
|
||||||
row.directors = _tags(meta, "Director")
|
row.directors = _tags(meta, "Director")
|
||||||
row.cast_names = _tags(meta, "Role", limit=20)
|
# The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched
|
||||||
|
# full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the
|
||||||
|
# person-page cast filter for names past the top ~20. The watermark gate below (`people_enriched_at
|
||||||
|
# is None`) normally protects this, but it is NOT enough on its own: a migration that resets the
|
||||||
|
# watermark re-opens the regression (0065 nulled it, which is exactly how the S5b fix got undone in
|
||||||
|
# prod). So guard STRUCTURALLY too, by LENGTH against the listing cap: a stored cast LONGER than the
|
||||||
|
# cap can only be an enriched fuller list, so keep it (robust to any watermark reset). A stored cast
|
||||||
|
# at or below the cap is itself listing-sourced, so take the fresh listing — which tracks Plex cast
|
||||||
|
# EDITS in EITHER direction (comparing against this listing's length instead would silently keep a
|
||||||
|
# stale longer cast when Plex TRIMS one).
|
||||||
|
if row.people_enriched_at is None:
|
||||||
|
listing_cast = _tags(meta, "Role", limit=_LISTING_CAST_CAP)
|
||||||
|
if len(row.cast_names or []) <= _LISTING_CAST_CAP:
|
||||||
|
row.cast_names = listing_cast
|
||||||
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
|
people = list(dict.fromkeys((row.directors or []) + (row.cast_names or [])))
|
||||||
row.people_text = " ".join(people) or None
|
row.people_text = " ".join(people) or None
|
||||||
|
|
||||||
@@ -242,6 +330,9 @@ def _apply_facet_fields(row, meta: dict) -> None:
|
|||||||
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
||||||
row.library_id = lib_id
|
row.library_id = lib_id
|
||||||
row.kind = kind
|
row.kind = kind
|
||||||
|
# EN<->HU pairing key (movies; harmless/unused on episodes). Keep the prior guid if this listing
|
||||||
|
# carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key).
|
||||||
|
row.guid = _canonical_guid(meta) or row.guid
|
||||||
row.title = (meta.get("title") or "").strip() or "Untitled"
|
row.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
row.summary = meta.get("summary")
|
row.summary = meta.get("summary")
|
||||||
row.year = meta.get("year")
|
row.year = meta.get("year")
|
||||||
@@ -261,7 +352,12 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||||
existing = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="movie")}
|
existing = {
|
||||||
|
r.rating_key: r
|
||||||
|
for r in db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
|
for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE):
|
||||||
rk = str(meta.get("ratingKey") or "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
@@ -276,7 +372,12 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -
|
|||||||
|
|
||||||
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||||
# 1) shows
|
# 1) shows
|
||||||
shows = {r.rating_key: r for r in db.query(PlexShow).filter_by(library_id=lib.id)}
|
shows = {
|
||||||
|
r.rating_key: r
|
||||||
|
for r in db.execute(
|
||||||
|
select(PlexShow).where(PlexShow.library_id == lib.id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
|
for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW):
|
||||||
rk = str(meta.get("ratingKey") or "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
@@ -286,6 +387,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
db.add(sh)
|
db.add(sh)
|
||||||
shows[rk] = sh
|
shows[rk] = sh
|
||||||
sh.library_id = lib.id
|
sh.library_id = lib.id
|
||||||
|
sh.guid = _canonical_guid(meta) or sh.guid # EN<->HU pairing key; keep prior if listing has none
|
||||||
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
sh.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
sh.summary = meta.get("summary")
|
sh.summary = meta.get("summary")
|
||||||
sh.year = meta.get("year")
|
sh.year = meta.get("year")
|
||||||
@@ -299,7 +401,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
show_id = {rk: sh.id for rk, sh in shows.items()}
|
show_id = {rk: sh.id for rk, sh in shows.items()}
|
||||||
|
|
||||||
# 2) seasons
|
# 2) seasons
|
||||||
seasons = {r.rating_key: r for r in db.query(PlexSeason).join(PlexShow).filter(PlexShow.library_id == lib.id)}
|
seasons = {
|
||||||
|
r.rating_key: r
|
||||||
|
for r in db.execute(
|
||||||
|
select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
|
for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON):
|
||||||
rk = str(meta.get("ratingKey") or "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
sid = show_id.get(str(meta.get("parentRatingKey") or ""))
|
||||||
@@ -318,7 +425,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
season_id = {rk: se.id for rk, se in seasons.items()}
|
season_id = {rk: se.id for rk, se in seasons.items()}
|
||||||
|
|
||||||
# 3) episodes (playable leaves)
|
# 3) episodes (playable leaves)
|
||||||
episodes = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="episode")}
|
episodes = {
|
||||||
|
r.rating_key: r
|
||||||
|
for r in db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode")
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
|
for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE):
|
||||||
rk = str(meta.get("ratingKey") or "")
|
rk = str(meta.get("ratingKey") or "")
|
||||||
if not rk:
|
if not rk:
|
||||||
@@ -335,6 +447,59 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_overlay(
|
||||||
|
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
|
||||||
|
) -> None:
|
||||||
|
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
|
||||||
|
NO browsable rows — an overlay library is never listed on its own. Only title/summary/genres differ
|
||||||
|
by language; the language-neutral people_text feeds both search vectors.
|
||||||
|
|
||||||
|
This is a full RECONCILE: every guid-bearing EN row's HU fields are cleared first, then the HU
|
||||||
|
section repopulates the matches — so an EN row whose HU twin vanished (or was re-guid'd) falls back
|
||||||
|
to EN instead of keeping stale Hungarian metadata. HU items with no EN match are skipped."""
|
||||||
|
if lib.kind == "movie":
|
||||||
|
rows = db.execute(
|
||||||
|
select(PlexItem).where(
|
||||||
|
PlexItem.library_id == en_lib_id,
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.guid.is_not(None),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
item_type = _TYPE_MOVIE
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
select(PlexShow).where(
|
||||||
|
PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
item_type = _TYPE_SHOW
|
||||||
|
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
|
||||||
|
by_guid: dict[str, list] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_guid.setdefault(r.guid, []).append(r)
|
||||||
|
# Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data
|
||||||
|
# become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below.
|
||||||
|
for group in by_guid.values():
|
||||||
|
for row in group:
|
||||||
|
row.title_hu = row.summary_hu = row.genres_hu = None
|
||||||
|
seen: set[str] = set() # first HU entry for a guid wins; a dup HU item can't overwrite or double-count
|
||||||
|
for meta in _paginate(plex, lib.plex_key, item_type):
|
||||||
|
g = _canonical_guid(meta)
|
||||||
|
targets = by_guid.get(g) if g else None
|
||||||
|
if not targets or g in seen:
|
||||||
|
continue
|
||||||
|
seen.add(g)
|
||||||
|
title_hu = (meta.get("title") or "").strip() or None
|
||||||
|
summary_hu = meta.get("summary")
|
||||||
|
genres_hu = _tags(meta, "Genre")
|
||||||
|
for row in targets:
|
||||||
|
row.title_hu = title_hu
|
||||||
|
row.summary_hu = summary_hu
|
||||||
|
row.genres_hu = genres_hu
|
||||||
|
stats["overlaid"] = stats.get("overlaid", 0) + 1
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
def _upsert_collection(
|
def _upsert_collection(
|
||||||
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
|
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
|
||||||
) -> PlexCollection:
|
) -> PlexCollection:
|
||||||
@@ -359,17 +524,21 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
|
|||||||
collection's membership on the affected member rows."""
|
collection's membership on the affected member rows."""
|
||||||
meta = plex.metadata(rk) or {}
|
meta = plex.metadata(rk) or {}
|
||||||
col = _upsert_collection(
|
col = _upsert_collection(
|
||||||
db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta
|
db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta
|
||||||
)
|
)
|
||||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||||
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
|
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
|
||||||
current = db.query(Model).filter(Model.collection_keys.contains([rk])).all()
|
current = db.execute(
|
||||||
|
select(Model).where(Model.collection_keys.contains([rk]))
|
||||||
|
).scalars().all()
|
||||||
for row in current: # drop rk from rows that are no longer members
|
for row in current: # drop rk from rows that are no longer members
|
||||||
if row.rating_key not in new_members:
|
if row.rating_key not in new_members:
|
||||||
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
||||||
to_add = new_members - {r.rating_key for r in current}
|
to_add = new_members - {r.rating_key for r in current}
|
||||||
if to_add:
|
if to_add:
|
||||||
for row in db.query(Model).filter(Model.rating_key.in_(to_add)):
|
for row in db.execute(
|
||||||
|
select(Model).where(Model.rating_key.in_(to_add))
|
||||||
|
).scalars():
|
||||||
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk]))
|
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk]))
|
||||||
db.flush()
|
db.flush()
|
||||||
return col
|
return col
|
||||||
@@ -378,9 +547,11 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
|
|||||||
def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
|
def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
|
||||||
"""Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row."""
|
"""Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row."""
|
||||||
Model = PlexItem if lib.kind == "movie" else PlexShow
|
Model = PlexItem if lib.kind == "movie" else PlexShow
|
||||||
for row in db.query(Model).filter(Model.collection_keys.contains([rk])):
|
for row in db.execute(
|
||||||
|
select(Model).where(Model.collection_keys.contains([rk]))
|
||||||
|
).scalars():
|
||||||
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
|
||||||
col = db.query(PlexCollection).filter_by(rating_key=rk).first()
|
col = by_rating_key(db, PlexCollection, rk)
|
||||||
if col is not None:
|
if col is not None:
|
||||||
db.delete(col)
|
db.delete(col)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -391,7 +562,12 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
|
|||||||
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
|
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
|
||||||
collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch."""
|
collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch."""
|
||||||
cols = plex.collections(lib.plex_key)
|
cols = plex.collections(lib.plex_key)
|
||||||
existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)}
|
existing = {
|
||||||
|
c.rating_key: c
|
||||||
|
for c in db.execute(
|
||||||
|
select(PlexCollection).where(PlexCollection.library_id == lib.id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
|
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
|
||||||
for meta in cols:
|
for meta in cols:
|
||||||
@@ -410,8 +586,8 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
|
|||||||
if rk not in seen:
|
if rk not in seen:
|
||||||
db.delete(col)
|
db.delete(col)
|
||||||
# Rebuild collection_keys on every member row of this library (clear + set).
|
# Rebuild collection_keys on every member row of this library (clear + set).
|
||||||
for row in db.query(PlexItem).filter_by(library_id=lib.id):
|
for row in db.execute(select(PlexItem).where(PlexItem.library_id == lib.id)).scalars():
|
||||||
row.collection_keys = membership.get(row.rating_key) or None
|
row.collection_keys = membership.get(row.rating_key) or None
|
||||||
for row in db.query(PlexShow).filter_by(library_id=lib.id):
|
for row in db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars():
|
||||||
row.collection_keys = membership.get(row.rating_key) or None
|
row.collection_keys = membership.get(row.rating_key) or None
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ history + onDeck, last-write-wins) build on this and land in later ships.
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -61,7 +61,10 @@ def _plex_watch_to_state(
|
|||||||
|
|
||||||
def _rk_to_id(db: Session) -> dict[str, int]:
|
def _rk_to_id(db: Session) -> dict[str, int]:
|
||||||
"""The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row."""
|
"""The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row."""
|
||||||
return {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)}
|
return {
|
||||||
|
rk: iid
|
||||||
|
for rk, iid in db.execute(select(PlexItem.rating_key, PlexItem.id)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _scan_plex_states(
|
def _scan_plex_states(
|
||||||
@@ -162,7 +165,9 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None:
|
|||||||
"""The active owner watch-push link for this user, or None when push is off / not the owner.
|
"""The active owner watch-push link for this user, or None when push is off / not the owner.
|
||||||
Push is gated on `initial_import_done` so we never push a half-known local state over Plex's
|
Push is gated on `initial_import_done` so we never push a half-known local state over Plex's
|
||||||
authoritative one before the one-time "Plex is master" import has reconciled them."""
|
authoritative one before the one-time "Plex is master" import has reconciled them."""
|
||||||
link = db.query(PlexLink).filter_by(user_id=user_id).first()
|
link = db.execute(
|
||||||
|
select(PlexLink).where(PlexLink.user_id == user_id)
|
||||||
|
).scalars().first()
|
||||||
if link and link.uses_admin and link.sync_enabled and link.initial_import_done:
|
if link and link.uses_admin and link.sync_enabled and link.initial_import_done:
|
||||||
return link
|
return link
|
||||||
return None
|
return None
|
||||||
@@ -217,7 +222,11 @@ def push_state_to_plex(
|
|||||||
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
|
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
|
||||||
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
|
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
|
||||||
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
|
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
|
||||||
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
st = db.execute(
|
||||||
|
select(PlexState).where(
|
||||||
|
PlexState.user_id == user_id, PlexState.item_id == item_id
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
if st is not None and _row_matches(st, expect_ts):
|
if st is not None and _row_matches(st, expect_ts):
|
||||||
st.synced_to_plex = True
|
st.synced_to_plex = True
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -272,9 +281,11 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -
|
|||||||
# Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so
|
# Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so
|
||||||
# only the "watched" changes have a row to flag).
|
# only the "watched" changes have a row to flag).
|
||||||
if synced_ids:
|
if synced_ids:
|
||||||
db.query(PlexState).filter(
|
db.execute(
|
||||||
PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)
|
update(PlexState)
|
||||||
).update({PlexState.synced_to_plex: True}, synchronize_session=False)
|
.where(PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids))
|
||||||
|
.values(synced_to_plex=True)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -290,11 +301,13 @@ _LWW_TOLERANCE_S = 90
|
|||||||
def _active_sync_links(db: Session) -> list[PlexLink]:
|
def _active_sync_links(db: Session) -> list[PlexLink]:
|
||||||
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
|
"""Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so
|
||||||
P5b friend links slot in later without touching this loop."""
|
P5b friend links slot in later without touching this loop."""
|
||||||
return (
|
return db.execute(
|
||||||
db.query(PlexLink)
|
select(PlexLink).where(
|
||||||
.filter_by(uses_admin=True, sync_enabled=True, initial_import_done=True)
|
PlexLink.uses_admin.is_(True),
|
||||||
.all()
|
PlexLink.sync_enabled.is_(True),
|
||||||
)
|
PlexLink.initial_import_done.is_(True),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
||||||
@@ -340,7 +353,11 @@ def _pull_apply(
|
|||||||
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
|
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
|
||||||
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
|
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
|
||||||
full reconcile's job) — this only pulls in watched/resume."""
|
full reconcile's job) — this only pulls in watched/resume."""
|
||||||
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
st = db.execute(
|
||||||
|
select(PlexState).where(
|
||||||
|
PlexState.user_id == user_id, PlexState.item_id == item_id
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
if _same_state(st, status, pos):
|
if _same_state(st, status, pos):
|
||||||
if st is not None and not st.synced_to_plex:
|
if st is not None and not st.synced_to_plex:
|
||||||
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
|
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
|
||||||
@@ -368,17 +385,18 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
|
|||||||
# re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's
|
# re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's
|
||||||
# duration argument.
|
# duration argument.
|
||||||
info_by_id: dict[int, tuple[str, int | None]] = {
|
info_by_id: dict[int, tuple[str, int | None]] = {
|
||||||
iid: (rk, dur) for iid, rk, dur in db.query(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s)
|
iid: (rk, dur)
|
||||||
|
for iid, rk, dur in db.execute(
|
||||||
|
select(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s)
|
||||||
|
).all()
|
||||||
}
|
}
|
||||||
dirty = (
|
dirty = db.execute(
|
||||||
db.query(PlexState)
|
select(PlexState).where(
|
||||||
.filter(
|
|
||||||
PlexState.user_id == link.user_id,
|
PlexState.user_id == link.user_id,
|
||||||
PlexState.synced_to_plex.is_(False),
|
PlexState.synced_to_plex.is_(False),
|
||||||
PlexState.status != "hidden",
|
PlexState.status != "hidden",
|
||||||
)
|
)
|
||||||
.all()
|
).scalars().all()
|
||||||
)
|
|
||||||
n = 0
|
n = 0
|
||||||
for st in dirty:
|
for st in dirty:
|
||||||
info = info_by_id.get(st.item_id)
|
info = info_by_id.get(st.item_id)
|
||||||
@@ -477,7 +495,9 @@ def run_plex_watch_reconcile(db: Session) -> dict:
|
|||||||
|
|
||||||
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
|
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
|
||||||
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
|
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
|
||||||
for st in db.query(PlexState).filter_by(user_id=link.user_id).all():
|
for st in db.execute(
|
||||||
|
select(PlexState).where(PlexState.user_id == link.user_id)
|
||||||
|
).scalars().all():
|
||||||
if st.item_id in plex_states or st.status == "hidden":
|
if st.item_id in plex_states or st.status == "hidden":
|
||||||
continue
|
continue
|
||||||
if st.synced_to_plex:
|
if st.synced_to_plex:
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ def can_spend(db: Session, units: int) -> bool:
|
|||||||
return remaining_today(db) >= units
|
return remaining_today(db) >= units
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_reserve_reached() -> bool:
|
||||||
|
"""True when today's remaining budget is at or below the backfill reserve — the shared yield
|
||||||
|
condition for every backfill/explore path (recent, deep, immediate full-history, explore, and
|
||||||
|
rolling revalidation).
|
||||||
|
|
||||||
|
Reads in its OWN short-lived session (like record_usage / log_action), for two reasons: it
|
||||||
|
replaces the remaining/reserve comparison that was open-coded at every call site, and — critically
|
||||||
|
for the per-page guards in paging loops — a guard must not hold the caller's transaction open
|
||||||
|
across the network fetch that follows it. Quota is committed independently of any caller, so a
|
||||||
|
fresh session reads the same numbers."""
|
||||||
|
with SessionLocal() as qs:
|
||||||
|
return remaining_today(qs) <= sysconfig.get_int(qs, "backfill_quota_reserve")
|
||||||
|
|
||||||
|
|
||||||
def log_action(user_id: int, action: str) -> None:
|
def log_action(user_id: int, action: str) -> None:
|
||||||
"""Record a zero-cost action event so per-user daily caps still count it.
|
"""Record a zero-cost action event so per-user daily caps still count it.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from app.models import (
|
|||||||
Invite,
|
Invite,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.routes.auth import revoke_preset_password_if_unverified
|
||||||
from app.utils import iso, valid_email
|
from app.utils import iso, valid_email
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
@@ -62,6 +63,10 @@ def _activate_user_by_email(db: Session, email: str) -> None:
|
|||||||
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
|
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
|
||||||
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
||||||
if user is not None and not user.is_active:
|
if user is not None and not user.is_active:
|
||||||
|
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
|
||||||
|
# verified must not hand a working login to a password set on it by whoever registered the
|
||||||
|
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
|
||||||
|
revoke_preset_password_if_unverified(user)
|
||||||
user.is_active = True
|
user.is_active = True
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import audit
|
from app import audit
|
||||||
from app.audit import AuditAction
|
from app.audit import AuditAction
|
||||||
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import AuditLog, User
|
from app.models import AuditLog, User
|
||||||
from app.routes.admin import admin_user
|
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/audit", tags=["admin"])
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ async def callback(
|
|||||||
return RedirectResponse(url="/?access=denied")
|
return RedirectResponse(url="/?access=denied")
|
||||||
user = existing
|
user = existing
|
||||||
user.google_sub = sub
|
user.google_sub = sub
|
||||||
|
# SEC-10: drop any password pre-set on this account while its email was still unverified,
|
||||||
|
# now that Google attests ownership (bumps the epoch to revoke any session it minted).
|
||||||
|
revoke_preset_password_if_unverified(user)
|
||||||
# Google verified the email and is_allowed granted access, so finish activating a
|
# Google verified the email and is_allowed granted access, so finish activating a
|
||||||
# previously pending password registration (otherwise current_user would bounce it).
|
# previously pending password registration (otherwise current_user would bounce it).
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
@@ -392,8 +395,12 @@ def _complete_link(
|
|||||||
Google identity owned by another account, or to silently swap a different linked identity.
|
Google identity owned by another account, or to silently swap a different linked identity.
|
||||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||||
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||||
# The marker must match the live session user (guards a stale/forged marker).
|
# SEC-11: the marker must name an account signed into THIS browser — the session default OR any
|
||||||
if link_uid != request.session.get("user_id"):
|
# account in the wallet — not only the default. A tab acting as a secondary account links Google to
|
||||||
|
# ITSELF (the marker was set from current_user, which resolves the ?account= it navigated with), so
|
||||||
|
# accepting wallet membership stops the identity/token landing on the default account A instead of B.
|
||||||
|
accounts = request.session.get("account_ids") or []
|
||||||
|
if link_uid != request.session.get("user_id") and link_uid not in accounts:
|
||||||
return RedirectResponse(url="/?link=error")
|
return RedirectResponse(url="/?link=error")
|
||||||
user = db.get(User, link_uid)
|
user = db.get(User, link_uid)
|
||||||
if user is None or user.is_demo:
|
if user is None or user.is_demo:
|
||||||
@@ -805,6 +812,18 @@ def bump_session_epoch(user: User) -> None:
|
|||||||
user.session_epoch = (user.session_epoch or 0) + 1
|
user.session_epoch = (user.session_epoch or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_preset_password_if_unverified(user: User) -> None:
|
||||||
|
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||||
|
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||||
|
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
||||||
|
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
||||||
|
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
||||||
|
rule — both the Google-callback and admin-activation paths call it so they can't drift."""
|
||||||
|
if not user.email_verified:
|
||||||
|
user.password_hash = None
|
||||||
|
bump_session_epoch(user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout-others")
|
@router.post("/logout-others")
|
||||||
def logout_others(
|
def logout_others(
|
||||||
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
|
request: Request, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ hidden, tags) and per-channel sync state. Read + local-write only — YouTube-si
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota, sysconfig
|
from app import quota
|
||||||
from app.auth import current_user, require_human, require_write_scope
|
from app.auth import admin_user, current_user, require_human, require_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
LIVE_OR_UPCOMING,
|
LIVE_OR_UPCOMING,
|
||||||
@@ -26,9 +26,8 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
)
|
)
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.routes.admin import admin_user
|
|
||||||
from app.sync.explore import explore_ingest_page
|
from app.sync.explore import explore_ingest_page
|
||||||
from app.sync.runner import run_recent_backfill
|
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
@@ -50,20 +49,19 @@ def list_channels(
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last
|
# Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last
|
||||||
# upload, total duration, and a Normal/Shorts/Live breakdown. Cheap — a single grouped scan
|
# upload, total duration. Cheap — a single grouped scan, so computed on read rather than
|
||||||
# (~35 ms over the full 233k-row catalog on the dev DB), so computed on read rather than
|
|
||||||
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns).
|
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns).
|
||||||
|
# (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer
|
||||||
|
# computed here — the manager table dropped its N/S/L column.)
|
||||||
channel_ids = [c.id for _, c in rows]
|
channel_ids = [c.id for _, c in rows]
|
||||||
agg: dict[str, dict] = {}
|
agg: dict[str, dict] = {}
|
||||||
if channel_ids:
|
if channel_ids:
|
||||||
for cid, n, last_up, total_dur, shorts, live in db.execute(
|
for cid, n, last_up, total_dur in db.execute(
|
||||||
select(
|
select(
|
||||||
Video.channel_id,
|
Video.channel_id,
|
||||||
func.count(Video.id),
|
func.count(Video.id),
|
||||||
func.max(Video.published_at),
|
func.max(Video.published_at),
|
||||||
func.coalesce(func.sum(Video.duration_seconds), 0),
|
func.coalesce(func.sum(Video.duration_seconds), 0),
|
||||||
func.count(Video.id).filter(Video.is_short.is_(True)),
|
|
||||||
func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)),
|
|
||||||
)
|
)
|
||||||
.where(Video.channel_id.in_(channel_ids))
|
.where(Video.channel_id.in_(channel_ids))
|
||||||
.group_by(Video.channel_id)
|
.group_by(Video.channel_id)
|
||||||
@@ -72,9 +70,6 @@ def list_channels(
|
|||||||
"stored": n,
|
"stored": n,
|
||||||
"last_video_at": iso(last_up),
|
"last_video_at": iso(last_up),
|
||||||
"total_duration_seconds": int(total_dur or 0),
|
"total_duration_seconds": int(total_dur or 0),
|
||||||
"count_short": int(shorts),
|
|
||||||
"count_live": int(live),
|
|
||||||
"count_normal": max(0, int(n) - int(shorts) - int(live)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Channels already in the *global* deep-backfill queue — i.e. at least one user (any
|
# Channels already in the *global* deep-backfill queue — i.e. at least one user (any
|
||||||
@@ -108,9 +103,6 @@ def list_channels(
|
|||||||
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
"stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
|
||||||
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
|
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
|
||||||
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
|
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
|
||||||
"count_normal": (agg.get(ch.id) or {}).get("count_normal", 0),
|
|
||||||
"count_short": (agg.get(ch.id) or {}).get("count_short", 0),
|
|
||||||
"count_live": (agg.get(ch.id) or {}).get("count_live", 0),
|
|
||||||
"priority": sub.priority,
|
"priority": sub.priority,
|
||||||
"hidden": sub.hidden,
|
"hidden": sub.hidden,
|
||||||
"deep_requested": sub.deep_requested,
|
"deep_requested": sub.deep_requested,
|
||||||
@@ -229,6 +221,9 @@ def _channel_detail_dict(
|
|||||||
explored: bool,
|
explored: bool,
|
||||||
blocked: bool,
|
blocked: bool,
|
||||||
stored: int,
|
stored: int,
|
||||||
|
count_normal: int,
|
||||||
|
count_short: int,
|
||||||
|
count_live: int,
|
||||||
deep_requested: bool,
|
deep_requested: bool,
|
||||||
priority: int,
|
priority: int,
|
||||||
tag_ids: list[int],
|
tag_ids: list[int],
|
||||||
@@ -253,6 +248,11 @@ def _channel_detail_dict(
|
|||||||
"explored": explored,
|
"explored": explored,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
"stored_videos": stored,
|
"stored_videos": stored,
|
||||||
|
# Per-type breakdown (Normal / Shorts / Live) — R14 moved it off the manager table onto the
|
||||||
|
# channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live).
|
||||||
|
"count_normal": count_normal,
|
||||||
|
"count_short": count_short,
|
||||||
|
"count_live": count_live,
|
||||||
# Deep-backfill state, so the channel page can offer "get full history" when the catalog
|
# Deep-backfill state, so the channel page can offer "get full history" when the catalog
|
||||||
# holds only the recent-backfill/RSS subset (video_count is YouTube's raw total).
|
# holds only the recent-backfill/RSS subset (video_count is YouTube's raw total).
|
||||||
"deep_requested": deep_requested,
|
"deep_requested": deep_requested,
|
||||||
@@ -304,7 +304,19 @@ def channel_detail(
|
|||||||
)
|
)
|
||||||
).first() is not None
|
).first() is not None
|
||||||
blocked = _is_blocked(db, user, channel_id)
|
blocked = _is_blocked(db, user, channel_id)
|
||||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
# Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager
|
||||||
|
# list), so the channel page can show the per-type breakdown R14 moved off the table.
|
||||||
|
n_all, n_short, n_live = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Video.id),
|
||||||
|
func.count(Video.id).filter(Video.is_short.is_(True)),
|
||||||
|
func.count(Video.id).filter(Video.live_status.in_(LIVE_OR_UPCOMING)),
|
||||||
|
).where(Video.channel_id == channel_id)
|
||||||
|
).one()
|
||||||
|
stored = int(n_all or 0)
|
||||||
|
count_short = int(n_short or 0)
|
||||||
|
count_live = int(n_live or 0)
|
||||||
|
count_normal = max(0, stored - count_short - count_live)
|
||||||
tag_ids = list(
|
tag_ids = list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(ChannelTag.tag_id).where(
|
select(ChannelTag.tag_id).where(
|
||||||
@@ -318,6 +330,9 @@ def channel_detail(
|
|||||||
explored=explored,
|
explored=explored,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
stored=int(stored),
|
stored=int(stored),
|
||||||
|
count_normal=count_normal,
|
||||||
|
count_short=count_short,
|
||||||
|
count_live=count_live,
|
||||||
deep_requested=bool(sub.deep_requested) if sub else False,
|
deep_requested=bool(sub.deep_requested) if sub else False,
|
||||||
priority=int(sub.priority) if sub else 0,
|
priority=int(sub.priority) if sub else 0,
|
||||||
tag_ids=tag_ids,
|
tag_ids=tag_ids,
|
||||||
@@ -342,7 +357,7 @@ def explore_channel(
|
|||||||
raise HTTPException(status_code=404, detail="Unknown channel")
|
raise HTTPException(status_code=404, detail="Unknown channel")
|
||||||
if _is_blocked(db, user, channel_id):
|
if _is_blocked(db, user, channel_id):
|
||||||
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
raise HTTPException(status_code=403, detail="You've blocked this channel.")
|
||||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
if quota.backfill_reserve_reached():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="The shared daily quota reserve is reached — try exploring later.",
|
detail="The shared daily quota reserve is reached — try exploring later.",
|
||||||
@@ -426,6 +441,7 @@ class ChannelUpdateIn(BaseModel):
|
|||||||
def update_channel(
|
def update_channel(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
body: ChannelUpdateIn,
|
body: ChannelUpdateIn,
|
||||||
|
background: BackgroundTasks,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -450,12 +466,18 @@ def update_channel(
|
|||||||
|
|
||||||
# Opting a channel into full history should give an immediate feed: if we haven't even
|
# Opting a channel into full history should give an immediate feed: if we haven't even
|
||||||
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
# fetched its recent uploads yet, do that one channel now (cheap) instead of waiting for
|
||||||
# the scheduler. Deep paging still follows on the scheduler's next run (recent-then-deep).
|
# the scheduler. (deep_turned_on is only ever True for a non-demo user — see the guard above.)
|
||||||
# (deep_turned_on is only ever True for a non-demo user — see the guard above.)
|
|
||||||
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
if deep_turned_on and channel is not None and channel.recent_synced_at is None:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
||||||
run_recent_backfill(db, [channel], max_channels=1)
|
run_recent_backfill(db, [channel], max_channels=1)
|
||||||
|
|
||||||
|
# …and kick the FULL deep backfill for this one channel immediately in the background, so the
|
||||||
|
# whole catalogue lands in seconds instead of over the scheduler's paced 10-minute ticks. The
|
||||||
|
# request returns right away; the channel page's live poll shows the count climb. The scheduler
|
||||||
|
# still finishes a very large channel's tail on its normal runs (deep_requested stays set).
|
||||||
|
if deep_turned_on:
|
||||||
|
background.add_task(backfill_channel_now, channel_id, user.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": channel_id,
|
"id": channel_id,
|
||||||
"priority": sub.priority,
|
"priority": sub.priority,
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ from app import audit
|
|||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.audit import AuditAction
|
from app.audit import AuditAction
|
||||||
|
from app.auth import admin_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.routes.admin import admin_user
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.db import get_db
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import links as linksmod
|
from app.downloads import links as linksmod
|
||||||
from app.downloads import formats, quota, service, storage
|
from app.downloads import formats, quota, service, storage
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DownloadJob,
|
DownloadJob,
|
||||||
DownloadLink,
|
DownloadLink,
|
||||||
@@ -544,9 +545,9 @@ def cancel_download(
|
|||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
job = _own_job(db, user, job_id)
|
job = _own_job(db, user, job_id)
|
||||||
if job.status not in ("done", "canceled"):
|
if job.status not in (JobStatus.DONE, JobStatus.CANCELED):
|
||||||
service.release_asset(db, job) # release while the job still counts as holding
|
service.release_asset(db, job) # release while the job still counts as holding
|
||||||
job.status = "canceled"
|
job.status = JobStatus.CANCELED
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_job(db, job)
|
return _serialize_job(db, job)
|
||||||
|
|
||||||
|
|||||||
+48
-427
@@ -1,29 +1,27 @@
|
|||||||
import base64
|
from datetime import date, datetime, timezone
|
||||||
import binascii
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.feedquery import (
|
||||||
|
decode_cursor,
|
||||||
|
encode_cursor,
|
||||||
|
feed_summary,
|
||||||
|
filtered_query,
|
||||||
|
keyset_predicate,
|
||||||
|
order_by,
|
||||||
|
serialize,
|
||||||
|
sort_keys,
|
||||||
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
LIVE_OR_UPCOMING,
|
|
||||||
BlockedChannel,
|
|
||||||
Channel,
|
|
||||||
ChannelTag,
|
ChannelTag,
|
||||||
ExploredChannel,
|
|
||||||
Playlist,
|
|
||||||
PlaylistItem,
|
|
||||||
SearchFind,
|
|
||||||
Subscription,
|
|
||||||
Tag,
|
Tag,
|
||||||
User,
|
User,
|
||||||
Video,
|
Video,
|
||||||
@@ -47,314 +45,6 @@ PROGRESS_MIN_SECONDS = 5
|
|||||||
FINISH_MARGIN_SECONDS = 10
|
FINISH_MARGIN_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
def _channel_url(channel_id: str, handle: str | None) -> str:
|
|
||||||
if handle and handle.startswith("@"):
|
|
||||||
return f"https://www.youtube.com/{handle}"
|
|
||||||
return f"https://www.youtube.com/channel/{channel_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _saved_expr(user: User):
|
|
||||||
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
|
|
||||||
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
|
|
||||||
return (
|
|
||||||
select(1)
|
|
||||||
.select_from(PlaylistItem)
|
|
||||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
|
||||||
.where(
|
|
||||||
Playlist.user_id == user.id,
|
|
||||||
Playlist.kind == "watch_later",
|
|
||||||
PlaylistItem.video_id == Video.id,
|
|
||||||
)
|
|
||||||
.exists()
|
|
||||||
.label("saved")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def feed_columns(user: User, state) -> list:
|
|
||||||
"""The shared feed-row projection (joined Channel fields + per-user watch state +
|
|
||||||
`saved`), consumed by both the feed query and the playlist-detail query so a new column
|
|
||||||
is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined)."""
|
|
||||||
return [
|
|
||||||
Video.id,
|
|
||||||
Video.title,
|
|
||||||
Video.channel_id,
|
|
||||||
Channel.title.label("channel_title"),
|
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
||||||
Channel.handle.label("channel_handle"),
|
|
||||||
Video.published_at,
|
|
||||||
Video.thumbnail_url,
|
|
||||||
Video.duration_seconds,
|
|
||||||
Video.view_count,
|
|
||||||
Video.is_short,
|
|
||||||
Video.live_status,
|
|
||||||
func.coalesce(state.status, "new").label("status"),
|
|
||||||
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
|
||||||
_saved_expr(user),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def serialize(row) -> dict:
|
|
||||||
return {
|
|
||||||
"id": row.id,
|
|
||||||
"title": row.title,
|
|
||||||
"channel_id": row.channel_id,
|
|
||||||
"channel_title": row.channel_title,
|
|
||||||
"channel_thumbnail": row.channel_thumbnail,
|
|
||||||
"channel_url": _channel_url(row.channel_id, row.channel_handle),
|
|
||||||
"published_at": iso(row.published_at),
|
|
||||||
"thumbnail_url": row.thumbnail_url,
|
|
||||||
"duration_seconds": row.duration_seconds,
|
|
||||||
"view_count": row.view_count,
|
|
||||||
"is_short": row.is_short,
|
|
||||||
"live_status": row.live_status,
|
|
||||||
"status": row.status or "new",
|
|
||||||
"position_seconds": row.position_seconds or 0,
|
|
||||||
"saved": bool(getattr(row, "saved", False)),
|
|
||||||
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _filtered_query(
|
|
||||||
db: Session,
|
|
||||||
user: User,
|
|
||||||
*,
|
|
||||||
tags: list[int],
|
|
||||||
tag_mode: str,
|
|
||||||
channel_ids: list[str],
|
|
||||||
q: str | None,
|
|
||||||
min_duration: int | None,
|
|
||||||
max_duration: int | None,
|
|
||||||
max_age_days: int | None,
|
|
||||||
published_after: date | None,
|
|
||||||
published_before: date | None,
|
|
||||||
show_normal: bool,
|
|
||||||
include_shorts: bool,
|
|
||||||
include_live: bool,
|
|
||||||
show: str,
|
|
||||||
scope: str = "my",
|
|
||||||
library_source: str = "organic",
|
|
||||||
exclude_tag_category: str | None = None,
|
|
||||||
) -> tuple[Select, object]:
|
|
||||||
"""Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count.
|
|
||||||
Returns the column-bearing select plus the priority/relevance rank expression for sorting.
|
|
||||||
|
|
||||||
`scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions.
|
|
||||||
`scope="all"` shows every video in the shared catalog (any user's ingested channels);
|
|
||||||
the subscription is then only LEFT-joined so per-channel priority still resolves for
|
|
||||||
channels the user happens to be subscribed to. Per-user watch state stays private in
|
|
||||||
either mode via the VideoState outer join."""
|
|
||||||
state = aliased(VideoState)
|
|
||||||
status_expr = func.coalesce(state.status, "new")
|
|
||||||
position_expr = func.coalesce(state.position_seconds, 0)
|
|
||||||
|
|
||||||
query = select(*feed_columns(user, state)).join(
|
|
||||||
Channel, Channel.id == Video.channel_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if scope == "all":
|
|
||||||
# Whole shared catalog; subscription is optional (only for priority sort).
|
|
||||||
query = query.outerjoin(
|
|
||||||
Subscription,
|
|
||||||
and_(
|
|
||||||
Subscription.channel_id == Video.channel_id,
|
|
||||||
Subscription.user_id == user.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# "Mine": the user's own videos = their non-hidden subscriptions OR videos they
|
|
||||||
# surfaced via their own live YouTube search. Both are LEFT-joined so the Source
|
|
||||||
# filter below can include either or both, and the subscription row stays available
|
|
||||||
# for the priority sort.
|
|
||||||
query = query.outerjoin(
|
|
||||||
Subscription,
|
|
||||||
and_(
|
|
||||||
Subscription.channel_id == Video.channel_id,
|
|
||||||
Subscription.user_id == user.id,
|
|
||||||
Subscription.hidden.is_(False),
|
|
||||||
),
|
|
||||||
).outerjoin(
|
|
||||||
SearchFind,
|
|
||||||
and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.outerjoin(
|
|
||||||
state, and_(state.video_id == Video.id, state.user_id == user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hide videos the maintenance job has flagged as currently unplayable (deleted/private/
|
|
||||||
# abandoned). They're either deleted after a grace period or unhidden if they recover;
|
|
||||||
# either way they shouldn't show in any feed view meanwhile.
|
|
||||||
query = query.where(Video.unavailable_since.is_(None))
|
|
||||||
|
|
||||||
# Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone
|
|
||||||
# opened an un-subscribed channel's page) is visible only to users who explored or
|
|
||||||
# subscribe to its channel, so one user's curiosity never leaks into everyone else's
|
|
||||||
# catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by
|
|
||||||
# asking for scope=all + channel_ids (the channel is in this accessible set).
|
|
||||||
accessible_explore = (
|
|
||||||
select(Subscription.channel_id).where(Subscription.user_id == user.id)
|
|
||||||
).union(
|
|
||||||
select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id)
|
|
||||||
)
|
|
||||||
query = query.where(
|
|
||||||
or_(
|
|
||||||
Video.via_explore.is_(False),
|
|
||||||
Video.channel_id.in_(accessible_explore),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Per-user channel blocklist: never show this user videos from a channel they blocked
|
|
||||||
# (applies to feed, Library, explore, and the channel page alike).
|
|
||||||
query = query.where(
|
|
||||||
Video.channel_id.notin_(
|
|
||||||
select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag
|
|
||||||
# (search-discovered by anyone). In "my" scope it's the user's OWN provenance:
|
|
||||||
# "organic" = your subscriptions, "search" = videos you found via your own search,
|
|
||||||
# "all" = both. Default "organic" keeps the Mine feed = subscriptions only.
|
|
||||||
if scope == "all":
|
|
||||||
if library_source == "organic":
|
|
||||||
query = query.where(Video.via_search.is_(False))
|
|
||||||
elif library_source == "search":
|
|
||||||
query = query.where(Video.via_search.is_(True))
|
|
||||||
# "all" → both organic and search-discovered videos
|
|
||||||
else:
|
|
||||||
subscribed = Subscription.channel_id.isnot(None)
|
|
||||||
searched = SearchFind.video_id.isnot(None)
|
|
||||||
if library_source == "search":
|
|
||||||
query = query.where(searched)
|
|
||||||
elif library_source == "all":
|
|
||||||
query = query.where(or_(subscribed, searched))
|
|
||||||
else: # "organic" (default): subscriptions only
|
|
||||||
query = query.where(subscribed)
|
|
||||||
|
|
||||||
if channel_ids:
|
|
||||||
# OR across the picked channels; every other filter still applies on top.
|
|
||||||
query = query.where(Video.channel_id.in_(channel_ids))
|
|
||||||
if min_duration is not None:
|
|
||||||
query = query.where(Video.duration_seconds >= min_duration)
|
|
||||||
if max_duration is not None:
|
|
||||||
query = query.where(Video.duration_seconds <= max_duration)
|
|
||||||
if max_age_days is not None:
|
|
||||||
cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400
|
|
||||||
query = query.where(
|
|
||||||
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
|
||||||
)
|
|
||||||
if published_after is not None:
|
|
||||||
start = datetime.combine(
|
|
||||||
published_after, datetime.min.time(), tzinfo=timezone.utc
|
|
||||||
)
|
|
||||||
query = query.where(Video.published_at >= start)
|
|
||||||
if published_before is not None:
|
|
||||||
end = datetime.combine(
|
|
||||||
published_before, datetime.min.time(), tzinfo=timezone.utc
|
|
||||||
) + timedelta(days=1)
|
|
||||||
query = query.where(Video.published_at < end)
|
|
||||||
# Full-text relevance search over the weighted search document (title > creator keywords +
|
|
||||||
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like
|
|
||||||
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
|
|
||||||
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so
|
|
||||||
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
|
|
||||||
rank_expr = None
|
|
||||||
if q:
|
|
||||||
ts_str = to_tsquery_str(q)
|
|
||||||
if ts_str:
|
|
||||||
tsq = func.to_tsquery("public.unaccent_simple", ts_str)
|
|
||||||
query = query.where(
|
|
||||||
or_(
|
|
||||||
Video.search_vector.op("@@")(tsq),
|
|
||||||
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Scale ts_rank (a float4) to an integer score so the keyset cursor compares it
|
|
||||||
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
|
|
||||||
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
|
|
||||||
# published_at then id.
|
|
||||||
rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
|
|
||||||
else:
|
|
||||||
# No usable tokens (e.g. only punctuation): fall back to a plain substring match.
|
|
||||||
like = func.unaccent(f"%{q}%")
|
|
||||||
query = query.where(
|
|
||||||
or_(
|
|
||||||
func.unaccent(Video.title).ilike(like),
|
|
||||||
func.unaccent(Channel.title).ilike(like),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if tags:
|
|
||||||
# AND across tag categories (e.g. language AND topic narrows), OR within a
|
|
||||||
# category; the any/all toggle controls multiple topic tags.
|
|
||||||
cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all()
|
|
||||||
by_category: dict[str, list[int]] = {}
|
|
||||||
for tag_id, category in cat_rows:
|
|
||||||
by_category.setdefault(category, []).append(tag_id)
|
|
||||||
# Facet counting drops the category being counted so its own chips don't zero each
|
|
||||||
# other out (standard drill-down faceting: a category's count ignores its own
|
|
||||||
# selections but still honours the other categories' filters).
|
|
||||||
if exclude_tag_category:
|
|
||||||
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:
|
|
||||||
sub = (
|
|
||||||
select(ChannelTag.channel_id)
|
|
||||||
.where(ChannelTag.tag_id.in_(ids), visible)
|
|
||||||
.group_by(ChannelTag.channel_id)
|
|
||||||
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sub = select(ChannelTag.channel_id).where(
|
|
||||||
ChannelTag.tag_id.in_(ids), visible
|
|
||||||
)
|
|
||||||
query = query.where(Video.channel_id.in_(sub))
|
|
||||||
|
|
||||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
|
||||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
|
||||||
explicit_view = show in ("watched", "hidden")
|
|
||||||
if not explicit_view:
|
|
||||||
type_clauses = []
|
|
||||||
if show_normal:
|
|
||||||
type_clauses.append(
|
|
||||||
and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING))
|
|
||||||
)
|
|
||||||
if include_shorts:
|
|
||||||
type_clauses.append(Video.is_short.is_(True))
|
|
||||||
if include_live:
|
|
||||||
type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING))
|
|
||||||
query = query.where(or_(*type_clauses) if type_clauses else false())
|
|
||||||
|
|
||||||
if show == "unwatched":
|
|
||||||
query = query.where(status_expr.notin_(("watched", "hidden")))
|
|
||||||
elif show == "in_progress":
|
|
||||||
# Started but not finished: a stored resume position, not yet watched/hidden.
|
|
||||||
query = query.where(
|
|
||||||
and_(position_expr > 0, status_expr.notin_(("watched", "hidden")))
|
|
||||||
)
|
|
||||||
elif show == "watched":
|
|
||||||
query = query.where(status_expr == "watched")
|
|
||||||
elif show == "hidden":
|
|
||||||
query = query.where(status_expr == "hidden")
|
|
||||||
else: # all
|
|
||||||
query = query.where(status_expr != "hidden")
|
|
||||||
|
|
||||||
return query, rank_expr
|
|
||||||
|
|
||||||
|
|
||||||
def to_tsquery_str(q: str) -> str | None:
|
|
||||||
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
|
|
||||||
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
|
|
||||||
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
|
|
||||||
(the string is built from sanitised tokens, never the raw query)."""
|
|
||||||
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
|
|
||||||
if not tokens:
|
|
||||||
return None
|
|
||||||
tokens[-1] = tokens[-1] + ":*"
|
|
||||||
return " & ".join(tokens)
|
|
||||||
|
|
||||||
|
|
||||||
# Shared query parameters for /feed and /feed/count.
|
# Shared query parameters for /feed and /feed/count.
|
||||||
def _feed_params(
|
def _feed_params(
|
||||||
tags: list[int] = Query(default=[]),
|
tags: list[int] = Query(default=[]),
|
||||||
@@ -392,101 +82,6 @@ def _feed_params(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Keyset (cursor) pagination ---------------------------------------------
|
|
||||||
#
|
|
||||||
# Each sort is described as an ordered list of key columns (expression + direction
|
|
||||||
# + whether it can be NULL + a JSON round-trip kind), always with Video.id as the
|
|
||||||
# final, unique tiebreaker. This drives BOTH the ORDER BY and the keyset WHERE so
|
|
||||||
# they can never drift apart — the prerequisite for correct cursor paging. Keyset
|
|
||||||
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
|
|
||||||
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll
|
|
||||||
# (OFFSET would shift and duplicate/skip rows).
|
|
||||||
def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
|
|
||||||
pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
|
|
||||||
table: dict[str, list[dict]] = {
|
|
||||||
"newest": [pub],
|
|
||||||
"oldest": [{**pub, "asc": True}],
|
|
||||||
"views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
|
|
||||||
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
|
|
||||||
"duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}],
|
|
||||||
"duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}],
|
|
||||||
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
|
|
||||||
"title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}],
|
|
||||||
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}],
|
|
||||||
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}],
|
|
||||||
# Your per-channel priority (set in the channel manager), newest first within a tier.
|
|
||||||
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
|
|
||||||
"priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub],
|
|
||||||
"priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub],
|
|
||||||
# Deterministic per-seed shuffle: the same seed yields the same total order, so
|
|
||||||
# the cursor stays valid across pages of one shuffled run.
|
|
||||||
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
|
|
||||||
}
|
|
||||||
# Relevance (search-result ranking) only exists when there's a query to rank against;
|
|
||||||
# without `rank_expr` it falls through to newest. Ties break by newest, then id.
|
|
||||||
if rank_expr is not None:
|
|
||||||
table["relevance"] = [
|
|
||||||
{"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"},
|
|
||||||
pub,
|
|
||||||
]
|
|
||||||
return table.get(sort) or table["newest"]
|
|
||||||
|
|
||||||
|
|
||||||
def _order_by(keys: list[dict]):
|
|
||||||
cols = []
|
|
||||||
for k in keys:
|
|
||||||
c = k["expr"].asc() if k["asc"] else k["expr"].desc()
|
|
||||||
cols.append(c.nulls_last() if k["nullable"] else c)
|
|
||||||
cols.append(Video.id.asc()) # unique final tiebreaker
|
|
||||||
return cols
|
|
||||||
|
|
||||||
|
|
||||||
def _keyset_predicate(keys: list[dict], cursor_id: str):
|
|
||||||
"""WHERE clause selecting rows strictly AFTER the cursor in the sort order.
|
|
||||||
|
|
||||||
Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL
|
|
||||||
sorts after every non-NULL value, so a cursor sitting on a non-NULL value must
|
|
||||||
also pull in the NULL group, and equality treats NULL == NULL."""
|
|
||||||
def eq(k):
|
|
||||||
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
|
|
||||||
|
|
||||||
def after(k):
|
|
||||||
if k["value"] is None:
|
|
||||||
return false() # nulls are last; nothing sorts strictly after them
|
|
||||||
cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"]
|
|
||||||
return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp
|
|
||||||
|
|
||||||
branches = []
|
|
||||||
for j in range(len(keys)):
|
|
||||||
branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j])))
|
|
||||||
branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id))
|
|
||||||
return or_(*branches)
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_cursor(keys: list[dict], row) -> str:
|
|
||||||
vals = []
|
|
||||||
for i, k in enumerate(keys):
|
|
||||||
v = getattr(row, f"_k{i}")
|
|
||||||
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
|
|
||||||
payload = json.dumps({"k": vals, "id": row.id})
|
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
|
|
||||||
try:
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
|
||||||
raw = payload["k"]
|
|
||||||
if len(raw) != len(keys):
|
|
||||||
raise ValueError("key arity mismatch")
|
|
||||||
vals = [
|
|
||||||
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
|
|
||||||
for k, v in zip(keys, raw)
|
|
||||||
]
|
|
||||||
return vals, payload["id"]
|
|
||||||
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError):
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid feed cursor")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/feed")
|
@router.get("/feed")
|
||||||
def get_feed(
|
def get_feed(
|
||||||
params: dict = Depends(_feed_params),
|
params: dict = Depends(_feed_params),
|
||||||
@@ -497,25 +92,31 @@ def get_feed(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
query, rank_expr = _filtered_query(db, user, **params)
|
query, rank_expr = filtered_query(db, user, **params)
|
||||||
keys = _sort_keys(sort, seed, rank_expr)
|
keys = sort_keys(sort, seed, rank_expr)
|
||||||
|
|
||||||
# Expose each key column on the row so we can build the next cursor from the last item.
|
# Expose each key column on the row so we can build the next cursor from the last item.
|
||||||
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
|
||||||
query = query.order_by(*_order_by(keys))
|
query = query.order_by(*order_by(keys))
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
vals, cursor_id = _decode_cursor(keys, cursor)
|
# The read-model raises a plain ValueError on a malformed cursor; translate it to the
|
||||||
|
# HTTP 400 here so feedquery stays web-framework-free.
|
||||||
|
try:
|
||||||
|
vals, cursor_id = decode_cursor(keys, cursor)
|
||||||
|
except ValueError:
|
||||||
|
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid feed cursor") from None
|
||||||
for k, v in zip(keys, vals):
|
for k, v in zip(keys, vals):
|
||||||
k["value"] = v
|
k["value"] = v
|
||||||
query = query.where(_keyset_predicate(keys, cursor_id))
|
query = query.where(keyset_predicate(keys, cursor_id))
|
||||||
|
|
||||||
rows = db.execute(query.limit(limit + 1)).all()
|
rows = db.execute(query.limit(limit + 1)).all()
|
||||||
has_more = len(rows) > limit
|
has_more = len(rows) > limit
|
||||||
page = rows[:limit]
|
page = rows[:limit]
|
||||||
return {
|
return {
|
||||||
"items": [serialize(r) for r in page],
|
"items": [serialize(r) for r in page],
|
||||||
"next_cursor": _encode_cursor(keys, page[-1]) if (has_more and page) else None,
|
"next_cursor": encode_cursor(keys, page[-1]) if (has_more and page) else None,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
}
|
}
|
||||||
@@ -527,7 +128,10 @@ def get_feed_count(
|
|||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
query, _rank = _filtered_query(db, user, **params)
|
# SUPERSEDED by /feed/summary (PERF-13) — the app no longer calls this. Kept as the reference
|
||||||
|
# oracle that test_feed_summary_matches_count_plus_facets asserts /feed/summary's `count` against;
|
||||||
|
# remove both together with that test if this endpoint is ever retired.
|
||||||
|
query, _rank = filtered_query(db, user, **params)
|
||||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||||
return {"count": total or 0}
|
return {"count": total or 0}
|
||||||
|
|
||||||
@@ -546,7 +150,11 @@ def get_facets(
|
|||||||
|
|
||||||
The count is the number of distinct channels that have at least one video in the current
|
The count is the number of distinct channels that have at least one video in the current
|
||||||
view, matching the existing channel-count chip semantics. JSON object keys are strings
|
view, matching the existing channel-count chip semantics. JSON object keys are strings
|
||||||
(tag ids)."""
|
(tag ids).
|
||||||
|
|
||||||
|
SUPERSEDED by /feed/summary (PERF-13) — the app no longer calls this. Kept as the reference
|
||||||
|
oracle for test_feed_summary_matches_count_plus_facets (it asserts /feed/summary's `facets`
|
||||||
|
against these `counts`); retire the two together."""
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
counts: dict[int, int] = {}
|
counts: dict[int, int] = {}
|
||||||
for category in ("language", "topic", "other"):
|
for category in ("language", "topic", "other"):
|
||||||
@@ -555,7 +163,7 @@ def get_facets(
|
|||||||
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
# 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).
|
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||||
base, _rank = _filtered_query(
|
base, _rank = filtered_query(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
**{**params, "exclude_tag_category": None if conjunctive else category},
|
**{**params, "exclude_tag_category": None if conjunctive else category},
|
||||||
@@ -574,6 +182,19 @@ def get_facets(
|
|||||||
return {"counts": counts}
|
return {"counts": counts}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed/summary")
|
||||||
|
def get_feed_summary(
|
||||||
|
params: dict = Depends(_feed_params),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The sidebar's total video count + per-tag channel facet counts in ONE request and ONE grouped
|
||||||
|
query, replacing the separate /feed/count (1 pass) + /facets (3 passes) the FE fired on every filter
|
||||||
|
change (PERF-13). Response: {"count": <videos>, "facets": {<tag_id>: <channel_count>}} — the same
|
||||||
|
numbers /feed/count's `count` and /facets' `counts` return, so the two are interchangeable."""
|
||||||
|
return feed_summary(db, user, **params)
|
||||||
|
|
||||||
|
|
||||||
class VideoStateIn(BaseModel):
|
class VideoStateIn(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ def switch_account(
|
|||||||
# Don't make a suspended account the active one — the next request's current_user would
|
# Don't make a suspended account the active one — the next request's current_user would
|
||||||
# see the suspension and clear the WHOLE wallet session, logging out every account here.
|
# see the suspension and clear the WHOLE wallet session, logging out every account here.
|
||||||
raise HTTPException(status_code=403, detail="That account is suspended.")
|
raise HTTPException(status_code=403, detail="That account is suspended.")
|
||||||
|
# Same reasoning for a REVOKED session (a password reset/change or "log out everywhere" bumped the
|
||||||
|
# account's epoch): making it the session default would trip current_user's epoch check on the next
|
||||||
|
# request and clear the whole wallet. Mirror the suspend guard — drop the stale account from the
|
||||||
|
# switch list and refuse, rather than logging everyone out. (B-core-9)
|
||||||
|
epochs = request.session.get("epochs") or {}
|
||||||
|
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
|
||||||
|
request.session["account_ids"] = [
|
||||||
|
a for a in (request.session.get("account_ids") or []) if a != target
|
||||||
|
]
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="That account was signed out elsewhere — sign in again."
|
||||||
|
)
|
||||||
request.session["user_id"] = target
|
request.session["user_id"] = target
|
||||||
return {"ok": True, "user_id": target}
|
return {"ok": True, "user_id": target}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from sqlalchemy.orm import Session, aliased
|
|||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope
|
from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import serialize, feed_columns
|
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
plan_push,
|
plan_push,
|
||||||
push_playlist,
|
push_playlist,
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
PlexItem,
|
PlexItem,
|
||||||
)
|
)
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
||||||
|
|
||||||
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
def item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||||
it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first()
|
it = by_rating_key(db, PlexItem, rating_key)
|
||||||
if it is None:
|
if it is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||||
return it
|
return it
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.auth import current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexItem,
|
PlexItem,
|
||||||
@@ -18,7 +19,6 @@ from app.plex import paths as plex_paths
|
|||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.routes.admin import admin_user
|
|
||||||
from app.utils import iso
|
from app.utils import iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
@@ -65,9 +65,9 @@ def _sample_plex_path(
|
|||||||
"""A real Plex-side media file path to validate the mount + path-map against. Prefers the
|
"""A real Plex-side media file path to validate the mount + path-map against. Prefers the
|
||||||
already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled
|
already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled
|
||||||
section straight from Plex."""
|
section straight from Plex."""
|
||||||
row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first()
|
fp = db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None)))
|
||||||
if row and row[0]:
|
if fp:
|
||||||
return row[0]
|
return fp
|
||||||
wanted = plex_sync._enabled_section_keys(db)
|
wanted = plex_sync._enabled_section_keys(db)
|
||||||
for s in sections:
|
for s in sections:
|
||||||
if s.get("type") != "movie":
|
if s.get("type") != "movie":
|
||||||
@@ -117,7 +117,9 @@ def watch_link_status(
|
|||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""This user's Plex watch-sync link status (drives the Settings toggle)."""
|
"""This user's Plex watch-sync link status (drives the Settings toggle)."""
|
||||||
link = db.query(PlexLink).filter_by(user_id=user.id).first()
|
link = db.execute(
|
||||||
|
select(PlexLink).where(PlexLink.user_id == user.id)
|
||||||
|
).scalars().first()
|
||||||
return _link_dict(link)
|
return _link_dict(link)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +139,9 @@ def watch_link_set(
|
|||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
|
raise HTTPException(status_code=409, detail="The Plex module is disabled.")
|
||||||
enabled = body.enabled
|
enabled = body.enabled
|
||||||
link = db.query(PlexLink).filter_by(user_id=user.id).first()
|
link = db.execute(
|
||||||
|
select(PlexLink).where(PlexLink.user_id == user.id)
|
||||||
|
).scalars().first()
|
||||||
if link is None:
|
if link is None:
|
||||||
link = PlexLink(
|
link = PlexLink(
|
||||||
user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)
|
user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)
|
||||||
@@ -158,7 +162,9 @@ def watch_import_now(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the
|
"""Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the
|
||||||
intersection; Siftlode-only states are preserved. Idempotent."""
|
intersection; Siftlode-only states are preserved. Idempotent."""
|
||||||
link = db.query(PlexLink).filter_by(user_id=user.id).first()
|
link = db.execute(
|
||||||
|
select(PlexLink).where(PlexLink.user_id == user.id)
|
||||||
|
).scalars().first()
|
||||||
if link is None or not link.sync_enabled:
|
if link is None or not link.sync_enabled:
|
||||||
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.")
|
raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.")
|
||||||
result = plex_watch.import_owner_watch_state(db, link)
|
result = plex_watch.import_owner_watch_state(db, link)
|
||||||
|
|||||||
+555
-523
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ from fastapi import (
|
|||||||
)
|
)
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, resolved_user_id
|
||||||
@@ -29,6 +30,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.plex import imgcache, subtitles
|
from app.plex import imgcache, subtitles
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex import people as plex_people
|
from app.plex import people as plex_people
|
||||||
from app.plex import stream as plex_stream
|
from app.plex import stream as plex_stream
|
||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
@@ -207,7 +209,11 @@ def _push_watch(
|
|||||||
return
|
return
|
||||||
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
# Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit
|
||||||
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
|
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
|
||||||
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
st = db.execute(
|
||||||
|
select(PlexState).where(
|
||||||
|
PlexState.user_id == user_id, PlexState.item_id == item_id
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
background.add_task(
|
background.add_task(
|
||||||
plex_watch.push_state_to_plex,
|
plex_watch.push_state_to_plex,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -246,7 +252,11 @@ def item_progress(
|
|||||||
duration = int(body.duration_seconds)
|
duration = int(body.duration_seconds)
|
||||||
final = body.final
|
final = body.final
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
st = db.execute(
|
||||||
|
select(PlexState).where(
|
||||||
|
PlexState.user_id == user.id, PlexState.item_id == it.id
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
was_watched = st is not None and st.status == "watched"
|
was_watched = st is not None and st.status == "watched"
|
||||||
|
|
||||||
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||||
@@ -316,7 +326,11 @@ def item_state(
|
|||||||
status = body.status
|
status = body.status
|
||||||
if status not in ("new", "watched", "hidden"):
|
if status not in ("new", "watched", "hidden"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
st = db.execute(
|
||||||
|
select(PlexState).where(
|
||||||
|
PlexState.user_id == user.id, PlexState.item_id == it.id
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
prev_status = st.status if st is not None else "new"
|
prev_status = st.status if st is not None else "new"
|
||||||
if status == "new":
|
if status == "new":
|
||||||
if st is not None:
|
if st is not None:
|
||||||
@@ -351,10 +365,12 @@ def _bulk_state(
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
existing = {
|
existing = {
|
||||||
s.item_id: s
|
s.item_id: s
|
||||||
for s in db.query(PlexState).filter(
|
for s in db.execute(
|
||||||
PlexState.user_id == user_id,
|
select(PlexState).where(
|
||||||
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
PlexState.user_id == user_id,
|
||||||
)
|
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
}
|
}
|
||||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||||
to_push: list[tuple[int, str, str]] = []
|
to_push: list[tuple[int, str, str]] = []
|
||||||
@@ -397,11 +413,13 @@ def show_state(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
|
eps = db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
|
||||||
|
).scalars().all()
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
return {"changed": changed, "watched": watched}
|
||||||
|
|
||||||
@@ -415,11 +433,13 @@ def season_state(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
|
||||||
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
|
se = by_rating_key(db, PlexSeason, rating_key)
|
||||||
if se is None:
|
if se is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
||||||
watched = body.watched
|
watched = body.watched
|
||||||
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
|
eps = db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode")
|
||||||
|
).scalars().all()
|
||||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
return {"changed": changed, "watched": watched}
|
return {"changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import QuotaEvent, User
|
from app.models import QuotaEvent, User
|
||||||
from app.routes.admin import admin_user
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
router = APIRouter(prefix="/api/quota", tags=["quota"])
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import audit, quota, state, sysconfig
|
from app import audit, quota, state, sysconfig
|
||||||
from app.audit import AuditAction
|
from app.audit import AuditAction
|
||||||
|
from app.auth import admin_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.routes.admin import admin_user
|
|
||||||
from app.scheduler import (
|
from app.scheduler import (
|
||||||
JOB_INTERVALS,
|
JOB_INTERVALS,
|
||||||
MAX_INTERVAL,
|
MAX_INTERVAL,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from app.models import (
|
|||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
from app.routes.feed import serialize, feed_columns
|
from app.feedquery import feed_columns, serialize
|
||||||
from app.sync.subscriptions import apply_channel_details
|
from app.sync.subscriptions import apply_channel_details
|
||||||
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
from app.sync.videos import insert_stubs, apply_video_details, parse_dt
|
||||||
from app.youtube import search_scrape
|
from app.youtube import search_scrape
|
||||||
@@ -268,7 +268,11 @@ def search_youtube(
|
|||||||
except (YouTubeError, search_scrape.ScrapeError) as exc:
|
except (YouTubeError, search_scrape.ScrapeError) as exc:
|
||||||
if collected:
|
if collected:
|
||||||
break # keep what we already gathered
|
break # keep what we already gathered
|
||||||
raise HTTPException(status_code=502, detail=f"YouTube search failed: {exc}")
|
# 422, not 502: this is a JSON endpoint, and the FE treats 502/503/504 as a gateway
|
||||||
|
# dropout (auto-retry + "connection lost" banner — lib/api/core RETRIABLE_GATEWAY),
|
||||||
|
# which hides the real message and burns a second quota-costed search. Upstream failure
|
||||||
|
# on a JSON endpoint = 422 + message; reserve 5xx for the raw byte-proxies. (B-core-1)
|
||||||
|
raise HTTPException(status_code=422, detail=f"YouTube search failed: {exc}") from exc
|
||||||
|
|
||||||
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
# Drop currently-live/upcoming, channel-less, and blocked-channel results.
|
||||||
candidates = [
|
candidates = [
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from datetime import timedelta
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, quota, sysconfig
|
from app import progress, quota
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Playlist, PlaylistItem, Video
|
from app.models import Playlist, PlaylistItem, Video
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
@@ -188,16 +188,16 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient) -> dict:
|
|||||||
now = _now()
|
now = _now()
|
||||||
# Process in videos.list-sized pages so we commit incrementally and stop on low quota.
|
# Process in videos.list-sized pages so we commit incrementally and stop on low quota.
|
||||||
while checked < batch:
|
while checked < batch:
|
||||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
if quota.backfill_reserve_reached():
|
||||||
break
|
break
|
||||||
page = (
|
page = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(Video)
|
select(Video)
|
||||||
.where(Video.unavailable_since.is_(None))
|
.where(Video.unavailable_since.is_(None))
|
||||||
.order_by(
|
# NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old
|
||||||
Video.last_checked_at.is_(None).desc(), # never-checked first
|
# `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the
|
||||||
Video.last_checked_at.asc(),
|
# partial ix_videos_recheck_order (migration 0069, PERF-18) so it rides that index.
|
||||||
)
|
.order_by(Video.last_checked_at.asc().nulls_first())
|
||||||
.limit(min(50, batch - checked))
|
.limit(min(50, batch - checked))
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -228,7 +228,7 @@ def run_maintenance(db: Session) -> dict:
|
|||||||
phase1 = _recheck_flagged(db, yt)
|
phase1 = _recheck_flagged(db, yt)
|
||||||
phase2 = (
|
phase2 = (
|
||||||
_revalidate_rolling(db, yt)
|
_revalidate_rolling(db, yt)
|
||||||
if quota.remaining_today(db) > sysconfig.get_int(db, "backfill_quota_reserve")
|
if not quota.backfill_reserve_reached()
|
||||||
else {"checked": 0, "flagged": 0, "skipped": "low quota"}
|
else {"checked": 0, "flagged": 0, "skipped": "low quota"}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
|||||||
if not vid or not cid or vid in seen:
|
if not vid or not cid or vid in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(vid)
|
seen.add(vid)
|
||||||
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")))
|
# published_at is NOT NULL (0068); apply_video_details re-fills a missing date, but seed a now()
|
||||||
|
# fallback here too so the constructor never carries an explicit NULL.
|
||||||
|
v = Video(id=vid, channel_id=cid, published_at=parse_dt(sn.get("publishedAt")) or now)
|
||||||
apply_video_details(v, it)
|
apply_video_details(v, it)
|
||||||
v.enriched_at = now
|
v.enriched_at = now
|
||||||
db.add(v)
|
db.add(v)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ user" (any user with a stored refresh token) unless a YOUTUBE_API_KEY is configu
|
|||||||
public reads.
|
public reads.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -13,6 +14,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app import progress, quota, sysconfig
|
from app import progress, quota, sysconfig
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.sync")
|
log = logging.getLogger("siftlode.sync")
|
||||||
|
from app.db import SessionLocal
|
||||||
from app.models import Channel, OAuthToken, Subscription, User
|
from app.models import Channel, OAuthToken, Subscription, User
|
||||||
from app.sync.subscriptions import import_subscriptions
|
from app.sync.subscriptions import import_subscriptions
|
||||||
from app.sync.videos import (
|
from app.sync.videos import (
|
||||||
@@ -181,7 +183,7 @@ def run_recent_backfill(
|
|||||||
videos_added = 0
|
videos_added = 0
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
if quota.backfill_reserve_reached():
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
videos_added += backfill_channel_recent(db, yt, channel)
|
videos_added += backfill_channel_recent(db, yt, channel)
|
||||||
@@ -227,8 +229,14 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
|||||||
videos_added = 0
|
videos_added = 0
|
||||||
with YouTubeClient(db, user) as yt:
|
with YouTubeClient(db, user) as yt:
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if quota.remaining_today(db) <= sysconfig.get_int(db, "backfill_quota_reserve"):
|
if quota.backfill_reserve_reached():
|
||||||
break
|
break
|
||||||
|
# Claim the channel (or skip it if an interactive backfill already owns it) so the two
|
||||||
|
# never advance the same cursor concurrently — symmetric with backfill_channel_now.
|
||||||
|
with _backfill_lock:
|
||||||
|
if channel.id in _backfill_in_progress:
|
||||||
|
continue
|
||||||
|
_backfill_in_progress.add(channel.id)
|
||||||
try:
|
try:
|
||||||
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
|
videos_added += backfill_channel_deep(db, yt, channel, max_pages)
|
||||||
processed += 1
|
processed += 1
|
||||||
@@ -236,6 +244,9 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
|||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
log.exception("Deep backfill failed for channel %s", channel.id)
|
log.exception("Deep backfill failed for channel %s", channel.id)
|
||||||
|
finally:
|
||||||
|
with _backfill_lock:
|
||||||
|
_backfill_in_progress.discard(channel.id)
|
||||||
reconciled = reconcile_full_history(db)
|
reconciled = reconcile_full_history(db)
|
||||||
return {
|
return {
|
||||||
"channels_processed": processed,
|
"channels_processed": processed,
|
||||||
@@ -244,6 +255,63 @@ def run_deep_backfill(db: Session, max_channels: int = 10, max_pages: int = 5) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Channels being deep-backfilled RIGHT NOW by EITHER path — the interactive backfill_channel_now (a
|
||||||
|
# FastAPI BackgroundTasks threadpool thread) OR the scheduler's run_deep_backfill (its own thread).
|
||||||
|
# BOTH register the channel they're paging (under the lock) so the other skips it: no two runs page
|
||||||
|
# the same channel's `backfill_cursor` concurrently, whichever started first. `_immediate_running`
|
||||||
|
# counts only the interactive path, capped so one user's burst can't drain the shared budget (the
|
||||||
|
# scheduler is already quota-paced). Single uvicorn process → one shared module state, lock suffices.
|
||||||
|
_backfill_in_progress: set[str] = set()
|
||||||
|
_immediate_running = 0
|
||||||
|
_backfill_lock = threading.Lock()
|
||||||
|
_MAX_CONCURRENT_IMMEDIATE = 2
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_channel_now(channel_id: str, user_id: int, max_pages: int = 400) -> None:
|
||||||
|
"""User-initiated "get full history": deep-backfill ONE channel to completion right now, in a
|
||||||
|
background task with its OWN session, so the full catalogue lands in seconds instead of over the
|
||||||
|
paced scheduler's 10-minute ticks (max_pages=5/channel/tick). max_pages is high enough to finish
|
||||||
|
any realistic channel in a single run (400 pages = 20 000 videos; e.g. BBC Earth's ~3400 = 69
|
||||||
|
pages — the old 60-page cap stopped one page short of large channels, so their tail dribbled in
|
||||||
|
over the scheduler's 10-minute ticks and looked stuck). backfill_channel_deep re-checks the shared
|
||||||
|
budget before each page and yields at the reserve, so a high cap still can't drain the day's quota.
|
||||||
|
Quota-guarded, capped to _MAX_CONCURRENT_IMMEDIATE interactive runs so one user can't drain the
|
||||||
|
shared backfill budget by opting many channels in at once (the overflow, and any channel too large
|
||||||
|
to finish under the reserve, is finished by the scheduler since deep_requested stays set), and
|
||||||
|
mutually excluded per-channel with the scheduler via _backfill_in_progress. Best-effort — logs and
|
||||||
|
swallows failures."""
|
||||||
|
global _immediate_running
|
||||||
|
with _backfill_lock:
|
||||||
|
if channel_id in _backfill_in_progress or _immediate_running >= _MAX_CONCURRENT_IMMEDIATE:
|
||||||
|
return # already backfilling this channel, or too many interactive runs — leave it to the scheduler
|
||||||
|
_backfill_in_progress.add(channel_id)
|
||||||
|
_immediate_running += 1
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
user = get_service_user(db)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
channel = db.get(Channel, channel_id)
|
||||||
|
if channel is None or channel.backfill_done or not channel.uploads_playlist_id:
|
||||||
|
return
|
||||||
|
if quota.backfill_reserve_reached():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Attribute the spend to the requesting user under the full-backfill action, so it
|
||||||
|
# shows up correctly in the usage breakdown (not the default OTHER/system bucket).
|
||||||
|
with quota.attribute(user_id, quota.QuotaAction.VIDEOS_BACKFILL_FULL):
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
backfill_channel_deep(db, yt, channel, max_pages)
|
||||||
|
reconcile_full_history(db)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
log.exception("Immediate backfill failed for channel %s", channel_id)
|
||||||
|
finally:
|
||||||
|
with _backfill_lock:
|
||||||
|
_backfill_in_progress.discard(channel_id)
|
||||||
|
_immediate_running -= 1
|
||||||
|
|
||||||
|
|
||||||
def estimate_deep_backfill(db: Session, channels: list[Channel]) -> dict:
|
def estimate_deep_backfill(db: Session, channels: list[Channel]) -> dict:
|
||||||
"""Rough ETA to finish full-history backfill of `channels`. Quota-bound: total
|
"""Rough ETA to finish full-history backfill of `channels`. Quota-bound: total
|
||||||
estimated API units / the daily budget left for backfill. Deliberately approximate —
|
estimated API units / the daily budget left for backfill. Deliberately approximate —
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy import and_, func, or_, select, update
|
|||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, sysconfig
|
from app import progress, quota, sysconfig
|
||||||
from app.models import LIVE_OR_UPCOMING, Channel, Video
|
from app.models import LIVE_OR_UPCOMING, Channel, Video
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
@@ -90,7 +90,10 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
|
|||||||
video_id = content.get("videoId")
|
video_id = content.get("videoId")
|
||||||
if not video_id:
|
if not video_id:
|
||||||
return None
|
return None
|
||||||
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt"))
|
# published_at is NOT NULL (migration 0068); fall back to now() for the rare item that carries no
|
||||||
|
# date at all, so the bulk stub insert never writes a NULL. The enrich pass replaces it if the
|
||||||
|
# video's own snippet later yields a real publish date.
|
||||||
|
published = parse_dt(content.get("videoPublishedAt") or snippet.get("publishedAt")) or _now()
|
||||||
return {
|
return {
|
||||||
"id": video_id,
|
"id": video_id,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
@@ -156,12 +159,21 @@ def backfill_channel_recent(db: Session, yt: YouTubeClient, channel: Channel) ->
|
|||||||
def backfill_channel_deep(
|
def backfill_channel_deep(
|
||||||
db: Session, yt: YouTubeClient, channel: Channel, max_pages: int = 5
|
db: Session, yt: YouTubeClient, channel: Channel, max_pages: int = 5
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Continue paging older uploads from the stored cursor (bounded per run)."""
|
"""Continue paging older uploads from the stored cursor (bounded per run).
|
||||||
|
|
||||||
|
Commits the advanced cursor after every page (not just at the end) so progress
|
||||||
|
is durable: a restart resumes from the last stored page instead of re-fetching —
|
||||||
|
and re-charging quota for — pages already ingested. Re-checks the shared budget
|
||||||
|
before each page and yields once it hits the backfill reserve, so a single large
|
||||||
|
channel can't drain the day's quota in one run even with a high max_pages; the
|
||||||
|
paced scheduler finishes the tail on later ticks (deep_requested stays set)."""
|
||||||
if channel.backfill_done or not channel.uploads_playlist_id:
|
if channel.backfill_done or not channel.uploads_playlist_id:
|
||||||
return 0
|
return 0
|
||||||
page_token = channel.backfill_cursor
|
page_token = channel.backfill_cursor
|
||||||
total = 0
|
total = 0
|
||||||
for _ in range(max_pages):
|
for _ in range(max_pages):
|
||||||
|
if quota.backfill_reserve_reached():
|
||||||
|
break
|
||||||
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
data = yt.get_playlist_items(channel.uploads_playlist_id, page_token)
|
||||||
rows = [
|
rows = [
|
||||||
s
|
s
|
||||||
@@ -173,11 +185,11 @@ def backfill_channel_deep(
|
|||||||
total += insert_stubs(db, rows)
|
total += insert_stubs(db, rows)
|
||||||
page_token = data.get("nextPageToken")
|
page_token = data.get("nextPageToken")
|
||||||
channel.backfill_cursor = page_token
|
channel.backfill_cursor = page_token
|
||||||
|
channel.backfill_done = not page_token
|
||||||
|
db.add(channel)
|
||||||
|
db.commit()
|
||||||
if not page_token:
|
if not page_token:
|
||||||
channel.backfill_done = True
|
|
||||||
break
|
break
|
||||||
db.add(channel)
|
|
||||||
db.commit()
|
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
@@ -234,7 +246,8 @@ def apply_video_details(video: Video, item: dict) -> None:
|
|||||||
video.title = normalize_title(raw_title)
|
video.title = normalize_title(raw_title)
|
||||||
video.description = snippet.get("description")
|
video.description = snippet.get("description")
|
||||||
if not video.published_at:
|
if not video.published_at:
|
||||||
video.published_at = parse_dt(snippet.get("publishedAt"))
|
# NOT NULL (0068): a fresh insert with no date yet falls back to now() (enrich normally has one).
|
||||||
|
video.published_at = parse_dt(snippet.get("publishedAt")) or _now()
|
||||||
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url
|
||||||
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
video.duration_seconds = parse_iso8601_duration(content.get("duration"))
|
||||||
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ bounds, and whether it's a secret. The resolver returns the DB override if one i
|
|||||||
stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be
|
stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be
|
||||||
stored in the DB and stay env-only).
|
stored in the DB and stay env-only).
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -15,6 +16,12 @@ from app import security
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import SystemConfig
|
from app.models import SystemConfig
|
||||||
|
|
||||||
|
log = logging.getLogger("siftlode.config")
|
||||||
|
|
||||||
|
# Config keys whose stored ciphertext failed to decrypt — tracked so the warning is logged once per
|
||||||
|
# key, not once per request (bounded by the number of secret config keys).
|
||||||
|
_decrypt_warned: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ConfigSpec:
|
class ConfigSpec:
|
||||||
@@ -86,6 +93,7 @@ SPECS: tuple[ConfigSpec, ...] = (
|
|||||||
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
|
ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True),
|
||||||
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
|
ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"),
|
||||||
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"),
|
||||||
|
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
|
||||||
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
|
||||||
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
|
||||||
# NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the
|
# NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the
|
||||||
@@ -117,11 +125,30 @@ def _coerce(s: ConfigSpec, raw: str):
|
|||||||
|
|
||||||
|
|
||||||
def _raw_override(db: Session, key: str) -> str | None:
|
def _raw_override(db: Session, key: str) -> str | None:
|
||||||
"""Stored (decrypted) override string for `key`, or None if no override is set."""
|
"""Stored (decrypted) override string for `key`, or None if no override is set.
|
||||||
|
|
||||||
|
A decrypt failure — a rotated or removed ``TOKEN_ENCRYPTION_KEY`` vs the key the value was written
|
||||||
|
with — must NOT crash the caller. `_raw_override` sits on hot paths (`/api/me` via `google_enabled`,
|
||||||
|
login, every YouTube scheduler job, account purge), so a raw ``InvalidToken`` here would 500 the
|
||||||
|
whole app and the SPA wouldn't even boot. Fall back to the env/config default instead, and warn once
|
||||||
|
per key so the operator gets a real signal without a per-request traceback. Recovery is documented in
|
||||||
|
docs/self-hosting.md: the key must never change once secrets are stored. (B-core-2)
|
||||||
|
"""
|
||||||
row = db.get(SystemConfig, key)
|
row = db.get(SystemConfig, key)
|
||||||
if row is None or row.value is None:
|
if row is None or row.value is None:
|
||||||
return None
|
return None
|
||||||
return security.decrypt(row.value) if row.encrypted else row.value
|
if not row.encrypted:
|
||||||
|
return row.value
|
||||||
|
try:
|
||||||
|
return security.decrypt(row.value)
|
||||||
|
except Exception: # noqa: BLE001 — Fernet InvalidToken / malformed ciphertext (key mismatch)
|
||||||
|
if key not in _decrypt_warned:
|
||||||
|
_decrypt_warned.add(key)
|
||||||
|
log.warning(
|
||||||
|
"config %r could not be decrypted (TOKEN_ENCRYPTION_KEY changed or missing?) — falling "
|
||||||
|
"back to the env/config default; the key must not change once secrets are stored", key,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get(db: Session, key: str):
|
def get(db: Session, key: str):
|
||||||
|
|||||||
@@ -23,3 +23,16 @@ def valid_email(value: str | None) -> bool:
|
|||||||
def now_utc() -> datetime:
|
def now_utc() -> datetime:
|
||||||
"""Current time as a timezone-aware UTC datetime."""
|
"""Current time as a timezone-aware UTC datetime."""
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def to_tsquery_str(q: str) -> str | None:
|
||||||
|
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
|
||||||
|
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
|
||||||
|
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
|
||||||
|
(the string is built from sanitised tokens, never the raw query). Shared by the YouTube feed
|
||||||
|
search and the Plex library search, hence this neutral home rather than either domain module."""
|
||||||
|
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
tokens[-1] = tokens[-1] + ":*"
|
||||||
|
return " & ".join(tokens)
|
||||||
|
|||||||
+37
-35
@@ -36,6 +36,7 @@ from app.db import SessionLocal
|
|||||||
from app.downloads import edit as editmod
|
from app.downloads import edit as editmod
|
||||||
from app.downloads import errors as dlerrors
|
from app.downloads import errors as dlerrors
|
||||||
from app.downloads import adapters, formats, quota, service, storage
|
from app.downloads import adapters, formats, quota, service, storage
|
||||||
|
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
|
||||||
from app.models import DownloadJob, MediaAsset
|
from app.models import DownloadJob, MediaAsset
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.titles import normalize_title
|
from app.titles import normalize_title
|
||||||
@@ -359,7 +360,7 @@ def _run_supervised(job_id: int, asset_id: int) -> None:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
status, stale = _job_liveness(job_id)
|
status, stale = _job_liveness(job_id)
|
||||||
if status is None or status in ("paused", "cancelled"):
|
if status is None or status in STOPPED_BY_ROUTE:
|
||||||
reason = "cancelled" # a route stopped it (or the row vanished)
|
reason = "cancelled" # a route stopped it (or the row vanished)
|
||||||
break
|
break
|
||||||
if status == "running":
|
if status == "running":
|
||||||
@@ -482,13 +483,14 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
|||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
asset = db.get(MediaAsset, asset_id)
|
asset = db.get(MediaAsset, asset_id)
|
||||||
if job and asset:
|
if job and asset:
|
||||||
job.status = "done"
|
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
|
||||||
job.progress = 100
|
if not job.display_name and asset.title:
|
||||||
job.phase = None
|
|
||||||
if not job.display_name:
|
|
||||||
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
|
||||||
job.display_name = asset.title[:255] if asset.title else None
|
fields["display_name"] = asset.title[:255]
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
# Atomic finalize: only marks the job done while it is still running, so a concurrent
|
||||||
|
# cancel/pause is never resurrected (see service.finalize_if_running).
|
||||||
|
service.finalize_if_running(db, job_id, **fields)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -868,20 +870,20 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
asset.expires_at = expires
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
if job:
|
|
||||||
job.status = "done"
|
|
||||||
job.progress = 100
|
|
||||||
job.phase = None
|
|
||||||
job.speed_bps = None
|
|
||||||
job.eta_s = None
|
|
||||||
if not job.display_name:
|
|
||||||
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
|
|
||||||
job.display_name = meta.title[:255] if meta.title else None
|
|
||||||
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
|
||||||
owner_id = job.user_id if job else None
|
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()
|
db.commit()
|
||||||
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
|
if finalized:
|
||||||
log.info("job %s done: %s", job_id, rel)
|
_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):
|
except (_Aborted, editmod.EditAborted):
|
||||||
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
|
||||||
@@ -895,12 +897,12 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
|
|||||||
if asset:
|
if asset:
|
||||||
asset.status = "error"
|
asset.status = "error"
|
||||||
asset.error = msg
|
asset.error = msg
|
||||||
job = db.get(DownloadJob, job_id)
|
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten
|
||||||
if job:
|
# with `error` (error also holds the asset → would re-open the double-release class).
|
||||||
job.status = "error"
|
service.finalize_if_running(
|
||||||
job.error = msg
|
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||||
job.error_code = dlerrors.classify(msg)
|
error_code=dlerrors.classify(msg), phase=None,
|
||||||
job.phase = None
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("job %s failed: %s", job_id, msg)
|
log.warning("job %s failed: %s", job_id, msg)
|
||||||
finally:
|
finally:
|
||||||
@@ -927,12 +929,11 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
|
|||||||
if asset:
|
if asset:
|
||||||
asset.status = "error"
|
asset.status = "error"
|
||||||
asset.error = msg
|
asset.error = msg
|
||||||
job = db.get(DownloadJob, job_id)
|
# Atomic fail: only while still running (see the download error path).
|
||||||
if job:
|
service.finalize_if_running(
|
||||||
job.status = "error"
|
db, job_id, status=JobStatus.ERROR, error=msg,
|
||||||
job.error = msg
|
error_code=dlerrors.classify(msg), phase=None,
|
||||||
job.error_code = dlerrors.classify(msg)
|
)
|
||||||
job.phase = None
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.warning("edit job %s failed: %s", job_id, msg)
|
log.warning("edit job %s failed: %s", job_id, msg)
|
||||||
|
|
||||||
@@ -1008,16 +1009,17 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
|||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
asset.expires_at = expires
|
asset.expires_at = expires
|
||||||
job = db.get(DownloadJob, job_id)
|
job = db.get(DownloadJob, job_id)
|
||||||
if job:
|
|
||||||
job.status = "done"
|
|
||||||
job.progress = 100
|
|
||||||
job.phase = None
|
|
||||||
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
|
||||||
owner_id = job.user_id if job else None
|
owner_id = job.user_id if job else None
|
||||||
dl_title = asset.title if asset 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()
|
db.commit()
|
||||||
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
|
if finalized:
|
||||||
log.info("edit job %s done: %s", job_id, rel)
|
_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:
|
except editmod.EditAborted:
|
||||||
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ class YouTubeClient:
|
|||||||
cached = decrypt_optional(tok.access_token)
|
cached = decrypt_optional(tok.access_token)
|
||||||
if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60):
|
if cached and tok.expiry and tok.expiry > now + timedelta(seconds=60):
|
||||||
return cached
|
return cached
|
||||||
refresh = decrypt(tok.refresh_token_enc)
|
try:
|
||||||
|
refresh = decrypt(tok.refresh_token_enc)
|
||||||
|
except Exception as exc: # noqa: BLE001 — Fernet InvalidToken (TOKEN_ENCRYPTION_KEY changed)
|
||||||
|
# A key mismatch must degrade like any other token problem (caller handles YouTubeError),
|
||||||
|
# not surface as a raw 500 on every scheduler job / connect check. (B-core-2)
|
||||||
|
raise YouTubeError("Stored OAuth token is unreadable — reconnect your account") from exc
|
||||||
if not refresh:
|
if not refresh:
|
||||||
raise YouTubeError("No refresh token; user must re-authenticate")
|
raise YouTubeError("No refresh token; user must re-authenticate")
|
||||||
resp = self._send(
|
resp = self._send(
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@testdb:5432/siftlode_test
|
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@testdb:5432/siftlode_test
|
||||||
|
# This lane HAS a Postgres + ffmpeg, so the DB/ffmpeg tests must actually run here: conftest turns
|
||||||
|
# a would-be silent skip into a hard failure (SIFTLODE_TEST_REQUIRE_DB).
|
||||||
|
SIFTLODE_TEST_REQUIRE_DB: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
|
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
|
||||||
# import it as `app.*`, matching runtime. The suite is pure-logic only (no DB/network) — see tests/.
|
# import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
|
||||||
|
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
|
||||||
|
# skipped lane is visible (the compose lane sets SIFTLODE_TEST_REQUIRE_DB=1 to make skips a hard error).
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
pythonpath = .
|
pythonpath = .
|
||||||
addopts = -q
|
addopts = -q -rs
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Pinned dependency lock for reproducible builds (SEC-5). GENERATED — do not hand-edit.
|
||||||
|
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
|
||||||
|
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
|
||||||
|
# The prod Dockerfile + Dockerfile.test + CI install THIS file; pip-audit scans it in CI.
|
||||||
|
APScheduler==3.11.3
|
||||||
|
Authlib==1.7.2
|
||||||
|
Mako==1.4.1
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
PyYAML==6.0.3
|
||||||
|
SQLAlchemy==2.0.52
|
||||||
|
alembic==1.19.1
|
||||||
|
annotated-doc==0.0.5
|
||||||
|
annotated-types==0.8.0
|
||||||
|
anyio==4.14.2
|
||||||
|
argon2-cffi-bindings==25.1.0
|
||||||
|
argon2-cffi==25.1.0
|
||||||
|
bgutil-ytdlp-pot-provider==1.3.1
|
||||||
|
brotli==1.2.0
|
||||||
|
certifi==2026.7.22
|
||||||
|
cffi==2.1.1
|
||||||
|
charset-normalizer==3.5.1
|
||||||
|
click==8.4.2
|
||||||
|
cryptography==50.0.0
|
||||||
|
fastapi==0.141.1
|
||||||
|
feedparser-sgmllib==2.1.0
|
||||||
|
feedparser==6.0.14
|
||||||
|
greenlet==3.5.5
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.8.0
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.18
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
joserfc==1.7.4
|
||||||
|
mutagen==1.48.1
|
||||||
|
numpy==2.5.2
|
||||||
|
psycopg-binary==3.3.4
|
||||||
|
psycopg==3.3.4
|
||||||
|
py3langid==0.3.0
|
||||||
|
pycparser==3.0
|
||||||
|
pycryptodomex==3.23.0
|
||||||
|
pydantic-settings==2.15.0
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
python-dotenv==1.2.3
|
||||||
|
requests==2.34.2
|
||||||
|
starlette==1.6.0
|
||||||
|
typing-inspection==0.4.4
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
tzlocal==5.4.4
|
||||||
|
urllib3==2.7.0
|
||||||
|
uvicorn==0.52.3
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.2.0
|
||||||
|
websockets==17.0.1
|
||||||
|
yt-dlp-ejs==0.8.0
|
||||||
|
yt-dlp==2026.7.4
|
||||||
@@ -11,7 +11,7 @@ feedparser>=6.0,<7.0
|
|||||||
apscheduler>=3.10,<4.0
|
apscheduler>=3.10,<4.0
|
||||||
py3langid>=0.3,<1.0
|
py3langid>=0.3,<1.0
|
||||||
itsdangerous>=2.1,<3.0
|
itsdangerous>=2.1,<3.0
|
||||||
cryptography>=46.0.7,<47
|
cryptography>=50.0.0,<51
|
||||||
argon2-cffi>=23.1,<26
|
argon2-cffi>=23.1,<26
|
||||||
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
# Download center: yt-dlp does the extraction/download; ffmpeg (OS-level in the Dockerfile)
|
||||||
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
|
# does the merge + postprocessing. The [default] extra pulls yt-dlp-ejs (the challenge-solver
|
||||||
|
|||||||
+119
-1
@@ -12,14 +12,31 @@ config the videos.search_vector generated column needs). Building it via migrati
|
|||||||
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so
|
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so
|
||||||
tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
|
tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
import app.models # noqa: F401 — register every model on Base.metadata
|
import app.models # noqa: F401 — register every model on Base.metadata
|
||||||
|
import app.state as app_state
|
||||||
|
from app import security
|
||||||
|
from app.auth import current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import Base, SessionLocal, engine
|
from app.db import Base, SessionLocal, engine, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
# Sentinel for "this dependency had no override before the fixture set one" (see override_dep).
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
# The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error
|
||||||
|
# — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run.
|
||||||
|
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1"
|
||||||
|
|
||||||
|
|
||||||
def _db_reachable() -> bool:
|
def _db_reachable() -> bool:
|
||||||
@@ -59,6 +76,23 @@ def _truncate_all():
|
|||||||
conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
|
conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Fail the whole run — loudly, once — when SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't
|
||||||
|
actually run the full suite. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
|
||||||
|
~60 DB tests + the poster tests into silent skips and the gate still exits green."""
|
||||||
|
if not _REQUIRE_FULL:
|
||||||
|
return
|
||||||
|
if not _DB_REACHABLE:
|
||||||
|
raise pytest.UsageError(
|
||||||
|
"SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the "
|
||||||
|
"DB-backed lane would silently skip. Fix the DB wiring or unset the flag."
|
||||||
|
)
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
raise pytest.UsageError(
|
||||||
|
"SIFTLODE_TEST_REQUIRE_DB=1 but ffmpeg is missing — the thumbnail/poster tests would skip."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db(_schema):
|
def db(_schema):
|
||||||
"""A real session against the test Postgres, on a clean (empty) database. The code under test
|
"""A real session against the test Postgres, on a clean (empty) database. The code under test
|
||||||
@@ -70,3 +104,87 @@ def db(_schema):
|
|||||||
finally:
|
finally:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_user(db):
|
||||||
|
"""Factory: create + commit a User in the test DB. Defaults produce a login-ready account (email
|
||||||
|
verified, active, with a known password); override any of it via kwargs (e.g. `role="admin"`,
|
||||||
|
`is_suspended=True`, `password=None` for a Google-only account). Returns the User."""
|
||||||
|
seq = {"n": 0}
|
||||||
|
|
||||||
|
def _make(email: str | None = None, *, password: str | None = "correct horse battery", **kw):
|
||||||
|
seq["n"] += 1
|
||||||
|
kw.setdefault("email_verified", True)
|
||||||
|
user = User(
|
||||||
|
email=email or f"user{seq['n']}@example.com",
|
||||||
|
password_hash=security.hash_password(password) if password else None,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def override_dep():
|
||||||
|
"""Set app.dependency_overrides for the duration of a test and restore the PRIOR value per key on
|
||||||
|
teardown — instead of a blanket `app.dependency_overrides.clear()`, which wipes every override and
|
||||||
|
stops fixtures composing (one fixture's teardown clobbering another's)."""
|
||||||
|
saved: dict = {}
|
||||||
|
|
||||||
|
def _set(dep, override):
|
||||||
|
if dep not in saved: # remember the pre-fixture value the FIRST time we touch this key
|
||||||
|
saved[dep] = app.dependency_overrides.get(dep, _MISSING)
|
||||||
|
app.dependency_overrides[dep] = override
|
||||||
|
|
||||||
|
yield _set
|
||||||
|
for dep, prev in saved.items():
|
||||||
|
if prev is _MISSING:
|
||||||
|
app.dependency_overrides.pop(dep, None)
|
||||||
|
else:
|
||||||
|
app.dependency_overrides[dep] = prev
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _configured(monkeypatch):
|
||||||
|
"""The setup gate 503s /auth and /api until the wizard has run; the request-issuing fixtures below
|
||||||
|
are all post-setup. Baked into client/as_user so no individual test has to remember it."""
|
||||||
|
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(db, override_dep, _configured):
|
||||||
|
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
|
||||||
|
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
|
||||||
|
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
|
||||||
|
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
|
||||||
|
override_dep(get_db, lambda: db)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_user(db, override_dep, _configured):
|
||||||
|
"""Factory -> a TestClient authenticated as a given User (bypassing login), for tests that need an
|
||||||
|
authed request but aren't exercising the login flow itself. Multiple users in ONE test are isolated:
|
||||||
|
the shared `current_user` override resolves the caller from an `x-test-user` header, and each
|
||||||
|
returned client tags its own requests with it — so `as_user(a)` and `as_user(b)` don't collide on
|
||||||
|
the process-global override the way a plain `lambda: user` would."""
|
||||||
|
registry: dict[int, User] = {}
|
||||||
|
|
||||||
|
def _resolver(request: Request) -> User:
|
||||||
|
raw = request.headers.get("x-test-user")
|
||||||
|
if raw is None or (uid := int(raw)) not in registry:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
return registry[uid]
|
||||||
|
|
||||||
|
override_dep(get_db, lambda: db)
|
||||||
|
override_dep(current_user, _resolver)
|
||||||
|
|
||||||
|
def _as(user: User) -> TestClient:
|
||||||
|
registry[user.id] = user
|
||||||
|
return TestClient(app, headers={"x-test-user": str(user.id)})
|
||||||
|
|
||||||
|
return _as
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Auth / session security core (TEST-1), DB-lane.
|
||||||
|
|
||||||
|
The wallet impersonation gate, epoch-based session revocation, single-use tokens and the
|
||||||
|
enumeration-safe uniform 401 are the security mag, and had zero tests (only `_oauth_redirect_uri` was
|
||||||
|
covered). Starlette's SessionMiddleware round-trips the signed cookie under TestClient, so the whole
|
||||||
|
browser-less flow is exercisable. Uses the real auth stack via the `client` fixture (NO current_user
|
||||||
|
override) so login, the wallet gate and epoch revoke are the code under test.
|
||||||
|
|
||||||
|
Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False}` when logged out, not a
|
||||||
|
401. A revoked session is therefore checked on a PROTECTED endpoint (`/api/feed`), which does 401.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.auth import ACTIVE_ACCOUNT_HEADER
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
_PW = "correct horse battery"
|
||||||
|
|
||||||
|
# The `client`/`as_user` fixtures bake in the post-setup `_configured` state, so no per-test patch here.
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, email, password):
|
||||||
|
return client.post("/auth/password-login", json={"email": email, "password": password})
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_login_then_me_is_authenticated(client, make_user):
|
||||||
|
make_user(email="alice@example.com", password=_PW)
|
||||||
|
assert _login(client, "alice@example.com", _PW).status_code == 200
|
||||||
|
me = client.get("/api/me")
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["authenticated"] is True
|
||||||
|
assert me.json()["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_and_unknown_email_give_a_byte_identical_401(client, make_user):
|
||||||
|
make_user(email="bob@example.com", password=_PW)
|
||||||
|
wrong_pw = _login(client, "bob@example.com", "not the password")
|
||||||
|
unknown = _login(client, "nobody@example.com", _PW)
|
||||||
|
assert wrong_pw.status_code == 401 and unknown.status_code == 401
|
||||||
|
# Identical body: a wrong password and a non-existent account must be indistinguishable (no
|
||||||
|
# account-enumeration oracle).
|
||||||
|
assert wrong_pw.json() == unknown.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_non_wallet_account_header_falls_back_to_the_default(client, make_user):
|
||||||
|
# The X-Siftlode-Account header may only re-target an account already signed into THIS browser
|
||||||
|
# (in the session wallet). A header naming someone else must be ignored, not honored — otherwise
|
||||||
|
# it's an impersonation gate hole.
|
||||||
|
make_user(email="alice2@example.com", password=_PW)
|
||||||
|
mallory = make_user(email="mallory@example.com", password=_PW)
|
||||||
|
assert _login(client, "alice2@example.com", _PW).status_code == 200
|
||||||
|
me = client.get("/api/me", headers={ACTIVE_ACCOUNT_HEADER: str(mallory.id)})
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["email"] == "alice2@example.com" # still Alice, not Mallory
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
|
||||||
|
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
|
||||||
|
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
|
||||||
|
# is re-stamped and stays in. Checked on /api/feed (a protected endpoint that 401s on revocation).
|
||||||
|
make_user(email="carol@example.com", password=_PW)
|
||||||
|
# A second, independent cookie jar; the `client` fixture already routed get_db to the test DB on
|
||||||
|
# the shared app, so this instance uses it too — it just has its own session cookie.
|
||||||
|
other = TestClient(app)
|
||||||
|
assert _login(client, "carol@example.com", _PW).status_code == 200
|
||||||
|
assert _login(other, "carol@example.com", _PW).status_code == 200
|
||||||
|
assert other.get("/api/feed").status_code == 200 # both valid to start
|
||||||
|
|
||||||
|
assert client.post("/auth/logout-others").status_code == 200
|
||||||
|
assert other.get("/api/feed").status_code == 401 # its epoch is now stale
|
||||||
|
assert client.get("/api/feed").status_code == 200 # the caller stays signed in
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_user, db):
|
||||||
|
from app.routes.auth import _issue_token
|
||||||
|
|
||||||
|
user = make_user(email="dave@example.com", password=_PW)
|
||||||
|
assert _login(client, "dave@example.com", _PW).status_code == 200
|
||||||
|
assert client.get("/api/feed").status_code == 200 # session valid at the pre-reset epoch
|
||||||
|
|
||||||
|
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||||
|
db.commit()
|
||||||
|
ok = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "brand new pw!"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
|
||||||
|
# A reset bumps the epoch (compromise response) -> the pre-reset cookie is now rejected.
|
||||||
|
assert client.get("/api/feed").status_code == 401
|
||||||
|
# The token is single-use: a replay is a clean 400, not a second reset.
|
||||||
|
replay = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "another pw!!"})
|
||||||
|
assert replay.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
|
||||||
|
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
|
||||||
|
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
|
||||||
|
# password a working login — it is dropped and any session it minted is revoked.
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
|
||||||
|
victim = make_user(
|
||||||
|
email="victim@example.com", password="attacker-chosen", email_verified=False, is_active=False
|
||||||
|
)
|
||||||
|
epoch0 = victim.session_epoch or 0
|
||||||
|
_activate_user_by_email(db, "victim@example.com")
|
||||||
|
assert victim.is_active is True
|
||||||
|
assert victim.password_hash is None
|
||||||
|
assert (victim.session_epoch or 0) == epoch0 + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
||||||
|
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
|
||||||
|
# approval — the guard must not break the normal flow.
|
||||||
|
from app.routes.admin import _activate_user_by_email
|
||||||
|
|
||||||
|
user = make_user(
|
||||||
|
email="legit@example.com", password="my own password", email_verified=True, is_active=False
|
||||||
|
)
|
||||||
|
_activate_user_by_email(db, "legit@example.com")
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.password_hash is not None
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
|
||||||
|
the update_channel -> immediate-backfill wiring (incl. the demo guard), and the interactive
|
||||||
|
"get full history" guard that keeps it from racing the scheduler's cursor or draining the budget."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.routes.channels as channels_route
|
||||||
|
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 Channel, Subscription, User, Video
|
||||||
|
from app.sync import runner, videos
|
||||||
|
|
||||||
|
_PUB = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||||||
|
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
|
||||||
|
ch = Channel(id="UC_nsl", title="N/S/L Channel", published_at=_PUB)
|
||||||
|
db.add(ch)
|
||||||
|
me = User(email="nsl@example.com")
|
||||||
|
db.add(me)
|
||||||
|
db.flush()
|
||||||
|
# DISTINCT counts (2 shorts, 1 live) so a short<->live filter/field swap can't pass:
|
||||||
|
# stored 5, short 2, live 1, normal = 5 - 2 - 1 = 2.
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Video(id="v_norm1", channel_id=ch.id),
|
||||||
|
Video(id="v_norm2", channel_id=ch.id),
|
||||||
|
Video(id="v_short1", channel_id=ch.id, is_short=True),
|
||||||
|
Video(id="v_short2", channel_id=ch.id, is_short=True),
|
||||||
|
Video(id="v_live1", channel_id=ch.id, live_status="live"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: me
|
||||||
|
try:
|
||||||
|
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["stored_videos"] == 5
|
||||||
|
assert body["count_short"] == 2
|
||||||
|
assert body["count_live"] == 1
|
||||||
|
assert body["count_normal"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_channel_and_sub(db, *, email, is_demo=False):
|
||||||
|
ch = Channel(
|
||||||
|
id="UC_wire",
|
||||||
|
title="Wire",
|
||||||
|
published_at=_PUB,
|
||||||
|
recent_synced_at=_PUB, # so update_channel skips the in-request recent backfill (no network)
|
||||||
|
uploads_playlist_id="UU_wire",
|
||||||
|
)
|
||||||
|
db.add(ch)
|
||||||
|
user = User(email=email, is_demo=is_demo)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.add(Subscription(user_id=user.id, channel_id=ch.id, deep_requested=False))
|
||||||
|
db.commit()
|
||||||
|
return ch, user
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_deep_requested(db, monkeypatch, user):
|
||||||
|
calls: list[tuple] = []
|
||||||
|
# background.add_task runs after the response in TestClient; record what would be scheduled.
|
||||||
|
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True)
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
try:
|
||||||
|
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
return resp, calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_request_schedules_immediate_backfill_for_non_demo(db, monkeypatch):
|
||||||
|
ch, me = _setup_channel_and_sub(db, email="wire@example.com")
|
||||||
|
resp, calls = _patch_deep_requested(db, monkeypatch, me)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["deep_requested"] is True
|
||||||
|
# scheduled exactly once, with (channel_id, user_id) — the args backfill_channel_now needs.
|
||||||
|
assert calls == [(ch.id, me.id)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_request_does_not_schedule_immediate_backfill_for_demo(db, monkeypatch):
|
||||||
|
# The SHARED demo account must not be able to kick immediate full backfills (budget drain).
|
||||||
|
_ch, demo = _setup_channel_and_sub(db, email="demo@example.com", is_demo=True)
|
||||||
|
resp, calls = _patch_deep_requested(db, monkeypatch, demo)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert calls == [], "demo must not schedule an immediate backfill"
|
||||||
|
# …and deep_requested is not persisted on the shared demo subscription either.
|
||||||
|
assert resp.json()["deep_requested"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestImmediateBackfillGuard:
|
||||||
|
"""backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per-
|
||||||
|
channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a
|
||||||
|
burst can't drain the shared budget)."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
runner._backfill_in_progress.clear()
|
||||||
|
runner._immediate_running = 0
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
runner._backfill_in_progress.clear()
|
||||||
|
runner._immediate_running = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _boom_session():
|
||||||
|
# Reached only AFTER the guard admits the channel; if the guard short-circuits (as it must
|
||||||
|
# in these tests) this never runs, so raising proves the short-circuit.
|
||||||
|
raise AssertionError("guard must short-circuit before opening a session")
|
||||||
|
|
||||||
|
def test_a_second_call_for_an_in_flight_channel_is_a_noop(self, monkeypatch):
|
||||||
|
runner._backfill_in_progress.add("UC_busy")
|
||||||
|
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
|
||||||
|
runner.backfill_channel_now("UC_busy", 1) # returns immediately; _boom_session never runs
|
||||||
|
assert runner._backfill_in_progress == {"UC_busy"} # the in-flight entry is untouched
|
||||||
|
|
||||||
|
def test_over_the_concurrency_cap_falls_back_to_the_scheduler(self, monkeypatch):
|
||||||
|
runner._immediate_running = runner._MAX_CONCURRENT_IMMEDIATE
|
||||||
|
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
|
||||||
|
runner.backfill_channel_now("UC_new", 1) # over the cap → no-op, scheduler will handle it
|
||||||
|
assert "UC_new" not in runner._backfill_in_progress
|
||||||
|
|
||||||
|
def test_scheduler_deep_pass_skips_an_in_flight_channel(self, db, monkeypatch):
|
||||||
|
# A channel the scheduler's query WOULD pick (deep-requested, recent-synced, not done)…
|
||||||
|
ch = Channel(
|
||||||
|
id="UC_inflight",
|
||||||
|
title="In-flight",
|
||||||
|
recent_synced_at=_PUB,
|
||||||
|
uploads_playlist_id="UU_inflight",
|
||||||
|
backfill_done=False,
|
||||||
|
)
|
||||||
|
db.add(ch)
|
||||||
|
me = User(email="svc@example.com")
|
||||||
|
db.add(me)
|
||||||
|
db.flush()
|
||||||
|
db.add(Subscription(user_id=me.id, channel_id=ch.id, deep_requested=True))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
paged: list[str] = []
|
||||||
|
monkeypatch.setattr(runner, "get_service_user", lambda _db: me)
|
||||||
|
monkeypatch.setattr(runner.quota, "remaining_today", lambda _db: 10_000)
|
||||||
|
monkeypatch.setattr(runner, "YouTubeClient", lambda *a, **k: _NullYt())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
runner, "backfill_channel_deep", lambda _db, _yt, channel, _mp: paged.append(channel.id) or 0
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(runner, "reconcile_full_history", lambda _db: 0)
|
||||||
|
|
||||||
|
# …is skipped by run_deep_backfill while an interactive backfill owns it (no cursor race).
|
||||||
|
runner._backfill_in_progress.add(ch.id)
|
||||||
|
runner.run_deep_backfill(db)
|
||||||
|
assert paged == [], "the scheduler must not page a channel an immediate backfill is handling"
|
||||||
|
|
||||||
|
# …and IS paged once the interactive backfill releases it.
|
||||||
|
runner._backfill_in_progress.discard(ch.id)
|
||||||
|
runner.run_deep_backfill(db)
|
||||||
|
assert paged == [ch.id]
|
||||||
|
|
||||||
|
|
||||||
|
class _NullYt:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _playlist_page(ids, next_token=None):
|
||||||
|
"""A playlistItems.list response shaped for stub_from_playlist_item."""
|
||||||
|
page = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"contentDetails": {"videoId": vid, "videoPublishedAt": "2020-01-01T00:00:00Z"},
|
||||||
|
"snippet": {"title": vid},
|
||||||
|
}
|
||||||
|
for vid in ids
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if next_token is not None:
|
||||||
|
page["nextPageToken"] = next_token
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
class _PagedYt:
|
||||||
|
"""Serves a fixed page-token -> page map and records the tokens it was asked for, so a test can
|
||||||
|
assert exactly how many pages a deep-backfill run fetched (and where it resumed from)."""
|
||||||
|
|
||||||
|
def __init__(self, pages):
|
||||||
|
self.pages = pages
|
||||||
|
self.calls: list[str | None] = []
|
||||||
|
|
||||||
|
def get_playlist_items(self, playlist_id, page_token=None):
|
||||||
|
self.calls.append(page_token)
|
||||||
|
return self.pages[page_token]
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingYt:
|
||||||
|
"""Serves pages like _PagedYt but raises when asked for `raise_on_token`, to simulate a
|
||||||
|
network/API failure that lands BETWEEN pages of a deep backfill."""
|
||||||
|
|
||||||
|
def __init__(self, pages, raise_on_token):
|
||||||
|
self.pages = pages
|
||||||
|
self.raise_on_token = raise_on_token
|
||||||
|
self.calls: list[str | None] = []
|
||||||
|
|
||||||
|
def get_playlist_items(self, playlist_id, page_token=None):
|
||||||
|
self.calls.append(page_token)
|
||||||
|
if page_token == self.raise_on_token:
|
||||||
|
raise RuntimeError("simulated mid-backfill failure")
|
||||||
|
return self.pages[page_token]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeepBackfillPaging:
|
||||||
|
"""backfill_channel_deep must commit the advanced cursor after every page (durable resume, no
|
||||||
|
re-charging quota for pages already stored) and yield the moment the shared budget hits the
|
||||||
|
backfill reserve — the two properties that make a high immediate max_pages safe."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel(db):
|
||||||
|
ch = Channel(
|
||||||
|
id="UC_deep",
|
||||||
|
title="Deep",
|
||||||
|
recent_synced_at=_PUB,
|
||||||
|
uploads_playlist_id="UU_deep",
|
||||||
|
backfill_done=False,
|
||||||
|
backfill_cursor=None,
|
||||||
|
)
|
||||||
|
db.add(ch)
|
||||||
|
db.commit()
|
||||||
|
return ch
|
||||||
|
|
||||||
|
def test_cursor_is_committed_each_page_and_a_later_run_resumes(self, db, monkeypatch):
|
||||||
|
monkeypatch.setattr(videos.quota, "backfill_reserve_reached", lambda: False) # budget ok
|
||||||
|
ch = self._channel(db)
|
||||||
|
yt = _PagedYt(
|
||||||
|
{
|
||||||
|
None: _playlist_page(["a1", "a2"], next_token="t1"),
|
||||||
|
"t1": _playlist_page(["b1", "b2"], next_token="t2"),
|
||||||
|
"t2": _playlist_page(["c1"], next_token=None), # last page: no nextPageToken
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# First run is bounded to 2 pages: it must stop mid-catalogue with the cursor durably parked
|
||||||
|
# on the next unfetched page and backfill_done still False.
|
||||||
|
added = videos.backfill_channel_deep(db, yt, ch, max_pages=2)
|
||||||
|
assert added == 4
|
||||||
|
assert yt.calls == [None, "t1"]
|
||||||
|
assert ch.backfill_cursor == "t2"
|
||||||
|
assert ch.backfill_done is False
|
||||||
|
# Durably persisted (committed per page), not just set on the in-memory object.
|
||||||
|
db.expire(ch)
|
||||||
|
assert ch.backfill_cursor == "t2"
|
||||||
|
assert db.query(Video).filter(Video.channel_id == ch.id).count() == 4
|
||||||
|
|
||||||
|
# A later run resumes FROM the stored cursor (not from the start — no wasted re-fetch) and
|
||||||
|
# finishes: reaching the token-less last page flips backfill_done and clears the cursor.
|
||||||
|
added2 = videos.backfill_channel_deep(db, yt, ch, max_pages=5)
|
||||||
|
assert added2 == 1
|
||||||
|
assert yt.calls == [None, "t1", "t2"] # resumed at "t2", did not re-fetch None/"t1"
|
||||||
|
assert ch.backfill_done is True
|
||||||
|
assert ch.backfill_cursor is None
|
||||||
|
|
||||||
|
def test_yields_when_the_shared_budget_hits_the_reserve(self, db, monkeypatch):
|
||||||
|
# Budget is fine for the first page, then hits the reserve — the guard is checked BEFORE each
|
||||||
|
# page, so the run fetches exactly one page and then yields, leaving the tail for the paced
|
||||||
|
# scheduler (cursor parked, backfill_done False).
|
||||||
|
reached = iter([False, True])
|
||||||
|
monkeypatch.setattr(videos.quota, "backfill_reserve_reached", lambda: next(reached))
|
||||||
|
ch = self._channel(db)
|
||||||
|
yt = _PagedYt(
|
||||||
|
{
|
||||||
|
None: _playlist_page(["a1", "a2"], next_token="t1"),
|
||||||
|
"t1": _playlist_page(["b1"], next_token="t2"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
added = videos.backfill_channel_deep(db, yt, ch, max_pages=50)
|
||||||
|
assert added == 2
|
||||||
|
assert yt.calls == [None] # stopped before fetching the second page
|
||||||
|
assert ch.backfill_cursor == "t1"
|
||||||
|
assert ch.backfill_done is False
|
||||||
|
|
||||||
|
def test_a_mid_run_failure_leaves_the_prior_page_durably_committed(self, db, monkeypatch):
|
||||||
|
# The whole point of committing per page (vs one commit after the loop): if paging blows up
|
||||||
|
# mid-run, the pages already ingested stay durable, so the next run resumes from the stored
|
||||||
|
# cursor instead of re-fetching — and re-charging quota for — them. A single end-of-loop
|
||||||
|
# commit would lose the advanced cursor on the crash (it never reaches the post-loop commit),
|
||||||
|
# so this asserts the property the per-page commit actually buys.
|
||||||
|
monkeypatch.setattr(videos.quota, "backfill_reserve_reached", lambda: False) # budget ok
|
||||||
|
ch = self._channel(db)
|
||||||
|
yt = _RaisingYt(
|
||||||
|
{None: _playlist_page(["a1", "a2"], next_token="t1")},
|
||||||
|
raise_on_token="t1", # page 1 succeeds; fetching page 2 (token 't1') fails
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
videos.backfill_channel_deep(db, yt, ch, max_pages=5)
|
||||||
|
|
||||||
|
# Page 1's cursor AND rows were committed before the page-2 crash — re-read from the DB
|
||||||
|
# (expire) to prove it's persisted, not just set on the in-memory object.
|
||||||
|
db.expire(ch)
|
||||||
|
assert ch.backfill_cursor == "t1"
|
||||||
|
assert ch.backfill_done is False
|
||||||
|
assert db.query(Video).filter(Video.channel_id == ch.id).count() == 2
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""DB-backed regression tests for `service.finalize_if_running` (B-sub-1).
|
||||||
|
|
||||||
|
The worker's terminal writes go through this helper so a child that finishes just after a route
|
||||||
|
cancel/pause can NEVER flip the job back to done/error — which would re-open the double-release ->
|
||||||
|
file-delete-under-live-holder class (done/error hold the asset, canceled does not). The guard is an
|
||||||
|
atomic `UPDATE ... WHERE status='running'`, so these tests assert it fires exactly when the job is
|
||||||
|
still running and is a no-op otherwise. Uses the real-Postgres `db` fixture; skipped without a DB.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.downloads import service
|
||||||
|
from app.downloads.status import JobStatus
|
||||||
|
from app.models import DownloadJob, User
|
||||||
|
|
||||||
|
|
||||||
|
def _running_job(db) -> DownloadJob:
|
||||||
|
user = User(email="dl@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
job = DownloadJob(
|
||||||
|
user_id=user.id,
|
||||||
|
source_kind="youtube",
|
||||||
|
source_ref="x" * 11,
|
||||||
|
status=JobStatus.RUNNING.value,
|
||||||
|
queue_pos=1,
|
||||||
|
profile_snapshot={},
|
||||||
|
)
|
||||||
|
db.add(job)
|
||||||
|
db.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _row(db, job_id):
|
||||||
|
# Read straight from the DB: SessionLocal uses expire_on_commit=False, so the ORM identity-map
|
||||||
|
# copy is stale after finalize_if_running's raw UPDATE + commit.
|
||||||
|
return db.execute(
|
||||||
|
text("SELECT status, progress FROM download_jobs WHERE id = :id"), {"id": job_id}
|
||||||
|
).one()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_marks_a_running_job_done_and_reports_it(db):
|
||||||
|
job = _running_job(db)
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100, phase=None)
|
||||||
|
db.commit()
|
||||||
|
assert won is True
|
||||||
|
status, progress = _row(db, job.id)
|
||||||
|
assert status == "done"
|
||||||
|
assert progress == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_a_noop_on_a_canceled_job(db):
|
||||||
|
# The exact B-sub-1 scenario: a route already canceled the job (released its asset once), and a
|
||||||
|
# finishing child then tries to write `done`. The guard must NOT resurrect it, or a later delete
|
||||||
|
# would double-release the asset.
|
||||||
|
job = _running_job(db)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE download_jobs SET status = 'canceled' WHERE id = :id"), {"id": job.id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.DONE, progress=100)
|
||||||
|
db.commit()
|
||||||
|
assert won is False
|
||||||
|
assert _row(db, job.id).status == "canceled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_a_noop_on_a_paused_job(db):
|
||||||
|
job = _running_job(db)
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE download_jobs SET status = 'paused' WHERE id = :id"), {"id": job.id}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
won = service.finalize_if_running(db, job.id, status=JobStatus.ERROR, error="boom", phase=None)
|
||||||
|
db.commit()
|
||||||
|
assert won is False
|
||||||
|
assert _row(db, job.id).status == "paused"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Feed cursor endpoint: a malformed ?cursor= must be a clean HTTP 400, not a 500.
|
||||||
|
|
||||||
|
Locks the refactor that split cursor handling across modules — feedquery.decode_cursor raises a
|
||||||
|
plain ValueError and routes/feed.get_feed translates it to 400 — so a future regression (a dropped
|
||||||
|
try/except, or a new decode_cursor caller without its own catch) turns the 400 back into a 500 and
|
||||||
|
fails right here. 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 _get(db, user, monkeypatch, path):
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||||
|
return client.get(path)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_feed_cursor_returns_400(db, monkeypatch):
|
||||||
|
user = User(email="feedcursor@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "Invalid feed cursor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_without_cursor_is_200(db, monkeypatch):
|
||||||
|
# Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the
|
||||||
|
# cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed.
|
||||||
|
user = User(email="feednocursor@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
resp = _get(db, user, monkeypatch, "/api/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["items"] == []
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Feed keyset paging (TEST-2), DB-lane.
|
||||||
|
|
||||||
|
The feed's cursor paging (feedquery.sort_keys / order_by / keyset_predicate / encode_cursor /
|
||||||
|
decode_cursor) is the NULLS-LAST + tiebreaker algebra where a page boundary bug hides as a duplicated or
|
||||||
|
skipped row — on the busiest endpoint, while the scheduler ingests mid-scroll. It had zero tests.
|
||||||
|
|
||||||
|
The invariant here is strong and order-agnostic: for EVERY sort, walking the feed in pages of 2 via the
|
||||||
|
cursor must reproduce EXACTLY the order a single unpaged query returns — same rows, same order, no dup, no
|
||||||
|
skip. The seed is built to stress it: duplicate sort values (so the id tiebreaker is load-bearing) and
|
||||||
|
NULLs in published_at / view_count / duration_seconds / title / subscriber_count (so nulls_last is
|
||||||
|
exercised on both a first and a second key). (The malformed-cursor -> 400 path is covered by
|
||||||
|
test_feed_cursor.py.)
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
|
||||||
|
# Every sort feedquery.sort_keys defines except "relevance" (which needs a search query to rank).
|
||||||
|
_SORTS = [
|
||||||
|
"newest", "oldest", "views", "views_asc", "duration_desc", "duration_asc",
|
||||||
|
"title", "title_desc", "subscribers", "subscribers_asc", "priority", "priority_asc", "shuffle",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
# Two channels; one has a NULL subscriber_count (exercises the subscribers sort's nulls_last).
|
||||||
|
db.add_all([
|
||||||
|
Channel(id="UCa", title="Channel A", subscriber_count=1000),
|
||||||
|
Channel(id="UCb", title="Channel B", subscriber_count=None),
|
||||||
|
])
|
||||||
|
user = User(email="keyset@example.com", email_verified=True)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
Subscription(user_id=user.id, channel_id="UCa", priority=5, hidden=False),
|
||||||
|
Subscription(user_id=user.id, channel_id="UCb", priority=0, hidden=False),
|
||||||
|
])
|
||||||
|
# 7 normal/organic videos. Deliberate duplicates (published_at day 1 x2, day 2 x2 and day 3 x2,
|
||||||
|
# view_count 100 x2, duration 60 x2 / 30 x2) and NULLs spread across the still-nullable sort columns
|
||||||
|
# (view_count / duration_seconds / title / subscriber_count) to exercise nulls_last. published_at is
|
||||||
|
# NOT NULL (migration 0068), so no NULL there — the duplicates keep the id tiebreaker load-bearing.
|
||||||
|
rows = [
|
||||||
|
# id ch published views dur title
|
||||||
|
("vid00", "UCa", _dt(1), 100, 60, "alpha"),
|
||||||
|
("vid01", "UCa", _dt(1), 100, 60, "beta"),
|
||||||
|
("vid02", "UCa", _dt(2), None, None, "gamma"),
|
||||||
|
("vid03", "UCb", _dt(2), 50, 120, None),
|
||||||
|
("vid04", "UCb", _dt(3), 200, 30, "delta"),
|
||||||
|
("vid05", "UCb", _dt(3), None, 30, "epsilon"),
|
||||||
|
("vid06", "UCa", _dt(28), None, None, "zeta"),
|
||||||
|
]
|
||||||
|
db.add_all([
|
||||||
|
Video(
|
||||||
|
id=vid, channel_id=ch, published_at=pub, view_count=views, duration_seconds=dur,
|
||||||
|
title=title, is_short=False, live_status="none", via_search=False, via_explore=False,
|
||||||
|
)
|
||||||
|
for vid, ch, pub, views, dur, title in rows
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
return user, [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _page(client, sort, cursor):
|
||||||
|
url = f"/api/feed?sort={sort}&limit=2"
|
||||||
|
if cursor:
|
||||||
|
url += f"&cursor={cursor}"
|
||||||
|
resp = client.get(url)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_surfaces_the_whole_seed(db, as_user):
|
||||||
|
user, ids = _seed(db)
|
||||||
|
body = as_user(user).get("/api/feed?limit=50").json()
|
||||||
|
assert {it["id"] for it in body["items"]} == set(ids)
|
||||||
|
assert body["has_more"] is False and body["next_cursor"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sort", _SORTS)
|
||||||
|
def test_cursor_walk_matches_the_unpaged_order(db, as_user, sort):
|
||||||
|
user, ids = _seed(db)
|
||||||
|
client = as_user(user)
|
||||||
|
|
||||||
|
# Ground truth: the full order in one query (no cursor).
|
||||||
|
full = [it["id"] for it in client.get(f"/api/feed?sort={sort}&limit=50").json()["items"]]
|
||||||
|
# Guard against a vacuous pass: if seeding ever stops surfacing the videos, `walked == full` would
|
||||||
|
# be [] == [] for every sort and never exercise the cursor. All 7 rows must be present.
|
||||||
|
assert len(full) == len(ids), f"{sort}: seed did not surface all rows — the walk would be vacuous"
|
||||||
|
|
||||||
|
# Walk it two at a time via the cursor and reassemble.
|
||||||
|
walked: list[str] = []
|
||||||
|
cursor = None
|
||||||
|
for _ in range(len(full) + 2): # generous upper bound; the loop breaks on the last page
|
||||||
|
body = _page(client, sort, cursor)
|
||||||
|
walked.extend(it["id"] for it in body["items"])
|
||||||
|
cursor = body["next_cursor"]
|
||||||
|
if not body["has_more"]:
|
||||||
|
assert cursor is None
|
||||||
|
break
|
||||||
|
|
||||||
|
assert walked == full, f"{sort}: paged order diverged from the unpaged query"
|
||||||
|
assert len(walked) == len(set(walked)), f"{sort}: cursor paging duplicated a row"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Feed search + watch-state views (E2 S3), DB-lane.
|
||||||
|
|
||||||
|
Regression nets for two query rewrites that must stay behaviour-equivalent:
|
||||||
|
- PERF-12: the search channel-name branch moved from an ORed `unaccent(channels.title) ILIKE` (which
|
||||||
|
defeated the search_vector GIN) to a pre-resolved `channel_id IN (...)`. A search must STILL return
|
||||||
|
both videos whose text matches AND videos whose CHANNEL name matches.
|
||||||
|
- PERF-16: the watched/hidden/in_progress views moved from an outer join + `coalesce(state.status,'new')`
|
||||||
|
to an inner join on video_states + the raw column. Each view must return exactly the same set.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import Channel, ChannelTag, Subscription, Tag, Video, VideoState
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _video(vid: str, ch: str, title: str) -> Video:
|
||||||
|
return Video(
|
||||||
|
id=vid, channel_id=ch, published_at=_dt(1), title=title,
|
||||||
|
is_short=False, live_status="none", via_search=False, via_explore=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_ids(client, query: str) -> set[str]:
|
||||||
|
resp = client.get(f"/api/feed?{query}&limit=50")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return {it["id"] for it in resp.json()["items"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_both_video_text_and_channel_name(db, as_user, make_user):
|
||||||
|
# Two subscribed channels: one whose NAME matches the query (its video's text does not), and one
|
||||||
|
# whose video TEXT matches (its name does not). The pre-resolved channel_id branch must still OR in
|
||||||
|
# the name match, so both videos come back — and an unrelated video on a matching-name-less channel
|
||||||
|
# stays out.
|
||||||
|
user = make_user(email="search@example.com")
|
||||||
|
db.add_all([
|
||||||
|
Channel(id="UCkube", title="Kubernetes Weekly", subscriber_count=10),
|
||||||
|
Channel(id="UCother", title="Some Other Channel", subscriber_count=10),
|
||||||
|
])
|
||||||
|
db.add_all([
|
||||||
|
Subscription(user_id=user.id, channel_id="UCkube", priority=0, hidden=False),
|
||||||
|
Subscription(user_id=user.id, channel_id="UCother", priority=0, hidden=False),
|
||||||
|
])
|
||||||
|
db.add_all([
|
||||||
|
_video("vidChan", "UCkube", "Totally unrelated content"), # channel NAME matches
|
||||||
|
_video("vidText", "UCother", "kubernetes deep dive"), # video TEXT matches
|
||||||
|
_video("vidNone", "UCother", "cooking pasta at home"), # neither matches
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ids = _feed_ids(as_user(user), "q=kubernetes&show=all")
|
||||||
|
assert ids == {"vidChan", "vidText"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_watch_state_views_return_the_right_sets(db, as_user, make_user):
|
||||||
|
user = make_user(email="views@example.com")
|
||||||
|
db.add(Channel(id="UCv", title="Channel V", subscriber_count=10))
|
||||||
|
db.add(Subscription(user_id=user.id, channel_id="UCv", priority=0, hidden=False))
|
||||||
|
db.add_all([
|
||||||
|
_video("vWatch", "UCv", "watched one"),
|
||||||
|
_video("vHide", "UCv", "hidden one"),
|
||||||
|
_video("vProg", "UCv", "in progress one"),
|
||||||
|
_video("vNew", "UCv", "brand new one"),
|
||||||
|
])
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
VideoState(user_id=user.id, video_id="vWatch", status="watched"),
|
||||||
|
VideoState(user_id=user.id, video_id="vHide", status="hidden"),
|
||||||
|
# in progress = a stored resume position while still "new" (not watched/hidden)
|
||||||
|
VideoState(user_id=user.id, video_id="vProg", status="new", position_seconds=30),
|
||||||
|
# vNew has NO state row -> "new"/unwatched
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client = as_user(user)
|
||||||
|
assert _feed_ids(client, "show=watched") == {"vWatch"}
|
||||||
|
assert _feed_ids(client, "show=hidden") == {"vHide"}
|
||||||
|
assert _feed_ids(client, "show=in_progress") == {"vProg"}
|
||||||
|
# unwatched excludes watched + hidden, keeps new AND in-progress
|
||||||
|
assert _feed_ids(client, "show=unwatched") == {"vProg", "vNew"}
|
||||||
|
# "all" shows everything except hidden
|
||||||
|
assert _feed_ids(client, "show=all") == {"vWatch", "vProg", "vNew"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_tagged(db, make_user):
|
||||||
|
"""A user subscribed to 4 channels with a spread of language/topic/other tags and differing video
|
||||||
|
counts, so the count sum and the per-category facet drill-down are all non-trivial."""
|
||||||
|
user = make_user(email="summary@example.com")
|
||||||
|
tags = {
|
||||||
|
"L1": Tag(user_id=None, name="Hungarian", category="language"),
|
||||||
|
"L2": Tag(user_id=None, name="English", category="language"),
|
||||||
|
"T1": Tag(user_id=None, name="Tech", category="topic"),
|
||||||
|
"T2": Tag(user_id=None, name="Gaming", category="topic"),
|
||||||
|
"O1": Tag(user_id=None, name="Favourite", category="other"),
|
||||||
|
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
|
||||||
|
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
|
||||||
|
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
|
||||||
|
}
|
||||||
|
db.add_all(tags.values())
|
||||||
|
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
|
||||||
|
db.add_all(chans.values())
|
||||||
|
db.flush()
|
||||||
|
# channel -> (system) tag keys
|
||||||
|
links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
|
||||||
|
for cid, tkeys in links.items():
|
||||||
|
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||||
|
for tk in tkeys:
|
||||||
|
db.add(ChannelTag(channel_id=cid, tag_id=tags[tk].id, user_id=None))
|
||||||
|
# The user's own private link (visible only to them): chB carries U1 for this user.
|
||||||
|
db.add(ChannelTag(channel_id="chB", tag_id=tags["U1"].id, user_id=user.id))
|
||||||
|
# differing video counts per channel (chA:3, chB:2, chC:1, chD:2)
|
||||||
|
counts = {"chA": 3, "chB": 2, "chC": 1, "chD": 2}
|
||||||
|
for cid, n in counts.items():
|
||||||
|
db.add_all([_video(f"{cid}v{i}", cid, f"{cid} clip {i}") for i in range(n)])
|
||||||
|
db.commit()
|
||||||
|
return user, {k: v.id for k, v in tags.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# Query-param combos (as a template with {L1}/{T1}/… placeholders) that stress every facet path:
|
||||||
|
# no tags, single-category, cross-category, disjunctive + conjunctive topic modes, and a search filter.
|
||||||
|
_SUMMARY_CASES = [
|
||||||
|
"show=all",
|
||||||
|
"show=all&tags={L1}",
|
||||||
|
"show=all&tags={T1}",
|
||||||
|
"show=all&tags={L1}&tags={T1}",
|
||||||
|
"show=all&tags={T1}&tags={T2}&tag_mode=or",
|
||||||
|
"show=all&tags={T1}&tags={T2}&tag_mode=and",
|
||||||
|
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||||
|
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||||
|
"show=all&q=clip",
|
||||||
|
"show=unwatched",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||||
|
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
||||||
|
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||||
|
# across the full matrix of tag filters — it reimplements the tag drill-down in Python, so this is
|
||||||
|
# the equivalence net.
|
||||||
|
user, tag_ids = _seed_tagged(db, make_user)
|
||||||
|
client = as_user(user)
|
||||||
|
qs = case.format(**tag_ids)
|
||||||
|
|
||||||
|
summary = client.get(f"/api/feed/summary?{qs}")
|
||||||
|
count = client.get(f"/api/feed/count?{qs}")
|
||||||
|
facets = client.get(f"/api/facets?{qs}")
|
||||||
|
assert summary.status_code == count.status_code == facets.status_code == 200
|
||||||
|
|
||||||
|
assert summary.json()["count"] == count.json()["count"], qs
|
||||||
|
assert summary.json()["facets"] == facets.json()["counts"], qs
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Migration hygiene (TEST-3), DB-lane.
|
||||||
|
|
||||||
|
Two things the prod boot never checks:
|
||||||
|
- **model <-> schema drift:** `entrypoint.sh` only ever runs `alembic upgrade head`; nothing verifies the
|
||||||
|
migrated schema still matches `Base.metadata`. A model column added without a migration (or a migration
|
||||||
|
that drifted from the model) ships silently.
|
||||||
|
- **the downgrade path:** every migration defines `downgrade()`, but it never runs, so a broken downgrade
|
||||||
|
is only discovered during an emergency rollback.
|
||||||
|
|
||||||
|
Both use the `db` fixture, which has already brought the schema to head via `_schema`.
|
||||||
|
|
||||||
|
Scope note: we gate on COLUMN/TABLE drift — a table or column present on one side and not the other, or
|
||||||
|
a column whose type/nullability disagrees. `compare_metadata` also reports index/constraint/sequence
|
||||||
|
differences, but those are dominated by migration-only objects (partial/expression/GIN indexes, CHECK
|
||||||
|
constraints, SERIAL sequences) that can't be expressed on `Base.metadata`, so comparing them wholesale is
|
||||||
|
noise; they're filtered out. The column/table signal is the "someone changed a model without a migration"
|
||||||
|
case worth failing on.
|
||||||
|
"""
|
||||||
|
from alembic import command
|
||||||
|
from alembic.autogenerate import compare_metadata
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
|
||||||
|
from app.db import Base, engine
|
||||||
|
|
||||||
|
# The diff ops that mean a real table/column disagreement (presence AND shape) — not the index/
|
||||||
|
# constraint/sequence noise from migration-only objects.
|
||||||
|
_DRIFT_OPS = {
|
||||||
|
"add_table", "remove_table", "add_column", "remove_column", "modify_type", "modify_nullable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _structural_drift():
|
||||||
|
"""Table/column presence diffs between the ORM models and the migrated schema (`compare_type=True`
|
||||||
|
mirrors alembic/env.py). compare_metadata nests per-column ops in a sub-list per table and returns
|
||||||
|
table-level ops as bare tuples; flatten both and keep only the presence ops."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn, opts={"compare_type": True})
|
||||||
|
diffs = compare_metadata(ctx, Base.metadata)
|
||||||
|
drift = []
|
||||||
|
for d in diffs:
|
||||||
|
for entry in (d if isinstance(d, list) else [d]):
|
||||||
|
if isinstance(entry, tuple) and entry and entry[0] in _DRIFT_OPS:
|
||||||
|
drift.append(entry)
|
||||||
|
return drift
|
||||||
|
|
||||||
|
|
||||||
|
def test_models_match_the_migrated_schema(db):
|
||||||
|
# Non-empty == the ORM models and the alembic-built schema disagree on a table/column — almost
|
||||||
|
# always a model change that forgot its migration.
|
||||||
|
drift = _structural_drift()
|
||||||
|
assert drift == [], "model/schema drift — add or fix a migration:\n" + "\n".join(
|
||||||
|
str(d) for d in drift
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_migration_downgrades_and_reupgrades(db):
|
||||||
|
# Exercise the newest migration's downgrade + re-upgrade so a broken downgrade is caught the sprint
|
||||||
|
# it lands, not during a rollback. Leaves the schema back at head for the rest of the session.
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "-1")
|
||||||
|
reupgraded = False
|
||||||
|
try:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
reupgraded = True
|
||||||
|
assert _structural_drift() == []
|
||||||
|
finally:
|
||||||
|
# If the re-upgrade itself failed, restore the SHARED schema to head so the failure doesn't
|
||||||
|
# cascade into every later DB-lane test. Skip it when the upgrade already succeeded — re-running
|
||||||
|
# it would be a redundant no-op and could mask a failing assert with the finally's own error.
|
||||||
|
if not reupgraded:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""create_notification title-clamp regression.
|
||||||
|
|
||||||
|
A downloaded FB/IG reel's "title" is the whole caption (hundreds to ~1900 chars). Before the
|
||||||
|
clamp that string went straight into Notification.title (String(255)) and, at the batched
|
||||||
|
download_gc commit, raised psycopg StringDataRightTruncation — aborting the entire GC run so
|
||||||
|
no expiry deletion / LRU eviction / orphan reap ever ran. The clamp trims the title to the
|
||||||
|
column limit centrally, defending all callers regardless of DB backend."""
|
||||||
|
from app.models import Notification, User
|
||||||
|
from app.notifications import TITLE_MAX_LEN, create_notification
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureDB:
|
||||||
|
"""Captures the added row without a real DB, so the clamp is verified on every lane
|
||||||
|
(the truncation only reproduces on Postgres; the clamp logic is backend-agnostic)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.added: list[Notification] = []
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def refresh(self, _obj):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_clamped_to_column_limit():
|
||||||
|
long_title = "x" * 2000 # e.g. a reel caption
|
||||||
|
notif = create_notification(
|
||||||
|
_CaptureDB(), user_id=1, type="download_expiring", title=long_title
|
||||||
|
)
|
||||||
|
assert len(notif.title) == TITLE_MAX_LEN
|
||||||
|
assert notif.title.endswith("…")
|
||||||
|
# The untruncated title still reaches the client via `data`, which is never clamped.
|
||||||
|
notif2 = create_notification(
|
||||||
|
_CaptureDB(),
|
||||||
|
user_id=1,
|
||||||
|
type="download_expiring",
|
||||||
|
title=long_title,
|
||||||
|
data={"kind": "download_expiring", "title": long_title},
|
||||||
|
)
|
||||||
|
assert notif2.data["title"] == long_title
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_title_is_untouched():
|
||||||
|
notif = create_notification(_CaptureDB(), user_id=1, type="t", title="short")
|
||||||
|
assert notif.title == "short"
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_at_exactly_the_limit_is_untouched():
|
||||||
|
exact = "y" * TITLE_MAX_LEN
|
||||||
|
notif = create_notification(_CaptureDB(), user_id=1, type="t", title=exact)
|
||||||
|
assert notif.title == exact
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_tracks_the_actual_column_width():
|
||||||
|
# The clamp is derived from the model, so it can't silently drift from the column a
|
||||||
|
# migration would change (which is exactly what re-introduces the truncation bug).
|
||||||
|
assert TITLE_MAX_LEN == Notification.__table__.c.title.type.length
|
||||||
|
|
||||||
|
|
||||||
|
def test_over_long_title_actually_commits_on_postgres(db):
|
||||||
|
# The stub tests above prove the clamp *logic*; this proves the real fix end-to-end —
|
||||||
|
# a caption-length title now COMMITS into varchar(TITLE_MAX_LEN) instead of raising
|
||||||
|
# StringDataRightTruncation. Uses the opt-in Postgres lane (skipped with no DB).
|
||||||
|
user = User(email="reel@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
notif = create_notification(
|
||||||
|
db, user_id=user.id, type="download_expiring", title="z" * 2000
|
||||||
|
)
|
||||||
|
db.refresh(notif)
|
||||||
|
assert len(notif.title) == TITLE_MAX_LEN # persisted, not truncated by the DB
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""Regression (E5 S5b): the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at
|
||||||
|
rich-fetch time and served from the DB row instead of a live plex.metadata() call.
|
||||||
|
|
||||||
|
- `rich_detail`/`apply_rich_detail` are pure — parse + persist the rich-only fields.
|
||||||
|
- `_enrich_people_batch` (the cast backfill) now also stamps those fields.
|
||||||
|
- `item_detail` serves a MOVIE's detail from the row (survives a live-Plex outage) while keeping
|
||||||
|
markers/streams live; an EPISODE (never enriched) still parses detail from the live meta.
|
||||||
|
- `show_detail` serves from the row; `related` stays live best-effort.
|
||||||
|
|
||||||
|
The endpoint tests use the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.routes.plex.browse as browse
|
||||||
|
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, User
|
||||||
|
from app.plex import detail
|
||||||
|
from app.plex.sync import _apply_facet_fields, _enrich_people_batch
|
||||||
|
|
||||||
|
# A full Plex metadata dict: an explicit IMDb Rating entry (must win over audienceRating), an IMDb Guid,
|
||||||
|
# a tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None).
|
||||||
|
RICH_META = {
|
||||||
|
"Rating": [
|
||||||
|
{"image": "imdb://image.rating.imdb", "value": 8.7},
|
||||||
|
{"image": "themoviedb://image.rating", "value": 7.0},
|
||||||
|
],
|
||||||
|
"audienceRating": 9.9, # ignored: the imdb Rating entry is preferred
|
||||||
|
"Guid": [{"id": "tmdb://238"}, {"id": "imdb://tt0068646"}],
|
||||||
|
"contentRating": "R",
|
||||||
|
"Genre": [{"tag": "Crime"}, {"tag": "Drama"}],
|
||||||
|
"Director": [{"tag": "Francis Ford Coppola"}],
|
||||||
|
"studio": "Paramount",
|
||||||
|
"tagline": "An offer you can't refuse.",
|
||||||
|
"Role": [
|
||||||
|
{"tag": "Marlon Brando", "role": "Vito Corleone", "thumb": "https://metadata-static.plex.tv/b.jpg"},
|
||||||
|
{"tag": "Al Pacino", "role": "Michael"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pure parse/persist --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_rich_detail_prefers_imdb_entry_and_builds_link():
|
||||||
|
d = detail.rich_detail(RICH_META)
|
||||||
|
assert d["imdb_rating"] == 8.7 # the imdb Rating entry, not audienceRating (9.9)
|
||||||
|
assert d["imdb_id"] == "tt0068646"
|
||||||
|
assert d["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
|
||||||
|
assert d["tagline"] == "An offer you can't refuse."
|
||||||
|
assert [c["name"] for c in d["cast"]] == ["Marlon Brando", "Al Pacino"]
|
||||||
|
assert d["cast"][0]["role"] == "Vito Corleone"
|
||||||
|
assert d["cast"][0]["thumb"] and d["cast"][0]["thumb"].startswith("/api/plex/person-image")
|
||||||
|
assert d["cast"][1]["thumb"] is None # no CDN headshot → not proxied
|
||||||
|
|
||||||
|
|
||||||
|
def test_rich_detail_is_imdb_only_no_audience_fallback():
|
||||||
|
# E5 S6: imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score
|
||||||
|
# is NOT mislabelled as an IMDb score (it's served separately via the `rating` column).
|
||||||
|
d = detail.rich_detail({"audienceRating": 7.25})
|
||||||
|
assert d["imdb_rating"] is None
|
||||||
|
assert d["imdb_id"] is None and d["imdb_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_rich_detail_writes_only_rich_fields():
|
||||||
|
row = SimpleNamespace(imdb_rating=None, imdb_id=None, tagline=None, full_cast=None)
|
||||||
|
detail.apply_rich_detail(row, RICH_META)
|
||||||
|
assert row.imdb_rating == 8.7
|
||||||
|
assert row.imdb_id == "tt0068646"
|
||||||
|
assert row.tagline == "An offer you can't refuse."
|
||||||
|
assert [c["name"] for c in row.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_rich_detail_empty_cast_persists_null():
|
||||||
|
row = SimpleNamespace(imdb_rating=1.0, imdb_id="x", tagline="t", full_cast=[{"name": "stale"}])
|
||||||
|
detail.apply_rich_detail(row, {}) # a poor/empty meta must clear to NULL, not keep stale data
|
||||||
|
assert row.full_cast is None
|
||||||
|
assert row.imdb_rating is None and row.imdb_id is None and row.tagline is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- backfill persistence (DB lane) --------------------------------------------------------------
|
||||||
|
|
||||||
|
class _StubClient:
|
||||||
|
"""Stand-in for PlexClient: `_enrich_people_batch` calls plex.metadata(rating_key); item_detail
|
||||||
|
uses it as a context manager and passes markers=True. `raise_` simulates a Plex outage."""
|
||||||
|
|
||||||
|
def __init__(self, meta: dict | None = None, raise_: bool = False):
|
||||||
|
self._meta = meta or {}
|
||||||
|
self._raise = raise_
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
if self._raise:
|
||||||
|
raise RuntimeError("plex unavailable")
|
||||||
|
return self._meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_facet_fields_preserves_enriched_cast_names():
|
||||||
|
"""A full sync must NOT downgrade an already-enriched full cast back to the listing top-20 — since
|
||||||
|
sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose
|
||||||
|
the names past the listing cap (regression guard for E5 S5b's enrich gating)."""
|
||||||
|
# The cheap listing's top few cast + the (always-present) director.
|
||||||
|
thin = {"Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], "Director": [{"tag": "Francis Ford Coppola"}]}
|
||||||
|
|
||||||
|
# Not-yet-enriched row: the listing cast IS applied.
|
||||||
|
fresh = SimpleNamespace(people_enriched_at=None, directors=None, cast_names=None)
|
||||||
|
_apply_facet_fields(fresh, thin)
|
||||||
|
assert fresh.cast_names == ["Al Pacino", "James Caan"]
|
||||||
|
|
||||||
|
# Enriched row: the fuller stored cast is KEPT, not overwritten by the thin listing.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
enriched = SimpleNamespace(
|
||||||
|
people_enriched_at=datetime.now(timezone.utc),
|
||||||
|
directors=["Francis Ford Coppola"],
|
||||||
|
cast_names=["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"],
|
||||||
|
)
|
||||||
|
_apply_facet_fields(enriched, thin)
|
||||||
|
assert enriched.cast_names == ["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"]
|
||||||
|
# people_text is rebuilt from the (updated) directors + the PRESERVED full cast.
|
||||||
|
assert "Diane Keaton" in enriched.people_text and "Francis Ford Coppola" in enriched.people_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_facet_fields_keeps_over_cap_cast_after_a_watermark_reset():
|
||||||
|
"""The enriched-cast guard must survive a watermark RESET (people_enriched_at nulled, e.g. by a
|
||||||
|
migration like 0065). A stored cast LONGER than the listing cap can only be an enriched fuller list,
|
||||||
|
so it's kept even though the row now reads as un-enriched — comparing against the cap, not the
|
||||||
|
current listing's length, is what makes this robust (regression guard for the review fix)."""
|
||||||
|
thin = {"Role": [{"tag": f"Actor {i}"} for i in range(20)]} # a full 20-name listing
|
||||||
|
over_cap = SimpleNamespace(
|
||||||
|
people_enriched_at=None, # watermark was reset
|
||||||
|
directors=None,
|
||||||
|
cast_names=[f"Enriched {i}" for i in range(22)], # 22 > the 20 cap => enriched
|
||||||
|
)
|
||||||
|
_apply_facet_fields(over_cap, thin)
|
||||||
|
assert over_cap.cast_names == [f"Enriched {i}" for i in range(22)] # kept, not downgraded
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_facet_fields_tracks_a_trimmed_listing_when_unenriched():
|
||||||
|
"""A listing-sourced cast (at or below the cap, never enriched) must TRACK Plex cast edits in both
|
||||||
|
directions. When Plex trims the cast, the shorter fresh listing replaces the stored one — the old
|
||||||
|
length-vs-this-listing guard would have wrongly kept the stale longer cast."""
|
||||||
|
stored = SimpleNamespace(
|
||||||
|
people_enriched_at=None,
|
||||||
|
directors=None,
|
||||||
|
cast_names=["A", "B", "C", "D", "E"], # 5, at/below the cap => listing-sourced
|
||||||
|
)
|
||||||
|
trimmed = {"Role": [{"tag": "A"}, {"tag": "B"}]} # Plex removed 3 cast members
|
||||||
|
_apply_facet_fields(stored, trimmed)
|
||||||
|
assert stored.cast_names == ["A", "B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrich_people_batch_persists_detail(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather")
|
||||||
|
db.add(mv)
|
||||||
|
db.commit()
|
||||||
|
assert mv.people_enriched_at is None and mv.full_cast is None
|
||||||
|
|
||||||
|
n = _enrich_people_batch(db, _StubClient(RICH_META))
|
||||||
|
assert n == 1
|
||||||
|
db.refresh(mv)
|
||||||
|
assert mv.imdb_rating == 8.7
|
||||||
|
assert mv.imdb_id == "tt0068646"
|
||||||
|
assert mv.tagline == "An offer you can't refuse."
|
||||||
|
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||||
|
assert mv.cast_names == ["Marlon Brando", "Al Pacino"] # people enrich still runs alongside
|
||||||
|
assert mv.people_enriched_at is not None # stamped so the backfill skips it next run
|
||||||
|
|
||||||
|
|
||||||
|
# ---- item_detail serving (DB lane) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get(db, user, monkeypatch, path):
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||||
|
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 _user(db, email):
|
||||||
|
u = User(email=email)
|
||||||
|
db.add(u)
|
||||||
|
db.flush()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch):
|
||||||
|
"""An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises —
|
||||||
|
only markers/streams (which need the live meta) degrade."""
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
mv = PlexItem(
|
||||||
|
rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather",
|
||||||
|
genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount",
|
||||||
|
content_rating="R", imdb_rating=8.7, imdb_id="tt0068646",
|
||||||
|
tagline="An offer you can't refuse.",
|
||||||
|
full_cast=[{"name": "Marlon Brando", "role": "Vito Corleone", "thumb": None}],
|
||||||
|
people_enriched_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(mv)
|
||||||
|
db.commit()
|
||||||
|
user = _user(db, "outage@example.com")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr(browse, "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()
|
||||||
|
assert body["imdb_rating"] == 8.7
|
||||||
|
assert body["imdb_id"] == "tt0068646"
|
||||||
|
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
|
||||||
|
assert body["tagline"] == "An offer you can't refuse."
|
||||||
|
assert [c["name"] for c in body["cast"]] == ["Marlon Brando"]
|
||||||
|
assert body["genres"] == ["Crime", "Drama"]
|
||||||
|
assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch):
|
||||||
|
"""A not-yet-enriched movie is filled from the live meta on view (and persisted for next time)."""
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather")
|
||||||
|
db.add(mv)
|
||||||
|
db.commit()
|
||||||
|
user = _user(db, "lazy@example.com")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr(browse, "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"
|
||||||
|
db.refresh(mv)
|
||||||
|
assert mv.imdb_id == "tt0068646" # persisted
|
||||||
|
assert mv.people_enriched_at is not None
|
||||||
|
assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_episode_serves_rich_from_live(db, monkeypatch):
|
||||||
|
"""Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the
|
||||||
|
live meta the endpoint fetches for markers/streams."""
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
||||||
|
db.add(show)
|
||||||
|
db.flush()
|
||||||
|
ep = PlexItem(
|
||||||
|
rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id
|
||||||
|
)
|
||||||
|
db.add(ep)
|
||||||
|
db.commit()
|
||||||
|
user = _user(db, "ep@example.com")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
monkeypatch.setattr(browse, "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()
|
||||||
|
assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row
|
||||||
|
assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"]
|
||||||
|
db.refresh(ep)
|
||||||
|
assert ep.full_cast is None # episodes are not persisted/enriched
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_detail_serves_from_row(db, monkeypatch):
|
||||||
|
"""A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail."""
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
show = PlexShow(
|
||||||
|
rating_key="s1", library_id=lib.id, title="A Show", genres=["Drama"],
|
||||||
|
imdb_rating=9.1, imdb_id="tt0903747",
|
||||||
|
full_cast=[{"name": "Bryan Cranston", "role": "Walter White", "thumb": None}],
|
||||||
|
people_enriched_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(show)
|
||||||
|
db.commit()
|
||||||
|
user = _user(db, "show@example.com")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# _show_enrichment raises → related degrades to []; the row-served detail must still render.
|
||||||
|
def _boom(db_, rk):
|
||||||
|
raise RuntimeError("plex unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(browse, "_show_enrichment", _boom)
|
||||||
|
resp = _get(db, user, monkeypatch, "/api/plex/show/s1")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["show"]
|
||||||
|
assert body["imdb_rating"] == 9.1
|
||||||
|
assert body["imdb_id"] == "tt0903747"
|
||||||
|
assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/"
|
||||||
|
assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"]
|
||||||
|
assert resp.json()["related"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
||||||
|
"""A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted +
|
||||||
|
stamped) so subsequent views serve from the row."""
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show")
|
||||||
|
db.add(show)
|
||||||
|
db.commit()
|
||||||
|
user = _user(db, "showfill@example.com")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# _show_enrichment returns (live meta, no related) → the fill branch runs.
|
||||||
|
monkeypatch.setattr(browse, "_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"
|
||||||
|
db.refresh(show)
|
||||||
|
assert show.imdb_id == "tt0068646" # persisted for next time
|
||||||
|
assert show.people_enriched_at is not None
|
||||||
|
assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""S2 · field-scoped search — the `field` param on /api/plex/library.
|
||||||
|
|
||||||
|
The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`,
|
||||||
|
the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`),
|
||||||
|
or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of
|
||||||
|
Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties
|
||||||
|
the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped
|
||||||
|
without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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,
|
||||||
|
PlexSeason,
|
||||||
|
PlexShow,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2024, 2, day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
"""One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is
|
||||||
|
unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary,
|
||||||
|
and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each
|
||||||
|
scoped field matches exactly its one row."""
|
||||||
|
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()
|
||||||
|
mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.",
|
||||||
|
year=2001, added_at=_dt(1))
|
||||||
|
mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.",
|
||||||
|
year=2002, added_at=_dt(2))
|
||||||
|
ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Quiet Harbor Two", people_text="Alice Stone",
|
||||||
|
summary="A story about zephyr winds.", year=2003, added_at=_dt(3))
|
||||||
|
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show",
|
||||||
|
summary="Weather anthology.", people_text="Bob Cloud",
|
||||||
|
year=2004, added_at=_dt(4), child_count=1)
|
||||||
|
db.add_all([mt, mp, ms, show])
|
||||||
|
db.flush()
|
||||||
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||||
|
db.add(season)
|
||||||
|
db.flush()
|
||||||
|
ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode",
|
||||||
|
show_id=show.id, season_id=season.id, season_number=1, episode_number=1,
|
||||||
|
added_at=_dt(5))
|
||||||
|
db.add(ep)
|
||||||
|
user = User(email="plexfield@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return {"user": 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 _ids(body):
|
||||||
|
return sorted(i["id"] for i in body["items"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldScopedSearch:
|
||||||
|
def test_everywhere_matches_every_facet(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||||
|
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
|
||||||
|
assert _ids(body) == ["mp", "ms", "mt"]
|
||||||
|
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||||
|
|
||||||
|
def test_field_all_is_identical_to_default(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json()
|
||||||
|
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||||
|
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
|
||||||
|
assert default["total"] == scoped["total"]
|
||||||
|
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
|
||||||
|
|
||||||
|
def test_title_scope_narrows_to_title_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json()
|
||||||
|
assert _ids(body) == ["mt"]
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["episodes"] == [] # non-episode field scopes suppress the episode section
|
||||||
|
|
||||||
|
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json()
|
||||||
|
assert _ids(body) == ["mp"]
|
||||||
|
|
||||||
|
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json()
|
||||||
|
assert _ids(body) == ["ms"]
|
||||||
|
|
||||||
|
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||||
|
|
||||||
|
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(
|
||||||
|
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie"
|
||||||
|
).json()
|
||||||
|
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["episodes"] == []
|
||||||
|
|
||||||
|
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json()
|
||||||
|
assert _ids(body) == ["mp", "ms", "mt"]
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""E5 S6: HU-overlay language serving. When ?lang=hu, the unified library, facets, genre filter and
|
||||||
|
detail endpoints serve the Hungarian `*_hu` columns (falling back to EN where a row has no HU twin);
|
||||||
|
?lang=en (the default) serves English. Also covers the imdb_rating IMDb-only / audience-score split.
|
||||||
|
DB lane (`db` fixture) — skipped without Postgres.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""One EN movie library with a single movie that has both EN and HU metadata, plus a second movie
|
||||||
|
that is EN-only (no HU twin) to prove the coalesce fallback."""
|
||||||
|
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="English Title", title_hu="Magyar Cím",
|
||||||
|
summary="English summary.", summary_hu="Magyar összefoglaló.",
|
||||||
|
genres=["Drama"], genres_hu=["Dráma"],
|
||||||
|
rating=6.5, imdb_rating=None,
|
||||||
|
year=2001, added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), # serve from row, skip live enrich
|
||||||
|
)
|
||||||
|
m2 = PlexItem(
|
||||||
|
rating_key="m2", library_id=lib.id, kind="movie",
|
||||||
|
title="EN Only", genres=["Comedy"], # no HU twin
|
||||||
|
year=2002, added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||||||
|
people_enriched_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
db.add_all([m1, m2])
|
||||||
|
user = User(email="plexlang@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _get(db, user, monkeypatch, path):
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
client = TestClient(app) # no context manager: skip lifespan
|
||||||
|
return client.get(path)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _titles(resp):
|
||||||
|
return {i["title"] for i in resp.json()["items"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_serves_active_language_titles(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu")
|
||||||
|
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en")
|
||||||
|
# HU serves the twin's Hungarian title; the EN-only row falls back to its English title.
|
||||||
|
assert _titles(hu) == {"Magyar Cím", "EN Only"}
|
||||||
|
assert _titles(en) == {"English Title", "EN Only"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_vocabulary_is_language_specific(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu")
|
||||||
|
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en")
|
||||||
|
hu_genres = {g["value"] for g in hu.json()["genres"]}
|
||||||
|
en_genres = {g["value"] for g in en.json()["genres"]}
|
||||||
|
assert "Dráma" in hu_genres and "Drama" not in hu_genres
|
||||||
|
assert "Drama" in en_genres and "Dráma" not in en_genres
|
||||||
|
|
||||||
|
|
||||||
|
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
# The HU genre chip filters the HU column; the same string against the EN column matches nothing.
|
||||||
|
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma")
|
||||||
|
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma")
|
||||||
|
assert _titles(hit) == {"Magyar Cím"}
|
||||||
|
assert miss.json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_detail_language_and_rating_split(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu")
|
||||||
|
body = resp.json()
|
||||||
|
assert body["title"] == "Magyar Cím"
|
||||||
|
assert body["summary"] == "Magyar összefoglaló."
|
||||||
|
# imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`.
|
||||||
|
assert body["imdb_rating"] is None
|
||||||
|
assert body["rating"] == 6.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_hu_search_still_finds_en_only_titles(db, monkeypatch):
|
||||||
|
user = _seed(db)
|
||||||
|
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
|
||||||
|
# must still match it via the EN vector (vector_match's union) — findable by its English title.
|
||||||
|
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only")
|
||||||
|
assert "EN Only" in _titles(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
# imdb 8.0 but audience 4.0 → a rating_min of 7 must MATCH (filter the displayed best score,
|
||||||
|
# IMDb preferred), where filtering the raw audience column (4.0) would wrongly exclude it.
|
||||||
|
db.add(PlexItem(
|
||||||
|
rating_key="r1", library_id=lib.id, kind="movie", title="High IMDb",
|
||||||
|
imdb_rating=8.0, rating=4.0, year=2001,
|
||||||
|
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||||
|
))
|
||||||
|
user = User(email="rating@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7")
|
||||||
|
assert "High IMDb" in _titles(hit)
|
||||||
|
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9")
|
||||||
|
assert "High IMDb" not in _titles(miss)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""DB-backed regression (E5 S5a): the HU-overlay sync writes Hungarian metadata onto the matched EN
|
||||||
|
rows (paired by guid), never creating browsable HU rows, and overlay libraries are excluded from the
|
||||||
|
browse scope. Also covers the canonical-guid parse and that the generated search_vector_hu populates.
|
||||||
|
|
||||||
|
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs against the real schema
|
||||||
|
(migrations 0063/0064) so the JSONB + STORED generated columns are exercised.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _StubPlex:
|
||||||
|
"""Minimal PlexClient stand-in: _sync_overlay only calls section_items via _paginate."""
|
||||||
|
|
||||||
|
def __init__(self, items: list[dict]):
|
||||||
|
self._items = items
|
||||||
|
|
||||||
|
def section_items(self, key, item_type, start, size):
|
||||||
|
return self._items[start : start + size], len(self._items)
|
||||||
|
|
||||||
|
|
||||||
|
def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict:
|
||||||
|
meta = {"ratingKey": rk, "title": title, "summary": summary, "Genre": [{"tag": g} for g in genres]}
|
||||||
|
if guid:
|
||||||
|
meta["Guid"] = [{"id": guid}]
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_guid_prefers_tmdb_then_imdb():
|
||||||
|
assert _canonical_guid({"Guid": [{"id": "imdb://tt1"}, {"id": "tmdb://55"}]}) == "tmdb:55"
|
||||||
|
assert _canonical_guid({"Guid": [{"id": "imdb://tt9"}, {"id": "tvdb://7"}]}) == "imdb:tt9"
|
||||||
|
assert _canonical_guid({"Guid": []}) is None
|
||||||
|
assert _canonical_guid({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||||
|
db.add_all([en_lib, hu_lib])
|
||||||
|
db.flush()
|
||||||
|
hu_lib.overlay_of = en_lib.id
|
||||||
|
matched = PlexItem(
|
||||||
|
rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1"
|
||||||
|
)
|
||||||
|
unmatched = PlexItem(
|
||||||
|
rating_key="m2", library_id=en_lib.id, kind="movie", title="Solo", guid="tmdb:2"
|
||||||
|
)
|
||||||
|
db.add_all([matched, unmatched])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
stub = _StubPlex(
|
||||||
|
[
|
||||||
|
_hu_movie("h1", "Öldöklés", "Egy rablás terve.", ["Akció", "Krimi"], "tmdb://1"),
|
||||||
|
_hu_movie("h2", "Ismeretlen", "Nincs EN párja.", ["Dráma"], "tmdb://999"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
stats = {"overlaid": 0}
|
||||||
|
_sync_overlay(db, stub, hu_lib, en_lib.id, stats)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
db.refresh(matched)
|
||||||
|
db.refresh(unmatched)
|
||||||
|
assert matched.title_hu == "Öldöklés"
|
||||||
|
assert matched.summary_hu == "Egy rablás terve."
|
||||||
|
assert matched.genres_hu == ["Akció", "Krimi"]
|
||||||
|
assert matched.search_vector_hu is not None # the STORED generated column populated from title_hu
|
||||||
|
# The unmatched EN row keeps NULL HU fields (serving falls back to EN).
|
||||||
|
assert unmatched.title_hu is None
|
||||||
|
assert stats["overlaid"] == 1
|
||||||
|
# No browsable rows were created — the HU items never became plex_items of the overlay library.
|
||||||
|
hu_row_count = db.scalar(
|
||||||
|
select(func.count(PlexItem.id)).where(PlexItem.library_id == hu_lib.id)
|
||||||
|
)
|
||||||
|
assert hu_row_count == 0
|
||||||
|
# The EN library still holds exactly its two movies (no dup from the overlay pass).
|
||||||
|
en_row_count = db.scalar(
|
||||||
|
select(func.count(PlexItem.id)).where(PlexItem.library_id == en_lib.id)
|
||||||
|
)
|
||||||
|
assert en_row_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class _StubClient:
|
||||||
|
"""Full-sync stand-in: one EN movie section (key 4) + its HU twin (key 9), paired by guid."""
|
||||||
|
|
||||||
|
def __init__(self, db=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def sections(self):
|
||||||
|
return [
|
||||||
|
{"key": "4", "title": "Movies", "type": "movie"},
|
||||||
|
{"key": "9", "title": "Filmek", "type": "movie"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def section_items(self, key, item_type, start, size):
|
||||||
|
if item_type != syncmod._TYPE_MOVIE:
|
||||||
|
return [], 0
|
||||||
|
if key == "4":
|
||||||
|
items = [{"ratingKey": "en1", "title": "Heat", "Guid": [{"id": "tmdb://1"}]}]
|
||||||
|
elif key == "9":
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"ratingKey": "hu1", "title": "Öldöklés", "summary": "Rablás.",
|
||||||
|
"Genre": [{"tag": "Akció"}], "Guid": [{"id": "tmdb://1"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
return items[start : start + size], len(items)
|
||||||
|
|
||||||
|
def collections(self, key):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def metadata(self, rk, markers=False):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_designates_overlay_and_writes_hu(db, monkeypatch):
|
||||||
|
sysconfig.set_value(db, "plex_enabled", True)
|
||||||
|
sysconfig.set_value(db, "plex_libraries", "4") # only the EN section is browsable
|
||||||
|
sysconfig.set_value(db, "plex_overlay_map", "9:4") # HU section 9 overlays EN section 4
|
||||||
|
db.commit()
|
||||||
|
monkeypatch.setattr(syncmod, "PlexClient", _StubClient)
|
||||||
|
|
||||||
|
stats = syncmod.sync(db)
|
||||||
|
|
||||||
|
en = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "4")).scalars().one()
|
||||||
|
hu = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "9")).scalars().one()
|
||||||
|
assert en.metadata_lang == "en" and en.overlay_of is None
|
||||||
|
assert hu.metadata_lang == "hu" and hu.overlay_of == en.id # pass-2 designation
|
||||||
|
# The EN movie captured its guid (from includeGuids listing) and received the HU overlay.
|
||||||
|
m = db.execute(select(PlexItem).where(PlexItem.rating_key == "en1")).scalars().one()
|
||||||
|
assert m.guid == "tmdb:1"
|
||||||
|
assert m.title_hu == "Öldöklés"
|
||||||
|
assert m.genres_hu == ["Akció"]
|
||||||
|
# The HU section is not browsable and mirrored no rows of its own.
|
||||||
|
movie_ids, _ = _content_libs(db)
|
||||||
|
assert en.id in movie_ids and hu.id not in movie_ids
|
||||||
|
assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0
|
||||||
|
assert stats["overlaid"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db):
|
||||||
|
"""A re-run whose HU section no longer carries a title's guid must drop that EN row's stale HU."""
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||||
|
db.add_all([en_lib, hu_lib])
|
||||||
|
db.flush()
|
||||||
|
hu_lib.overlay_of = en_lib.id
|
||||||
|
row = PlexItem(
|
||||||
|
rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat",
|
||||||
|
guid="tmdb:1", title_hu="Öldöklés", summary_hu="régi", genres_hu=["Akció"],
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
# The HU section no longer lists this film (empty overlay).
|
||||||
|
_sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {"overlaid": 0})
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
assert row.title_hu is None and row.summary_hu is None and row.genres_hu is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_item_keeps_prior_guid_when_listing_lacks_guid():
|
||||||
|
row = PlexItem(rating_key="m1", kind="movie", guid="tmdb:1")
|
||||||
|
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat"}) # no Guid array
|
||||||
|
assert row.guid == "tmdb:1" # the prior guid survives a guid-less listing
|
||||||
|
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat", "Guid": [{"id": "tmdb://2"}]})
|
||||||
|
assert row.guid == "tmdb:2" # a real guid in the listing still updates it
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_applies_to_all_editions_sharing_a_guid(db):
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
|
||||||
|
db.add_all([en_lib, hu_lib])
|
||||||
|
db.flush()
|
||||||
|
hu_lib.overlay_of = en_lib.id
|
||||||
|
ed1 = PlexItem(rating_key="a", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1")
|
||||||
|
ed2 = PlexItem(rating_key="b", library_id=en_lib.id, kind="movie", title="Heat (Director's Cut)", guid="tmdb:1")
|
||||||
|
db.add_all([ed1, ed2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Two HU entries share the guid — first wins, no double count.
|
||||||
|
stub = _StubPlex(
|
||||||
|
[
|
||||||
|
_hu_movie("h1", "Öldöklés", "első", ["Akció"], "tmdb://1"),
|
||||||
|
_hu_movie("h2", "Öldöklés (dup)", "második", ["Krimi"], "tmdb://1"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
stats = {"overlaid": 0}
|
||||||
|
_sync_overlay(db, stub, hu_lib, en_lib.id, stats)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ed1)
|
||||||
|
db.refresh(ed2)
|
||||||
|
assert ed1.title_hu == "Öldöklés" and ed2.title_hu == "Öldöklés" # both editions overlaid
|
||||||
|
assert ed1.genres_hu == ["Akció"] and ed2.genres_hu == ["Akció"] # first HU entry won
|
||||||
|
assert stats["overlaid"] == 1 # counted once for the guid, not per edition or per dup HU item
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_libs_excludes_overlay_libraries(db):
|
||||||
|
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||||
|
en_shows = PlexLibrary(plex_key="1", title="TV Shows", kind="show")
|
||||||
|
db.add_all([en_lib, en_shows])
|
||||||
|
db.flush()
|
||||||
|
overlay = PlexLibrary(
|
||||||
|
plex_key="9", title="Filmek", kind="movie", metadata_lang="hu", overlay_of=en_lib.id
|
||||||
|
)
|
||||||
|
db.add(overlay)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
movie_ids, show_ids = _content_libs(db)
|
||||||
|
assert en_lib.id in movie_ids
|
||||||
|
assert overlay.id not in movie_ids # the HU overlay is never browsable
|
||||||
|
assert show_ids == [en_shows.id]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's
|
||||||
|
filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name,
|
||||||
|
each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs`
|
||||||
|
scoping (disabled libraries excluded) and the kind filter (episodes are not movies).
|
||||||
|
|
||||||
|
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
PlexShow,
|
||||||
|
PlexState,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.routes.plex.browse import _content_libs
|
||||||
|
|
||||||
|
NAME = "Robert De Niro"
|
||||||
|
|
||||||
|
|
||||||
|
def _get(db, user, monkeypatch, path):
|
||||||
|
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
|
||||||
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
app.dependency_overrides[current_user] = lambda: user
|
||||||
|
try:
|
||||||
|
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_detail_returns_cast_and_director_credits(db, monkeypatch):
|
||||||
|
movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False)
|
||||||
|
db.add_all([movies_lib, shows_lib, off_lib])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
heat = PlexItem(
|
||||||
|
rating_key="m1", library_id=movies_lib.id, kind="movie", title="Heat",
|
||||||
|
year=1995, cast_names=[NAME, "Al Pacino"],
|
||||||
|
)
|
||||||
|
# Director-only credit: the name is NOT in cast_names, only directors → the OR must still catch it.
|
||||||
|
directed = PlexItem(
|
||||||
|
rating_key="m2", library_id=movies_lib.id, kind="movie", title="A Bronx Tale",
|
||||||
|
year=1993, cast_names=["Chazz Palminteri"], directors=[NAME],
|
||||||
|
)
|
||||||
|
# Excluded: a movie in a DISABLED library.
|
||||||
|
hidden = PlexItem(
|
||||||
|
rating_key="m3", library_id=off_lib.id, kind="movie", title="Hidden",
|
||||||
|
year=2000, cast_names=[NAME],
|
||||||
|
)
|
||||||
|
# Excluded: an episode (kind != "movie") that happens to list the name.
|
||||||
|
episode = PlexItem(
|
||||||
|
rating_key="e1", library_id=movies_lib.id, kind="episode", title="An Episode",
|
||||||
|
year=2001, cast_names=[NAME],
|
||||||
|
)
|
||||||
|
show = PlexShow(rating_key="s1", library_id=shows_lib.id, title="A Show", cast_names=[NAME])
|
||||||
|
db.add_all([heat, directed, hidden, episode, show])
|
||||||
|
db.add(PlexPerson(name=NAME, photo_thumb="/img/deniro.jpg"))
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
user = User(email="person@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
assert body["name"] == NAME
|
||||||
|
assert body["photo"] == "/img/deniro.jpg"
|
||||||
|
# Both the cast credit (Heat) and the director-only credit (A Bronx Tale), year desc; NOT the
|
||||||
|
# disabled-library movie and NOT the episode.
|
||||||
|
assert [m["title"] for m in body["movies"]] == ["Heat", "A Bronx Tale"]
|
||||||
|
assert body["movies"][0]["status"] == "watched" # from the caller's PlexState
|
||||||
|
assert body["movies"][1]["status"] == "new"
|
||||||
|
assert all(m["type"] == "movie" for m in body["movies"])
|
||||||
|
# The show credit.
|
||||||
|
assert [s["title"] for s in body["shows"]] == ["A Show"]
|
||||||
|
assert body["shows"][0]["type"] == "show"
|
||||||
|
|
||||||
|
|
||||||
|
def test_person_detail_unknown_name_is_empty(db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Solo", cast_names=["Nobody Else"]))
|
||||||
|
user = User(email="empty@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
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": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_libs_partitions_only_enabled(db):
|
||||||
|
db.add_all([
|
||||||
|
PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True),
|
||||||
|
PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True),
|
||||||
|
PlexLibrary(plex_key="3", title="OffMovie", kind="movie", enabled=False),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
movie_ids, show_ids = _content_libs(db)
|
||||||
|
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""S1 characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion.
|
||||||
|
|
||||||
|
These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library
|
||||||
|
union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup
|
||||||
|
aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conversion can be proven
|
||||||
|
behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane
|
||||||
|
(`db` fixture) — skipped without Postgres.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete, 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, PlexSeason, PlexShow, PlexState, User
|
||||||
|
from app.plex import rollup, sync
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2024, 1, day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
"""A minimal cross-library corpus: 2 movies, 1 show with 2 episodes, and a per-user watch-state
|
||||||
|
where movie m1 is watched and episode e1 is in-progress (so the show rolls up to in_progress)."""
|
||||||
|
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()
|
||||||
|
m1 = PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Alpha Movie", year=2001, added_at=_dt(1))
|
||||||
|
m2 = PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Bravo Movie", year=2002, added_at=_dt(2))
|
||||||
|
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Charlie Show",
|
||||||
|
year=2003, added_at=_dt(3), child_count=1)
|
||||||
|
db.add_all([m1, m2, show])
|
||||||
|
db.flush()
|
||||||
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||||
|
db.add(season)
|
||||||
|
db.flush()
|
||||||
|
e1 = PlexItem(rating_key="e1", library_id=show_lib.id, kind="episode", title="Charlie Pilot",
|
||||||
|
show_id=show.id, season_id=season.id, season_number=1, episode_number=1, added_at=_dt(4))
|
||||||
|
e2 = PlexItem(rating_key="e2", library_id=show_lib.id, kind="episode", title="Charlie Finale",
|
||||||
|
show_id=show.id, season_id=season.id, season_number=1, episode_number=2, added_at=_dt(5))
|
||||||
|
db.add_all([e1, e2])
|
||||||
|
user = User(email="plexbrowse@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
PlexState(user_id=user.id, item_id=m1.id, status="watched"),
|
||||||
|
PlexState(user_id=user.id, item_id=e1.id, position_seconds=120),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show}
|
||||||
|
|
||||||
|
|
||||||
|
def _request(db, user, monkeypatch, method, path, json=None):
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||||
|
return client.request(method, path, json=json)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(db, user, monkeypatch, path):
|
||||||
|
return _request(db, user, monkeypatch, "GET", path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedLibrary:
|
||||||
|
def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["total"] == 3 # 2 movies + 1 show, unioned
|
||||||
|
# default sort = added_at desc: show (day 3) > bravo (day 2) > alpha (day 1)
|
||||||
|
assert [i["id"] for i in body["items"]] == ["s1", "m2", "m1"]
|
||||||
|
show_item = next(i for i in body["items"] if i["type"] == "show")
|
||||||
|
assert show_item["status"] == "in_progress" # e1 in-progress -> show rollup
|
||||||
|
assert show_item["season_count"] == 1
|
||||||
|
assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched"
|
||||||
|
|
||||||
|
def test_scope_movie_only(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json()
|
||||||
|
assert body["total"] == 2
|
||||||
|
assert all(i["type"] == "movie" for i in body["items"])
|
||||||
|
|
||||||
|
def test_scope_show_only(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json()
|
||||||
|
assert [i["id"] for i in body["items"]] == ["s1"]
|
||||||
|
assert body["items"][0]["season_count"] == 1
|
||||||
|
|
||||||
|
def test_watch_state_branches(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
# watched: only movie m1 (the show is in_progress, not fully watched)
|
||||||
|
watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json()
|
||||||
|
assert [i["id"] for i in watched["items"]] == ["m1"]
|
||||||
|
# in_progress: only the show (no movie is in progress)
|
||||||
|
inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json()
|
||||||
|
assert [i["id"] for i in inprog["items"]] == ["s1"]
|
||||||
|
# unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means
|
||||||
|
# 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.)
|
||||||
|
unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json()
|
||||||
|
assert [i["id"] for i in unwatched["items"]] == ["m2"]
|
||||||
|
|
||||||
|
def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json()
|
||||||
|
# only the show title matches in the item grid; the movies don't contain "charlie"
|
||||||
|
assert [i["id"] for i in body["items"]] == ["s1"]
|
||||||
|
assert body["total"] == 1
|
||||||
|
# matching episodes come back grouped in the separate episodes list
|
||||||
|
assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"]
|
||||||
|
|
||||||
|
def test_pagination_caps_items_not_total(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json()
|
||||||
|
assert body["total"] == 3
|
||||||
|
assert [i["id"] for i in body["items"]] == ["s1", "m2"] # first page of the added-desc order
|
||||||
|
|
||||||
|
|
||||||
|
class TestRollup:
|
||||||
|
def test_state_map_new_inprogress_watched(self, db):
|
||||||
|
s = _seed(db)
|
||||||
|
show, user = s["show"], s["user"]
|
||||||
|
# e1 in-progress, e2 new -> in_progress
|
||||||
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"}
|
||||||
|
# empty input short-circuits
|
||||||
|
assert rollup.state_map(db, user.id, []) == {}
|
||||||
|
# a fresh user with no state -> new
|
||||||
|
other = User(email="rollup-other@example.com")
|
||||||
|
db.add(other)
|
||||||
|
db.commit()
|
||||||
|
assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"}
|
||||||
|
# mark every episode watched for `user` -> watched
|
||||||
|
db.execute(delete(PlexState).where(PlexState.user_id == user.id))
|
||||||
|
for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars():
|
||||||
|
db.add(PlexState(user_id=user.id, item_id=e.id, status="watched"))
|
||||||
|
db.commit()
|
||||||
|
assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_paginate(by_type):
|
||||||
|
def _p(_plex, _key, typ):
|
||||||
|
return list(by_type.get(typ, []))
|
||||||
|
return _p
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncUpsertIdempotency:
|
||||||
|
def test_movies_upsert_in_place_no_duplicates(self, db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.commit()
|
||||||
|
metas = [
|
||||||
|
{"ratingKey": "100", "title": "First", "year": 2000, "addedAt": 1700000000},
|
||||||
|
{"ratingKey": "101", "title": "Second", "year": 2001, "addedAt": 1700000001},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(sync, "_paginate", _fake_paginate({sync._TYPE_MOVIE: metas}))
|
||||||
|
sync._sync_movies(db, object(), lib, {"movies": 0})
|
||||||
|
db.commit()
|
||||||
|
# second run with one title changed: same rows, updated in place (upsert, not re-insert)
|
||||||
|
metas[0]["title"] = "First Renamed"
|
||||||
|
sync._sync_movies(db, object(), lib, {"movies": 0})
|
||||||
|
db.commit()
|
||||||
|
rows = db.execute(
|
||||||
|
select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||||
|
).scalars().all()
|
||||||
|
assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows
|
||||||
|
assert next(r for r in rows if r.rating_key == "100").title == "First Renamed"
|
||||||
|
|
||||||
|
def test_shows_upsert_links_episodes_and_is_idempotent(self, db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.commit()
|
||||||
|
by_type = {
|
||||||
|
sync._TYPE_SHOW: [
|
||||||
|
{"ratingKey": "s1", "title": "Show One", "childCount": 1, "addedAt": 1700000000}
|
||||||
|
],
|
||||||
|
sync._TYPE_SEASON: [
|
||||||
|
{"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1}
|
||||||
|
],
|
||||||
|
sync._TYPE_EPISODE: [
|
||||||
|
{"ratingKey": "e1", "title": "Ep One", "grandparentRatingKey": "s1",
|
||||||
|
"parentRatingKey": "se1", "parentIndex": 1, "index": 1, "addedAt": 1700000001}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type))
|
||||||
|
for _ in range(2): # run twice — must be idempotent
|
||||||
|
sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0})
|
||||||
|
db.commit()
|
||||||
|
shows = db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars().all()
|
||||||
|
assert {sh.rating_key for sh in shows} == {"s1"} # no duplicate show
|
||||||
|
ep = db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars().one()
|
||||||
|
assert ep.rating_key == "e1"
|
||||||
|
assert ep.show_id == shows[0].id # episode linked to its show
|
||||||
|
assert (ep.season_number, ep.episode_number) == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFacetsWithData:
|
||||||
|
"""The facets endpoint's wsq builder is the most complex converted query (genre/content-rating
|
||||||
|
JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only
|
||||||
|
covers the null-genre guard; this locks the aggregated numbers with real data."""
|
||||||
|
|
||||||
|
def test_facet_aggregation(self, db, monkeypatch):
|
||||||
|
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()
|
||||||
|
db.add_all([
|
||||||
|
PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", title="M1",
|
||||||
|
genres=["Comedy", "Drama"], content_rating="PG", year=2000, rating=7.5,
|
||||||
|
duration_s=6000),
|
||||||
|
PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", title="M2",
|
||||||
|
genres=["Comedy"], content_rating="R", year=2010, rating=8.0, duration_s=7200),
|
||||||
|
PlexShow(rating_key="s1", library_id=show_lib.id, title="S1",
|
||||||
|
genres=["Drama"], content_rating="TV-14", year=2005, rating=9.0),
|
||||||
|
])
|
||||||
|
user = User(email="facetdata@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json()
|
||||||
|
assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2}
|
||||||
|
assert {c["value"]: c["count"] for c in body["content_ratings"]} == {
|
||||||
|
"PG": 1, "R": 1, "TV-14": 1
|
||||||
|
}
|
||||||
|
assert (body["year_min"], body["year_max"]) == (2000, 2010)
|
||||||
|
assert body["rating_max"] == 9.0
|
||||||
|
# Duration bounds are movie-only.
|
||||||
|
assert (body["duration_min"], body["duration_max"]) == (6000, 7200)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlaylistAndCollectionEndpoints:
|
||||||
|
"""Smoke coverage for the converted CRUD endpoints that had no tests: playlist create/list/detail/
|
||||||
|
add/reorder/remove/delete, and the collections list. A 1.x->2.0 conversion slip in any of these
|
||||||
|
(a lost .scalars(), a wrong .all(), a broken order_by) surfaces here instead of shipping green."""
|
||||||
|
|
||||||
|
def _movie(self, db, lib_id, rk, title):
|
||||||
|
return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title)
|
||||||
|
|
||||||
|
def test_playlist_crud_roundtrip(self, db, monkeypatch):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")])
|
||||||
|
user = User(email="pl@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# create (seeded with m1) -> item_count 1
|
||||||
|
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists",
|
||||||
|
{"title": "Faves", "item_rating_key": "m1"})
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
pid = created.json()["id"]
|
||||||
|
assert created.json()["item_count"] == 1
|
||||||
|
|
||||||
|
# list -> the playlist with its count
|
||||||
|
listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"]
|
||||||
|
assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)]
|
||||||
|
|
||||||
|
# add m2, then reorder to [m2, m1]
|
||||||
|
add = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items",
|
||||||
|
{"item_rating_key": "m2"})
|
||||||
|
assert add.status_code == 200, add.text
|
||||||
|
_request(db, user, monkeypatch, "PUT", f"/api/plex/playlists/{pid}/order",
|
||||||
|
{"item_rating_keys": ["m2", "m1"]})
|
||||||
|
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||||
|
assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order
|
||||||
|
|
||||||
|
# remove m1 -> one item left
|
||||||
|
rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1")
|
||||||
|
assert rm.status_code == 200, rm.text
|
||||||
|
after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||||
|
assert [it["id"] for it in after["items"]] == ["m2"]
|
||||||
|
|
||||||
|
# delete the playlist -> gone from the list
|
||||||
|
_request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}")
|
||||||
|
assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == []
|
||||||
|
|
||||||
|
def test_playlist_bulk_add_and_remove(self, db, monkeypatch):
|
||||||
|
# Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's
|
||||||
|
# synchronize_session=False path, which the single add/remove roundtrip never exercises.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")])
|
||||||
|
user = User(email="bulk@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"})
|
||||||
|
pid = created.json()["id"]
|
||||||
|
|
||||||
|
added = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items/bulk",
|
||||||
|
{"item_rating_keys": ["m1", "m2", "m3"]})
|
||||||
|
assert added.status_code == 200, added.text
|
||||||
|
assert added.json() == {"added": 3, "item_count": 3}
|
||||||
|
|
||||||
|
removed = _request(db, user, monkeypatch, "POST",
|
||||||
|
f"/api/plex/playlists/{pid}/items/remove-bulk",
|
||||||
|
{"item_rating_keys": ["m2"]})
|
||||||
|
assert removed.status_code == 200, removed.text
|
||||||
|
assert removed.json() == {"removed": 1, "item_count": 2}
|
||||||
|
|
||||||
|
detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json()
|
||||||
|
assert [it["id"] for it in detail["items"]] == ["m1", "m3"] # input order preserved, m2 gone
|
||||||
|
|
||||||
|
def test_collections_list(self, db, monkeypatch):
|
||||||
|
from app.models import PlexCollection
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12),
|
||||||
|
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5),
|
||||||
|
])
|
||||||
|
user = User(email="col@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# unified scope (no library param) -> most-populated first
|
||||||
|
body = _get(db, user, monkeypatch, "/api/plex/collections").json()
|
||||||
|
assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"]
|
||||||
|
# name filter
|
||||||
|
filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json()
|
||||||
|
assert [c["title"] for c in filtered["collections"]] == ["Pixar"]
|
||||||
|
|
||||||
|
def test_item_state_watched_then_new(self, db, monkeypatch):
|
||||||
|
# No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which
|
||||||
|
# is what the conversion touched (the st lookup + the delete on ->new).
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
|
||||||
|
user = User(email="state@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
watched = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state",
|
||||||
|
{"status": "watched"})
|
||||||
|
assert watched.status_code == 200, watched.text
|
||||||
|
assert watched.json()["status"] == "watched"
|
||||||
|
row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one()
|
||||||
|
assert row.status == "watched"
|
||||||
|
|
||||||
|
# -> new deletes the state row
|
||||||
|
back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"})
|
||||||
|
assert back.status_code == 200, back.text
|
||||||
|
assert db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() is None
|
||||||
@@ -55,3 +55,15 @@ def test_log_action_survives_caller_rollback(db):
|
|||||||
assert events[0].units == 0
|
assert events[0].units == 0
|
||||||
# ... and the caller's uncommitted user is gone.
|
# ... and the caller's uncommitted user is gone.
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 1
|
assert db.scalar(select(func.count()).select_from(User)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfill_reserve_reached_tracks_the_boundary(db):
|
||||||
|
# The shared yield gate for every backfill/explore/revalidation path: True once today's remaining
|
||||||
|
# budget is at or below the backfill reserve. It reads in its OWN session, so it must reflect
|
||||||
|
# record_usage's independently-committed spend — and the boundary is inclusive (<=). Defaults:
|
||||||
|
# quota_daily_budget 9000, backfill_quota_reserve 2000 -> reached once 7000 units are used.
|
||||||
|
assert quota.backfill_reserve_reached() is False # 0 used, remaining 9000
|
||||||
|
quota.record_usage(6999) # remaining 2001, just above the reserve
|
||||||
|
assert quota.backfill_reserve_reached() is False
|
||||||
|
quota.record_usage(1) # remaining 2000, exactly at the reserve
|
||||||
|
assert quota.backfill_reserve_reached() is True # boundary is <=, so the reserve is reached
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Crypto primitives — a guard for the cryptography (Fernet) + argon2 dependency bumps.
|
||||||
|
|
||||||
|
Added with the cryptography 46->50 CVE bump: encrypt/decrypt/decrypt_optional had NO test, and they are
|
||||||
|
the token-at-rest path (Google/API/SMTP/Plex secrets in system_config, OAuth refresh tokens). A fresh
|
||||||
|
Fernet key is monkeypatched in so the test doesn't depend on TOKEN_ENCRYPTION_KEY being set in the env.
|
||||||
|
Pure-logic (no DB).
|
||||||
|
"""
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
|
||||||
|
def test_fernet_encrypt_decrypt_roundtrip(monkeypatch):
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
assert security.decrypt(security.encrypt("hunter2")) == "hunter2"
|
||||||
|
assert security.encrypt(None) is None
|
||||||
|
assert security.decrypt(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_optional_degrades_instead_of_raising(monkeypatch):
|
||||||
|
# Garbage / absent input -> None (never raises).
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||||
|
assert security.decrypt_optional("not-a-valid-token") is None
|
||||||
|
assert security.decrypt_optional(None) is None
|
||||||
|
# And a ciphertext written under a DIFFERENT key (the TOKEN_ENCRYPTION_KEY-mismatch case that B-core-2
|
||||||
|
# made non-fatal) also degrades to None rather than raising InvalidToken.
|
||||||
|
ciphertext = security.encrypt("secret")
|
||||||
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) # rotate the key
|
||||||
|
assert security.decrypt_optional(ciphertext) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_hash_and_verify_roundtrip():
|
||||||
|
hashed = security.hash_password("correct horse battery")
|
||||||
|
assert security.verify_password("correct horse battery", hashed) is True
|
||||||
|
assert security.verify_password("wrong password", hashed) is False
|
||||||
|
assert security.verify_password("anything", None) is False
|
||||||
@@ -88,7 +88,7 @@ def test_no_progress_stall_kills_and_fails_the_job(monkeypatch, rec):
|
|||||||
|
|
||||||
|
|
||||||
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
||||||
# A route flipped the job to 'paused'/'cancelled'. Kill + free the asset, but do NOT overwrite the
|
# A route flipped the job to 'paused'. Kill + free the asset, but do NOT overwrite the
|
||||||
# status with an error and do NOT requeue.
|
# status with an error and do NOT requeue.
|
||||||
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
||||||
_liveness(monkeypatch, ("paused", 1.0))
|
_liveness(monkeypatch, ("paused", 1.0))
|
||||||
@@ -98,6 +98,19 @@ def test_route_pause_kills_but_keeps_the_route_status(monkeypatch, rec):
|
|||||||
assert "set_job" not in names and "requeue" not in names
|
assert "set_job" not in names and "requeue" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_cancel_kills_but_keeps_the_route_status(monkeypatch, rec):
|
||||||
|
# B-sub-1 regression: the cancel ROUTE writes the literal 'canceled' (models.py), but the supervisor
|
||||||
|
# used to compare against 'cancelled' — so this branch was DEAD and a canceled job's child ran on to
|
||||||
|
# completion, overwriting 'canceled' with 'done' and double-releasing its asset. The supervisor MUST
|
||||||
|
# treat the route's actual literal as a stop: kill + free the asset, no error write, no requeue.
|
||||||
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
||||||
|
_liveness(monkeypatch, ("canceled", 1.0))
|
||||||
|
worker._run_supervised(1, 99)
|
||||||
|
names = _names(rec)
|
||||||
|
assert "stop_child" in names and "reset_asset" in names
|
||||||
|
assert "set_job" not in names and "requeue" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
def test_wall_clock_cap_kills_even_while_progressing(monkeypatch, rec):
|
||||||
monkeypatch.setattr(worker.settings, "download_wall_clock_max_seconds", 0)
|
monkeypatch.setattr(worker.settings, "download_wall_clock_max_seconds", 0)
|
||||||
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
_spawn(monkeypatch, _FakeProc(["timeout"]))
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ The installer asks for the **public URL** where the instance will be reached (ju
|
|||||||
`http://localhost:8080` to try it locally). It then:
|
`http://localhost:8080` to try it locally). It then:
|
||||||
|
|
||||||
- generates the secrets it needs (`SECRET_KEY`, `TOKEN_ENCRYPTION_KEY`, a database password) into a
|
- generates the secrets it needs (`SECRET_KEY`, `TOKEN_ENCRYPTION_KEY`, a database password) into a
|
||||||
local `.env` file — keep that file private,
|
local `.env` file — keep that file private. **Do not change `TOKEN_ENCRYPTION_KEY` once the app has
|
||||||
|
stored any secret** (Google credentials, API keys, SMTP password, Plex token): those values are
|
||||||
|
encrypted with it, and a changed key makes them unreadable. If it is ever lost or rotated, clear the
|
||||||
|
affected rows from the `system_config` table and re-enter the secrets in the admin Configuration page,
|
||||||
- pulls the image and starts the app + database,
|
- pulls the image and starts the app + database,
|
||||||
- prints the **setup wizard URL**, which looks like `…/setup?token=…`.
|
- prints the **setup wizard URL**, which looks like `…/setup?token=…`.
|
||||||
|
|
||||||
|
|||||||
@@ -351,7 +351,13 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
|
||||||
|
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
|
||||||
|
can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies
|
||||||
|
`contain: layout`, making this column a containing block for `position: fixed` descendants
|
||||||
|
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
|
||||||
|
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
|
||||||
|
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
|
||||||
{channelView ? (
|
{channelView ? (
|
||||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ export default function ChannelLink({
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
|
||||||
}}
|
}}
|
||||||
|
// title = the full name, so a truncated fluid-column name is still readable on hover.
|
||||||
|
title={name}
|
||||||
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium truncate min-w-0">{name}</span>
|
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
|
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { PriorityStepper } from "./PriorityStepper";
|
import { PriorityStepper } from "./PriorityStepper";
|
||||||
@@ -79,9 +81,12 @@ export default function ChannelPage({
|
|||||||
const MAX_BANNER_RETRIES = 3;
|
const MAX_BANNER_RETRIES = 3;
|
||||||
const [bannerAttempt, setBannerAttempt] = useState(0);
|
const [bannerAttempt, setBannerAttempt] = useState(0);
|
||||||
|
|
||||||
const detail = useQuery({
|
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
|
||||||
queryKey: qk.channel(channelId),
|
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
|
||||||
queryFn: () => api.channelDetail(channelId),
|
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
|
||||||
|
// in. Same queryKey as before, so mutation invalidations still hit.
|
||||||
|
const detail = useLiveQuery(qk.channel(channelId), () => api.channelDetail(channelId), {
|
||||||
|
intervalMs: channelDetailPollMs,
|
||||||
});
|
});
|
||||||
const ch = detail.data;
|
const ch = detail.data;
|
||||||
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
||||||
@@ -264,6 +269,15 @@ export default function ChannelPage({
|
|||||||
ch?.total_view_count != null
|
ch?.total_view_count != null
|
||||||
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
||||||
: null,
|
: null,
|
||||||
|
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
|
||||||
|
// once we actually hold some of the channel's videos.
|
||||||
|
ch && ch.stored_videos > 0
|
||||||
|
? t("channel.videoTypes", {
|
||||||
|
normal: ch.count_normal,
|
||||||
|
short: ch.count_short,
|
||||||
|
live: ch.count_live,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||||
@@ -408,13 +422,18 @@ export default function ChannelPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
|
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
|
||||||
Once queued (deep_requested) it shows as loading; hidden once the backfill is done. */}
|
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
|
||||||
|
While loading, the label carries the live stored/total count (the detail poll refreshes
|
||||||
|
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||||
|
spinner. */}
|
||||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||||
<button
|
<button
|
||||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||||
title={
|
title={
|
||||||
ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")
|
ch.deep_requested
|
||||||
|
? t("channel.fullHistoryLoadingHint")
|
||||||
|
: t("channel.fullHistory")
|
||||||
}
|
}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
|
||||||
>
|
>
|
||||||
@@ -424,7 +443,14 @@ export default function ChannelPage({
|
|||||||
<History className="w-4 h-4" />
|
<History className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden sm:inline whitespace-nowrap">
|
<span className="hidden sm:inline whitespace-nowrap">
|
||||||
{ch.deep_requested ? t("channel.fullHistoryLoading") : t("channel.fullHistory")}
|
{!ch.deep_requested
|
||||||
|
? t("channel.fullHistory")
|
||||||
|
: ch.video_count != null && ch.stored_videos < ch.video_count
|
||||||
|
? t("channel.fullHistoryProgress", {
|
||||||
|
stored: ch.stored_videos,
|
||||||
|
total: ch.video_count,
|
||||||
|
})
|
||||||
|
: t("channel.fullHistoryLoading")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ExcludedChannelList from "./ExcludedChannelList";
|
|||||||
import { api, type ManagedChannel } from "../lib/api";
|
import { api, type ManagedChannel } from "../lib/api";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { buildChannelColumns } from "./channelColumns";
|
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable from "./DataTable";
|
import DataTable from "./DataTable";
|
||||||
@@ -115,7 +115,7 @@ export default function Channels() {
|
|||||||
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
|
||||||
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||||
@@ -131,7 +131,7 @@ export default function Channels() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
||||||
@@ -236,17 +236,22 @@ export default function Channels() {
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
}, [filtersResetToken]);
|
}, [filtersResetToken]);
|
||||||
|
|
||||||
// The sync-status AND tag filters are now controlled column filters (`filters.sync` / `filters.tags`)
|
// Tag/priority/text filters stay controlled COLUMN filters (`filters.tags` etc.) shared by the top
|
||||||
// shared by the top chips, the column funnels, and the card layout — no separate pre-filter. Hidden
|
// chips, the column funnels and the cards. The sync-status filter (`filters.sync`) is no longer a
|
||||||
// channels have their own tab, so they're excluded from the subscribed table (no more dimmed rows).
|
// column — R14 retired the Sync column and folded its status into a ✓ charm on Stored — so it's
|
||||||
// Only the name search filters here; the column filters (sync/tags/priority/text funnels) are applied
|
// applied as a pre-filter (`applySyncFilter`) over the rows fed to BOTH the table and the cards,
|
||||||
// by DataTable in table mode and by filterRows below in card mode, over the same `columns`.
|
// still driven by the same top chips. Hidden channels have their own tab (excluded here). Only the
|
||||||
|
// name search filters at this step; the remaining column funnels run in DataTable / filterRows below.
|
||||||
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
const hiddenChannels = (channelsQuery.data ?? []).filter((c) => c.hidden);
|
||||||
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
const channels = (channelsQuery.data ?? []).filter((c) => !c.hidden);
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const visibleChannels = channels.filter(
|
const visibleChannels = channels.filter(
|
||||||
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
(c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
|
// Sync-status pre-filter (top chips → `filters.sync`), applied before the table/cards see the rows.
|
||||||
|
// (Inline, not memoised: `visibleChannels` is a fresh array each render, so a useMemo keyed on it
|
||||||
|
// could never hit its cache — and applySyncFilter is a cheap single pass anyway.)
|
||||||
|
const syncedChannels = applySyncFilter(visibleChannels, filters.sync);
|
||||||
const s = statusQuery.data;
|
const s = statusQuery.data;
|
||||||
|
|
||||||
const onView = (c: ManagedChannel) =>
|
const onView = (c: ManagedChannel) =>
|
||||||
@@ -371,8 +376,8 @@ export default function Channels() {
|
|||||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||||
const sortedChannels = isCards
|
const sortedChannels = isCards
|
||||||
? sortRows(filterRows(visibleChannels, columns, filters), columns, sort)
|
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort)
|
||||||
: visibleChannels;
|
: syncedChannels;
|
||||||
const {
|
const {
|
||||||
paged: pagedCards,
|
paged: pagedCards,
|
||||||
pagerProps,
|
pagerProps,
|
||||||
@@ -527,7 +532,7 @@ export default function Channels() {
|
|||||||
<LoadingState label={t("channels.loading")} />
|
<LoadingState label={t("channels.loading")} />
|
||||||
) : layout === "table" ? (
|
) : layout === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={visibleChannels}
|
rows={syncedChannels}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey={(c) => c.id}
|
rowKey={(c) => c.id}
|
||||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||||
@@ -539,6 +544,7 @@ export default function Channels() {
|
|||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
|
fixedLayout
|
||||||
emptyText={t("channels.empty")}
|
emptyText={t("channels.empty")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The R14 zoom-fit foundation: DataTable's opt-in `fixedLayout` renders a <colgroup> and puts the
|
||||||
|
// table into `table-layout: fixed`, so columns with a width keep it and the rest (fluid text columns)
|
||||||
|
// split the remainder + truncate instead of pushing the table wider than its container. Off by
|
||||||
|
// default so existing content-sized tables are untouched.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type Row = { a: string; b: string };
|
||||||
|
const columns: Column<Row>[] = [
|
||||||
|
{ key: "a", header: "A", width: "5rem", render: (r) => r.a },
|
||||||
|
{ key: "b", header: "B", render: (r) => r.b }, // fluid — no width
|
||||||
|
];
|
||||||
|
const rows: Row[] = [{ a: "1", b: "hello" }];
|
||||||
|
|
||||||
|
describe("DataTable fixedLayout", () => {
|
||||||
|
it("renders a width-bearing colgroup and table-fixed only when fixedLayout is set", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DataTable rows={rows} columns={columns} rowKey={(r) => r.a} fixedLayout />,
|
||||||
|
);
|
||||||
|
const table = container.querySelector("table");
|
||||||
|
expect(table?.className).toContain("table-fixed");
|
||||||
|
|
||||||
|
const cols = container.querySelectorAll("colgroup col");
|
||||||
|
expect(cols).toHaveLength(2);
|
||||||
|
expect((cols[0] as HTMLElement).style.width).toBe("5rem"); // fixed column keeps its width
|
||||||
|
expect((cols[1] as HTMLElement).style.width).toBe(""); // fluid column: no width, splits remainder
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the colgroup and stays auto-layout by default", () => {
|
||||||
|
const { container } = render(<DataTable rows={rows} columns={columns} rowKey={(r) => r.a} />);
|
||||||
|
expect(container.querySelector("colgroup")).toBeNull();
|
||||||
|
expect(container.querySelector("table")?.className).not.toContain("table-fixed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,7 @@ export default function DataTable<T>({
|
|||||||
onSortChange,
|
onSortChange,
|
||||||
filters: filtersProp,
|
filters: filtersProp,
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
|
fixedLayout = false,
|
||||||
}: {
|
}: {
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: Column<T>[];
|
columns: Column<T>[];
|
||||||
@@ -96,6 +97,12 @@ export default function DataTable<T>({
|
|||||||
// default internal, persistKey-backed filters.
|
// default internal, persistKey-backed filters.
|
||||||
filters?: FilterMap;
|
filters?: FilterMap;
|
||||||
onFiltersChange?: (next: FilterMap) => void;
|
onFiltersChange?: (next: FilterMap) => void;
|
||||||
|
// Opt into `table-layout: fixed` (default is `auto`). In fixed mode the table width == its
|
||||||
|
// container at every zoom/text-size — the columns with an explicit `width` keep it, and the rest
|
||||||
|
// (the fluid text columns) split what remains and TRUNCATE instead of pushing the table wider than
|
||||||
|
// the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are
|
||||||
|
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
|
||||||
|
fixedLayout?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initial = loadPersist(persistKey);
|
const initial = loadPersist(persistKey);
|
||||||
@@ -224,7 +231,17 @@ export default function DataTable<T>({
|
|||||||
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
|
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
|
||||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className={`w-full border-collapse text-sm ${fixedLayout ? "table-fixed" : ""}`}>
|
||||||
|
{/* In fixed-layout mode a colgroup drives the column widths: those with an explicit
|
||||||
|
`width` keep it, and the unspecified (fluid) columns split the remainder — so the
|
||||||
|
table never exceeds its container and the fluid cells truncate instead. */}
|
||||||
|
{fixedLayout && (
|
||||||
|
<colgroup>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
)}
|
||||||
<ColumnHead
|
<ColumnHead
|
||||||
columns={columns}
|
columns={columns}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
|
|||||||
@@ -226,9 +226,11 @@ export default function Feed({
|
|||||||
}
|
}
|
||||||
}, [query.dataUpdatedAt]);
|
}, [query.dataUpdatedAt]);
|
||||||
|
|
||||||
const countQuery = useQuery({
|
// One shared query with the sidebar (same key + filters -> React Query dedupes to a single request):
|
||||||
queryKey: qk.feedCount(queryFilters),
|
// the feed reads `.count`, the sidebar reads `.facets` (PERF-13, replaces feedCount + facets).
|
||||||
queryFn: () => api.feedCount(queryFilters),
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: qk.feedSummary(queryFilters),
|
||||||
|
queryFn: () => api.feedSummary(queryFilters),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
enabled: !ytActive,
|
enabled: !ytActive,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
@@ -252,7 +254,11 @@ export default function Feed({
|
|||||||
api
|
api
|
||||||
.setState(id, status)
|
.setState(id, status)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
// Mark stale WITHOUT refetching: the optimistic `overrides` layer + the matchesView filter
|
||||||
|
// already show the correct state (a hidden/watched card drops out immediately), so refetching
|
||||||
|
// every loaded infinite-query page here was pure waste — 10 pages x rapid marks = a refetch
|
||||||
|
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
if (status === "hidden") {
|
if (status === "hidden") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
notify({
|
notify({
|
||||||
@@ -309,7 +315,8 @@ export default function Feed({
|
|||||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
setOverrides((o) => ({ ...o, [id]: "new" }));
|
||||||
api
|
api
|
||||||
.clearState(id)
|
.clearState(id)
|
||||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
|
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setOverrides((o) => {
|
setOverrides((o) => {
|
||||||
const next = { ...o };
|
const next = { ...o };
|
||||||
@@ -358,8 +365,7 @@ export default function Feed({
|
|||||||
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
// pre-search cache. Drop them so the feed re-fetches fresh on return.
|
||||||
const dropFeedCaches = () => {
|
const dropFeedCaches = () => {
|
||||||
qc.removeQueries({ queryKey: qk.feed() });
|
qc.removeQueries({ queryKey: qk.feed() });
|
||||||
qc.removeQueries({ queryKey: qk.feedCount() });
|
qc.removeQueries({ queryKey: qk.feedSummary() });
|
||||||
qc.removeQueries({ queryKey: qk.facets() });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Live YouTube search mode -------------------------------------------------------------
|
// --- Live YouTube search mode -------------------------------------------------------------
|
||||||
@@ -622,10 +628,10 @@ export default function Feed({
|
|||||||
)}
|
)}
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||||
{countQuery.data
|
{summaryQuery.data
|
||||||
? t("feed.videoCount", {
|
? t("feed.videoCount", {
|
||||||
count: countQuery.data.count,
|
count: summaryQuery.data.count,
|
||||||
formattedCount: countQuery.data.count.toLocaleString(),
|
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||||
})
|
})
|
||||||
: " "}
|
: " "}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
import { type ReactNode } from "react";
|
import { forwardRef, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Youtube } from "lucide-react";
|
import { Youtube } from "lucide-react";
|
||||||
import type { Me } from "../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
import PlexSearchBox from "./PlexSearchBox";
|
||||||
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
|
||||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
// 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
|
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||||
// sync chip at the right end.
|
// sync chip at the right end.
|
||||||
export default function Header({
|
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
||||||
me,
|
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
||||||
onYtSearch,
|
// owns both the header slot and the padded content wrapper (E5 S7).
|
||||||
}: {
|
const Header = forwardRef<
|
||||||
me: Me;
|
HTMLDivElement,
|
||||||
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
{
|
||||||
// which can't spend the shared quota).
|
me: Me;
|
||||||
onYtSearch: (q: string) => void;
|
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
||||||
}) {
|
// which can't spend the shared quota).
|
||||||
|
onYtSearch: (q: string) => void;
|
||||||
|
}
|
||||||
|
>(function Header({ me, onYtSearch }, ref) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page } = useNavigation();
|
const { page } = useNavigation();
|
||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
@@ -34,8 +38,6 @@ export default function Header({
|
|||||||
// "go to full history" jump is a ChannelsProvider action.
|
// "go to full history" jump is a ChannelsProvider action.
|
||||||
const { search: playlistsSearch } = usePlaylists();
|
const { search: playlistsSearch } = usePlaylists();
|
||||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||||
const { q: plexQ } = usePlex();
|
|
||||||
const { setQ: setPlexQ } = usePlexActions();
|
|
||||||
const { search: channelsSearch } = useChannels();
|
const { search: channelsSearch } = useChannels();
|
||||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||||
@@ -70,14 +72,7 @@ export default function Header({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === "plex" && me.plex_enabled) {
|
} else if (page === "plex" && me.plex_enabled) {
|
||||||
search = (
|
search = <PlexSearchBox />;
|
||||||
<SearchBar
|
|
||||||
value={plexQ}
|
|
||||||
placeholder={t("plex.searchPlaceholder")}
|
|
||||||
onChange={setPlexQ}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "channels") {
|
} else if (page === "channels") {
|
||||||
search = (
|
search = (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
@@ -102,7 +97,10 @@ export default function Header({
|
|||||||
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
||||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
||||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||||
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
|
<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"
|
||||||
|
>
|
||||||
<ModuleName
|
<ModuleName
|
||||||
label={t(moduleLabelKey[page])}
|
label={t(moduleLabelKey[page])}
|
||||||
labels={moduleLabels}
|
labels={moduleLabels}
|
||||||
@@ -112,6 +110,9 @@ export default function Header({
|
|||||||
canNext={multiModule}
|
canNext={multiModule}
|
||||||
/>
|
/>
|
||||||
{search}
|
{search}
|
||||||
|
{/* 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. */}
|
||||||
|
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
||||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
{/* 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
|
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
|
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
||||||
@@ -121,4 +122,6 @@ export default function Header({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default Header;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function NotificationsPanel() {
|
|||||||
.setState(meta.videoId, "new")
|
.setState(meta.videoId, "new")
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
|
||||||
resolveVideo(meta.videoId);
|
resolveVideo(meta.videoId);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Feed/channels/status now have content — refresh the app behind the wizard.
|
// Feed/channels/status now have content — refresh the app behind the wizard.
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||||
qc.invalidateQueries({ queryKey: qk.feedCount() });
|
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E5 S7: PageShell measures the floating header and publishes its real height as `--hdr-h` onto the
|
||||||
|
// content wrapper, so a header that flex-wraps to 2–3 rows on a narrow window never overlaps the
|
||||||
|
// content below it. jsdom does no layout (offsetHeight is 0) and has no ResizeObserver, so we stub
|
||||||
|
// both: a ResizeObserver that hands us its callback, and offset metrics on the measured header.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import PageShell from "./PageShell";
|
||||||
|
|
||||||
|
vi.mock("./PageScroller", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let roCb: (() => void) | null = null;
|
||||||
|
beforeEach(() => {
|
||||||
|
roCb = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
constructor(cb: () => void) {
|
||||||
|
roCb = cb;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PageShell --hdr-h (E5 S7)", () => {
|
||||||
|
it("publishes the measured header height onto the content wrapper", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PageShell header={<div data-testid="hdr">H</div>}>content</PageShell>,
|
||||||
|
);
|
||||||
|
const content = container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement;
|
||||||
|
expect(content).toBeTruthy();
|
||||||
|
// Give the header a real (multi-row) box, then fire the ResizeObserver callback the effect made.
|
||||||
|
const hdr = container.querySelector('[data-testid="hdr"]') as HTMLElement;
|
||||||
|
Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true });
|
||||||
|
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
|
||||||
|
roCb?.();
|
||||||
|
expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no `--hdr-h` pad when there is no header", () => {
|
||||||
|
const { container } = render(<PageShell>content</PageShell>);
|
||||||
|
expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
import {
|
||||||
|
cloneElement,
|
||||||
|
createContext,
|
||||||
|
isValidElement,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactElement,
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import PageScroller from "./PageScroller";
|
import PageScroller from "./PageScroller";
|
||||||
|
|
||||||
@@ -52,10 +63,39 @@ export default function PageShell({
|
|||||||
fadeTop?: boolean;
|
fadeTop?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||||
|
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
|
||||||
|
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
|
||||||
|
// would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto
|
||||||
|
// its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so
|
||||||
|
// the two live together with no assumption about the header's DOM parent.
|
||||||
|
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const h = headerRef.current;
|
||||||
|
const c = contentRef.current;
|
||||||
|
if (!h || !c) return;
|
||||||
|
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
|
||||||
|
const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`);
|
||||||
|
apply();
|
||||||
|
const ro = new ResizeObserver(apply);
|
||||||
|
ro.observe(h);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
c.style.removeProperty("--hdr-h");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const measuredHeader = isValidElement(header)
|
||||||
|
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
|
||||||
|
ref: headerRef,
|
||||||
|
})
|
||||||
|
: header;
|
||||||
return (
|
return (
|
||||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||||
{header}
|
{measuredHeader}
|
||||||
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}>
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||||
|
>
|
||||||
{banners}
|
{banners}
|
||||||
<div ref={setToolbarSlot} className="shrink-0" />
|
<div ref={setToolbarSlot} className="shrink-0" />
|
||||||
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
||||||
|
|||||||
@@ -862,10 +862,13 @@ export default function PlayerModal({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
window.removeEventListener("pagehide", onHide);
|
window.removeEventListener("pagehide", onHide);
|
||||||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
// Final flush, then mark the feed + playlists stale so resume bars reflect this session on the
|
||||||
|
// next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2:
|
||||||
|
// opening/closing the player through a watch session otherwise re-runs the whole infinite query
|
||||||
|
// each time). Progress is already persisted server-side; only the on-screen resume bar waits.
|
||||||
Promise.resolve(persist()).finally(() => {
|
Promise.resolve(persist()).finally(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
|
||||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" });
|
||||||
});
|
});
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
if (p && typeof p.destroy === "function") {
|
if (p && typeof p.destroy === "function") {
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { healPlexSort } from "../lib/plexSort";
|
import { healPlexField } from "../lib/plexSearch";
|
||||||
|
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
|
import { pickRatingBadge } from "../lib/plexRating";
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -42,6 +47,7 @@ import {
|
|||||||
type PlexCard,
|
type PlexCard,
|
||||||
type PlexCastMember,
|
type PlexCastMember,
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
|
type PlexPersonDetail,
|
||||||
type PlexSeasonDetail,
|
type PlexSeasonDetail,
|
||||||
type PlexUnifiedResult,
|
type PlexUnifiedResult,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
@@ -74,6 +80,7 @@ type Sub =
|
|||||||
| { kind: "season"; showId: string; seasonId: string }
|
| { kind: "season"; showId: string; seasonId: string }
|
||||||
| { kind: "player"; id: string; queue?: string[] }
|
| { kind: "player"; id: string; queue?: string[] }
|
||||||
| { kind: "info"; id: string }
|
| { kind: "info"; id: string }
|
||||||
|
| { kind: "person"; name: string }
|
||||||
| { kind: "playlist"; id: number };
|
| { kind: "playlist"; id: number };
|
||||||
|
|
||||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||||
@@ -84,15 +91,40 @@ type Sub =
|
|||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
|
|
||||||
|
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
|
||||||
|
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
|
||||||
|
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
|
||||||
|
// browse grid and the person page, each of which owns a different cache shape.
|
||||||
|
async function toggleWatchState(
|
||||||
|
card: PlexCard,
|
||||||
|
t: TFunction,
|
||||||
|
handlers: { optimistic: (next: string) => void; resync: () => void },
|
||||||
|
) {
|
||||||
|
const next = card.status === "watched" ? "new" : "watched";
|
||||||
|
handlers.optimistic(next);
|
||||||
|
try {
|
||||||
|
await api.plexSetState(card.id, next);
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, t("plex.watchedFailed"));
|
||||||
|
}
|
||||||
|
handlers.resync();
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlexBrowse() {
|
export default function PlexBrowse() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
||||||
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
|
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
||||||
|
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
||||||
|
const lang = plexLang();
|
||||||
|
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
|
||||||
|
// genre-clear effect (below) can settle first (see the effect for why).
|
||||||
|
const [committedLang, setCommittedLang] = useState(lang);
|
||||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||||
const plexChips = usePlexActiveFilters();
|
const plexChips = usePlexActiveFilters();
|
||||||
@@ -100,6 +132,9 @@ export default function PlexBrowse() {
|
|||||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||||
const sortState = healPlexSort(sort, scope);
|
const sortState = healPlexSort(sort, scope);
|
||||||
|
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||||
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||||
|
const fieldState = healPlexField(field, scope);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||||
|
|
||||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||||
@@ -112,6 +147,19 @@ export default function PlexBrowse() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openPlaylist]);
|
}, [openPlaylist]);
|
||||||
|
|
||||||
|
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||||
|
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumpTo != null) {
|
||||||
|
if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top
|
||||||
|
sub.open(
|
||||||
|
jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id },
|
||||||
|
);
|
||||||
|
setJumpTo(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [jumpTo]);
|
||||||
|
|
||||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||||
@@ -141,7 +189,7 @@ export default function PlexBrowse() {
|
|||||||
const tag = (el?.tagName || "").toLowerCase();
|
const tag = (el?.tagName || "").toLowerCase();
|
||||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||||
return;
|
return;
|
||||||
if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
|
if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sub.back();
|
sub.back();
|
||||||
}
|
}
|
||||||
@@ -151,15 +199,19 @@ export default function PlexBrowse() {
|
|||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
|
||||||
enabled: !!scope && sub.view.kind === "grid",
|
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
|
||||||
|
// settles), not a throwaway one with stale-language genres followed by a second.
|
||||||
|
enabled: !!scope && sub.view.kind === "grid" && committedLang === lang,
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexLibrary({
|
api.plexLibrary({
|
||||||
scope,
|
scope,
|
||||||
q: dq || undefined,
|
q: dq || undefined,
|
||||||
|
field: fieldState,
|
||||||
sort: sortState,
|
sort: sortState,
|
||||||
show,
|
show,
|
||||||
|
lang,
|
||||||
filters,
|
filters,
|
||||||
offset: pageParam as number,
|
offset: pageParam as number,
|
||||||
limit: PAGE,
|
limit: PAGE,
|
||||||
@@ -170,6 +222,18 @@ export default function PlexBrowse() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
|
||||||
|
// selection made in one language matches nothing against the other's column and would silently empty
|
||||||
|
// the grid. On a language flip, drop the genre selection — and gate the grid query on the language
|
||||||
|
// being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and
|
||||||
|
// then a second after the clear; the query waits for both to settle into a single fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
if (committedLang !== lang) {
|
||||||
|
setCommittedLang(lang);
|
||||||
|
if (filters.genres.length) setFilters({ ...filters, genres: [] });
|
||||||
|
}
|
||||||
|
}, [lang, committedLang, filters, setFilters]);
|
||||||
|
|
||||||
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const total = browseQ.data?.pages[0]?.total ?? 0;
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
||||||
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
|
||||||
@@ -215,6 +279,14 @@ export default function PlexBrowse() {
|
|||||||
infoScrollRef.current = 0; // fresh info page starts at the top
|
infoScrollRef.current = 0; // fresh info page starts at the top
|
||||||
sub.open({ kind: "info", id: card.id });
|
sub.open({ kind: "info", id: card.id });
|
||||||
}
|
}
|
||||||
|
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
|
||||||
|
// Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where
|
||||||
|
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
|
||||||
|
// later info page would wrongly restore this stale offset.
|
||||||
|
function onOpenPerson(name: string) {
|
||||||
|
if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
sub.open({ kind: "person", name });
|
||||||
|
}
|
||||||
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
|
||||||
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
|
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
|
||||||
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
|
||||||
@@ -232,18 +304,11 @@ export default function PlexBrowse() {
|
|||||||
: old,
|
: old,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
async function toggleWatched(card: PlexCard) {
|
const toggleWatched = (card: PlexCard) =>
|
||||||
const next = card.status === "watched" ? "new" : "watched";
|
toggleWatchState(card, t, {
|
||||||
optimisticStatus(card.id, next);
|
optimistic: (next) => optimisticStatus(card.id, next),
|
||||||
// On failure surface the server's reason (403/404/429) — the toggle used to fail silently, so the
|
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||||
// click looked dead. The invalidate below runs either way, resyncing the optimistic flip to truth.
|
});
|
||||||
try {
|
|
||||||
await api.plexSetState(card.id, next);
|
|
||||||
} catch (e) {
|
|
||||||
notifyActionError(e, t("plex.watchedFailed"));
|
|
||||||
}
|
|
||||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
|
||||||
}
|
|
||||||
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
|
||||||
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
|
||||||
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
||||||
@@ -295,6 +360,19 @@ export default function PlexBrowse() {
|
|||||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sub.view.kind === "person") {
|
||||||
|
const personName = sub.view.name;
|
||||||
|
return (
|
||||||
|
<PlexPersonView
|
||||||
|
name={personName}
|
||||||
|
onBack={sub.back}
|
||||||
|
onOpenCard={onCard}
|
||||||
|
onInfoCard={onInfo}
|
||||||
|
onFilter={applyFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -308,6 +386,7 @@ export default function PlexBrowse() {
|
|||||||
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
|
||||||
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
onOpenShow={(id) => sub.open({ kind: "show", id })}
|
||||||
onFilter={applyFilter}
|
onFilter={applyFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -328,7 +407,7 @@ export default function PlexBrowse() {
|
|||||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||||
while only the poster grid scrolls. */}
|
while only the poster grid scrolls. */}
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
|
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
{browseQ.isLoading
|
{browseQ.isLoading
|
||||||
? " "
|
? " "
|
||||||
@@ -336,6 +415,18 @@ export default function PlexBrowse() {
|
|||||||
? t("plex.searchCount", { count: total })
|
? t("plex.searchCount", { count: total })
|
||||||
: t("plex.count", { count: total })}
|
: t("plex.count", { count: total })}
|
||||||
</p>
|
</p>
|
||||||
|
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
|
||||||
|
<SortControl
|
||||||
|
options={sortOptions}
|
||||||
|
sort={sortState}
|
||||||
|
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||||
|
label={t("plex.filter.sort")}
|
||||||
|
ascLabel={t("plex.filter.dir.asc")}
|
||||||
|
descLabel={t("plex.filter.dir.desc")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|
||||||
@@ -346,26 +437,35 @@ export default function PlexBrowse() {
|
|||||||
{personCards.map((p) => (
|
{personCards.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
|
||||||
>
|
>
|
||||||
{p.photo ? (
|
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
|
||||||
<img
|
<button
|
||||||
src={p.photo}
|
onClick={() => onOpenPerson(p.name)}
|
||||||
alt=""
|
title={t("plex.info.viewPerson", { name: p.name })}
|
||||||
loading="lazy"
|
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg text-left"
|
||||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
>
|
||||||
/>
|
{p.photo ? (
|
||||||
) : (
|
<img
|
||||||
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
src={p.photo}
|
||||||
<User className="w-5 h-5" />
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate max-w-[150px] group-hover/person:text-accent">
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xs text-muted">
|
||||||
|
{t("plex.people.count", { count: p.count })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
|
|
||||||
<div className="text-2xs text-muted">
|
|
||||||
{t("plex.people.count", { count: p.count })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => removeActor(p.name)}
|
onClick={() => removeActor(p.name)}
|
||||||
title={t("plex.people.remove")}
|
title={t("plex.people.remove")}
|
||||||
@@ -603,15 +703,18 @@ function PlexInfoView({
|
|||||||
onPlay,
|
onPlay,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
onFilter,
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPlayItem: (id: string) => void;
|
onPlayItem: (id: string) => void;
|
||||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||||
return (
|
return (
|
||||||
<div className="w-[90%] max-w-[1600px] mx-auto">
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
@@ -638,6 +741,7 @@ function PlexInfoView({
|
|||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
|
||||||
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
// query), so q refetches automatically — a manual refetch here just double-fetched.
|
||||||
/>
|
/>
|
||||||
@@ -647,6 +751,128 @@ function PlexInfoView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
|
||||||
|
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
|
||||||
|
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
|
||||||
|
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
|
||||||
|
export function PlexPersonView({
|
||||||
|
name,
|
||||||
|
onBack,
|
||||||
|
onOpenCard,
|
||||||
|
onInfoCard,
|
||||||
|
onFilter,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenCard: (card: PlexCard) => void;
|
||||||
|
onInfoCard: (card: PlexCard) => void;
|
||||||
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
|
||||||
|
|
||||||
|
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
|
||||||
|
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
|
||||||
|
// helper; also invalidate the library so its grid agrees.
|
||||||
|
const toggle = (card: PlexCard) =>
|
||||||
|
toggleWatchState(card, t, {
|
||||||
|
optimistic: (next) =>
|
||||||
|
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
|
||||||
|
old
|
||||||
|
? {
|
||||||
|
...old,
|
||||||
|
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
|
||||||
|
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
|
||||||
|
}
|
||||||
|
: old,
|
||||||
|
),
|
||||||
|
resync: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const section = (titleKey: string, cards: PlexCard[]) =>
|
||||||
|
cards.length > 0 ? (
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<PlexPosterCard
|
||||||
|
key={c.id}
|
||||||
|
card={c}
|
||||||
|
onOpen={() => onOpenCard(c)}
|
||||||
|
onInfo={() => onInfoCard(c)}
|
||||||
|
onToggleWatched={toggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const d = q.data;
|
||||||
|
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
|
||||||
|
return (
|
||||||
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
|
<div className="p-4 pb-0">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{t("plex.backToLibrary")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{q.isError && !d ? (
|
||||||
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||||
|
{t("common.loadError")}
|
||||||
|
</StateMessage>
|
||||||
|
) : q.isLoading || !d ? (
|
||||||
|
<LoadingState label={t("plex.loading")} />
|
||||||
|
) : (
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
||||||
|
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
|
||||||
|
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||||
|
{d.photo ? (
|
||||||
|
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full w-full place-items-center opacity-40">
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||||
|
<p className="mb-2 text-xs text-muted">
|
||||||
|
{t("plex.person.credits", {
|
||||||
|
movies: d.movies.length,
|
||||||
|
shows: d.shows.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onFilter({ actors: [d.name] })}
|
||||||
|
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
{t("plex.person.filterBy", { name: d.name })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{empty ? (
|
||||||
|
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{section("plex.person.movies", d.movies)}
|
||||||
|
{section("plex.person.shows", d.shows)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
// --- Series drill-down: show detail page → season subpage → player -------------------------------
|
||||||
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
|
||||||
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
|
||||||
@@ -797,14 +1023,17 @@ function SeasonCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CastStrip({
|
export function CastStrip({
|
||||||
cast,
|
cast,
|
||||||
onFilter,
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
}: {
|
}: {
|
||||||
cast: PlexCastMember[];
|
cast: PlexCastMember[];
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
|
||||||
@@ -816,7 +1045,7 @@ function CastStrip({
|
|||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}
|
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
|
||||||
>
|
>
|
||||||
{c.thumb ? (
|
{c.thumb ? (
|
||||||
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
@@ -827,18 +1056,24 @@ function CastStrip({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}
|
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
</div>
|
</div>
|
||||||
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return onFilter ? (
|
return clickable ? (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onFilter({ actors: [c.name] })}
|
onClick={() =>
|
||||||
title={t("plex.info.filterActor", { name: c.name })}
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
onOpenPerson
|
||||||
|
? t("plex.info.viewPerson", { name: c.name })
|
||||||
|
: t("plex.info.filterActor", { name: c.name })
|
||||||
|
}
|
||||||
className="group/cast w-20 shrink-0 text-center"
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
@@ -1078,6 +1313,7 @@ function PlexShowView({
|
|||||||
onOpenSeason,
|
onOpenSeason,
|
||||||
onOpenShow,
|
onOpenShow,
|
||||||
onFilter,
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
}: {
|
}: {
|
||||||
showId: string;
|
showId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -1085,11 +1321,16 @@ function PlexShowView({
|
|||||||
onOpenSeason: (seasonId: string) => void;
|
onOpenSeason: (seasonId: string) => void;
|
||||||
onOpenShow: (id: string) => void;
|
onOpenShow: (id: string) => void;
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
const [collOpen, setCollOpen] = useState(false);
|
const [collOpen, setCollOpen] = useState(false);
|
||||||
@@ -1182,20 +1423,26 @@ function PlexShowView({
|
|||||||
{show.season_count != null && (
|
{show.season_count != null && (
|
||||||
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
<span>· {t("plex.seasons", { count: show.season_count })}</span>
|
||||||
)}
|
)}
|
||||||
{show.imdb_rating != null && (
|
{(() => {
|
||||||
<button
|
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
|
||||||
onClick={
|
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
|
||||||
onFilter
|
// rating instead of dropping the badge entirely.
|
||||||
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
|
const badge = pickRatingBadge(show.imdb_rating, show.rating);
|
||||||
: undefined
|
if (!badge) return null;
|
||||||
}
|
const { score } = badge;
|
||||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
return (
|
||||||
>
|
<button
|
||||||
{/* decorative rating gold — intentionally NOT a status token */}
|
onClick={
|
||||||
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
{show.imdb_rating}
|
}
|
||||||
</button>
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
|
||||||
)}
|
>
|
||||||
|
{/* decorative rating gold — intentionally NOT a status token */}
|
||||||
|
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
|
||||||
|
{score.toFixed(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{show.genres.length > 0 && (
|
{show.genres.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
@@ -1284,7 +1531,9 @@ function PlexShowView({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
|
{show.cast.length > 0 && showCast && (
|
||||||
|
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
|
||||||
|
)}
|
||||||
{d.related.length > 0 && showRelated && (
|
{d.related.length > 0 && showRelated && (
|
||||||
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
<RelatedStrip related={d.related} onOpen={onOpenShow} />
|
||||||
)}
|
)}
|
||||||
@@ -1318,7 +1567,11 @@ function PlexSeasonView({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
|
const lang = plexLang();
|
||||||
|
const q = useQuery({
|
||||||
|
queryKey: qk.plexShow(showId, lang),
|
||||||
|
queryFn: () => api.plexShow(showId, lang),
|
||||||
|
});
|
||||||
const d = q.data;
|
const d = q.data;
|
||||||
const se = d?.seasons.find((s) => s.id === seasonId);
|
const se = d?.seasons.find((s) => s.id === seasonId);
|
||||||
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
|
import { pickRatingBadge } from "../lib/plexRating";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import {
|
import {
|
||||||
DetailCustomizeMenu,
|
DetailCustomizeMenu,
|
||||||
@@ -31,6 +32,8 @@ type Props = {
|
|||||||
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
||||||
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
// When provided, a cast member's photo/name opens their filmography page instead of filtering.
|
||||||
|
onOpenPerson?: (name: string) => void;
|
||||||
// Play a sibling title from a collection strip (opens its player).
|
// Play a sibling title from a collection strip (opens its player).
|
||||||
onPlayItem?: (id: string) => void;
|
onPlayItem?: (id: string) => void;
|
||||||
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
// The item's library key — enables the admin "Manage collections" editor (page variant, movies).
|
||||||
@@ -44,6 +47,7 @@ export default function PlexInfo({
|
|||||||
onClose,
|
onClose,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
onFilter,
|
onFilter,
|
||||||
|
onOpenPerson,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
library,
|
library,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -192,37 +196,42 @@ export default function PlexInfo({
|
|||||||
{detail.studio}
|
{detail.studio}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
)}
|
)}
|
||||||
{detail.imdb_rating != null && (
|
{(() => {
|
||||||
// decorative rating gold — intentionally NOT a status token
|
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
|
||||||
<button
|
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
|
||||||
onClick={
|
if (!badge) return null;
|
||||||
onFilter
|
const { score, isImdb } = badge;
|
||||||
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
|
return (
|
||||||
: undefined
|
// decorative rating gold — intentionally NOT a status token
|
||||||
}
|
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
<button
|
||||||
title={
|
onClick={
|
||||||
onFilter
|
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||||
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
|
}
|
||||||
: undefined
|
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||||
}
|
title={
|
||||||
>
|
onFilter
|
||||||
<Star className="w-3.5 h-3.5 fill-current" />
|
? t("plex.info.filterRating", { n: Math.floor(score) })
|
||||||
{detail.imdb_rating.toFixed(1)}
|
: undefined
|
||||||
</button>
|
}
|
||||||
{detail.imdb_url && (
|
|
||||||
<a
|
|
||||||
href={detail.imdb_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title={t("plex.info.openImdb")}
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
<Star className="w-3.5 h-3.5 fill-current" />
|
||||||
</a>
|
{score.toFixed(1)}
|
||||||
)}
|
</button>
|
||||||
</span>
|
{isImdb && detail.imdb_url && (
|
||||||
)}
|
<a
|
||||||
|
href={detail.imdb_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t("plex.info.openImdb")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail.tagline && (
|
{detail.tagline && (
|
||||||
@@ -341,12 +350,13 @@ export default function PlexInfo({
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
{cast.map((c, i) => {
|
{cast.map((c, i) => {
|
||||||
|
const clickable = !!(onOpenPerson || onFilter);
|
||||||
const inner = (
|
const inner = (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
||||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||||
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
|
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
||||||
>
|
>
|
||||||
{c.thumb ? (
|
{c.thumb ? (
|
||||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
||||||
@@ -358,7 +368,7 @@ export default function PlexInfo({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
||||||
onFilter ? "group-hover/cast:text-accent" : ""
|
clickable ? "group-hover/cast:text-accent" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -372,11 +382,17 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return onFilter ? (
|
return clickable ? (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onFilter({ actors: [c.name] })}
|
onClick={() =>
|
||||||
title={t("plex.info.filterActor", { name: c.name })}
|
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
onOpenPerson
|
||||||
|
? t("plex.info.viewPerson", { name: c.name })
|
||||||
|
: t("plex.info.filterActor", { name: c.name })
|
||||||
|
}
|
||||||
className="group/cast w-20 shrink-0 text-center"
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E5 S4: the actor detail page. Covers the two pieces the /code-review flagged as untested — the
|
||||||
|
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
|
||||||
|
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
|
||||||
|
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
|
||||||
|
// and the api is mocked so the query resolves synchronously in the test.
|
||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const plexPerson = vi.fn();
|
||||||
|
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
|
||||||
|
vi.mock("../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
plexPerson: (...a: unknown[]) => plexPerson(...a),
|
||||||
|
plexSetState: (...a: unknown[]) => plexSetState(...a),
|
||||||
|
},
|
||||||
|
EMPTY_PLEX_FILTERS: {},
|
||||||
|
plexFilterCount: () => 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CastStrip, PlexPersonView } from "./PlexBrowse";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
year: 2000,
|
||||||
|
duration_seconds: 3600,
|
||||||
|
thumb: `/api/plex/image/${id}`,
|
||||||
|
playable: "direct",
|
||||||
|
status: "new",
|
||||||
|
position_seconds: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrap(ui: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CastStrip cast click", () => {
|
||||||
|
it("opens the person page (onOpenPerson) rather than filtering, when both are wired", () => {
|
||||||
|
const onOpenPerson = vi.fn();
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
render(
|
||||||
|
<CastStrip cast={[{ name: "Jane Doe" }]} onOpenPerson={onOpenPerson} onFilter={onFilter} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTitle("plex.info.viewPerson"));
|
||||||
|
expect(onOpenPerson).toHaveBeenCalledWith("Jane Doe");
|
||||||
|
expect(onFilter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the filter gesture when only onFilter is wired (e.g. the overlay)", () => {
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
render(<CastStrip cast={[{ name: "Jane Doe" }]} onFilter={onFilter} />);
|
||||||
|
fireEvent.click(screen.getByTitle("plex.info.filterActor"));
|
||||||
|
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PlexPersonView", () => {
|
||||||
|
it("renders the Movies + Shows sections and the filter-by-person action", async () => {
|
||||||
|
plexPerson.mockResolvedValue({
|
||||||
|
name: "Jane Doe",
|
||||||
|
photo: null,
|
||||||
|
movies: [card("m1", "Movie One")],
|
||||||
|
shows: [card("s1", "Show One", "show")],
|
||||||
|
});
|
||||||
|
const onFilter = vi.fn();
|
||||||
|
wrap(
|
||||||
|
<PlexPersonView
|
||||||
|
name="Jane Doe"
|
||||||
|
onBack={() => {}}
|
||||||
|
onOpenCard={() => {}}
|
||||||
|
onInfoCard={() => {}}
|
||||||
|
onFilter={onFilter}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Both section grids render their cards once the mocked query resolves.
|
||||||
|
await screen.findByText("Movie One");
|
||||||
|
expect(screen.getByText("Show One")).toBeTruthy();
|
||||||
|
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
|
||||||
|
expect(screen.getByText("plex.person.shows")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("plex.person.filterBy"));
|
||||||
|
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
|
||||||
|
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the empty message when the person has no titles", async () => {
|
||||||
|
plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] });
|
||||||
|
wrap(
|
||||||
|
<PlexPersonView
|
||||||
|
name="Nobody"
|
||||||
|
onBack={() => {}}
|
||||||
|
onOpenCard={() => {}}
|
||||||
|
onInfoCard={() => {}}
|
||||||
|
onFilter={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await screen.findByText("plex.person.empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } fr
|
|||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { plexLang } from "../i18n";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import {
|
import {
|
||||||
@@ -77,7 +78,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
|||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [id, setId] = useState(itemId);
|
const [id, setId] = useState(itemId);
|
||||||
const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
|
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
|
||||||
|
const lang = plexLang();
|
||||||
|
const detailQ = useQuery({
|
||||||
|
queryKey: qk.plexItem(id, lang),
|
||||||
|
queryFn: () => api.plexItem(id, lang),
|
||||||
|
});
|
||||||
const detail = detailQ.data;
|
const detail = detailQ.data;
|
||||||
const duration = detail?.duration_seconds ?? 0;
|
const duration = detail?.duration_seconds ?? 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||||
|
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
@@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|||||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||||
// into (and is never greeted by a stale value from) the feed.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
|
|
||||||
|
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
|
||||||
|
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
|
||||||
|
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
|
||||||
|
|
||||||
type PlexState = {
|
type PlexState = {
|
||||||
scope: string; // movie | show | both (unified cross-library scope)
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
show: string;
|
show: string;
|
||||||
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
q: string;
|
q: string;
|
||||||
|
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
|
||||||
playlistOpen: number | null;
|
playlistOpen: number | null;
|
||||||
|
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlexActions {
|
interface PlexActions {
|
||||||
@@ -34,7 +41,9 @@ interface PlexActions {
|
|||||||
setSort: (v: SortState) => void;
|
setSort: (v: SortState) => void;
|
||||||
setFilters: (f: PlexFilters) => void;
|
setFilters: (f: PlexFilters) => void;
|
||||||
setQ: (q: string) => void;
|
setQ: (q: string) => void;
|
||||||
|
setField: (f: PlexSearchField) => void;
|
||||||
setPlaylistOpen: (id: number | null) => void;
|
setPlaylistOpen: (id: number | null) => void;
|
||||||
|
setJumpTo: (t: PlexJumpTarget | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StateContext = createContext<PlexState>({
|
const StateContext = createContext<PlexState>({
|
||||||
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
|
|||||||
sort: PLEX_SORT_FALLBACK,
|
sort: PLEX_SORT_FALLBACK,
|
||||||
filters: EMPTY_PLEX_FILTERS,
|
filters: EMPTY_PLEX_FILTERS,
|
||||||
q: "",
|
q: "",
|
||||||
|
field: "all",
|
||||||
playlistOpen: null,
|
playlistOpen: null,
|
||||||
|
jumpTo: null,
|
||||||
});
|
});
|
||||||
const ActionsContext = createContext<PlexActions>({
|
const ActionsContext = createContext<PlexActions>({
|
||||||
setScope: () => {},
|
setScope: () => {},
|
||||||
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
|
|||||||
setSort: () => {},
|
setSort: () => {},
|
||||||
setFilters: () => {},
|
setFilters: () => {},
|
||||||
setQ: () => {},
|
setQ: () => {},
|
||||||
|
setField: () => {},
|
||||||
setPlaylistOpen: () => {},
|
setPlaylistOpen: () => {},
|
||||||
|
setJumpTo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function usePlex(): PlexState {
|
export function usePlex(): PlexState {
|
||||||
@@ -69,6 +82,7 @@ function readPlexScope(): string {
|
|||||||
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
||||||
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
||||||
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
||||||
|
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
|
||||||
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
||||||
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
||||||
// upgrade rather than snapping back to the default.
|
// upgrade rather than snapping back to the default.
|
||||||
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [filtersRaw]);
|
}, [filtersRaw]);
|
||||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||||
const [q, setQState] = useState<string>(readPlexQ);
|
const [q, setQState] = useState<string>(readPlexQ);
|
||||||
|
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
|
||||||
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||||
|
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
|
||||||
|
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||||
|
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||||
|
|
||||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||||
const markDirty = useAccountRehydrate(() => {
|
const markDirty = useAccountRehydrate(() => {
|
||||||
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
setSortState(readPlexSort());
|
setSortState(readPlexSort());
|
||||||
setFiltersRaw(readPlexFiltersRaw());
|
setFiltersRaw(readPlexFiltersRaw());
|
||||||
setQState(readPlexQ());
|
setQState(readPlexQ());
|
||||||
|
setFieldState(readPlexField());
|
||||||
});
|
});
|
||||||
|
|
||||||
const setScope = useCallback(
|
const setScope = useCallback(
|
||||||
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
[markDirty],
|
[markDirty],
|
||||||
);
|
);
|
||||||
|
const setField = useCallback(
|
||||||
|
(v: PlexSearchField) => {
|
||||||
|
markDirty();
|
||||||
|
setFieldState(v);
|
||||||
|
setAccountRaw(LS.plexField, v);
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
);
|
||||||
|
|
||||||
const state = useMemo<PlexState>(
|
const state = useMemo<PlexState>(
|
||||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||||
[scope, show, sort, filters, q, playlistOpen],
|
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
|
||||||
);
|
);
|
||||||
const actions = useMemo<PlexActions>(
|
const actions = useMemo<PlexActions>(
|
||||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
|
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
|
||||||
[setScope, setShow, setSort, setFilters, setQ],
|
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
|
||||||
|
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
|
||||||
|
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
|
||||||
|
// floating-ui), so no observer stubs are needed.
|
||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } 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: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const plexState = { scope: "both" } as { scope: string };
|
||||||
|
const setScope = vi.fn();
|
||||||
|
vi.mock("./PlexProvider", () => ({
|
||||||
|
usePlex: () => plexState,
|
||||||
|
usePlexActions: () => ({ setScope }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PlexScopeToggle from "./PlexScopeToggle";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
plexState.scope = "both";
|
||||||
|
setScope.mockReset();
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("PlexScopeToggle", () => {
|
||||||
|
it("renders the three library scopes, current one pressed", () => {
|
||||||
|
plexState.scope = "movie";
|
||||||
|
render(<PlexScopeToggle />);
|
||||||
|
const group = screen.getByRole("group", { name: "plex.filter.scope" });
|
||||||
|
const btns = [...group.querySelectorAll("button")];
|
||||||
|
expect(btns.map((b) => b.textContent)).toEqual([
|
||||||
|
"plex.filter.scopeOpt.both",
|
||||||
|
"plex.filter.scopeOpt.movie",
|
||||||
|
"plex.filter.scopeOpt.show",
|
||||||
|
]);
|
||||||
|
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||||
|
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the picked scope back to the provider", () => {
|
||||||
|
render(<PlexScopeToggle />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||||
|
expect(setScope).toHaveBeenCalledWith("show");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SegmentedToggle from "./SegmentedToggle";
|
||||||
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
|
||||||
|
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
|
||||||
|
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
|
||||||
|
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
|
||||||
|
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
|
||||||
|
const SCOPES = ["both", "movie", "show"] as const;
|
||||||
|
|
||||||
|
export default function PlexScopeToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { scope } = usePlex();
|
||||||
|
const { setScope } = usePlexActions();
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto shrink-0">
|
||||||
|
<SegmentedToggle
|
||||||
|
size="md"
|
||||||
|
ariaLabel={t("plex.filter.scope")}
|
||||||
|
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Contract of the Plex header search box (S2): the field scope-pill and the instant typeahead.
|
||||||
|
// Covers the pieces the /code-review flagged: the pill's per-scope options (Episode only in
|
||||||
|
// show/both), the suggestion list rendering, keyboard pick → jumpTo hand-off, and the regression
|
||||||
|
// that closing the panel (Escape) must NOT wedge it shut while the input keeps focus — typing again
|
||||||
|
// re-opens it. i18n is key-passthrough; the provider, api, and debounce are mocked so the test drives
|
||||||
|
// state directly. ActionMenu is floating-ui based, so we stub the observers and wrap in a FloatingTree.
|
||||||
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
// Identity debounce so a typed value is reflected immediately.
|
||||||
|
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||||
|
|
||||||
|
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
|
||||||
|
const plexState = { scope: "both", field: "all", q: "" } as {
|
||||||
|
scope: string;
|
||||||
|
field: string;
|
||||||
|
q: string;
|
||||||
|
};
|
||||||
|
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
|
||||||
|
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
|
||||||
|
// the panel re-appears from cache.
|
||||||
|
const setQ = vi.fn((v: string) => {
|
||||||
|
plexState.q = v;
|
||||||
|
});
|
||||||
|
const setField = vi.fn();
|
||||||
|
const setJumpTo = vi.fn();
|
||||||
|
vi.mock("./PlexProvider", () => ({
|
||||||
|
usePlex: () => plexState,
|
||||||
|
usePlexActions: () => ({ setQ, setField, setJumpTo }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// api.plexLibrary stub — returns whatever the current test queued.
|
||||||
|
const plexLibrary = vi.fn();
|
||||||
|
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||||
|
|
||||||
|
import PlexSearchBox from "./PlexSearchBox";
|
||||||
|
|
||||||
|
const card = (id: string, title: string, type: "movie" | "show" = "movie", year = 2000) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
thumb: `/api/plex/image/${id}`,
|
||||||
|
});
|
||||||
|
const result = (items: ReturnType<typeof card>[]) => ({
|
||||||
|
scope: "both",
|
||||||
|
total: items.length,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
items,
|
||||||
|
episodes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||||
|
// jsdom has no layout, so scrollIntoView (used to keep the active option in view) is absent.
|
||||||
|
Element.prototype.scrollIntoView ??= () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
plexState.scope = "both";
|
||||||
|
plexState.field = "all";
|
||||||
|
plexState.q = "";
|
||||||
|
setQ.mockClear(); // clear calls but KEEP the write-back implementation
|
||||||
|
setField.mockReset();
|
||||||
|
setJumpTo.mockReset();
|
||||||
|
plexLibrary.mockReset();
|
||||||
|
plexLibrary.mockResolvedValue(result([]));
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const renderBox = () => {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<FloatingTree>
|
||||||
|
<PlexSearchBox />
|
||||||
|
</FloatingTree>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPill = () =>
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "plex.searchField.aria" }));
|
||||||
|
|
||||||
|
describe("PlexSearchBox — scope-pill", () => {
|
||||||
|
it("offers all five field scopes when shows are in scope", () => {
|
||||||
|
plexState.scope = "both";
|
||||||
|
renderBox();
|
||||||
|
openPill();
|
||||||
|
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"plex.searchField.all",
|
||||||
|
"plex.searchField.title",
|
||||||
|
"plex.searchField.people",
|
||||||
|
"plex.searchField.summary",
|
||||||
|
"plex.searchField.episode",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the Episode scope in a movie-only scope", () => {
|
||||||
|
plexState.scope = "movie";
|
||||||
|
renderBox();
|
||||||
|
openPill();
|
||||||
|
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||||
|
expect(labels).toEqual([
|
||||||
|
"plex.searchField.all",
|
||||||
|
"plex.searchField.title",
|
||||||
|
"plex.searchField.people",
|
||||||
|
"plex.searchField.summary",
|
||||||
|
]);
|
||||||
|
expect(labels).not.toContain("plex.searchField.episode");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PlexSearchBox — typeahead", () => {
|
||||||
|
it("renders the top matches on focus and picks one via keyboard → jumpTo", async () => {
|
||||||
|
plexState.q = "matrix";
|
||||||
|
plexLibrary.mockResolvedValue(
|
||||||
|
result([card("m1", "The Matrix"), card("m2", "The Matrix Reloaded")]),
|
||||||
|
);
|
||||||
|
renderBox();
|
||||||
|
fireEvent.focus(screen.getByRole("combobox"));
|
||||||
|
|
||||||
|
// Both matches show up as options.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||||
|
expect.stringContaining("The Matrix"),
|
||||||
|
expect.stringContaining("The Matrix Reloaded"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// The request carried the active field scope + the limit.
|
||||||
|
expect(plexLibrary).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ scope: "both", q: "matrix", field: "all", limit: 10 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ArrowDown highlights the first, Enter opens it via the jumpTo hand-off (not a Go search).
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reopens after Escape when the user types again (focus/dismiss are decoupled)", async () => {
|
||||||
|
plexState.q = "matrix";
|
||||||
|
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||||
|
renderBox();
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
fireEvent.focus(input);
|
||||||
|
await screen.findByRole("listbox");
|
||||||
|
|
||||||
|
// Escape dismisses the panel (input stays focused — Escape does not blur).
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
|
|
||||||
|
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
|
||||||
|
// for the new term (not just re-show the cached list).
|
||||||
|
plexLibrary.mockClear();
|
||||||
|
fireEvent.change(input, { target: { value: "matrixx" } });
|
||||||
|
await screen.findByRole("listbox");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
||||||
|
plexState.q = "matrix";
|
||||||
|
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||||
|
renderBox();
|
||||||
|
const input = screen.getByRole("combobox");
|
||||||
|
// Collapsed until focused.
|
||||||
|
expect(input.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
const list = await screen.findByRole("listbox");
|
||||||
|
// Expanded, and the input points at the listbox it controls.
|
||||||
|
expect(input.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(input.getAttribute("aria-controls")).toBe(list.id);
|
||||||
|
|
||||||
|
// Escape collapses it again and drops the association.
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
|
||||||
|
expect(input.getAttribute("aria-controls")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import ActionMenu from "./ActionMenu";
|
||||||
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
|
||||||
|
|
||||||
|
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
||||||
|
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||||
|
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
||||||
|
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
||||||
|
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
||||||
|
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
||||||
|
const MIN_CHARS = 2;
|
||||||
|
const SUGGEST_LIMIT = 10;
|
||||||
|
|
||||||
|
export default function PlexSearchBox() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { q, scope, field } = usePlex();
|
||||||
|
const { setQ, setField, setJumpTo } = usePlexActions();
|
||||||
|
const activeField = healPlexField(field, scope);
|
||||||
|
const dq = useDebounced(q.trim(), 200);
|
||||||
|
// `focused` mirrors the input's real DOM focus (only onFocus/onBlur touch it). `dismissed` is the
|
||||||
|
// separate "the user closed the panel" flag (Escape / after a pick) — kept apart so closing the
|
||||||
|
// panel never desyncs from actual focus: typing again clears `dismissed` and the panel re-opens
|
||||||
|
// without needing a blur+refocus cycle.
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listId = useId();
|
||||||
|
|
||||||
|
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
||||||
|
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
||||||
|
const suggestEnabled =
|
||||||
|
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
|
||||||
|
const suggestQ = useQuery({
|
||||||
|
queryKey: qk.plexSuggest(scope, dq, activeField),
|
||||||
|
enabled: suggestEnabled,
|
||||||
|
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
|
||||||
|
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
||||||
|
|
||||||
|
// A fresh query resets the highlight.
|
||||||
|
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
||||||
|
// Keep the highlighted option scrolled into view during arrow-key navigation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hi < 0) return;
|
||||||
|
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [hi]);
|
||||||
|
|
||||||
|
const pick = (i: number) => {
|
||||||
|
const c = suggestions[i];
|
||||||
|
if (!c) return;
|
||||||
|
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
||||||
|
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
|
||||||
|
setHi(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = availablePlexFields(scope);
|
||||||
|
const pillLabel = t(plexFieldLabelKey(activeField));
|
||||||
|
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
|
||||||
|
// on a collapsed combobox is an inconsistent a11y state.
|
||||||
|
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchBar
|
||||||
|
value={q}
|
||||||
|
placeholder={t("plex.searchPlaceholder")}
|
||||||
|
// Typing after a dismiss (Escape / pick) re-opens the panel.
|
||||||
|
onChange={(v) => {
|
||||||
|
setDismissed(false);
|
||||||
|
setQ(v);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
setDismissed(false);
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((h) => Math.max(h - 1, -1));
|
||||||
|
} else if (e.key === "Enter" && hi >= 0) {
|
||||||
|
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||||
|
pick(hi);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setDismissed(true);
|
||||||
|
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
role: "combobox",
|
||||||
|
"aria-autocomplete": "list",
|
||||||
|
"aria-expanded": open,
|
||||||
|
"aria-controls": open ? listId : undefined,
|
||||||
|
"aria-activedescendant": activeOptionId,
|
||||||
|
}}
|
||||||
|
leading={
|
||||||
|
<ActionMenu
|
||||||
|
ariaLabel={t("plex.searchField.aria")}
|
||||||
|
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||||
|
trigger={
|
||||||
|
<>
|
||||||
|
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
|
||||||
|
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
items={fields.map((f) => ({
|
||||||
|
id: f,
|
||||||
|
label: t(plexFieldLabelKey(f)),
|
||||||
|
onSelect: () => setField(f),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
below={
|
||||||
|
open ? (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={listId}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("plex.suggest.aria")}
|
||||||
|
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{suggestions.length === 0 && suggestQ.isFetching && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||||
|
)}
|
||||||
|
{suggestions.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
id={`${listId}-opt-${i}`}
|
||||||
|
data-idx={i}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={hi === i}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||||
|
onMouseEnter={() => setHi(i)}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
|
||||||
|
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={c.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
||||||
|
{c.year != null && (
|
||||||
|
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
||||||
|
)}
|
||||||
|
{c.type === "show" ? (
|
||||||
|
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user