feat(plex): P1 frontend — Plex feed source, browse/search, show drill-down

- PlexBrowse.tsx: self-contained Plex mode (library scope tabs, own search +
  sort, poster-card grid with watch state + playability tint, paged; show →
  seasons/episodes drill-down). Playback announces (P2 wires the real player).
- Feed source dropdown gains a 'Plex' option (gated on me.plex_enabled); App
  swaps <Feed> for <PlexBrowse> when librarySource==='plex' so the video-feed
  hooks don't run in Plex mode. me exposes plex_enabled.
- api client (plexLibraries/plexBrowse/plexShow/plexImageUrl) + types; librarySource
  union + share-URL restore gain 'plex'; new plex i18n namespace en/hu/de.
- Verified over HTTP (authed): libraries, browse+FTS, image proxy 200 image/jpeg.
This commit is contained in:
npeter83 2026-07-05 02:32:00 +02:00
parent 63c6e782c8
commit 62636319b1
12 changed files with 452 additions and 5 deletions

View file

@ -13,6 +13,7 @@ export interface Me {
has_google: boolean;
has_password: boolean;
google_enabled: boolean;
plex_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
@ -179,7 +180,7 @@ export interface FeedFilters {
show: string;
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
librarySource?: "organic" | "all" | "search";
librarySource?: "organic" | "all" | "search" | "plex";
channelId?: string;
channelName?: string;
maxAgeDays?: number;
@ -623,6 +624,54 @@ export interface PlexTestResult {
sections: PlexSection[];
}
// Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards).
export interface PlexLibrary {
key: string;
title: string;
kind: "movie" | "show";
count: number;
}
export interface PlexCard {
id: string;
type: "movie" | "show" | "episode";
title: string;
year?: number | null;
duration_seconds?: number | null;
thumb: string;
playable?: string; // direct | remux | transcode
status?: string; // new | watched | hidden
position_seconds?: number;
season_count?: number | null; // show
season_number?: number | null; // episode
episode_number?: number | null; // episode
summary?: string | null; // episode
}
export interface PlexBrowseResult {
kind: "movie" | "show";
total: number;
offset: number;
limit: number;
items: PlexCard[];
}
export interface PlexSeasonDetail {
id: string;
season_number: number | null;
title: string;
thumb: string;
episodes: PlexCard[];
}
export interface PlexShowDetail {
show: {
id: string;
title: string;
summary?: string | null;
year?: number | null;
thumb: string;
art: string;
};
seasons: PlexSeasonDetail[];
}
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
@ -936,6 +985,27 @@ export const api = {
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
req("/api/plex/libraries"),
plexBrowse: (p: {
library: string;
q?: string;
sort?: string;
offset?: number;
limit?: number;
}): Promise<PlexBrowseResult> => {
const u = new URLSearchParams({ library: p.library });
if (p.q) u.set("q", p.q);
if (p.sort) u.set("sort", p.sort);
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
return req(`/api/plex/browse?${u.toString()}`);
},
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
// --- admin: users & roles ---
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>

View file

@ -61,7 +61,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) {
const s = params.get("source");
f.librarySource = s === "all" || s === "search" ? s : "organic";
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
}
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";