feat(plex): sort direction, genre any/all, multi-person filters; fix back-nav + player stop
UAT follow-ups on the Plex filter epic: - Sort now has an asc/desc toggle (sort_dir), applied to any sort field. - Genre multi-select gains an Any/All mode (genre_mode: OR vs AND containment). - Director/actor/studio become multi-value: people AND (titles featuring all selected), studios OR; clicking them on the info page stacks (unions) instead of replacing, and the sidebar shows each as a removable 'Active' chip. - fix(history): clicking a metadata filter on the info page now pushes a fresh grid entry instead of history.back(), so browser Back returns to the info page rather than leaving the Plex module. - fix(player): fully tear down the <video> + hls on unmount and guard late play() calls, so backing out of a just-started video no longer leaves audio playing in the background. i18n en/hu/de (match any/all, sort direction).
This commit is contained in:
parent
eefd7e3abd
commit
1982cfa7b9
10 changed files with 160 additions and 60 deletions
|
|
@ -193,6 +193,7 @@ def browse(
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = Query(default=40, ge=1, le=100),
|
limit: int = Query(default=40, ge=1, le=100),
|
||||||
genres: str | None = None,
|
genres: str | None = None,
|
||||||
|
genre_mode: str = "any",
|
||||||
content_ratings: str | None = None,
|
content_ratings: str | None = None,
|
||||||
year_min: int | None = None,
|
year_min: int | None = None,
|
||||||
year_max: int | None = None,
|
year_max: int | None = None,
|
||||||
|
|
@ -200,9 +201,10 @@ def browse(
|
||||||
duration_min: int | None = None,
|
duration_min: int | None = None,
|
||||||
duration_max: int | None = None,
|
duration_max: int | None = None,
|
||||||
added_within: str | None = None,
|
added_within: str | None = None,
|
||||||
director: str | None = None,
|
directors: str | None = None,
|
||||||
actor: str | None = None,
|
actors: str | None = None,
|
||||||
studio: str | None = None,
|
studios: str | None = None,
|
||||||
|
sort_dir: str = "desc",
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -230,9 +232,10 @@ def browse(
|
||||||
else: # all — hide only the explicitly hidden
|
else: # all — hide only the explicitly hidden
|
||||||
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||||
# --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
|
# --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
|
||||||
genre_any = _csv(genres)
|
gsel = _csv(genres)
|
||||||
if genre_any:
|
if gsel:
|
||||||
query = query.filter(or_(*[PlexItem.genres.contains([g]) for g in genre_any]))
|
conds = [PlexItem.genres.contains([g]) for g in gsel]
|
||||||
|
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
|
||||||
crs = _csv(content_ratings)
|
crs = _csv(content_ratings)
|
||||||
if crs:
|
if crs:
|
||||||
query = query.filter(PlexItem.content_rating.in_(crs))
|
query = query.filter(PlexItem.content_rating.in_(crs))
|
||||||
|
|
@ -249,12 +252,13 @@ def browse(
|
||||||
cutoff = _added_cutoff(added_within)
|
cutoff = _added_cutoff(added_within)
|
||||||
if cutoff is not None:
|
if cutoff is not None:
|
||||||
query = query.filter(PlexItem.added_at >= cutoff)
|
query = query.filter(PlexItem.added_at >= cutoff)
|
||||||
if director:
|
for d in _csv(directors): # AND — titles this whole set directed
|
||||||
query = query.filter(PlexItem.directors.contains([director]))
|
query = query.filter(PlexItem.directors.contains([d]))
|
||||||
if actor:
|
for a in _csv(actors): # AND — titles featuring all these people
|
||||||
query = query.filter(PlexItem.cast_names.contains([actor]))
|
query = query.filter(PlexItem.cast_names.contains([a]))
|
||||||
if studio:
|
stds = _csv(studios)
|
||||||
query = query.filter(PlexItem.studio == studio)
|
if stds: # OR — a title has one studio
|
||||||
|
query = query.filter(PlexItem.studio.in_(stds))
|
||||||
else:
|
else:
|
||||||
query = query.filter(PlexShow.library_id == lib.id)
|
query = query.filter(PlexShow.library_id == lib.id)
|
||||||
|
|
||||||
|
|
@ -268,18 +272,21 @@ def browse(
|
||||||
|
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
||||||
elif sort == "title":
|
else:
|
||||||
order = [func.lower(model.title).asc()]
|
if sort == "title":
|
||||||
|
col = func.lower(model.title)
|
||||||
elif sort == "year" and model is PlexItem:
|
elif sort == "year" and model is PlexItem:
|
||||||
order = [PlexItem.year.desc().nullslast()]
|
col = PlexItem.year
|
||||||
elif sort == "rating" and model is PlexItem:
|
elif sort == "rating" and model is PlexItem:
|
||||||
order = [PlexItem.rating.desc().nullslast()]
|
col = PlexItem.rating
|
||||||
elif sort == "duration" and model is PlexItem:
|
elif sort == "duration" and model is PlexItem:
|
||||||
order = [PlexItem.duration_s.desc().nullslast()]
|
col = PlexItem.duration_s
|
||||||
elif sort == "release" and model is PlexItem:
|
elif sort == "release" and model is PlexItem:
|
||||||
order = [PlexItem.originally_available_at.desc().nullslast()]
|
col = PlexItem.originally_available_at
|
||||||
else: # added — newest first
|
else: # added
|
||||||
order = [model.added_at.desc().nullslast()]
|
col = model.added_at
|
||||||
|
asc = sort_dir == "asc"
|
||||||
|
order = [(col.asc() if asc else col.desc()).nullslast()]
|
||||||
order.append(model.id.desc())
|
order.append(model.id.desc())
|
||||||
|
|
||||||
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,21 @@ export default function PlexBrowse({ q, library, show, sort, filters, setFilters
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||||
onFilter={(patch) => {
|
onFilter={(patch) => {
|
||||||
// Clicking a metadata chip sets that filter and returns to the (now filtered) grid.
|
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
|
||||||
setFilters({ ...filters, ...patch });
|
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
|
||||||
sub.back();
|
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
|
||||||
|
// info page instead of leaving the Plex module.
|
||||||
|
const merged = { ...filters } as unknown as Record<string, unknown>;
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
const cur = merged[k];
|
||||||
|
if (Array.isArray(v) && Array.isArray(cur)) {
|
||||||
|
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
||||||
|
} else {
|
||||||
|
merged[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFilters(merged as unknown as PlexFilters);
|
||||||
|
sub.open({ kind: "grid" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
{detail.studio && (
|
{detail.studio && (
|
||||||
<Filterable
|
<Filterable
|
||||||
className={mutedCls}
|
className={mutedCls}
|
||||||
onClick={onFilter ? () => onFilter({ studio: detail.studio! }) : undefined}
|
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||||
>
|
>
|
||||||
{detail.studio}
|
{detail.studio}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
|
|
@ -232,7 +232,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
<span key={d}>
|
<span key={d}>
|
||||||
<Filterable
|
<Filterable
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
onClick={onFilter ? () => onFilter({ director: d }) : undefined}
|
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
|
|
@ -331,7 +331,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
return onFilter ? (
|
return onFilter ? (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onFilter({ actor: c.name })}
|
onClick={() => onFilter({ actors: [c.name] })}
|
||||||
title={t("plex.info.filterActor", { name: c.name })}
|
title={t("plex.info.filterActor", { name: c.name })}
|
||||||
className="group/cast w-20 shrink-0 text-center"
|
className="group/cast w-20 shrink-0 text-center"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
|
||||||
|
// can't start a detached <video> playing audio in the background.
|
||||||
|
const aliveRef = useRef(true);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||||
const durationRef = useRef(0);
|
const durationRef = useRef(0);
|
||||||
|
|
@ -143,7 +146,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
enableSubs();
|
enableSubs();
|
||||||
setReady(true);
|
setReady(true);
|
||||||
video.play().catch(() => {});
|
if (aliveRef.current) video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
||||||
// otherwise leave the spinner up forever — surface it instead.
|
// otherwise leave the spinner up forever — surface it instead.
|
||||||
|
|
@ -157,7 +160,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
const onMeta = () => {
|
const onMeta = () => {
|
||||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
video.play().catch(() => {});
|
if (aliveRef.current) video.play().catch(() => {});
|
||||||
video.removeEventListener("loadedmetadata", onMeta);
|
video.removeEventListener("loadedmetadata", onMeta);
|
||||||
};
|
};
|
||||||
video.addEventListener("loadedmetadata", onMeta);
|
video.addEventListener("loadedmetadata", onMeta);
|
||||||
|
|
@ -179,6 +182,28 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
};
|
};
|
||||||
}, [detail, loadSession]);
|
}, [detail, loadSession]);
|
||||||
|
|
||||||
|
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
||||||
|
// play() can't leave audio playing on a detached element after leaving the player.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false;
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (v) {
|
||||||
|
try {
|
||||||
|
v.pause();
|
||||||
|
v.removeAttribute("src");
|
||||||
|
v.load();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- time tracking + progress checkpoints ----------------------------------------------------
|
// --- time tracking + progress checkpoints ----------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ export default function PlexSidebar({
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort + direction */}
|
||||||
<Section label={t("plex.filter.sort")}>
|
<Section label={t("plex.filter.sort")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
{sorts.map((s) => (
|
{sorts.map((s) => (
|
||||||
|
|
@ -164,24 +164,43 @@ export default function PlexSidebar({
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
|
<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>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Metadata filters (movie libraries only) */}
|
{/* Metadata filters (movie libraries only) */}
|
||||||
{isMovieLib && (
|
{isMovieLib && (
|
||||||
<>
|
<>
|
||||||
{/* Active people / studio — set by clicking the info page. */}
|
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||||
{(filters.director || filters.actor || filters.studio) && (
|
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||||
<Section label={t("plex.filter.active")}>
|
<Section label={t("plex.filter.active")}>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{filters.director && (
|
{filters.directors.map((d) => (
|
||||||
<RemovableChip label={filters.director} onRemove={() => patch({ director: null })} />
|
<RemovableChip
|
||||||
)}
|
key={`d:${d}`}
|
||||||
{filters.actor && (
|
label={d}
|
||||||
<RemovableChip label={filters.actor} onRemove={() => patch({ actor: null })} />
|
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
||||||
)}
|
/>
|
||||||
{filters.studio && (
|
))}
|
||||||
<RemovableChip label={filters.studio} onRemove={() => patch({ studio: null })} />
|
{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) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -200,9 +219,26 @@ export default function PlexSidebar({
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Genres (from facets) */}
|
{/* Genres (from facets) + Any/All when more than one is picked */}
|
||||||
{facets && facets.genres.length > 0 && (
|
{facets && facets.genres.length > 0 && (
|
||||||
<Section label={t("plex.filter.genre")}>
|
<Section label={t("plex.filter.genre")}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
{facets.genres.map((g) => (
|
{facets.genres.map((g) => (
|
||||||
<Chip
|
<Chip
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
"durationOpt": { "short": "Unter 90 Min.", "mid": "90–120 Min.", "long": "Über 2 Std." },
|
"durationOpt": { "short": "Unter 90 Min.", "mid": "90–120 Min.", "long": "Über 2 Std." },
|
||||||
"added": "Zu Plex hinzugefügt",
|
"added": "Zu Plex hinzugefügt",
|
||||||
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
||||||
"contentRating": "Altersfreigabe"
|
"contentRating": "Altersfreigabe",
|
||||||
|
"match": { "any": "Beliebig", "all": "Alle" },
|
||||||
|
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }
|
||||||
},
|
},
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"searchCount": "{{count}} Treffer",
|
"searchCount": "{{count}} Treffer",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
"durationOpt": { "short": "Under 90m", "mid": "90–120m", "long": "Over 2h" },
|
"durationOpt": { "short": "Under 90m", "mid": "90–120m", "long": "Over 2h" },
|
||||||
"added": "Added to Plex",
|
"added": "Added to Plex",
|
||||||
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
||||||
"contentRating": "Age rating"
|
"contentRating": "Age rating",
|
||||||
|
"match": { "any": "Any", "all": "All" },
|
||||||
|
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }
|
||||||
},
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@
|
||||||
"durationOpt": { "short": "90 perc alatt", "mid": "90–120 perc", "long": "2 óra felett" },
|
"durationOpt": { "short": "90 perc alatt", "mid": "90–120 perc", "long": "2 óra felett" },
|
||||||
"added": "Plexhez adva",
|
"added": "Plexhez adva",
|
||||||
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
||||||
"contentRating": "Korhatár"
|
"contentRating": "Korhatár",
|
||||||
|
"match": { "any": "Bármelyik", "all": "Mind" },
|
||||||
|
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }
|
||||||
},
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
|
|
|
||||||
|
|
@ -723,6 +723,7 @@ export interface PlexCastMember {
|
||||||
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
||||||
export interface PlexFilters {
|
export interface PlexFilters {
|
||||||
genres: string[];
|
genres: string[];
|
||||||
|
genreMode?: "any" | "all"; // how multiple genres combine (default any)
|
||||||
contentRatings: string[];
|
contentRatings: string[];
|
||||||
yearMin?: number | null;
|
yearMin?: number | null;
|
||||||
yearMax?: number | null;
|
yearMax?: number | null;
|
||||||
|
|
@ -730,11 +731,18 @@ export interface PlexFilters {
|
||||||
durationMin?: number | null; // seconds
|
durationMin?: number | null; // seconds
|
||||||
durationMax?: number | null;
|
durationMax?: number | null;
|
||||||
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
|
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
|
||||||
director?: string | null;
|
directors: string[]; // AND — titles featuring ALL selected
|
||||||
actor?: string | null;
|
actors: string[]; // AND — titles featuring ALL selected
|
||||||
studio?: string | null;
|
studios: string[]; // OR — from any selected studio
|
||||||
|
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
||||||
}
|
}
|
||||||
export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], contentRatings: [] };
|
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||||
|
genres: [],
|
||||||
|
contentRatings: [],
|
||||||
|
directors: [],
|
||||||
|
actors: [],
|
||||||
|
studios: [],
|
||||||
|
};
|
||||||
export function plexFilterCount(f: PlexFilters): number {
|
export function plexFilterCount(f: PlexFilters): number {
|
||||||
return (
|
return (
|
||||||
f.genres.length +
|
f.genres.length +
|
||||||
|
|
@ -743,9 +751,9 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||||
(f.ratingMin != null ? 1 : 0) +
|
(f.ratingMin != null ? 1 : 0) +
|
||||||
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
|
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
|
||||||
(f.addedWithin ? 1 : 0) +
|
(f.addedWithin ? 1 : 0) +
|
||||||
(f.director ? 1 : 0) +
|
f.directors.length +
|
||||||
(f.actor ? 1 : 0) +
|
f.actors.length +
|
||||||
(f.studio ? 1 : 0)
|
f.studios.length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export interface PlexFacets {
|
export interface PlexFacets {
|
||||||
|
|
@ -1097,6 +1105,7 @@ export const api = {
|
||||||
const f = p.filters;
|
const f = p.filters;
|
||||||
if (f) {
|
if (f) {
|
||||||
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
||||||
|
if (f.genreMode === "all") u.set("genre_mode", "all");
|
||||||
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
||||||
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
||||||
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
||||||
|
|
@ -1104,9 +1113,10 @@ export const api = {
|
||||||
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
||||||
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
||||||
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
||||||
if (f.director) u.set("director", f.director);
|
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
||||||
if (f.actor) u.set("actor", f.actor);
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||||
if (f.studio) u.set("studio", f.studio);
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||||
|
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
||||||
}
|
}
|
||||||
return req(`/api/plex/browse?${u.toString()}`);
|
return req(`/api/plex/browse?${u.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
date: "2026-07-05",
|
date: "2026-07-05",
|
||||||
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||||
features: [
|
features: [
|
||||||
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length).",
|
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length) with an ascending/descending toggle.",
|
||||||
"Plex info page: the year, genres, director, cast and studio are now clickable — tap one to filter the whole library by it (e.g. every film with an actor, or everything rated 7+).",
|
"Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page).",
|
||||||
|
"Plex info page: the year, genres, director, cast and studio are now clickable — tap one to filter the whole library by it (e.g. every film with an actor, or everything rated 7+). Browser Back returns to the info page.",
|
||||||
"Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits.",
|
"Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits.",
|
||||||
],
|
],
|
||||||
|
fixes: [
|
||||||
|
"Plex player: leaving a video with Back now fully stops it — no more audio playing in the background until a refresh.",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: "0.25.0",
|
version: "0.25.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue