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

@ -406,6 +406,12 @@ function PlexPosterCard({
{t("plex.seasons", { count: card.season_count })}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-accent/80 text-accent-fg px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.inProgress")}
</span>
)}
{card.playable && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}

View file

@ -27,7 +27,8 @@ type Props = {
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
const SHOW_SORTS = ["added", "title"] as const;
// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime).
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
// Duration buckets → [minSeconds|null, maxSeconds|null].
@ -70,7 +71,7 @@ export default function PlexSidebar({
const facetsQ = useQuery({
queryKey: ["plex-facets", library],
queryFn: () => 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<PlexFilters>) => setFilters({ ...filters, ...p });
@ -205,18 +205,16 @@ export default function PlexSidebar({
</button>
)}
{/* Watch state (movie libraries only) */}
{isMovieLib && (
<Section label={t("plex.filter.show")}>
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
</Section>
)}
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
<Section label={t("plex.filter.show")}>
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
</Section>
{/* Sort + direction */}
<Section label={t("plex.filter.sort")}>
@ -236,8 +234,8 @@ export default function PlexSidebar({
</div>
</Section>
{/* 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({
</div>
</Section>
{/* Duration buckets */}
<Section label={t("plex.filter.duration")}>
<ChipRow>
<Chip
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
</Chip>
{DURATION_BUCKETS.map((b) => (
{/* Duration buckets — movie-only (a show has no single runtime) */}
{isMovieLib && (
<Section label={t("plex.filter.duration")}>
<ChipRow>
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => 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")}
</Chip>
))}
</ChipRow>
</Section>
{DURATION_BUCKETS.map((b) => (
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
>
{t(`plex.filter.durationOpt.${b.key}`)}
</Chip>
))}
</ChipRow>
</Section>
)}
{/* Added to Plex */}
<Section label={t("plex.filter.added")}>

View file

@ -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",

View file

@ -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",

View file

@ -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",