diff --git a/backend/alembic/versions/0052_plex_show_meta.py b/backend/alembic/versions/0052_plex_show_meta.py new file mode 100644 index 0000000..523421c --- /dev/null +++ b/backend/alembic/versions/0052_plex_show_meta.py @@ -0,0 +1,91 @@ +"""Plex show-level filterable/orderable metadata + +Revision ID: 0052_plex_show_meta +Revises: 0051_plex_link +Create Date: 2026-07-10 + +Gives TV shows the same filterable metadata movies already have (0045/0046), mirrored cheaply from +the Plex show section listing (no per-item calls): rating (audienceRating ~ IMDb), content rating, +studio/network, first-air date, and GIN-indexed genre / director / cast arrays + a `people_text` +blob folded into the generated `search_vector` (weight B). This lets the TV grid be filtered and +sorted like the movie grid. A generated column's expression can't be altered in place, so the +search_vector is dropped + recreated. All new columns are populated on the next Plex sync. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0052_plex_show_meta" +down_revision: Union[str, None] = "0051_plex_link" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_CFG = "public.unaccent_simple" +_NEW_VECTOR = ( + f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || " + f"setweight(to_tsvector('{_CFG}', coalesce(people_text, '')), 'B') || " + f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')" +) +_OLD_VECTOR = ( + f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || " + f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')" +) + + +def _rebuild_vector(expr: str) -> None: + op.execute("DROP INDEX IF EXISTS ix_plex_shows_search_vector") + op.execute("ALTER TABLE plex_shows DROP COLUMN search_vector") + op.execute( + f"ALTER TABLE plex_shows ADD COLUMN search_vector tsvector " + f"GENERATED ALWAYS AS ({expr}) STORED" + ) + op.execute("CREATE INDEX ix_plex_shows_search_vector ON plex_shows USING gin (search_vector)") + + +def upgrade() -> None: + op.add_column("plex_shows", sa.Column("rating", sa.Float(), nullable=True)) + op.add_column("plex_shows", sa.Column("content_rating", sa.String(length=16), nullable=True)) + op.add_column("plex_shows", sa.Column("studio", sa.String(length=255), nullable=True)) + op.add_column("plex_shows", sa.Column("originally_available_at", sa.Date(), nullable=True)) + op.add_column("plex_shows", sa.Column("genres", postgresql.JSONB(), nullable=True)) + op.add_column("plex_shows", sa.Column("directors", postgresql.JSONB(), nullable=True)) + op.add_column("plex_shows", sa.Column("cast_names", postgresql.JSONB(), nullable=True)) + op.add_column("plex_shows", sa.Column("people_text", sa.Text(), nullable=True)) + + op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"]) + op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"]) + op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"]) + op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"]) + op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin") + op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin") + op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin") + # NB: ix_plex_shows_collections_gin already exists (created with collections in 0047). + + _rebuild_vector(_NEW_VECTOR) + + +def downgrade() -> None: + _rebuild_vector(_OLD_VECTOR) + for ix in ( + "ix_plex_shows_cast_gin", + "ix_plex_shows_directors_gin", + "ix_plex_shows_genres_gin", + "ix_plex_shows_originally_available_at", + "ix_plex_shows_studio", + "ix_plex_shows_content_rating", + "ix_plex_shows_rating", + ): + op.drop_index(ix, table_name="plex_shows") + for col in ( + "people_text", + "cast_names", + "directors", + "genres", + "originally_available_at", + "studio", + "content_rating", + "rating", + ): + op.drop_column("plex_shows", col) diff --git a/backend/app/models.py b/backend/app/models.py index 4638109..5e0c915 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -956,9 +956,17 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin): class PlexShow(Base, TimestampMixin, UpdatedAtMixin): - """A TV show (grandparent of episodes) — holds poster + summary for the drill-down page.""" + """A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus + the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV + grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing.""" __tablename__ = "plex_shows" + __table_args__ = ( + Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"), + Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"), + Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"), + # ix_plex_shows_collections_gin already created with collections (migration 0047). + ) id: Mapped[int] = mapped_column(primary_key=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) @@ -973,10 +981,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): child_count: Mapped[int | None] = mapped_column(Integer) # seasons collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + # Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem). + rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb) + content_rating: Mapped[str | None] = mapped_column(String(16), index=True) + studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio + originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date + genres: Mapped[list | None] = mapped_column(JSONB) + directors: Mapped[list | None] = mapped_column(JSONB) + cast_names: Mapped[list | None] = mapped_column(JSONB) + people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector search_vector: Mapped[object | None] = mapped_column( TSVECTOR, Computed( "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", persisted=True, ), diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 7cc3059..975369c 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -230,6 +230,16 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> sh.art_key = meta.get("art") sh.child_count = meta.get("childCount") sh.added_at = _epoch(meta.get("addedAt")) + # Filterable / orderable metadata — same cheap section-listing tags as movies. + sh.rating = _rating(meta) + sh.content_rating = (meta.get("contentRating") or None) + sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255] + sh.originally_available_at = _date(meta.get("originallyAvailableAt")) + sh.genres = _tags(meta, "Genre") + sh.directors = _tags(meta, "Director") + sh.cast_names = _tags(meta, "Role", limit=20) + people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or []))) + sh.people_text = " ".join(people) or None stats["shows"] += 1 db.flush() show_id = {rk: sh.id for rk, sh in shows.items()} diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index f44b5ec..59f700e 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -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 { diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 02a32a7..397f57a 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -406,6 +406,12 @@ function PlexPosterCard({ {t("plex.seasons", { count: card.season_count })} )} + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} {card.playable && ( api.plexFacets(library), - enabled: !!library && isMovieLib, + enabled: !!library, // facets now come for movie AND show libraries (0052) }); const facets = facetsQ.data; @@ -80,7 +81,7 @@ export default function PlexSidebar({ const collectionsQ = useQuery({ queryKey: ["plex-collections", library, collSearchDeb], queryFn: () => api.plexCollections(library, collSearchDeb || undefined), - enabled: !!library && isMovieLib && !filters.collection, + enabled: !!library && !filters.collection, // collections apply to shows too }); const collections = collectionsQ.data?.collections ?? []; @@ -89,9 +90,8 @@ export default function PlexSidebar({ if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key); }, [libs, library, setLibrary]); - const fCount = isMovieLib ? plexFilterCount(filters) : 0; - const activeCount = - fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0); + const fCount = plexFilterCount(filters); // shows carry the same filters now (0052) + const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -205,18 +205,16 @@ export default function PlexSidebar({ )} - {/* Watch state (movie libraries only) */} - {isMovieLib && ( -
- - {SHOW_OPTS.map((s) => ( - setShow(s)}> - {t(`plex.filter.showOpt.${s}`)} - - ))} - -
- )} + {/* Watch state — movies per-title, shows aggregated across their episodes (0052) */} +
+ + {SHOW_OPTS.map((s) => ( + setShow(s)}> + {t(`plex.filter.showOpt.${s}`)} + + ))} + +
{/* Sort + direction */}
@@ -236,8 +234,8 @@ export default function PlexSidebar({
- {/* Metadata filters (movie libraries only) */} - {isMovieLib && ( + {/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */} + {( <> {/* Active people / studios — set by clicking the info page (stackable). */} {filters.directors.length + filters.actors.length + filters.studios.length > 0 && ( @@ -370,26 +368,28 @@ export default function PlexSidebar({ - {/* Duration buckets */} -
- - patch({ durationMin: null, durationMax: null })} - > - {t("plex.filter.any")} - - {DURATION_BUCKETS.map((b) => ( + {/* Duration buckets — movie-only (a show has no single runtime) */} + {isMovieLib && ( +
+ patch({ durationMin: b.min, durationMax: b.max })} + active={!durBucketKey && filters.durationMin == null && filters.durationMax == null} + onClick={() => patch({ durationMin: null, durationMax: null })} > - {t(`plex.filter.durationOpt.${b.key}`)} + {t("plex.filter.any")} - ))} - -
+ {DURATION_BUCKETS.map((b) => ( + patch({ durationMin: b.min, durationMax: b.max })} + > + {t(`plex.filter.durationOpt.${b.key}`)} + + ))} +
+
+ )} {/* Added to Plex */}
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index d2fb8d0..d3145c6 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -63,6 +63,7 @@ "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "loadMore": "Mehr laden", "watched": "Angesehen", + "inProgress": "Läuft", "play": "Abspielen", "resume": "Fortsetzen", "openShow": "Serie öffnen", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 9c9dfba..2aef3b5 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -63,6 +63,7 @@ "empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "loadMore": "Load more", "watched": "Watched", + "inProgress": "In progress", "play": "Play", "resume": "Resume", "openShow": "Open show", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 6cfeb88..9df0b51 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -63,6 +63,7 @@ "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", "loadMore": "Több betöltése", "watched": "Megnézve", + "inProgress": "Folyamatban", "play": "Lejátszás", "resume": "Folytatás", "openShow": "Sorozat megnyitása",