"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters.""" from fastapi import APIRouter, Depends, Query from sqlalchemy import ( Integer, String, and_, cast, func, literal, null, select, ) from sqlalchemy.orm import Session, aliased from app import sysconfig from app.auth import current_user from app.db import get_db from app.models import ( PlexItem, PlexLibrary, PlexShow, PlexState, User, ) from app.plex import rollup from app.plex.cards import episode_card from app.plex.query import ( FIELD_COLUMNS, TS_CONFIG, LibraryFilters, apply_meta_filters, apply_movie_state_filter, apply_show_state_filter, field_recheck, genres_col, meta_filter_conds, movie_status_case, show_status_case, split_csv, summary_col, title_col, vector_col, vector_match, ) from app.routes.plex._shared import _content_libs, _norm_lang from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) @router.get("/libraries") def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata.""" libs = ( db.execute( select(PlexLibrary) .where(PlexLibrary.overlay_of.is_(None)) .order_by(PlexLibrary.kind.desc(), PlexLibrary.title) ) .scalars() .all() ) out = [] for lib in libs: if lib.kind == "movie": count = db.scalar( select(func.count(PlexItem.id)).where( PlexItem.library_id == lib.id, PlexItem.kind == "movie" ) ) else: count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id)) out.append( { "key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0, } ) return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} @router.get("/library") def unified_library( scope: str = "both", q: str | None = None, field: str = "all", sort: str = "added", sort_dir: str = "desc", show: str = "all", lang: str = "en", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of its episodes). On a search that also matches episodes (and shows are in scope), the matching episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" offset = max(0, offset) lang = _norm_lang(lang) p = f.as_dict() 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) ts = to_tsquery_str(q) if q else None tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None if field not in FIELD_COLUMNS and field != "all": field = "all" # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. grid_field = field != "episode" recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None selects = [] if want_movies and grid_field: st = aliased(PlexState) m_status = movie_status_case(st) mq = ( select( PlexItem.rating_key.label("rk"), literal("movie").label("kind"), title_col(PlexItem, lang).label("title"), PlexItem.year.label("year"), PlexItem.rating.label("rating"), # Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis. func.left(summary_col(PlexItem, lang), 300).label("summary"), PlexItem.added_at.label("added_at"), PlexItem.originally_available_at.label("rel"), PlexItem.duration_s.label("duration_s"), PlexItem.playable.label("playable"), cast(null(), Integer).label("season_count"), func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), # "last watched" sort key: when the movie was last resumed, else when it was marked # watched. NULL for never-touched movies → nullslast sinks them (see the sort dict). func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"), ( func.ts_rank(vector_col(PlexItem, lang), tsq) if tsq is not None else literal(0.0) ).label("rank"), ) .outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) ) mq = apply_movie_state_filter(mq, st, show) mq = apply_meta_filters(mq, PlexItem, p, lang) if f.duration_min is not None: mq = mq.where(PlexItem.duration_s >= f.duration_min) if f.duration_max is not None: mq = mq.where(PlexItem.duration_s <= f.duration_max) if tsq is not None: mq = mq.where(vector_match(PlexItem, lang, tsq)) if recheck is not None: mq = mq.where(recheck(PlexItem, field, tsq, lang)) selects.append(mq) if want_shows and grid_field: agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) s_status = show_status_case(tot, wat, inp) sq = ( select( PlexShow.rating_key.label("rk"), literal("show").label("kind"), title_col(PlexShow, lang).label("title"), PlexShow.year.label("year"), PlexShow.rating.label("rating"), func.left(summary_col(PlexShow, lang), 300).label("summary"), PlexShow.added_at.label("added_at"), PlexShow.originally_available_at.label("rel"), cast(null(), Integer).label("duration_s"), cast(null(), String).label("playable"), PlexShow.child_count.label("season_count"), literal(0).label("position_seconds"), s_status.label("status"), # Show leg's "last watched": max(resume/watched time) across the show's episodes (see # rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast. agg.c.resumed_at.label("last_watched"), ( func.ts_rank(vector_col(PlexShow, lang), tsq) if tsq is not None else literal(0.0) ).label("rank"), ) .outerjoin(agg, agg.c.show_id == PlexShow.id) .where(PlexShow.library_id.in_(show_lib_ids)) ) sq = apply_show_state_filter(sq, tot, wat, inp, show) sq = apply_meta_filters(sq, PlexShow, p, lang) if tsq is not None: sq = sq.where(vector_match(PlexShow, lang, tsq)) if recheck is not None: sq = sq.where(recheck(PlexShow, field, tsq, lang)) selects.append(sq) items = [] total = 0 if selects: union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) u = union.subquery() total = db.scalar(select(func.count()).select_from(u)) or 0 if tsq is not None: order = [u.c.rank.desc()] else: col = { "title": func.lower(u.c.title), "year": u.c.year, "rating": u.c.rating, "duration": u.c.duration_s, "release": u.c.rel, "lastwatched": u.c.last_watched, }.get(sort, u.c.added_at) order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order.append(u.c.rk.desc()) rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all() for r in rows: if r.kind == "movie": items.append( { "id": r.rk, "type": "movie", "title": r.title, "year": r.year, "rating": r.rating, "summary": r.summary, "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", "playable": r.playable, "status": r.status, "position_seconds": r.position_seconds, } ) else: items.append( { "id": r.rk, "type": "show", "title": r.title, "year": r.year, "rating": r.rating, "summary": r.summary, "thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status, } ) # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes # target the movie/show grid, so they suppress episodes. episodes = [] if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) ep_vec = ( func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) if field == "episode" else PlexItem.search_vector ) eps = db.execute( select(PlexItem, ep_st, PlexShow.title.label("show_title")) .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), ep_vec.op("@@")(tsq), ) .order_by(func.ts_rank(ep_vec, tsq).desc()) .limit(24) ).all() for e, s, show_title in eps: card = episode_card(e, s) card["show_title"] = show_title episodes.append(card) return { "scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes, } @router.get("/facets") def facets( scope: str = "both", show: str = "all", lang: str = "en", f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active filters (faceted search): each group's values/bounds are computed with all the OTHER active filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while a multi-select group still lets you add more. Merged across the scope's libraries; duration is movie-only. (Search text is not factored in — facets track the sidebar filters.)""" empty = { "genres": [], "content_ratings": [], "studios": [], "year_min": None, "year_max": None, "rating_max": None, "duration_min": None, "duration_max": None, } 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: return empty lang = _norm_lang(lang) p = f.as_dict() uid = user.id def wsq(query, model, ids, pp, kind_movie, with_dur): """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a facet aggregation query, so the facets reflect exactly the visible result set.""" query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang)) if kind_movie: query = query.where(model.kind == "movie") if with_dur and pp.get("duration_min") is not None: query = query.where(model.duration_s >= pp["duration_min"]) if with_dur and pp.get("duration_max") is not None: query = query.where(model.duration_s <= pp["duration_max"]) # Join watch-state and reuse the grid's exact movie state filter (unified_library) — # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. st = aliased(PlexState) query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) query = apply_movie_state_filter(query, st, show) else: if show in ("watched", "in_progress", "unwatched"): agg = rollup.agg_subquery(db, uid).subquery() query = query.outerjoin(agg, agg.c.show_id == model.id) tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) query = apply_show_state_filter(query, tot, wat, inp, show) return query genre_counts: dict[str, int] = {} cr_counts: dict[str, int] = {} studio_counts: dict[str, int] = {} years: list[int] = [] ratings: list[float] = [] durs: list[int] = [] def per_table(model, ids, kind_movie: bool) -> None: if not ids: return # Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid # OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we # offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND # the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since # every matching title carries them, so they stay togglable.) genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None} # Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON # `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. # In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)). gcol = genres_col(model, lang) expanded = ( wsq( select(func.jsonb_array_elements_text(gcol).label("g")), model, ids, genre_pp, kind_movie, True, ) .where(func.jsonb_typeof(gcol) == "array") .subquery() ) for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all(): genre_counts[g] = genre_counts.get(g, 0) + c # Content ratings — exclude its own. crq = ( wsq( select(model.content_rating, func.count()), model, ids, {**p, "content_ratings": None}, kind_movie, True, ) .where(model.content_rating.isnot(None)) .group_by(model.content_rating) ) for cr, c in db.execute(crq).all(): cr_counts[cr] = cr_counts.get(cr, 0) + c # Studios — exclude its own (each offered studio is a valid OR-addition that only widens). stq = ( wsq( select(model.studio, func.count()), model, ids, {**p, "studios": None}, kind_movie, True, ) .where(model.studio.isnot(None)) .group_by(model.studio) ) for s, c in db.execute(stq).all(): studio_counts[s] = studio_counts.get(s, 0) + c # Year bounds — exclude the year filter. by = db.execute( wsq( select(func.min(model.year), func.max(model.year)), model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True, ) ).first() if by[0] is not None: years.append(by[0]) if by[1] is not None: years.append(by[1]) # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the # rating filter and the UI badge use (IMDb preferred, audience fallback). br = db.scalar( wsq( select(func.max(func.coalesce(model.imdb_rating, model.rating))), model, ids, {**p, "rating_min": None}, kind_movie, True, ) ) if br is not None: ratings.append(float(br)) # Duration bounds (movies) — exclude the duration filter. if kind_movie: bd = db.execute( wsq( select(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False, ) ).first() if bd[0] is not None: durs.append(bd[0]) if bd[1] is not None: durs.append(bd[1]) per_table(PlexItem, movie_ids, True) per_table(PlexShow, show_ids, False) # Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise # be absent): the sidebar renders genres solely from this list and hides the whole genre section — # chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). for g in split_csv(f.genres): genre_counts.setdefault(g, 0) # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. for s in split_csv(f.studios): studio_counts.setdefault(s, 0) return { "genres": [ {"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) ], "content_ratings": [ {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) ], "studios": [ {"value": s, "count": c} for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, "duration_min": min(durs) if durs else None, "duration_max": max(durs) if durs else None, }