feat(plex): TV-show metadata sync + series filters (Phase 1 of series view)

Give TV shows the same filterable metadata as movies so the TV grid can be
filtered/sorted, not just library+sort. Backend: migration 0052 adds
rating/content_rating/studio/originally_available_at/genres/directors/cast_names/
people_text to plex_shows (+ GIN/indexes, people_text folded into search_vector);
_sync_shows populates them cheaply from the show section listing (no per-item
calls). /browse show-branch gains the movie filter set (minus duration) plus an
aggregate per-user watch-state (a show rolls up its episodes: all watched=watched,
any progress=in_progress, none=new) with year/rating/release sorts; /facets returns
show facets. Frontend: PlexSidebar renders the metadata filters + watch-state for TV
libraries (duration stays movie-only); show cards show a watched/in-progress badge.
i18n plex.inProgress (en/hu/de). Needs a Plex re-sync to populate the new columns.
This commit is contained in:
npeter83 2026-07-10 21:43:09 +02:00
parent f0bab315ad
commit 569d31235d
9 changed files with 266 additions and 52 deletions

View file

@ -18,7 +18,7 @@ from urllib.parse import quote
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_, text
from sqlalchemy import and_, case, func, or_, text
from sqlalchemy.orm import Session, aliased
from app import sysconfig
@ -228,7 +228,7 @@ def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
}
def _show_card(sh: PlexShow) -> dict:
def _show_card(sh: PlexShow, status: str = "new") -> dict:
return {
"id": sh.rating_key,
"type": "show",
@ -236,9 +236,51 @@ def _show_card(sh: PlexShow) -> dict:
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
}
def _show_status(total: int, watched: int, inprog: int) -> str:
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
if total and watched >= total:
return "watched"
if (watched or 0) > 0 or (inprog or 0) > 0:
return "in_progress"
return "new"
def _show_agg_subq(db: Session, user_id: int):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
ps = aliased(PlexState)
return (
db.query(
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
1,
)
)
).label("inprog"),
)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
)
def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids:
return {}
rows = _show_agg_subq(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
return {
"id": e.rating_key,
@ -358,6 +400,43 @@ def browse(
query = query.filter(PlexItem.studio.in_(stds))
else:
query = query.filter(PlexShow.library_id == lib.id)
# Aggregate per-user watch-state across the show's episodes (a show isn't one row of state).
if show in ("watched", "in_progress", "unwatched"):
agg = _show_agg_subq(db, user.id).subquery()
query = query.outerjoin(agg, agg.c.show_id == PlexShow.id)
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
if show == "watched":
query = query.filter(tot > 0, wat >= tot)
elif show == "in_progress":
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
else: # unwatched — no episode started
query = query.filter(wat == 0, inp == 0)
# --- Metadata filters (same as movies, minus duration which shows don't have) ---
gsel = _csv(genres)
if gsel:
conds = [PlexShow.genres.contains([g]) for g in gsel]
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
crs = _csv(content_ratings)
if crs:
query = query.filter(PlexShow.content_rating.in_(crs))
if year_min is not None:
query = query.filter(PlexShow.year >= year_min)
if year_max is not None:
query = query.filter(PlexShow.year <= year_max)
if rating_min is not None:
query = query.filter(PlexShow.rating >= rating_min)
cutoff = _added_cutoff(added_within)
if cutoff is not None:
query = query.filter(PlexShow.added_at >= cutoff)
for d in _csv(directors): # AND
query = query.filter(PlexShow.directors.contains([d]))
for a in _csv(actors): # AND
query = query.filter(PlexShow.cast_names.contains([a]))
stds = _csv(studios)
if stds: # OR
query = query.filter(PlexShow.studio.in_(stds))
# Collection filter applies to both movies and shows (both carry collection_keys).
if collection:
@ -376,14 +455,14 @@ def browse(
else:
if sort == "title":
col = func.lower(model.title)
elif sort == "year" and model is PlexItem:
col = PlexItem.year
elif sort == "rating" and model is PlexItem:
col = PlexItem.rating
elif sort == "year": # both movies and shows have year
col = model.year
elif sort == "rating": # both have rating (audienceRating)
col = model.rating
elif sort == "duration" and model is PlexItem:
col = PlexItem.duration_s
elif sort == "release" and model is PlexItem:
col = PlexItem.originally_available_at
elif sort == "release": # both have originally_available_at
col = model.originally_available_at
else: # added
col = model.added_at
asc = sort_dir == "asc"
@ -402,7 +481,8 @@ def browse(
}
items = [_movie_card(r, states.get(r.id)) for r in rows]
else:
items = [_show_card(r) for r in rows]
statuses = _show_state_map(db, user.id, [r.id for r in rows])
items = [_show_card(r, statuses.get(r.id, "new")) for r in rows]
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
@ -413,8 +493,9 @@ def facets(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Available filter values for a movie library — genres + content ratings (with counts) and the
year/rating/duration bounds so the sidebar only offers what the library actually contains."""
"""Available filter values for a movie OR show library — genres + content ratings (with counts)
and the year/rating[/duration] bounds so the sidebar only offers what the library contains.
Shows carry the same filterable metadata as movies now (0052), minus duration."""
empty = {
"genres": [],
"content_ratings": [],
@ -425,9 +506,14 @@ def facets(
"duration_max": None,
}
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None or lib.kind != "movie":
if lib is None:
return empty
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
if lib.kind == "movie":
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
dur_sel = "min(duration_s), max(duration_s)"
else: # show library — same columns on plex_shows, no per-row duration
base = "FROM plex_shows WHERE library_id = :lib"
dur_sel = "NULL, NULL"
genres = db.execute(
text(
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
@ -440,7 +526,7 @@ def facets(
{"lib": lib.id},
).all()
b = db.execute(
text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"),
text(f"SELECT min(year), max(year), max(rating), {dur_sel} {base}"),
{"lib": lib.id},
).first()
return {