Commit Graph
17 Commits
Author SHA1 Message Date
peter 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.
2026-08-17 20:41:13 +02:00
peter 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.
2026-08-17 19:07:43 +02:00
peter 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.
2026-08-17 18:23:03 +02:00
peter 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.
2026-08-17 17:47:30 +02:00
peter 5f09259bbf refactor(feed): extract read-model to app.feedquery + admin_user sweep
B-core-3 (2026-08-17 review): decouple two things from router modules.

1. Feed read-model → new app/feedquery.py: the pure query-building and
   row-serialization layer (feed_columns, serialize, _filtered_query,
   to_tsquery_str, and the keyset-pagination helpers) moves out of
   routes/feed.py, which keeps only the route handlers, _feed_params, and
   the request models. The three external consumers (playlists, search,
   plex/browse) now import from app.feedquery instead of reaching into the
   feed router.

2. admin_user sweep: 7 modules imported admin_user via app.routes.admin
   (an incidental re-export of app.auth.admin_user). Repoint them all to
   the definition in app.auth (audit, channels, config, quota, scheduler,
   plex/admin, plex/browse); routes.admin keeps its own import for its use.

Pure code motion — no behaviour change. Full DB-lane suite 287 passed /
2 skipped; ruff clean.
2026-08-17 17:42:01 +02:00
peter 4e97fc1a84 feat(plex): S5b persist rich detail + serve item/show detail from DB
Persist the info-page rich fields that were previously read LIVE from plex.metadata() on every view —
imdb_rating (IMDb-specific, vs `rating`=audienceRating), imdb_id, tagline, and full_cast
([{name,role,thumb}]) — onto plex_items/plex_shows, and serve the detail endpoints from the row.

- migration 0065: ADD imdb_rating/imdb_id/tagline/full_cast (nullable → no rewrite/lock, unlike 0064);
  NULL people_enriched_at on movie/show rows so the existing cast backfill re-fetches each once and
  fills the new columns.
- new app/plex/detail.py: rich_detail(meta) (moved out of browse._rich_meta so sync + serve share one
  parser) + apply_rich_detail(row, meta); wired into _enrich_people_batch and the info-page lazy fill.
- item_detail: movies serve cast/IMDb/tagline/genres from the row and keep plex.metadata(markers=True)
  ONLY for markers + audio/subtitle streams (so detail survives a live-Plex outage); episodes (never
  enriched) still parse detail from that live meta.
- show_detail: serves cast/IMDb from the row; `related` stays live best-effort and also lazily fills a
  not-yet-enriched show.
- tests: rich_detail/apply_rich_detail parsing + persistence, backfill stamping, item_detail
  outage-resilience + lazy fill + episode-live, show_detail row serving. Full suite 285 pass / 2 skip;
  migration verified reversible (up→down→up).

Precondition for S6's pure-DB language-aware serving.
2026-08-16 23:11:15 +02:00
peter c959859d29 feat(plex): S5a HU-overlay backend — schema, guid, overlay sync
Adds the storage + sync for the Hungarian-metadata overlay (the browsable EN sections gain HU
twin fields written from a paired HU section, matched by guid; no browsable HU rows).

Schema (2 migrations, ADD-only; run before the first HU sync while *_hu is NULL so the generated
column build is trivial):
- 0063: plex_libraries.metadata_lang ('en' default) + self-FK overlay_of.
- 0064: plex_items/plex_shows.guid (indexed) + title_hu/summary_hu/genres_hu (+ GIN) + a STORED
  generated search_vector_hu mirroring the EN vector (title_hu A / people_text B / summary_hu C) + GIN.

Sync:
- includeGuids=1 on section_items so the canonical guid rides the cheap listing.
- _canonical_guid(meta) (tmdb > imdb > tvdb) captured onto every EN row in _apply_item/_sync_shows.
- Two-pass sync(): pass 1 mirrors browsable (non-overlay) sections; pass 2 reads plex_overlay_map
  ('huKey:enKey', new sysconfig), designates each HU section (metadata_lang='hu' + overlay_of), and
  _sync_overlay writes title_hu/summary_hu/genres_hu onto the guid-matched EN rows.

Browse: _content_libs now excludes overlay libraries (overlay_of is None); the unified_library and
facets inline library partitions fold into it, and list_libraries/collections exclude overlays too.

Tests: DB-lane coverage of the overlay write (matched vs unmatched rows, no browsable HU rows, the
generated search_vector_hu populates), the guid parse, and the browse exclusion. Full suite 272
passed; migrations verified reversible (upgrade→downgrade→upgrade). S5b (persist the live detail
fields + serve item/show detail from DB) is the follow-up.
2026-08-16 22:16:54 +02:00
peter 072d8fc88f fix(plex): address S4 review — cap filmography, dedupe toggle, scroll guard, tests
- Efficiency: cap each person section at _PERSON_SECTION_MAX (200) so a prolific person can't
  return an unbounded list the client mounts a card per row for.
- Reuse: extract the optimistic watch-toggle (flip → persist → resync) into a shared
  toggleWatchState helper; the grid and the person page each supply their own cache updater.
- Correctness (scroll): onOpenPerson now stashes infoScrollRef ONLY when leaving an info page
  (not from a show/grid context), and a jumped-to info page resets it — so a stale offset can't
  wrongly restore a fresh info page.
- Test-coverage: add PlexPersonView.dom.test.tsx (export CastStrip + PlexPersonView) covering
  the cast-row rewiring (open-person vs filter fallback) and the person page's sections + filter
  + empty state.

The name-as-identity finding is inherent to Plex cast carrying no per-person id (PlexPerson is
name-keyed too); documented in the endpoint docstring, not structurally fixable in S4.
2026-08-16 21:55:34 +02:00
peter 8b8c077a29 feat(plex): S4 actor detail page — filmography endpoint + person subview
Backend: new GET /api/plex/person/{name} returns a cast/crew member's cached headshot plus
the movies (PlexItem, kind=movie) and shows (PlexShow) in the enabled libraries whose
cast_names OR directors include the name — the inverse of the cast → filter link. Introduce a
shared _content_libs(db) helper (enabled-library partition) and reuse it in people_cards;
S5 will extend it with the HU-overlay exclusion. Watch-state comes from PlexState / rollup
(reusing _movie_card / _show_card / state_map). DB-lane tests cover cast+director credits,
library scoping, the kind filter, unknown-name empties, and the partition helper.

Frontend: a new `person` history-subview. A cast-row click (PlexInfo cast row + the show
page's CastStrip) now opens the person's filmography page instead of filtering; the filter
gesture moves to a "Filter library by {name}" button on the page itself. PlexPersonView
mirrors PlexInfoView's fetch-wrapper + glass hero (headshot + name + credit counts) and
renders Movies / Shows as PlexPosterCard grids, with an optimistic per-card watch toggle on
the person cache. New plexPerson query key + api client + PlexPersonDetail type; HU/EN copy.
2026-08-16 21:26:58 +02:00
peter f9681deecf feat(plex): field-scoped search (S2) + instant search typeahead
Add a search scope-pill to the Plex header search box (GitHub-style, one
field-scope at a time) and an instant typeahead of the top matches below it.

Backend (unified_library):
- New `field` param: all | title | people | summary | episode. `all` is
  byte-identical to the pre-scope behaviour (untouched weighted search_vector);
  a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a
  per-column tsvector recheck, so scoped hits are a strict subset of Everywhere.
  Per-field expressions mirror the search_vector Computed def (same TS config,
  same left(summary,1000) truncation).
- `episode` scope empties the movie/show grid and title-only-matches the grouped
  Episodes section; the title/people/summary scopes suppress that section.
- Unknown field falls back to `all`. New DB-lane characterization tests
  (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is
  identical to today, episode empties the grid and needs shows in scope.

Frontend:
- PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing
  ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/
  keydown slots so other modules' search boxes are untouched.
- Episode scope offered only when shows are in scope (movie-only hides it),
  healed at the point of use like the sort state.
- Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the
  sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc),
  and jumps straight to a movie's info page or a show's drill-down via a new
  provider jumpTo hand-off (mirrors the playlistOpen pattern).
- `field` persisted per-account; en/hu labels added.
2026-08-16 08:05:18 +02:00
peter e008c48284 fix(plex): address S1 code-review findings
- Restore synchronize_session=False on the remove-bulk Core delete (the 2.0
  default is 'auto'; keep the pre-conversion contract explicit).
- _episode_nav: list(...scalars()) instead of [r[0] for r in ...all()], matching
  the house style used across the conversion.
- Extract app/plex/lookups.by_rating_key() and migrate the get-by-rating_key
  pattern at 13 sites (imgcache x4, _shared.item_or_404, playback show/season,
  browse x6).
- Coverage: add TestFacetsWithData (the converted wsq aggregation with real
  data — the highest-risk untested builder), TestPlaylistAndCollectionEndpoints
  (playlist CRUD + collections list), and an item-state playback test.

Full suite 256 passed, 2 skipped; ruff clean.
2026-08-16 07:06:19 +02:00
peter f8df504d18 refactor(plex): convert browse.py + rollup db.query() to 2.0 select() (wave 2)
Completes the plex-package 1.x->2.0 conversion, behaviour-equivalent (full
suite 253 passed, 2 skipped; the characterization net locks every path):
- routes/plex/browse.py (61 sites): libraries, facets (the wsq builder +
  jsonb genre unnest), collections, playlists (+ bulk add/remove/reorder),
  seasons, people/cards, collection strips, episode-nav, item detail, and
  the unified_library union builder.
- plex/rollup.py: agg_subquery now returns a select(); state_map executes it
  (the atomic unit coupled to unified_library via .subquery()).

Query builders -> select(); .filter/.filter_by -> .where; terminals ->
db.execute(...).scalars()/.all()/db.scalar(...); bulk .delete()/.update() ->
execute(delete()/update()). Zero db.query()/.filter_by remain in the package.
2026-08-16 06:06:21 +02:00
peter 59d50615c4 refactor(plex): convert service-layer db.query() to 2.0 select() (wave 1)
Mechanical 1.x->2.0 conversion of the self-contained plex modules, behaviour-
equivalent (the characterization net + existing plex tests stay green, 39
passed): app/plex/{sync(15),watch_sync(9),people(1),imgcache(4)} and
app/routes/plex/{admin(4),playback(8),_shared(1)}. Query builders become
select(); .first()/.all()->execute().scalars(); a bulk .update() ->
execute(update()); a single-column .first() -> db.scalar().

browse.py (61) + rollup.agg_subquery (the atomic unit with unified_library)
remain for wave 2.
2026-08-16 05:52:40 +02:00
peter 01ded15bb4 docs(progress): document the intentional feed-vs-Plex finish-margin divergence
R12 S5 item: the feed's FINISH_MARGIN_SECONDS (10) and the Plex player's
_FINISH_MARGIN_S (30) were flagged as drifted duplicates. They are a
deliberate divergence, not drift — the feed clears a short video's resume
bar, Plex marks long-form items (movies/episodes with credits) watched.
Cross-reference + document both so it reads as intentional.
2026-08-13 01:55:14 +02:00
peter 51463b226e refactor: R12 S3 — invert scheduler->routes, publish cross-module privates
Layering cleanup, no behaviour change (ruff clean backend+scripts, pytest 193):

- Invert the scheduler->routes dependency: reset_demo_state (+ its demo-seed
  helpers) move from routes/admin.py to a new service module app/demo.py.
  Both routes/admin.py and scheduler.py now import it from app.demo, so the
  scheduler no longer reaches into the routes layer — its lazy import (the
  routes<->scheduler cycle workaround) is gone, hoisted to a top-level import.

- Publish the private symbols that were imported across module boundaries (the
  underscore convention was lying): feed._serialize -> serialize,
  feed._to_tsquery_str -> to_tsquery_str, videos._insert_stubs -> insert_stubs,
  videos._stub_from_playlist_item -> stub_from_playlist_item,
  worker._uploader_url -> uploader_url, plex._shared._item_or_404 ->
  item_or_404. Scoped renames — the 5 other route modules keep their own truly
  private _serialize helpers.

- _release_asset moves from routes/downloads.py to app/downloads/service.py as
  the public release_asset (asset bookkeeping is a service concern); the routes
  call it via service.release_asset.
2026-08-11 22:58:10 +02:00
peter f484de1008 refactor(plex): address /code-review on the R12 S1 split
Cleanup only, no behaviour change (ruff clean + pytest 193):
- imports hoisted above `router =` and grouped/sorted (isort) across the split
  files; ruff-format pass restores the 2-blank-line PEP8 spacing the generator
  collapsed to 1.
- the shared on-disk cache root moves to a neutral app/plex/cache.py, so
  subtitles.py no longer imports it from imgcache.py (drops the cross-import).
- the former plex.py module docstring's access-model note (read endpoints open
  to any authed user; admin endpoints test/sync) is restored in the package
  __init__.py.
2026-08-11 21:46:43 +02:00
peter 99befa6e38 refactor(plex): split routes/plex.py into an admin/browse/playback package
R12 S1 step B. The 2011-line routes/plex.py becomes an app/routes/plex/
package:

- admin.py     — connection test, catalog sync, owner watch-link config (5)
- browse.py    — libraries, facets, collections, playlists, show/item, people (23)
- playback.py  — image/subtitle proxies, HLS streaming, watch-state writes (10)
- _shared.py   — the two genuinely cross-module helpers (_item_or_404, log)
- __init__.py  — assembles `router` from the three sub-routers; the app wiring
                 (plex_routes.router in main.py) is unchanged.

Mechanically generated and proven behaviour-equivalent: every function/class
source relocated verbatim (get_source_segment equality) and the (method, path)
route set is identical to before. Endpoints keep the /api/plex prefix on each
sub-router. No SQL change (db.query untouched; 2.0 select() is the next step).

Gate: ruff clean, pytest 193 passed (pure-logic + DB lane).
2026-08-11 21:30:01 +02:00