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:
parent
f0bab315ad
commit
569d31235d
9 changed files with 266 additions and 52 deletions
|
|
@ -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"}`}
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue