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.
This commit is contained in:
2026-09-06 03:31:02 +02:00
parent 8d9a99ce54
commit 013f19faca
7 changed files with 41 additions and 40 deletions
+25 -1
View File
@@ -8,8 +8,9 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session
from app.models import PlexItem
from app.models import PlexItem, PlexLibrary
TS_CONFIG = "public.unaccent_simple"
@@ -342,3 +343,26 @@ def apply_show_state_filter(query, tot, wat, inp, show: str):
if show == "unwatched":
return query.where(wat == 0, inp == 0)
return query
def content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
move."""
libs = (
db.execute(
select(PlexLibrary).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
)
.scalars()
.all()
)
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
return movie_lib_ids, show_lib_ids
+1 -23
View File
@@ -3,13 +3,9 @@
import logging
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import (
PlexItem,
PlexLibrary,
)
from app.models import PlexItem
from app.plex.lookups import by_rating_key
log = logging.getLogger("siftlode.plex")
@@ -25,21 +21,3 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem:
def _norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
def _content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5)."""
libs = (
db.execute(
select(PlexLibrary).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
)
.scalars()
.all()
)
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
return movie_lib_ids, show_lib_ids
+4 -3
View File
@@ -34,6 +34,7 @@ from app.plex.query import (
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
@@ -47,7 +48,7 @@ from app.plex.query import (
vector_col,
vector_match,
)
from app.routes.plex._shared import _content_libs, _norm_lang
from app.routes.plex._shared import _norm_lang
from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -109,7 +110,7 @@ def unified_library(
offset = max(0, offset)
lang = _norm_lang(lang)
p = f.as_dict()
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
@@ -328,7 +329,7 @@ def facets(
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = _content_libs(db)
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
+2 -3
View File
@@ -19,8 +19,7 @@ from app.plex import sync as plex_sync
from app.plex.cards import movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.plex.query import like_escape, runtime_by_key, top_art_keys
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs, like_escape, runtime_by_key, top_art_keys
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
@@ -128,7 +127,7 @@ def collections(
# members (a collection lives in one library, so exactly one side has art) — a show collection would
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
# Runtime is movie-only (shows have no single duration).
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
keys = [c.rating_key for c in rows]
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
+3 -4
View File
@@ -19,8 +19,7 @@ from app.models import (
from app.plex import rollup
from app.plex import tmdb as plex_tmdb
from app.plex.cards import movie_card, show_card
from app.plex.query import split_csv
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logging.getLogger("siftlode.plex")
@@ -71,7 +70,7 @@ def people_cards(
wanted = split_csv(names)[:16]
if not wanted:
return {"people": []}
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {
@@ -185,7 +184,7 @@ def person_detail(
and _should_schedule_enrich(person.id)
):
background_tasks.add_task(_enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = _content_libs(db)
movie_lib_ids, show_lib_ids = content_libs(db)
movies: list[dict] = []
if movie_lib_ids:
+3 -3
View File
@@ -12,7 +12,7 @@ import app.plex.sync as syncmod
from app import sysconfig
from app.models import PlexItem, PlexLibrary
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs
class _StubPlex:
@@ -155,7 +155,7 @@ def test_sync_designates_overlay_and_writes_hu(db, monkeypatch):
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)
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
@@ -240,7 +240,7 @@ def test_content_libs_excludes_overlay_libraries(db):
db.add(overlay)
db.commit()
movie_ids, show_ids = _content_libs(db)
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]
+3 -3
View File
@@ -1,6 +1,6 @@
"""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`
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.
@@ -20,7 +20,7 @@ from app.models import (
PlexState,
User,
)
from app.routes.plex._shared import _content_libs
from app.plex.query import content_libs
NAME = "Robert De Niro"
@@ -151,5 +151,5 @@ def test_content_libs_partitions_only_enabled(db):
]
)
db.commit()
movie_ids, show_ids = _content_libs(db)
movie_ids, show_ids = content_libs(db)
assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded