11aef4ab7aef97c71e5205a5bbf1cebe7fdb5e79
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
71d7646bf1 |
fix(plex): a name this session cannot write fails at once, and the layout has one spelling
Round 6 turned `artifact_may_arrive` into a positive rule, but keyed it to the HLS vocabulary in
general rather than to what THIS session produces: the playlist regex accepted master/index/stream_*
for every session and the `seq == 0` branch accepted any variant token unconditionally. The two argv
branches are disjoint — a single-audio session is given `seg_%d.ts` + `index.m3u8` and no `%v` at
all, so `master.m3u8`, `stream_*.m3u8` and every `seg_<variant>_<n>.ts` are names it can never write,
and vice versa — so each of those was read as "an HLS name, so wait and see" and held a threadpool
worker, plus its pooled DB connection, for the full timeout. That is verbatim the round-5 outage the
route's own comment claims it prevents.
Measured through the real route against a live single-audio session, before: master.m3u8 404 after
30.0s, stream_q7z.m3u8 404 after 30.0s, seg_a0_0.ts / seg_zzzz_0.ts / seg_0_0.ts 404 after 20.1s
each. After, on the running localdev API: 404 in 44-316ms for all of them, while index.m3u8,
seg_0.ts and seg_1.ts still answer 200 — nothing is over-refused.
The session now carries the rendition names its ffmpeg was told to write, and `entry` is derived from
them instead of passed. And "one place" now means the argv BUILDS its patterns from the same
functions the parser reads back (`segment_name("%d", "%v")`, `variant_playlist_name("%v")`): the
layout was still spelled out independently in six places, with `_VIDEO_VARIANT` read by no consumer,
which is the drift that caused round 6's HIGH. A test asserts the argv's own patterns are what those
functions produce, and that every name they expand to is one the guard accepts.
The load-bearing MEASURED comment cited ffmpeg 8.1.1 — the Windows host's. The binary that writes
these files is the API container's 7.1.5; re-measured there, the conclusion holds.
|
||
|
|
7f17b17c13 |
fix(plex): an HLS session's file names are one vocabulary, and a name it cannot produce fails at once
Round 5 added a guard so a request for a segment beyond the produced region answers instead of polling out the full timeout — the route is a SYNC one, so every such wait parks a threadpool worker and, through `current_user`, a pooled DB connection. Measured against real ffmpeg, the guard could not fire where it mattered. `-hls_segment_filename seg_%v_%d.ts` substitutes the VARIANT first: a dual-audio session writes seg_0_0.ts (video) and seg_a0_0.ts / seg_a1_0.ts (the `name:aN` renditions). The regex read those as sequence-first, so every video segment parsed as segment 0 and every audio segment did not parse at all — the guard was inert on every multi-audio item, which is most of this library. `_seg()` carried the same inversion: the warm-up waited 8 s for `seg_1_0.ts`, a name ffmpeg never writes, on every multi-audio session start AND every seek-restart. The layout is now one constant plus one parser (`segment_name` / `_SEG_NAME_RE`), with a test pinning them to each other, instead of three comments that could drift — and they had drifted, all three ways. The guard is also a positive rule now. It was a blocklist, so anything that was not a single-audio segment name was waved through: `GET …/anything.m3u8` still parked a worker for 30 s, while the route's comment claimed a flood of hopeless names was what it prevented. A session produces its playlists and its segments and nothing else. `.vtt` leaves `_HLS_CT` with it — ffmpeg runs with `-sn` and subtitles are a separate route, so that extension could only ever name a file that will not arrive. Also here: - A 5000-digit segment index reached the client as a 500: `int()` on an unbounded digit run raises past 4300 digits and only OSError was caught. The name is bounded on the route and the index in the regex — the same bug class the subtitle routes were bounded for. - A session torn down between the wait and the read is a 404, not a 500. `_kill` rmtrees the directory and no lock spans those two steps; the player's recovery is keyed on the STATUS, so a 500 skipped the reclaim and ended playback with "couldn't play" instead of restarting at the playhead. The trigger is ordinary — `loadSession` awaits the new session before destroying the old hls instance. - Two comments claimed an open player checkpoints every 10 s "whether or not it is playing", which is the stated reason the 30 s finished-idle reap is safe. `flush` sends nothing once the finish latch is up, or before a position is known. The states it does cover — playing or paused mid-item — are the ones the rule needs; the comments now say that instead of more. - The route's tests leave the DB lane: the route reads nothing from the database since the auto-start was removed, and there those four regressions SKIP on a machine with no Postgres while pytest prints green. 14 tests, mutation-checked: the old parse, the old warm-up name, the blocklist, both bounds off the absurd name, the unguarded read, and the route dropping `proc` — each red on the test that names it. |
||
|
|
1f81684b6e |
fix(ui,plex): a dialog keeps the focus its child asked for, and a hopeless segment fails at once
Both found by UAT in a real browser, after the tests for them were already green — which is the whole reason this repo settles focus and timing claims by running them. - **The autoFocus fix worked, and then the dialog took the focus back.** React 18's StrictMode runs every effect mount→cleanup→mount in development: the cleanup handed focus back to the trigger, and the second mount, finding the focus outside the dialog, moved it to the first focusable — off the `autoFocus` field a beat after the browser had put it there. Chrome measured it: INPUT (commitMount) → BODY (cleanup) → ✕ (re-mount). The effect is idempotent now — it remembers what the dialog was focused on and restores THAT on a re-mount — and the close-time restore is conditional on the dialog still holding the focus, which it tracks as it changes rather than computing at cleanup, where the panel is already out of the document. A dialog no longer yanks focus back from wherever the user has since moved it. - **Passing ffmpeg's handle to the segment wait only shortened the FINISHED case.** Measured against the running localdev API: a request for `seg_99999.ts` on a live session still took 20 195 ms, because that ffmpeg has not exited and the wait has no other reason to stop. ffmpeg writes segments in order, so a segment whose predecessor does not exist is not the next one and cannot arrive — and that is knowable at once. Same request now: 45 ms. Verified on localdev against the real library (Westworld S01E06, remux, multi-audio): Mod+K opens the command palette with the focus in its search field and typing reaches it; playback runs on the new session-scoped HLS URL; a request naming a stale session id is a 404; and a viewer paused for longer than the finished-session rule keeps their 2.4 GB session across two reaper passes — the progress checkpoint touches it — and resumes cleanly. vitest 1009, pytest 1127. |
||
|
|
47af4d5c05 |
fix(plex): an HLS artifact names the session it belongs to, and a paused viewer says so
Deep-review round 5, over round 4's own fixes. - Sessions are keyed by rating_key alone while a session's DIRECTORY is per (key, offset, tag), and the artifact URL carried no session identity — so once a seek-restart or a second viewer of the same item took the registry slot, the old viewer's request for `seg_12.ts` was answered 200 with the new session's twelfth segment: bytes from a different `-ss` offset under `-copyts`, on a timeline the client is not on. Wrong content with a success status is the one failure a player cannot notice, and round 4's reclaim-on-404 could never fire on it. The id is in the path now (so the playlist's relative names resolve back into the same session), a mismatch is a 404, and the player reclaims at the playhead. The entry playlist's auto-start is gone with it: it could only ever begin at 0, silently discarding the viewer's position. - Round 4 exempted paused viewers from the reaper by keeping EVERY finished session for the full ten-minute idle window — one whole segmented copy of a film per item, on the volume the download center shares. The signal it needed already existed: an open player checkpoints its position every 10 s whether or not it is playing, so that route now touches the session and the periodic pass is back on the short rule. - POST /session's opportunistic reap was never scoped to the key being started: it walked the whole registry with the 30 s finished rule, so pressing play on ANY item — even a direct-play one, the call ran before that early return — killed another viewer's paused session. It is gone; the periodic pass owns reaping. - A segment that can never arrive no longer polls out the full 20 s: the producer's handle is passed to `wait_for`, so a finished ffmpeg answers at once. This is a sync route, so each such wait parked a threadpool worker AND a pooled DB connection, and an ordinary seek past the produced region was enough to do it. - The DataError handler's re-raise branch — the half that keeps a genuine DataError a 500 — is pinned, so simplifying it to an unconditional 422 cannot pass in silence. Six regression tests (two route-level, in the DB lane), each mutation-checked red on the assertion it is named after. pytest 1124, vitest 999. |
||
|
|
6d49da17fe |
fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced. - `req` marked gateway (502/503/504) and network failures "already reported" because they raise the connection notice. But that notice is about connectivity and removes itself on the very next answer — which every caller's own resync refetch fetches at once — so a delete that hit a keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed. - A connection notice the user closed swallowed every later one for the rest of the visit; it is raised again. - `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a 502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true. - A taken Plex collection name is a structured 409, localized, and the create/rename calls are quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone. - The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve / deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes a level now). The user role / suspend / delete actions had no onError at all: a user deleted in another tab failed silently and stayed listed — now said, and the list refetched. |
||
|
|
31dc4184f5 |
fix(api): a paused viewer keeps its HLS session, a refused subtitle publish backs off, and a NUL is a 422 everywhere
Deep-review round 4, backend half. - The periodic HLS reaper (round 3) applied the "finished and unfetched for 30 s" rule that only a NEW session request may apply. A remux finishes long before the film, and a paused player with a full buffer fetches nothing, so a one-minute pause killed the session and playback died ~30 s after resuming. The periodic pass is `reap_abandoned` now (the long idle limit, finished or not). - A subtitle `publish` that could not keep its output (full / read-only cache volume) returned quietly, so every poll queued the window and the whole-container pass again. It reports failure and the pass is backed off like a failed read; a cached VTT that cannot be READ is answered `unavailable` (`cache_unreadable`) instead of scheduling reads that can never land. - The subtitle routes bound `rating_key` / `sub_ord` (they become a cache file name: a 250-digit one was ENAMETOOLONG, which pathlib's exists() re-raises -> a 500), and the cache helpers no longer throw on such a name. - One app-wide handler turns psycopg's "text fields cannot contain NUL" DataError into a 422: round 3 guarded only the by-rating_key lookup, and `contains=`, `contains_group=`, reorder/bulk key lists and the collection search still 500'd. - The hand-written `db.get(...) is None -> 404 "Unknown ..."` siblings use `get_or_404` with a `what` (channel / video / user), so the Hungarian UI no longer shows English for a gone row. |
||
|
|
86971c574d |
fix(api): progress checkpoints past the int4 column are a 422, and idle HLS sessions are reaped on a timer
Deep-review round 3 of bug hunt 2026-09. - The YouTube and Plex progress bodies are bounded by POSITION_MAX_S (the int4 position columns): a finite position of 2^31 or more was a DataError, i.e. a 500 — the sibling of round 2's non-finite fix. - plex/stream.py promised "a periodic reaper", but reap_idle ran only inside the NEXT session request: after the last playback an abandoned session (its ffmpeg still working, its directory a whole segmented copy of the film) lived until someone pressed play again. A lifespan task reaps every 60 s. - Tests for what round 2 left untested: the 422's body keeps the list shape the UI localizes, and stream_session refuses non-finite start/aoff. |
||
|
|
1cfe6a7f59 |
fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once
Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
|
||
|
|
473d9f02f2 |
fix(plex): subtitle windows land where the full pass does, and the extractor stops re-reading, respawning and outliving the process
Deep-review round 2 of the 2026-09 bug hunt, over the round-1 fix diff. - A window was LATE by the distance to the keyframe its input seek landed on, and then doubled for good once the full pass merged in: a cue already running at the window's start came out at a negative time, and the webvtt muxer's default shifted the whole output so it sat at 0. Measured on the real Westworld S01E06 at 25:00: every window cue +5.005 s, 682 cues merged instead of 608. `-avoid_negative_ts disabled` keeps the times exact (the parser reads ffmpeg's signed-field spelling of a negative time); now 608/608 on that file, and a real-ffmpeg test pins it. - A full pass that failed was re-armed every minute while the viewer watched what it did publish — a whole-container read per minute. It now backs off for an hour; windows keep running meanwhile. - A failed WINDOW set the track's failure memo, so any uncovered position read "failed" — a final answer that ends the player's poll, even with the full pass about to deliver, and one any account could plant with an absurd `at`. It now marks only the span it failed to read; "failed" is given once the full pass AND a window there have both failed. - Windows join by what they COVER, not a bucketed identity: a backward seek no longer waits behind a window that cannot cover it, and a playhead crossing a bucket edge no longer queues a second read. The queue is bounded per track and overall. - shutdown() sets a flag a worker checks before it spawns ffmpeg (one still in its Plex lookup used to start a read after the kill sweep); queued-job cancellation and the lifespan call are now tested. - A window starts where ffmpeg is told to seek (rounded to the ms it gets), a rounded-up millisecond carries into the minute (no more `00:00:60.000`), a key's first publish claims nothing before its VTT lands, and `at`/`offset`/`start`/`aoff` refuse non-finite values (`at` also an absurd one). |
||
|
|
cf7ec5e9ec |
fix(plex): the subtitle extractor's seams — doubled cues, a truncated track cached as whole, a poll that respawned ffmpeg
Deep-review of the bug-hunt round (R1). The background extractor from
|
||
|
|
889d2acf52 |
feat(plex): the collection page sorts, and a linked collection has a name
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
|
||
|
|
ed4ab4e13c |
fix(plex): subtitles stop blocking the request, and stop re-reading the file
Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.
Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:
full pass, one track 154 s
full pass, both tracks in ONE run 111 s
-c:s copy (no WebVTT conversion) 143 s
from 1:00:00 to the end of file 66 s
a 630 s window at 1:00:00, both 8 s
So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:
* a window around the playhead is extracted and published FIRST (seconds), and
the full pass completes behind it;
* every embedded text track rides that same single read — picking Hungarian used
to cost a full pass and then switching to English cost another one;
* none of it happens inside a request any more. The endpoint answers 202 while a
background worker extracts, the player polls /status (carrying its position, so
the window follows the viewer), and one ffmpeg run finally has a timeout.
-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.
The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.
Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.
Verified in the browser against the real Plex library, and every new test
mutation-checked.
|
||
|
|
b8bff504d8 |
fix(plex): "Clear resume point" now tells Plex about an in-progress item
The button deletes the local row and mirrors the un-mark to Plex, but it decided whether to push from the previous STATUS alone (prev_status != "new") — and an in-progress item's row is status="new" carrying a position, which is exactly how the importer maps a bare viewOffset. So for the single case the button exists for, nothing was ever sent to Plex: the offset stayed, and the next pull imported it straight back. The user cleared Protector twice and it returned to Continue watching at 92s both times. Push now when Plex holds anything to clear — a view count, or a resume point. "hidden" stays Siftlode-only and pushes nothing, which is what this endpoint's docstring always claimed and the status-only guard contradicted. One unscrobble clears BOTH viewCount and viewOffset — measured against the live server from a watched+offset state and an offset-only one, so the push itself needed no change. The client docstring that claimed "clears viewCount" is what sent the first diagnosis down a wrong path (a timeline write with time=0, which does not clear an offset at all); it now records what the server actually does. Verified end to end on the real Plex server: the button leaves viewCount=None viewOffset=None, and a full 44,885-item re-import brings back no row for it. |
||
|
|
cedebe0ffb |
test(e24): close the review's coverage gaps, and correct a comment that was untrue
`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It now seeds a video, a movie and an episode, and asserts all three kinds are present before the loop, so it cannot go vacuous again. The Plex half of the surprise rail had no test for either of its promises: every test that reached `plex_surprise` passed budget="any" against a library with no state rows, so the duration bounds and the already-started exclusion could both be deleted with the suite green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half of one principle. `plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at all, leaving the rating_key → internal-id translation and both shelf arms uncovered, including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick` was exercised only through its domain function, so the route's own query-string translation could regress silently; the new test excludes all three candidates, which is what proves the whole list parsed rather than one unsplit string. `PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route re-typed the kind list. Both now drive off the constants. The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced, `(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via `==`, so two null keys compare equal and `None < None` is never reached; three independent runs confirmed it over every permutation. The rationale now says what the sentinel actually buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling has been swept to match. Its test is renamed and re-scoped to the mutation it does catch. Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player typechecked and shipped green. All eight fixes mutation-checked; every mutant died on its intended test. |
||
|
|
5703c4d713 |
fix(plex): a null progress timestamp must sort a rail row last, not raise
The Continue-watching and Surprise rails sorted on `(ts is not None, ts)`. That key LOOKS null-safe and is not: two rows that both lack a timestamp compare equal on the flag and then compare `None < None`, which raises — so a show whose rollup has no `resumed_at`, or a movie state written before `progress_updated_at` existed, could 500 the rail instead of sorting last. Replaced with an explicit epoch sentinel, and named it beside the other rail constants so the sibling rail E24 adds cannot re-derive the same broken key. |
||
|
|
013f19faca |
refactor(plex): move content_libs down into the query layer
The browse scope is a pure DB query with no request state, but it lived in routes/plex/_shared.py. E24's Home surface needs the same scope from a domain package, and a domain package importing from routes/ inverts the one-way dependency CLAUDE.md draws. Moved to app/plex/query.py and renamed to content_libs (public — it is a domain helper now). Swept all five call sites and both tests in one go rather than leaving a re-export shim: the canonical-patterns table already names the routes.admin re-export as the anti-pattern. |
||
|
|
d7e9240e54 |
merge main (0.78.2) into playback-class before release
# Conflicts: # backend/app/plex/stream.py |
||
|
|
bf98b48517 |
fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter: - Sticky watermark (medium): _apply_item reset the bit-depth probe watermark only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC) kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the file was replaced), not just the codec. - i18n parity (medium): the HU playback-class labels were mixed-language (only 'direct' translated). Translated remux/transcode_hw/transcode_sw and the tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas). - Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared source of truth for facet order + zero-fill). - a11y (low): the browse-card playback-class dot conveyed meaning only via a mouse-hover title; added role=img + aria-label so SR/touch users get the class. - tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet self-exclusion under an active playable filter, the migration codec-set == HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order, the transient probe-failure retry path, and the same-codec re-encode re-probe. Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/ vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the known merge-order constraint — this branch's runtime is codec-only; the facet becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left as a sequencing constraint, not code-changed here. |
||
|
|
4827aebb7f |
feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable, HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on the prod iGPU at ~4.2x realtime, ~1 CPU core. - stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable source picks the tonemap graph when the OpenCL runtime is present, else drops to software. `_opencl_present` gates on a registered ICD. `_venc_opts` / `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices, `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept as the hw-half wrapper. The runtime HW→SW fallback still backstops a session that emits no segment. - Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5; the .deb runs on the trixie base — verified live). Harmless without /dev/dri. - route + FE: thread `tonemap` through the session response; the stats overlay mode row reads "transcode · GPU · HDR→SDR" for the tonemap path. - tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on × 8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag. |
||
|
|
840526b1b8 |
feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how they play — the groundwork for collecting HW-transcodable (or no-transcode) versions. Four classes derived from the stored `playable` + a new `transcode_hw` facet: - Direct Play (direct), Direct Stream (remux) - Transcode · HW — playable=transcode AND the iGPU can hardware-transcode it - Transcode · SW — playable=transcode, software libx264 only The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW. Backend: - New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at. Migration backfills transcode_hw codec-only so the filter works immediately. - `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the runtime encoder choice can't disagree by codec. - Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe). - Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON. Frontend: - Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity. - Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal overlay swallowed the cursor; the dot now sits above it (z-20). Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip). |
||
|
|
0658f9550b |
feat(plex): transcode playback mode — VAAPI/libx264 re-encode (E6 S1b)
Non-h264 items (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) now play by re-encoding the video to H.264 inside the existing HLS session, instead of the old HTTP 501. VAAPI hardware encode (h264_vaapi, full GPU decode→scale→encode pipeline) where the render node is present and the source is HW-decodable; libx264 software fallback otherwise (yuv420p 8-bit, -threads reserve-one-core). The re-encode mints a keyframe at every segment boundary (-force_key_frames) so the HLS muxer still cuts independent segments and seeking works; everything else — -copyts, the HLS tail, multi-audio renditions, the AAC audio path — is the same machinery as remux, so hls.js/subtitles/seeking are unchanged. The session route returns transcode/hw so the player can surface the active mode. pytest pins both command branches (software + VAAPI shape) and the HW-selection gate (render node present AND source HW-decodable). Browser-UAT'd on the XviD fixture (Winning London): 501→plays, real 528×384 H.264 frames, 0 dropped, seek lands clean; stats overlay Mode row reads "transcode". |
||
|
|
f80ed115ad |
fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus smaller items; all fixed with tests. - watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY local row (a change whose immediate push failed) — that stranded the value forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push. - PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab persisted mount-time DEFAULTS over the account's stored scope/sort/filters (base read before the account was pinned). Persist/keep only URL-present dims; re-hydrate the omitted ones from storage. - playback resume write-back: guard against out-of-order delivery (a superseded older push now drops) and evict the throttle map so it can't grow unbounded. - PlexPlayer: announce mid-playback buffering to assistive tech (role=status + sr-only label); new plex.player.buffering (HU+EN). - columnSort: hoist the shared key:dir sort codec used by both URL-state modules. - Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber + refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519. |
||
|
|
8299ea72b7 |
feat(plex): 'last watched' browse sort + auto-apply in the in-progress view (S3)
Add a 'lastwatched' sort key to the unified Plex browse. Movies rank by coalesce(progress_updated_at, watched_at); shows by max(coalesce(...)) across their episodes (rollup.resumed_at, now broadened from progress-only so watched episodes count too). Never-watched titles sink via nullslast. Frontend auto-applies lastwatched:desc when entering the in-progress watch-state view and restores the pre-entry sort on exit; a manual sort change while there still wins. i18n HU+EN. Backend tests cover both union legs + mixed nullslast. |
||
|
|
3f8257059d |
feat(plex): Plex-authoritative watch-sync — resume write-back + faster pull (S2)
Make Plex the source of truth for watch state: - Write-back during playback: item_progress now pushes the resume position to Plex on the periodic checkpoint too (not only the final flush), throttled to one push per item every ~10s (in-memory, single-process rate limiter). The FE now also fires an immediate (debounced) push on seek. - Faster incremental pull: 30 → 15 min (plex_watch_sync_interval_min). - Plex-authoritative pull: _pull_apply resolves a genuine divergence to Plex's state instead of last-write-wins; the _same_state + synced_to_plex short-circuit still prevents a freshly-pushed row from bouncing. Removed the now-unused _LWW_TOLERANCE_S / _sift_ts. Tests: 5 new in test_plex_watch_sync.py (Plex-wins resume/watched, no-bounce, throttle final vs periodic). |
||
|
|
b1832afd18 |
style: apply ruff format across backend + scripts (line-length 100)
Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it. |
||
|
|
7d2bee59a3 |
fix(plex): round-2 deep-review follow-ups on the review fixes
- collections: the resync-failed-after-successful-write path now returns 422 (honest "reconcile on next sync" detail), NOT 502. The frontend treats 502/503/504 as gateway-unreachable and raises a sticky offline banner (api/core.ts RETRIABLE_GATEWAY); the app answered, so a 422 action-error toast is correct. Removes the false-offline regression the 502 introduced, and — since 422 isn't retried — the create double-write vector with it. - tests: make the mutate-then-resync rollback assertion non-vacuous — the resync stub now flushes a partial local change before raising, so the test proves db.rollback() actually undoes it; split into a write-failed (422) case and a resync-failed (422 + reconcile detail + rollback) case. - New-collection dup hint is now muted-tone with its own nameMaybeTaken copy (per-library uniqueness means the name may be free here) instead of the red authoritative nameTaken, which the still-enabled create flow contradicts. - history: cover the navCounter post-reload collision guard (ensureNavId advances the counter past a higher persisted sfScroll id). |
||
|
|
6af238c2ea |
fix(plex): resolve deep-review findings on the Discover/Collections batch
Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all verified) on the 22-commit branch: - collections: split the mutate-then-resync error surface. A resync READ that fails after a successful Plex mutation no longer reports a false 422 "Plex write failed" + rollback; it returns 502 "sync pending" (the next full sync reconciles the mirror). New _write_or_pending_error helper; delete is exempt (no Plex read). Covered by new happy-path / 422-rollback / 502 tests. - New-collection dup-name guard is now advisory only: existingNames is the search-scoped, all-library union, but the backend enforces uniqueness PER library — so it no longer blocks a valid same-name collection in another library, and the backend 409 is localized to nameTaken. - member-remove now confirms before the write-through (matches delete-collection). - add-title / new-collection library search render an error state on request failure instead of a misleading "No matches". - history: seed navCounter past the current entry's persisted sfScroll on load so post-reload ids can't collide with back-stack entries and mis-restore scroll. - PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded). - share isIntrinsicReadOnly between the browse tile and the info page so the lock/edit affordance can't drift. |
||
|
|
b71b33654a |
feat(plex): forbid duplicate collection names + a "New collection" button
Two follow-ups after testing the collection editing against real Plex, which revealed that Plex CONFLATES same-named collections (two rating_keys resolve to one merged member list) — a genuine footgun. - Duplicate-name guard: create and rename now reject (409) a title that already exists in the target section (case-insensitive), before any Plex write. create() can also derive the library from the seed item, so the caller needn't know the section key. The create dialogs surface the 409, and the New-collection panel warns inline before submit. - A "New collection" button on the Collections browse surface (admin only) opens a panel: a name field (with the live duplicate check) + a search to pick the FIRST title — Plex can't create an empty collection, it needs a seed. Creating opens the new collection's detail page straight away, so you can keep adding titles there. This is the B-route the user wanted, vs. the only prior path (a movie's "add to collection"). Verified live: the 409 guard (creating an existing name is blocked, no Plex write), and the full New-collection flow (name + first title → create → lands on the detail page); test collections created and deleted, real account left clean. Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 7 collections tests (+ a duplicate-name-rejection assertion). |
||
|
|
5d99a5f253 |
feat(plex): Collection info page — hero, play/shuffle, and write-through editing (B)
Clicking a collection tile now opens a dedicated Collection info page (like the Plex-web
one) instead of just applying it as a filter. New GET /collections/{rk} returns the hero
aggregates (year range, most-restrictive member cert, total runtime) + the members ordered
by release year then title; a new PlexCollectionView renders the hero (art/title/meta/
summary+More) and the member grid, with a Play-all and Shuffle over the playable leaves
(reusing the player's existing queue + auto-advance).
For a non-external, admin-enabled collection the page offers rename / delete / add-title
(a search picker) / remove-item, all through the pre-existing admin endpoints that write
THROUGH to Plex (each edit awaits and re-fetches — no optimism, since a Plex write can 422).
An eligible collection shows "Enable editing" first (the DB editable flag). Read-only ones
(smart / imdb / tmdb / … auto-lists) are marked with a lock on both the info page AND the
browse tile, up front.
New `{ kind: "collection" }` sub-view wired through plexSubUrl (bookmarkable ?v=collection:rk),
PlexBrowse (render branch, Backspace back-nav, tile handler), with a round-trip test.
Verified live: tile→page, hero + chronological members (James Bond 1962→2021, PG-13, 55h),
Play/Shuffle, Enable editing → edit controls + remove-X, the add search (marks already-in
titles "Added"), and the read-only lock on external collections. Real Plex mutations wired
but not executed against the live account.
Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 6 collections tests
(+ a detail-hero/ordering assertion).
|
||
|
|
c0d869089d |
feat(plex): add a "Recently watched" rail to Discover (D)
A new Discover rail listing what the user last FULLY watched, newest first: a movie as its own card, a show as the specific EPISODE watched (carrying its show title via leaf_card). Sits above Recently added. Movies honour the sidebar filters like the other rails; episodes carry no filterable metadata so they don't. Clicking a card plays it (the existing onCard path already routes episodes to the player). Bilingual heading added. Verified live: the rail renders watched movies + episodes with Watched badges. Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 13 discover tests (+ a recently-watched movie+episode assertion). |
||
|
|
15f5a5694a |
feat(plex): Surprise-me shows only unseen titles, capped 3 movies + 3 shows (C)
The Surprise rail drew a random 20+20 sample including already-watched titles. Its point is to surface something to WATCH, so exclude seen titles (a watched movie, a fully-watched show — a partially-watched or never-started show still counts as unseen) and cap at 3 movies + 3 shows (6 total), interleaved. Verified live: /surprise returns 6 items, 3+3, none watched. Gate: backend ruff clean, 12 discover tests (+ a watched-exclusion/cap assertion). |
||
|
|
14a44bff6e |
fix(plex): show-library collections get montage art too (A3)
The collection browse-tile montage queried only movie members, so show-library collections (IMDb Popular, TMDb Trending, … in the TV library) rendered as bare colour tiles. Union the art from PlexShow members as well (a collection lives in one library, so exactly one side has art); fall back to the collection's own poster, then the colour hash. Runtime stays movie-only (shows have no single duration). Verified live: every collection now shows a montage (0 art-less tiles, was ~all the show collections). Gate: backend ruff clean, 5 collections tests (+ a show-collection art assertion). |
||
|
|
37d11b602f |
feat(plex): redesign browse tiles — 2-poster montage top, readable label band
The full-bleed 4-poster collage drowned the on-tile text. Split PosterTile in two: the top half shows up to 2 montage posters, the bottom half is a solid colour-hash band that carries the label, so the text is always readable. Genre tiles drop the count (just the label now); collection tiles show the name on one truncated line plus a sub-line of item count · total runtime. A group with no art falls back to an all-colour tile. Backend: `/collections` cards gain `total_runtime_s` (a new `runtime_by_key` grouped sum) and their montage art is capped at 2. Frontend: PosterTile reworked; a `collectionMeta` helper builds the count·runtime sub-line via the existing formatRuntime. Verified live: collection and genre tiles render 2 posters over a readable colour band with the right sub-lines; colour fallback intact for art-less groups. Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, collections tests green (+ a new art/runtime assertion). |
||
|
|
da3cb5e694 |
feat(plex): poster collages on the genre + collection browse tiles
The Browse-by-genre tiles and the Collections browse tiles were flat color-hash
gradients. Give each a 2×2 collage of its top films' poster art, with the color hash
kept as the fallback so a group with no matching art (a show-only or empty collection)
never renders blank.
Backend: a shared `top_art_keys` windowed query returns, per key (a genre value or a
collection rating_key), the top few items' rating_keys — best-rated then newest, in one
query. Wired into the Discover genre tiles and the collections union endpoint; the client
renders the posters from the existing /api/plex/image/{key} endpoint, so no new image
plumbing. Frontend: a shared PosterTile component (collage + scrim + label, color-hash
fallback) replaces the two ad-hoc colored-tile implementations.
Verified live on localdev: genre tiles and collection tiles render real poster collages,
with the color fallback correctly kicking in for groups without art.
Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 11 discover tests
(incl. a new genre-art assertion).
|
||
|
|
9798e506cf |
feat(plex): apply the sidebar filters to the Discover rails too
Discover now behaves like the Browse grid — the active metadata filters (genre / rating / year / collection / studio / …) narrow every rail: Continue watching, Recently added, Surprise me, the genre tiles, and Because you watched. Same LibraryFilters / meta_filter_conds the grid uses, applied to each rail's movie + show query (movie duration included); a rail may come back empty, which the client already hides. Genre-language matches the grid. Because Discover honours filters itself, adding a filter no longer force-flips the Discover landing to the grid (only a header SEARCH does, since Discover has no search rail) — so you stay on Discover with narrowed rails. Frontend threads `filters` into api.plexDiscover/plexSurprise + their query keys (plexSurprise is variadic now) and the watched-toggle setQueryData; PlexDiscover reads them from usePlex. Tests: two discover filter-narrowing cases; PlexDiscover dom test updated for the provider default + 3-arg call. |
||
|
|
bad2aae753 |
fix(e5): deep-review round — combobox Enter, parse_person collision, enrich hardening + coverage
Full-epic /deep-review (11 finders + 12 adversarial verifiers) surfaced no HIGH; every mechanism finding calibrated to LOW on verification. Fix all 13: Correctness / reliability: - Combobox: Enter now commits the VISUALLY highlighted row (highlightIndex ?? activeIndex), not the bare keyboard activeIndex — so arrow-then-hover-then-Enter no longer toggles a different option than the one painted, and hover-with-no-arrow is no longer a silent no-op. - parse_person: dedup the known_for pool by (id, media_type), not the bare id — TMDB movie and tv id spaces are independent, so a same-id movie+show pair is two distinct credits (matches the collision-safe key the rest of the module already uses). - person-enrich: a per-person in-flight + 60s cooldown guard (people._should_schedule_enrich) dedups concurrent opens and rate-limits re-triggering while TMDB is degraded (a transient TmdbError leaves the watermark NULL), so bursts can't pile 8s tasks onto the shared threadpool. - PlexInfo compact()/money(): format with i18n.language (keeping currency USD) instead of a hardcoded en/en-US, matching the app's Intl convention. - delete the dead `plex.filter.add` i18n key (both locales) — the +Add button that used it is gone. Reuse: - _continue_watching reuses rollup.agg_subquery(include_resumed=True) + query.apply_show_state_filter instead of a third hand-inlined copy of the per-show rollup + in_progress predicate, so the Discover rail can't drift from the grid's definition. Test coverage (the gaps a regression would ship through): - resolve_library_guids scope-exclusion (a credit in a non-scoped/disabled library is dropped) + the []-vs-None distinction; parse_person cross-media-id retention; enrich_person transient (NULL watermark) and 404 (stamp) branches; _enrich_person_bg body (fresh session close/rollback, None-guard) + the cooldown dedup; continue_watching fully-watched-show exclusion; Combobox Enter-commits-highlighted + async/controlled-query mode; PopoverItem aria-checked contract; RatingsRow missing-IMDb + imdb==audience branches. Backend lane 413→420, FE vitest 351→357; all gates green. No migration. |
||
|
|
13fb2b4e61 |
refactor(e5): deferred review-tail (backend) — enrich off request path, reuse, tests
Address the deep-review's deferred backend items so E5 is review-complete: - person-enrich OFF the request path (ABUSE finding): person_detail no longer makes the TMDB call inline (even bounded to 8s it sat on the serve thread, un-throttled, demo-reachable — it violated the module's "no live TMDB call at serve time" invariant). It now schedules a FastAPI BackgroundTask (people._enrich_person_bg, fresh session, best-effort) after the response; the first open serves the row as-is and the bio lands by the next open. The inline try/rollback recovery is gone with it. - reuse: a shared tmdb.resolve_library_guids owns the collision-safe guid→library bucketing (movie vs tv ids are independent sequences) that resolve_recs and people._resolve_known_for each hand-duplicated; a new rollup.item_state_map dedups the "PlexState-by-item_id" fetch across the person + discover paths. - test gaps: enrich_batch transient TmdbError leaves the watermark NULL to retry; guid_to_tmdb_id resolves a tvdb source + tv media_type via /find's tv_results; _continue_watching includes a NULL-status started movie and excludes a hidden one; /api/search/youtube returns 422 (not 5xx) when ingest fails (route-level, B-core-1). Backend lane 403 → 413, ruff clean. No migration; serve contracts unchanged. |
||
|
|
0c746f5bcd |
fix(e5): deep-review survivors — TMDB key log leak, person-enrich rollback + timeout
Three verified deep-review findings (the 4th, the filter combobox a11y gap, is deferred to a dedicated session on the combobox architecture): - SEC (medium): TmdbClient sent the api_key as a URL query param, so an HTTP error's httpx exception string embedded the full URL incl. the key, which enrich_batch logged. _get now raises status+path only (never str(e)/the URL) and severs the secret-bearing cause with `from None`. Regression test asserts neither the status nor the network error path contains the key. - STATE (low): person_detail's lazy-enrich except now db.rollback()s, so a failed enrich_person commit can't poison the session and 500 the subsequent SELECTs (SQLAlchemy 2.x deactivates the tx on a commit-time flush failure and does not auto-reset it). Test locks that the page renders despite a poisoned enrich. - ABUSE (low): the request-path lazy person enrich now caps its TMDB client at an 8s timeout (was the 30s default) so a slow TMDB can't tie up a worker; TmdbClient gained a `timeout` param (batch keeps 30s). The fuller fix (move person enrichment off the request path) is logged for later. |
||
|
|
2b5de89eda |
fix(e5): address code-review findings (TMDB guid media_type, search ingest guard, discover keying)
Follow-ups from the epic-end /code-review of the E5 branch: - tmdb.resolve_recs + people._resolve_known_for: match recommendations/ credits to the library bucketed by media_type. TMDB movie ids and tv ids are independent sequences, so a bare `tmdb:<id>` match could deep-link a movie rec to an unrelated show (or vice-versa) on a numeric collision. Regression test added (test_resolve_recs_matches_by_media_type_not_bare_id). - routes/search: wrap the _ingest_candidates call so a DB/enrich failure degrades to a 422 with a message instead of a bare 500 hidden behind the FE's generic "search failed" (extends the B-core-1 rationale); rollback on failure. Stamp enriched_at with its own timestamp taken after the enrich, not the stub-insert placeholder. - routes/plex/discover: key serialized cards back by rating_key (globally unique in Plex) instead of id(obj); fix the stale rows 3-tuple annotation. |
||
|
|
d56a791c04 |
E5 S7: Plex filter UI — listbox-select pickers + studio filter
Reworks the always-expanded filter chip walls into compact, Seerr-style "+ Add" → searchable-popover pickers (the user's space-saving ask), and adds the missing studio filter. - New MultiSelectChips: selected values as removable chips + a "+ Add" trigger opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox, arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary renders a 50-row cap + "N more — refine with search" footer (studios run to ~1500). Replaces the flat Genre + Age-rating chip lists. - New Studio filter via the same picker. Backend: /facets now aggregates the studio vocabulary (distinct studio + count, self-excluded like genres). The studio filter itself already existed (only addable from the detail page before). - Collection converted to the same "+ Add" pattern (CollectionPicker): a single-select popover with server-side search + the 50-cap; the selected collection still shows as a removable chip. Retires the always-inline search+list. New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults (en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) + test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402), Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search. Gate: vitest 342, backend lane 403. |
||
|
|
187b8007f8 |
E5 S6: enriched person page (TMDB bio + birth facts + Known-for rail)
The person page now leads with a TMDB bio, lazily fetched the first time it's
opened (off the S2-linked `tmdb_person_id`) and then served from the row:
- Migration 0072: ADD-only nullable person columns on plex_people — biography,
birthday, deathday, place_of_birth, known_for_department, also_known_as,
known_for ([{tmdb_id, media_type, title}]), tmdb_enriched_at.
- tmdb.py: TmdbClient.fetch_person (/person/{id}+combined_credits), parse_person
(bio + a popularity-ranked, deduped known_for), apply_person, enrich_person
(lazy one-shot, key+id guarded, commits, 404 stamps the watermark).
- people.py person_detail: loads the full person row, lazily enriches (best-effort
— never fails the page), serves the bio fields + resolves known_for to library
rows by guid (in-library only, popularity order).
- FE PlexPersonView: bio/birth-facts/aka hero + a "Known for" Carousel rail above
the existing Movies/Shows filmography; PlexPersonDetail type + i18n (en+hu).
Tests: test_plex_person_bio.py (6: parse / lazy-enrich guards / serve) + extended
PlexPersonView.dom.test.tsx + the unknown-name contract in test_plex_person.py.
Browser-E2E'd on Jim Carrey (live TMDB enrich → bio 1937 chars, born 1962-01-17
Newmarket, Known-for resolved). Gate: vitest 337, backend lane 402.
|
||
|
|
f9b63745f9 |
E5 S4: Plex Discover rail-surface (default landing)
A dedicated Plex "Discover" landing — a stack of horizontal Carousel rails served from ONE consolidated read (serve-from-DB, no live Plex/TMDB call): - GET /api/plex/discover → continue_watching (in-progress movies + shows, most recently progressed) · recently_added · genres (top-N tiles) · because_you_watched (recs of the most-recent watched, TMDB-enriched movie, resolved to library rows by guid via serve_block's resolver). - GET /api/plex/surprise → a fresh random, scope-bounded sample for the ↻ rail. Frontend: PlexDiscover.tsx (rails reuse the S1 Carousel + S3 PlexPosterCard; colored genre tiles → tap to filter the grid; ↻ reshuffles the surprise rail alone). Discover is now the root sub-view; the flat grid becomes the bookmarkable "Browse all" secondary view (?v=browse) reached by a header search/filter (a browse-intent flip) or the new Discover⟷Browse toggle. resolve_recs is promoted from _resolve_recs (now cross-module). Tests: test_plex_discover.py (rail shape/order, continue-watching, because-you-watched + null, surprise bounds, scope) and PlexDiscover.dom.test.tsx; plexSubUrl round-trip updated for discover-root/grid-browse. Backend lane 393, vitest 332. |
||
|
|
5b5e76b73d |
E5 S3: unified Plex card — scrim-reveal hover + rest polish
The poster card (grid + person page) gets the Seerr-style hover: on hover a bottom scrim slides up with the title, a "year · runtime · ★rating" meta line, and a 3-line synopsis — the info you'd otherwise click through for — plus a less-flat play ring (radial-gradient + ring shadow) and a top-right quick-action cluster (Info + watched-toggle, revealed on hover / always on touch). Rest-state badges (season / type tag) yield to the scrim on hover; the playable-cost tint stays. (§1 A + C from the enrichment study.) Backend (the data the scrim needs, mirroring S1's `art`): the grid (browse.unified_library) now emits a lang-coalesced, truncated `summary` (new query.summary_col) + the Plex `rating`; movie_card/show_card do the same (EN) for the detail/related/person cards. PlexCard.rating type added (summary already existed for episodes). Gate: tsc x3 / eslint / prettier / knip / vitest 326 green; ruff green; backend lane full suite green. Self-E2E on the live dev server (rebuilt on this branch, S2-enriched): the grid serves rating+summary (Shawshank 9.3, Godfather 9.2, real synopses) and the card renders the play ring + scrim (The Expanse: ring, synopsis, ★8.1) — verified via the DOM (the pane wouldn't composite for a screenshot). |
||
|
|
3bde114b14 |
E5 S2: TMDB enrichment engine (Plex catalog)
Matches each mirrored Plex movie/show to TMDB by its stored canonical guid, fetches once (append_to_response), and PERSISTS the enrichment onto the row — served from the DB, never a live TMDB call at serve time. Additive: the Plex-sourced imdb_rating/rating are untouched; tmdb_rating is the distinct TMDb community score (ratings scope = TMDb + Plex's IMDb/audience, no OMDb/RT). - config.py: tmdb_api_key + api_base/language/interval/batch defaults. - sysconfig SPECS: tmdb_api_key (secret, Fernet) + tmdb_language, auto-rendered in the admin Config page; read like the Plex token. - migration 0071 (ADD-only nullable, reversible): plex_items + plex_shows gain tmdb_id / tmdb_enriched_at (watermark) / tmdb_rating / tmdb_vote_count / budget / revenue / keywords / crew / studios / production_countries / recommendations / backdrops; plex_people gains tmdb_person_id (the S2 foundation for S6 person bios). No watermark reset needed. - app/plex/tmdb.py: TmdbClient (mirrors PlexClient; throttled; 404->None, transient->TmdbError), parse_tmdb / apply_tmdb_detail (one-parser rule), guid_to_tmdb_id (tmdb: direct, imdb:/tvdb: via /find), enrich_batch (movies then shows, transient error leaves the watermark NULL to retry, definitive no-match still stamps so it isn't re-fetched), _link_people (upserts tmdb_person_id by name), serve_block (recommendations resolved to library rows by guid), run_enrich (no-op without a key). - scheduler: tmdb_enrich nightly job (default 120 min, no YouTube quota), runtime-tunable + "run now" from the Scheduler dashboard. - detail endpoints emit `tmdb` (movies + shows) so it's curl-verifiable now; the FE render lands in S5. Note: _link_people inserts plex_people rows for TMDB cast/crew; unreferenced crew (composers etc., not in cast_names/directors) get pruned by the orphan sweep — acceptable, the person page is cast-driven and cast rows survive. Gate: ruff + py_compile green; backend test lane FULL suite 387 passed (incl. migration drift downgrade->re-upgrade + test_plex_tmdb 9 tests). Frontend unchanged since S1. Not merged (epic branch, stacks on S1). |
||
|
|
52b6535dc6 |
refactor(plex): split browse.py god-module into per-concern routers (E3 S1)
routes/plex/browse.py was a 1693-line god-module (libraries + facets + collections + playlists + show/item detail + people). Split it into focused sibling routers under the same /api/plex prefix — pure code motion, no SQL / route-path / response-shape changes: browse.py 1693 -> 469 (list_libraries, unified_library, facets) collections.py (new 253) browse + admin collection editor; owns _collection_source playlists.py (new 405) the user's orderable playlists detail.py (new 438) show/item detail + enrichment; _lib_key lives here people.py (new 139) cast/crew chips + person filmography Cross-module helpers _norm_lang + _content_libs move to _shared.py (their one home); detail imports _collection_source from collections (one-way, no cycle). __init__.py includes the four new routers. Three DB-lane tests follow the moved symbols (import path for _content_libs; monkeypatch targets for PlexClient / _show_enrichment now on detail). Behaviour-equivalent: ruff clean, 348 DB-lane pass (unchanged), parity net green. |
||
|
|
9bf7da2cbe |
refactor(plex): dedup movie/show watch-state predicates into query.py (E3 S1)
The `show` scope (all|watched|in_progress|unwatched) filter and the status CASE were hand-duplicated between unified_library (the grid) and facets.wsq — the exact drift risk the review flagged (a hidden movie kept out of the grid must not inflate facets). Extract them into four shared query.py helpers (movie_status_case, show_status_case, apply_movie_state_filter, apply_show_state_filter), model-agnostic (callers pass the joined state expressions), and point all three call sites at them. Pure behaviour-equivalence refactor: browse.py -67 lines, no query changes. Green under the E3 S1 parity net + query_conversion/facets/field-scoped/lang tests (348 DB-lane pass, ruff clean). |
||
|
|
9fb4db2546 |
fix(plex): address S6 code-review — 5 findings
1. (correctness) Show hero rating regression: PlexBrowse's show hero rendered a rating only when show.imdb_rating was set, so after the S6 IMDb-only change a show with no imdb:// entry showed no rating. Now falls back to the audience `rating` like PlexInfo. 2. (correctness) ratingMin column mismatch: the rating filter and the /facets rating_max now key off coalesce(imdb_rating, rating) — the SAME "best score" the badge displays — so a ratingMin picked from a shown IMDb score narrows the right column instead of the raw audience `rating`. 3. (altitude) HU search gap: new query.vector_match() matches EITHER vector in HU (or_(search_vector_hu, search_vector)) so a row with no HU twin stays findable by its English title; ranking still uses the HU vector. field_recheck unions the HU+EN per-field tsvectors in HU, staying a strict subset of the prefilter. 4. (test-coverage) Extracted the badge logic to lib/plexRating.pickRatingBadge (shared by PlexInfo + the show hero) with a unit test (imdb-preferred, audience fallback, 0-score, none). Backend tests added for the HU union search + the IMDb-preferred rating filter. 5. (efficiency) Language switch no longer double-fetches the grid: browseQ is gated on a committedLang state that lags `lang` by one render, so the genre-clear effect settles into a single fetch instead of one throwaway fetch with stale-language genres plus a second. DB-lane suite 295 passed / 2 skip; FE tsc/knip/lint clean, 217 vitest pass. |
||
|
|
030ba7297a |
feat(plex): S6 backend — language-aware serving + IMDb-only rating
E5 S6 backend half (no migration — the *_hu / rich columns exist from S5a/S5b). Language-aware serving (?lang=hu serves the HU overlay twin, EN fallback per row): - query.py: column pickers title_col / genres_col / vector_col; field_recheck and meta_filter_conds take `lang`. DISPLAY/FILTER pickers coalesce(*_hu, *) (EN fallback); SEARCH pickers use the raw *_hu column mirroring the generated search_vector_hu (coalesce to '', not EN) so the field recheck stays a strict subset of the GIN vector (the S2 invariant). genres_col type_coerces the coalesce to JSONB so the GIN `.contains` survives. - browse.py: `lang` param on /library, /facets, /item, /show (validated to en|hu). unified_library title/vector/rank/field-scope + meta filters are language-aware for both the movie and show branches; facets unnest the active-language genre vocabulary (jsonb_typeof guard kept); detail endpoints serve HU title/summary/ genres/show_title with EN fallback. Episodes stay EN (not overlay-localized). IMDb-only rating (folded-in S5b review #5): - detail.py rich_detail: drop the audienceRating/rating fallback → imdb_rating is NULL when there's no imdb:// Rating entry (stop mislabelling a Plex audience score as IMDb). item_detail now also serves the `rating` column (audience score; show_detail already did) so the UI can show it when there's no IMDb score. Tests: test_plex_lang.py (HU title serving + EN fallback, language-specific facet vocabulary, genre-filter language matching, item_detail lang + rating split); test_plex_detail updated for IMDb-only. DB-lane suite 293 passed / 2 skipped; ruff clean. |
||
|
|
2ab188d873 |
refactor: address code-review — layering + public API naming
Applies the three findings from the /code-review high pass on this stack:
1. to_tsquery_str moved from app.feedquery to app.utils — it's a generic
tsquery sanitizer with no feed dependency; housing it in the feed
read-model coupled the Plex browse module to the feed domain. Both feed
and plex now import it from the neutral util module.
2. feedquery.decode_cursor no longer raises fastapi.HTTPException — it raises
a plain ValueError, and routes/feed.get_feed translates it to the HTTP 400.
The read-model no longer imports fastapi, matching its "no request state"
docstring.
3. Dropped the leading underscore on the helpers that are now a cross-module
public API: feedquery {filtered_query, sort_keys, order_by, keyset_predicate,
encode_cursor, decode_cursor}; plex.query {TS_CONFIG, FIELD_COLUMNS,
field_recheck, split_csv, added_cutoff, meta_filter_conds, apply_meta_filters,
like_escape}; plex.cards {movie_card, show_card, episode_card, leaf_card}.
Genuinely-internal helpers (_channel_url, _saved_expr, _ADDED_WINDOWS,
_lib_key) keep their underscore.
Pure code motion + rename. DB-lane suite 287 passed / 2 skipped; ruff clean.
|
||
|
|
e7b5b8e87f |
refactor(plex): extract card serializers + query builder from browse.py
Second half of the 2026-08-17 review's "Plex query/cards extraction" — slim the 1841-line browse.py god-module ahead of S6 (which reworks the library query's column selection for the HU overlay). - app/plex/cards.py: the pure grid/leaf card serializers (_movie_card, _show_card, _episode_card, _leaf_card). - app/plex/query.py: the library query-shaping layer — field-scope tsvector rechecks (_TS_CONFIG, _FIELD_COLUMNS, _field_recheck) and the shared sidebar metadata filters (_csv, _added_cutoff, _meta_filter_conds, _apply_meta_filters, LibraryFilters, _like_escape). This is the seam S6 will touch. browse.py imports both and keeps its route handlers; the entangled collection/playlist card cluster (_collection_source/_collection_editable) stays put deliberately. All helpers were browse-internal, so no external importers change. Pure code motion — DB-lane suite 287 passed / 2 skipped; ruff clean. browse.py 1841 -> 1667 lines. |