2026-07-06 02:25:48 +02:00
|
|
|
|
import { useEffect, useState, type ReactNode } from "react";
|
2026-07-05 03:29:20 +02:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).
Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
|
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
|
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
2026-07-06 02:25:48 +02:00
|
|
|
|
import { useDebounced } from "../lib/useDebounced";
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
|
|
|
|
|
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
|
|
|
|
|
// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State
|
|
|
|
|
|
// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the
|
|
|
|
|
|
// backend so the sidebar only offers what the library actually contains.
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
|
library: string;
|
|
|
|
|
|
setLibrary: (v: string) => void;
|
|
|
|
|
|
show: string;
|
|
|
|
|
|
setShow: (v: string) => void;
|
|
|
|
|
|
sort: string;
|
|
|
|
|
|
setSort: (v: string) => void;
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
filters: PlexFilters;
|
|
|
|
|
|
setFilters: (f: PlexFilters) => void;
|
feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).
Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
|
|
|
|
onOpenPlaylist: (id: number) => void;
|
2026-07-05 03:29:20 +02:00
|
|
|
|
collapsed: boolean;
|
|
|
|
|
|
onToggleCollapse: () => void;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
2026-07-10 21:43:09 +02:00
|
|
|
|
// 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;
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
const RATING_STEPS = [5, 6, 7, 8, 9];
|
|
|
|
|
|
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
|
|
|
|
|
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
|
|
|
|
|
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
|
|
|
|
|
{ key: "short", min: null, max: 90 * 60 },
|
|
|
|
|
|
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
|
|
|
|
|
{ key: "long", min: 120 * 60, max: null },
|
|
|
|
|
|
];
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
|
|
|
|
|
export default function PlexSidebar({
|
|
|
|
|
|
library,
|
|
|
|
|
|
setLibrary,
|
|
|
|
|
|
show,
|
|
|
|
|
|
setShow,
|
|
|
|
|
|
sort,
|
|
|
|
|
|
setSort,
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
filters,
|
|
|
|
|
|
setFilters,
|
feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).
Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
|
|
|
|
onOpenPlaylist,
|
2026-07-05 03:29:20 +02:00
|
|
|
|
collapsed,
|
|
|
|
|
|
onToggleCollapse,
|
|
|
|
|
|
}: Props) {
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).
Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
|
|
|
|
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
|
|
|
|
|
const [newPlaylist, setNewPlaylist] = useState("");
|
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
|
async function createPlaylist() {
|
|
|
|
|
|
const title = newPlaylist.trim();
|
|
|
|
|
|
if (!title) return;
|
|
|
|
|
|
const pl = await api.plexCreatePlaylist(title);
|
|
|
|
|
|
setNewPlaylist("");
|
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
|
|
|
|
|
onOpenPlaylist(pl.id);
|
|
|
|
|
|
}
|
2026-07-05 03:29:20 +02:00
|
|
|
|
const libs = libsQ.data?.libraries ?? [];
|
|
|
|
|
|
const activeLib = libs.find((l) => l.key === library);
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
const isMovieLib = activeLib?.kind === "movie";
|
|
|
|
|
|
|
|
|
|
|
|
const facetsQ = useQuery({
|
|
|
|
|
|
queryKey: ["plex-facets", library],
|
|
|
|
|
|
queryFn: () => api.plexFacets(library),
|
2026-07-10 21:43:09 +02:00
|
|
|
|
enabled: !!library, // facets now come for movie AND show libraries (0052)
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
});
|
|
|
|
|
|
const facets = facetsQ.data;
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
2026-07-06 02:25:48 +02:00
|
|
|
|
// Collections picker (searchable; the list is only fetched when none is active).
|
|
|
|
|
|
const [collSearch, setCollSearch] = useState("");
|
|
|
|
|
|
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
|
|
|
|
|
const collectionsQ = useQuery({
|
|
|
|
|
|
queryKey: ["plex-collections", library, collSearchDeb],
|
|
|
|
|
|
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
2026-07-10 21:43:09 +02:00
|
|
|
|
enabled: !!library && !filters.collection, // collections apply to shows too
|
2026-07-06 02:25:48 +02:00
|
|
|
|
});
|
|
|
|
|
|
const collections = collectionsQ.data?.collections ?? [];
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
// Default to the first library once loaded (or if the stored one vanished).
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
|
|
|
|
|
}, [libs, library, setLibrary]);
|
|
|
|
|
|
|
2026-07-10 21:43:09 +02:00
|
|
|
|
const fCount = plexFilterCount(filters); // shows carry the same filters now (0052)
|
|
|
|
|
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
const anyActive = activeCount > 0;
|
|
|
|
|
|
|
|
|
|
|
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
|
|
|
|
|
const toggleIn = (arr: string[], v: string) =>
|
|
|
|
|
|
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
|
|
|
|
|
const clearAll = () => {
|
|
|
|
|
|
setFilters(EMPTY_PLEX_FILTERS);
|
|
|
|
|
|
setShow("all");
|
|
|
|
|
|
setSort("added");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
|
|
|
|
|
const durBucketKey = DURATION_BUCKETS.find(
|
|
|
|
|
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
|
|
|
|
|
)?.key;
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
|
|
|
|
|
if (collapsed) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onToggleCollapse}
|
|
|
|
|
|
title={t("sidebar.expandPanel")}
|
|
|
|
|
|
aria-label={t("sidebar.expandPanel")}
|
|
|
|
|
|
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronRight className="w-4 h-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onToggleCollapse}
|
|
|
|
|
|
title={t("sidebar.filters")}
|
|
|
|
|
|
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SlidersHorizontal className="w-5 h-5" />
|
|
|
|
|
|
{activeCount > 0 && (
|
|
|
|
|
|
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
|
|
|
|
|
|
{activeCount}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
|
|
|
|
|
{/* Library scope */}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<Section label={t("plex.filter.library")}>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
{libs.map((l) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={l.key}
|
|
|
|
|
|
onClick={() => setLibrary(l.key)}
|
|
|
|
|
|
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
|
|
|
|
|
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
|
|
|
|
|
<span className="flex-1 text-left truncate">{l.title}</span>
|
|
|
|
|
|
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</Section>
|
|
|
|
|
|
|
feat(plex): Playlists Phase 1 — per-user ordered watch-lists (Siftlode-native)
Personal ordered lists of Plex items, kept in Siftlode's own DB (works for users
without a Plex account, like watch-state) — the "your own lists" counterpart to
shared collections. Plex-direction sync is a later phase (plex_rating_key
reserved).
Backend: migration 0048 (plex_playlists + plex_playlist_items with position),
PlexPlaylist/PlexPlaylistItem models, and per-user CRUD endpoints under
/api/plex/playlists (list [+?contains for the add dialog], create [seeded],
detail [ordered cards], rename, delete, add/remove item, reorder). _leaf_card
handles the movie/episode mix. Frontend: "Add to playlist" dialog from the movie
info page (all users), a Playlists section in PlexSidebar (list + create),
PlexPlaylistView (reorder up/down, remove, rename, delete, Play all), and
PlexPlayer gained an optional `queue` so play-through follows the list order
(prev/next + auto-advance). i18n en/hu/de. Verified end-to-end on localdev
(backend CRUD + the create→add→view→play-through UI flow).
2026-07-06 19:05:12 +02:00
|
|
|
|
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
|
|
|
|
|
|
a filter. Available in any library. */}
|
|
|
|
|
|
<Section label={t("plex.playlist.section")}>
|
|
|
|
|
|
<div className="mb-1.5 flex gap-1.5">
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={newPlaylist}
|
|
|
|
|
|
onChange={(e) => setNewPlaylist(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
|
|
|
|
|
placeholder={t("plex.playlist.newPlaceholder")}
|
|
|
|
|
|
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={createPlaylist}
|
|
|
|
|
|
disabled={!newPlaylist.trim()}
|
|
|
|
|
|
title={t("plex.playlist.create")}
|
|
|
|
|
|
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
|
|
|
|
|
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
|
{playlistsQ.data!.playlists.map((p) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={p.id}
|
|
|
|
|
|
onClick={() => onOpenPlaylist(p.id)}
|
|
|
|
|
|
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
|
|
|
|
|
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
{anyActive && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={clearAll}
|
|
|
|
|
|
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="w-3.5 h-3.5" />
|
|
|
|
|
|
{t("plex.filter.clearAll")} ({activeCount})
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
2026-07-10 21:43:09 +02:00
|
|
|
|
{/* 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>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
|
2026-07-06 00:15:31 +02:00
|
|
|
|
{/* Sort + direction */}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<Section label={t("plex.filter.sort")}>
|
|
|
|
|
|
<ChipRow>
|
|
|
|
|
|
{sorts.map((s) => (
|
|
|
|
|
|
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
{t(`plex.sort.${s}`)}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</Chip>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
))}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</ChipRow>
|
2026-07-06 00:15:31 +02:00
|
|
|
|
<div className="mt-1.5 flex gap-1">
|
|
|
|
|
|
{(["desc", "asc"] as const).map((d) => (
|
|
|
|
|
|
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}>
|
|
|
|
|
|
{t(`plex.filter.dir.${d}`)}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</Section>
|
|
|
|
|
|
|
2026-07-10 21:43:09 +02:00
|
|
|
|
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
|
|
|
|
|
|
{(
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<>
|
2026-07-06 00:15:31 +02:00
|
|
|
|
{/* Active people / studios — set by clicking the info page (stackable). */}
|
|
|
|
|
|
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<Section label={t("plex.filter.active")}>
|
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
2026-07-06 00:15:31 +02:00
|
|
|
|
{filters.directors.map((d) => (
|
|
|
|
|
|
<RemovableChip
|
|
|
|
|
|
key={`d:${d}`}
|
|
|
|
|
|
label={d}
|
|
|
|
|
|
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{filters.actors.map((a) => (
|
|
|
|
|
|
<RemovableChip
|
|
|
|
|
|
key={`a:${a}`}
|
|
|
|
|
|
label={a}
|
|
|
|
|
|
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{filters.studios.map((s) => (
|
|
|
|
|
|
<RemovableChip
|
|
|
|
|
|
key={`s:${s}`}
|
|
|
|
|
|
label={s}
|
|
|
|
|
|
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-06 02:25:48 +02:00
|
|
|
|
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
|
|
|
|
|
<Section label={t("plex.filter.collection")}>
|
|
|
|
|
|
{filters.collection ? (
|
|
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
|
|
|
|
<RemovableChip
|
|
|
|
|
|
label={filters.collectionTitle || filters.collection}
|
|
|
|
|
|
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={collSearch}
|
|
|
|
|
|
onChange={(e) => setCollSearch(e.target.value)}
|
|
|
|
|
|
placeholder={t("plex.filter.collectionSearch")}
|
|
|
|
|
|
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
|
|
|
|
|
{collections.map((c) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={c.id}
|
|
|
|
|
|
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
|
|
|
|
|
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
|
|
|
|
|
<span className="flex-1 truncate">{c.title}</span>
|
|
|
|
|
|
<span className="text-xs text-muted">{c.child_count}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{collections.length === 0 && (
|
|
|
|
|
|
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
{/* IMDb rating min */}
|
|
|
|
|
|
<Section label={t("plex.filter.rating")}>
|
|
|
|
|
|
<ChipRow>
|
|
|
|
|
|
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
|
|
|
|
|
{t("plex.filter.any")}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
{RATING_STEPS.map((r) => (
|
|
|
|
|
|
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
|
|
|
|
|
{r}+
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ChipRow>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
2026-07-06 00:15:31 +02:00
|
|
|
|
{/* Genres (from facets) + Any/All when more than one is picked */}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
{facets && facets.genres.length > 0 && (
|
|
|
|
|
|
<Section label={t("plex.filter.genre")}>
|
2026-07-06 00:15:31 +02:00
|
|
|
|
{filters.genres.length > 1 && (
|
|
|
|
|
|
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
|
|
|
|
|
{(["any", "all"] as const).map((m) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={m}
|
|
|
|
|
|
onClick={() => patch({ genreMode: m })}
|
|
|
|
|
|
className={`px-2 py-0.5 transition ${
|
|
|
|
|
|
(filters.genreMode ?? "any") === m
|
|
|
|
|
|
? "bg-accent text-accent-fg"
|
|
|
|
|
|
: "text-muted hover:bg-surface"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`plex.filter.match.${m}`)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<ChipRow>
|
|
|
|
|
|
{facets.genres.map((g) => (
|
|
|
|
|
|
<Chip
|
|
|
|
|
|
key={g.value}
|
|
|
|
|
|
active={filters.genres.includes(g.value)}
|
|
|
|
|
|
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
|
|
|
|
|
>
|
|
|
|
|
|
{g.value}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ChipRow>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Year range */}
|
|
|
|
|
|
<Section label={t("plex.filter.year")}>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<NumberInput
|
|
|
|
|
|
value={filters.yearMin ?? null}
|
|
|
|
|
|
placeholder={facets?.year_min ?? undefined}
|
|
|
|
|
|
onChange={(v) => patch({ yearMin: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-muted text-xs">–</span>
|
|
|
|
|
|
<NumberInput
|
|
|
|
|
|
value={filters.yearMax ?? null}
|
|
|
|
|
|
placeholder={facets?.year_max ?? undefined}
|
|
|
|
|
|
onChange={(v) => patch({ yearMax: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
2026-07-10 21:43:09 +02:00
|
|
|
|
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
|
|
|
|
|
{isMovieLib && (
|
|
|
|
|
|
<Section label={t("plex.filter.duration")}>
|
|
|
|
|
|
<ChipRow>
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
<Chip
|
2026-07-10 21:43:09 +02:00
|
|
|
|
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
|
|
|
|
|
|
onClick={() => patch({ durationMin: null, durationMax: null })}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
>
|
2026-07-10 21:43:09 +02:00
|
|
|
|
{t("plex.filter.any")}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
</Chip>
|
2026-07-10 21:43:09 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
|
|
|
|
|
|
{/* Added to Plex */}
|
|
|
|
|
|
<Section label={t("plex.filter.added")}>
|
|
|
|
|
|
<ChipRow>
|
|
|
|
|
|
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
|
|
|
|
|
{t("plex.filter.any")}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
{ADDED_OPTS.map((a) => (
|
|
|
|
|
|
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
|
|
|
|
|
{t(`plex.filter.addedOpt.${a}`)}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ChipRow>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content rating (from facets) */}
|
|
|
|
|
|
{facets && facets.content_ratings.length > 0 && (
|
|
|
|
|
|
<Section label={t("plex.filter.contentRating")}>
|
|
|
|
|
|
<ChipRow>
|
|
|
|
|
|
{facets.content_ratings.map((c) => (
|
|
|
|
|
|
<Chip
|
|
|
|
|
|
key={c.value}
|
|
|
|
|
|
active={filters.contentRatings.includes(c.value)}
|
|
|
|
|
|
onClick={() => patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })}
|
|
|
|
|
|
>
|
|
|
|
|
|
{c.value}
|
|
|
|
|
|
</Chip>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ChipRow>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-05 03:29:20 +02:00
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
|
|
|
|
|
|
|
function Section({ label, children }: { label: string; children: ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ChipRow({ children }: { children: ReactNode }) {
|
|
|
|
|
|
return <div className="flex flex-wrap gap-1">{children}</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Chip({
|
|
|
|
|
|
active,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
|
|
|
|
|
active ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
|
|
|
|
|
<span className="truncate max-w-[9rem]">{label}</span>
|
|
|
|
|
|
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
|
|
|
|
|
<X className="w-3 h-3" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function NumberInput({
|
|
|
|
|
|
value,
|
|
|
|
|
|
placeholder,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: number | null;
|
|
|
|
|
|
placeholder?: number;
|
|
|
|
|
|
onChange: (v: number | null) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
value={value ?? ""}
|
|
|
|
|
|
placeholder={placeholder != null ? String(placeholder) : ""}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const n = e.target.value === "" ? null : Number(e.target.value);
|
|
|
|
|
|
onChange(n != null && Number.isFinite(n) ? n : null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|