diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8de7b2e..9d8a06d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,6 +22,8 @@ export { // importable from "./api". Their method slices are spread into `api` below. import { downloadsApi } from "./api/downloads"; export * from "./api/downloads"; +import { plexApi } from "./api/plex"; +export * from "./api/plex"; export interface Me { id: number; @@ -485,269 +487,6 @@ export interface SystemConfigData { secrets_manageable: boolean; } -// Admin: result of testing the Plex server connection (also drives the library-picker). -interface PlexSection { - key: string; - title: string; - type: string; // "movie" | "show" - count?: number; -} -export interface PlexTestResult { - ok: boolean; - server_name: string; - version?: string; - sections: PlexSection[]; - // Whether a sample media file resolved + was readable through the local mount (bind-mount + - // path-map check). checked=false when the library is empty (nothing to probe yet). - media_check?: { - checked: boolean; - ok?: boolean; - plex_path?: string; - local_path?: string; - }; -} - -// 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 - show_title?: string | null; // episode (in a mixed playlist) - show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) - summary?: string | null; // episode -} -// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that -// also matches episodes (the grouped "Episodes" section). -export interface PlexUnifiedResult { - scope: string; // movie | show | both - total: number; - offset: number; - limit: number; - items: PlexCard[]; - episodes: PlexCard[]; -} -export interface PlexSeasonDetail { - id: string; // season rating_key - season_number: number | null; - title: string; - thumb: string; - episode_count: number; - status: string; // aggregate: new | in_progress | watched - resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched) - first?: PlexCard | null; // first episode (Play from the start) - episodes: PlexCard[]; -} -export interface PlexShowDetail { - show: { - id: string; - title: string; - summary?: string | null; - year?: number | null; - thumb: string; - art: string; - library?: string | null; // Plex section key (for the admin collection editor) - content_rating?: string | null; - rating?: number | null; - genres: string[]; - studio?: string | null; - season_count?: number | null; - imdb_rating?: number | null; - imdb_id?: string | null; - imdb_url?: string | null; - cast: PlexCastMember[]; - status: string; // aggregate across all episodes - resume?: PlexCard | null; - first?: PlexCard | null; - episode_count: number; - collection_keys: string[]; - }; - seasons: PlexSeasonDetail[]; - related: PlexCard[]; -} - -export interface PlexMarker { - type: "intro" | "credits"; - start_s: number; - end_s: number; -} -export interface PlexItemDetail { - id: string; - kind: "movie" | "episode"; - title: string; - summary?: string | null; - year?: number | null; - duration_seconds: number | null; - playable: string; // direct | remux | transcode - thumb: string; - art: string; - library?: string | null; // Plex section key (for the admin collection editor) - cast: PlexCastMember[]; - imdb_rating?: number | null; - imdb_id?: string | null; - imdb_url?: string | null; - content_rating?: string | null; - genres: string[]; - directors: string[]; - studio?: string | null; - tagline?: string | null; - markers: PlexMarker[]; - audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; - // `text`=true → offered as a WebVTT ; image subs (PGS/VobSub) are text=false (hidden). - subtitle_streams: { - ord: number; - label: string; - language?: string | null; - codec?: string; - text: boolean; - }[]; - status: string; - position_seconds: number; - show_title?: string | null; - season_number?: number | null; - episode_number?: number | null; - prev_id?: string | null; - next_id?: string | null; - // `source` buckets the strip so each type can be shown/hidden independently on the info page: - // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart". - collections?: { id: string; title: string; source: string; items: PlexCard[] }[]; -} -export interface PlexCollection { - id: string; - title: string; - summary?: string | null; - thumb?: string | null; - child_count?: number | null; - smart: boolean; - source?: string; - editable: boolean; - can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection -} -export interface PlexPlaylist { - id: number; - title: string; - item_count: number; - thumb?: string | null; - has_item?: boolean; // only when the list was fetched with ?contains= - group_in?: number; // only when fetched with ?contains_group=: how many of the group it holds -} -export interface PlexPlaylistDetail { - id: number; - title: string; - items: PlexCard[]; -} -export interface PlexCastMember { - name: string; - role?: string | null; - thumb?: string | null; // proxied person-image url, or null when Plex has no photo -} -export interface PlexPersonCard { - name: string; - photo?: string | null; // proxied person-image url, or null (placeholder) - count: number; // in-scope movies/shows featuring them -} -// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. -export interface PlexFilters { - genres: string[]; - genreMode?: "any" | "all"; // how multiple genres combine (default any) - contentRatings: string[]; - yearMin?: number | null; - yearMax?: number | null; - ratingMin?: number | null; - durationMin?: number | null; // seconds - durationMax?: number | null; - addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y" - directors: string[]; // AND — titles featuring ALL selected - actors: string[]; // OR (any) by default, or AND (all) via actorMode - actorMode?: "any" | "all"; // how multiple actors combine (default any = OR) - studios: string[]; // OR — from any selected studio - collection?: string | null; // a single Plex collection (rating_key) - collectionTitle?: string | null; // its title, for the active-filter chip - sortDir?: "asc" | "desc"; // sort direction (default desc) -} -export const EMPTY_PLEX_FILTERS: PlexFilters = { - genres: [], - contentRatings: [], - directors: [], - actors: [], - studios: [], -}; -export function plexFilterCount(f: PlexFilters): number { - return ( - f.genres.length + - f.contentRatings.length + - (f.yearMin != null || f.yearMax != null ? 1 : 0) + - (f.ratingMin != null ? 1 : 0) + - (f.durationMin != null || f.durationMax != null ? 1 : 0) + - (f.addedWithin ? 1 : 0) + - f.directors.length + - f.actors.length + - f.studios.length + - (f.collection ? 1 : 0) - ); -} -// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid -// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are -// each caller's own concern and stay out of here). -function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { - 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.yearMin != null) u.set("year_min", String(f.yearMin)); - if (f.yearMax != null) u.set("year_max", String(f.yearMax)); - if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin)); - if (f.durationMin != null) u.set("duration_min", String(f.durationMin)); - if (f.durationMax != null) u.set("duration_max", String(f.durationMax)); - if (f.addedWithin) u.set("added_within", f.addedWithin); - if (f.directors?.length) u.set("directors", f.directors.join(",")); - if (f.actors?.length) u.set("actors", f.actors.join(",")); - if (f.actorMode === "all") u.set("actor_mode", "all"); - if (f.studios?.length) u.set("studios", f.studios.join(",")); - if (f.collection) u.set("collection", f.collection); -} -export interface PlexFacets { - genres: { value: string; count: number }[]; - content_ratings: { value: string; count: number }[]; - year_min: number | null; - year_max: number | null; - rating_max: number | null; - duration_min: number | null; - duration_max: number | null; -} -export interface PlexPlaySession { - mode: "direct" | "hls"; - url: string; - start_s: number; -} - -// Two-way Plex watch-state sync link status (owner account; Phase A). -export interface PlexWatchLink { - linked: boolean; - sync_enabled: boolean; - uses_admin: boolean; - initial_import_done: boolean; - last_watch_sync_at: string | null; -} -export interface PlexWatchImport { - watched: number; - in_progress: number; - scanned: number; -} - // Admin Users & roles page. export interface AdminUserRow { id: number; @@ -956,216 +695,7 @@ export const api = { req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), - testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), - syncPlex: (): Promise> => req("/api/plex/sync", { method: "POST" }), - plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => - req("/api/plex/libraries"), - plexLibrary: (p: { - scope: string; // movie | show | both - q?: string; - sort?: string; - show?: string; - offset?: number; - limit?: number; - filters?: PlexFilters; - }): Promise => { - const u = new URLSearchParams({ scope: p.scope }); - if (p.q) u.set("q", p.q); - if (p.sort) u.set("sort", p.sort); - if (p.show && p.show !== "all") u.set("show", p.show); - if (p.offset) u.set("offset", String(p.offset)); - if (p.limit) u.set("limit", String(p.limit)); - const f = p.filters; - if (f) { - appendPlexFilters(u, f); - u.set("sort_dir", f.sortDir ?? "asc"); // default ascending - } - return req(`/api/plex/library?${u.toString()}`); - }, - // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group - // narrows the others and the bounds match the visible result set. - plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => { - const u = new URLSearchParams({ scope }); - if (show && show !== "all") u.set("show", show); - if (f) appendPlexFilters(u, f); - return req(`/api/plex/facets?${u.toString()}`); - }, - // With a library plex_key → that library's collections (editor). Without one → the union across all - // enabled libraries (the unified-scope sidebar picker). - plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => { - const u = new URLSearchParams(); - if (library) u.set("library", library); - if (q) u.set("q", q); - const qs = u.toString(); - return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); - }, - // --- Collection editing (admin only; writes back to Plex) --- - plexCreateCollection: ( - library: string, - title: string, - item_rating_key: string, - ): Promise => - req(`/api/plex/collections`, { - method: "POST", - body: JSON.stringify({ library, title, item_rating_key }), - }), - plexCollectionAddItem: (rk: string, itemRk: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { - method: "POST", - }), - plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { - method: "DELETE", - }), - plexRenameCollection: (rk: string, title: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}`, { - method: "PATCH", - body: JSON.stringify({ title }), - }), - plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => - req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), - plexSetCollectionEditable: (rk: string, editable: boolean): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { - method: "POST", - body: JSON.stringify({ editable }), - }), - // --- Playlists (Siftlode-native, per-user, ordered) --- - // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole - // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog). - plexPlaylists: ( - contains?: string, - containsGroup?: string[], - ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => { - const p = new URLSearchParams(); - if (contains) p.set("contains", contains); - if (containsGroup) p.set("contains_group", containsGroup.join(",")); - const qs = p.toString(); - return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); - }, - plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), - plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => - req(`/api/plex/playlists`, { - method: "POST", - body: JSON.stringify({ title, item_rating_key }), - }), - plexPlaylistAddBulk: ( - id: number, - itemRks: string[], - ): Promise<{ added: number; item_count: number }> => - req(`/api/plex/playlists/${id}/items/bulk`, { - method: "POST", - body: JSON.stringify({ item_rating_keys: itemRks }), - }), - plexPlaylistRemoveBulk: ( - id: number, - itemRks: string[], - ): Promise<{ removed: number; item_count: number }> => - req(`/api/plex/playlists/${id}/items/remove-bulk`, { - method: "POST", - body: JSON.stringify({ item_rating_keys: itemRks }), - }), - plexRenamePlaylist: (id: number, title: string): Promise => - req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), - plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => - req(`/api/plex/playlists/${id}`, { method: "DELETE" }), - plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => - req(`/api/plex/playlists/${id}/items`, { - method: "POST", - body: JSON.stringify({ item_rating_key: itemRk }), - }), - plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => - req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), - plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> => - req(`/api/plex/playlists/${id}/order`, { - method: "PUT", - body: JSON.stringify({ item_rating_keys: itemRks }), - }), - plexShow: (id: string): Promise => - req(`/api/plex/show/${encodeURIComponent(id)}`), - // Mark a whole show / season watched or unwatched (all its episodes) for the current user. - plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => - req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { - method: "POST", - body: JSON.stringify({ watched }), - }), - plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => - req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { - method: "POST", - body: JSON.stringify({ watched }), - }), - plexItem: (id: string): Promise => - req(`/api/plex/item/${encodeURIComponent(id)}`), - // Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count. - plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> => - req( - `/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, - ), - plexSession: ( - id: string, - start = 0, - audio?: number | null, - aoff = 0, - multi = false, - ): Promise => { - const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) }); - if (audio != null) p.set("audio", String(audio)); - if (aoff) p.set("aoff", String(aoff)); - if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch) - return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { - method: "POST", - }); - }, - // WebVTT URL for a text subtitle track (used directly as a ). Same-origin → cookie-authed. - // `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute - // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek. - plexSubtitleUrl: (id: string, ord: number, offset = 0): string => - `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`, - // `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the - // periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a - // final checkpoint, so a single watch doesn't spray Plex with timeline updates. - plexProgress: ( - id: string, - position_seconds: number, - duration_seconds: number, - final = false, - ): Promise => - req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { - method: "POST", - body: JSON.stringify({ position_seconds, duration_seconds, final }), - }), - // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is - // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position - // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we - // replicate req()'s credentials + account header (no retry — there's no page left to retry on). - plexProgressBeacon: ( - id: string, - position_seconds: number, - duration_seconds: number, - final = false, - ): void => - beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, { - position_seconds, - duration_seconds, - final, - }), - plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise => - req(`/api/plex/item/${encodeURIComponent(id)}/state`, { - method: "POST", - body: JSON.stringify({ status }), - }), - // Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import). - plexWatchLink: (): Promise => req("/api/plex/watch/link"), - plexWatchSetLink: ( - enabled: boolean, - ): Promise => - req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), - plexWatchImport: (): Promise => - req("/api/plex/watch/import", { method: "POST" }), - plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, - plexDownloadUrl: (id: string): string => - `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`, - plexImageUrl: (id: string, variant?: "thumb" | "art"): string => - `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, + ...plexApi, // --- admin: users & roles --- adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts new file mode 100644 index 0000000..e1c8059 --- /dev/null +++ b/frontend/src/lib/api/plex.ts @@ -0,0 +1,481 @@ +// Plex domain: browse/search/drill-down of the mirrored catalog, the admin collection editor, +// Siftlode-native Plex playlists, HLS/direct playback sessions + progress mirroring, and the +// two-way watch-sync link. Self-contained — its types reference only each other, and its methods +// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper. +import { req, beacon } from "./core"; + +// Admin: result of testing the Plex server connection (also drives the library-picker). +interface PlexSection { + key: string; + title: string; + type: string; // "movie" | "show" + count?: number; +} +export interface PlexTestResult { + ok: boolean; + server_name: string; + version?: string; + sections: PlexSection[]; + // Whether a sample media file resolved + was readable through the local mount (bind-mount + + // path-map check). checked=false when the library is empty (nothing to probe yet). + media_check?: { + checked: boolean; + ok?: boolean; + plex_path?: string; + local_path?: string; + }; +} + +// 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 + show_title?: string | null; // episode (in a mixed playlist) + show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) + summary?: string | null; // episode +} +// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that +// also matches episodes (the grouped "Episodes" section). +export interface PlexUnifiedResult { + scope: string; // movie | show | both + total: number; + offset: number; + limit: number; + items: PlexCard[]; + episodes: PlexCard[]; +} +export interface PlexSeasonDetail { + id: string; // season rating_key + season_number: number | null; + title: string; + thumb: string; + episode_count: number; + status: string; // aggregate: new | in_progress | watched + resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched) + first?: PlexCard | null; // first episode (Play from the start) + episodes: PlexCard[]; +} +export interface PlexShowDetail { + show: { + id: string; + title: string; + summary?: string | null; + year?: number | null; + thumb: string; + art: string; + library?: string | null; // Plex section key (for the admin collection editor) + content_rating?: string | null; + rating?: number | null; + genres: string[]; + studio?: string | null; + season_count?: number | null; + imdb_rating?: number | null; + imdb_id?: string | null; + imdb_url?: string | null; + cast: PlexCastMember[]; + status: string; // aggregate across all episodes + resume?: PlexCard | null; + first?: PlexCard | null; + episode_count: number; + collection_keys: string[]; + }; + seasons: PlexSeasonDetail[]; + related: PlexCard[]; +} + +export interface PlexMarker { + type: "intro" | "credits"; + start_s: number; + end_s: number; +} +export interface PlexItemDetail { + id: string; + kind: "movie" | "episode"; + title: string; + summary?: string | null; + year?: number | null; + duration_seconds: number | null; + playable: string; // direct | remux | transcode + thumb: string; + art: string; + library?: string | null; // Plex section key (for the admin collection editor) + cast: PlexCastMember[]; + imdb_rating?: number | null; + imdb_id?: string | null; + imdb_url?: string | null; + content_rating?: string | null; + genres: string[]; + directors: string[]; + studio?: string | null; + tagline?: string | null; + markers: PlexMarker[]; + audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; + // `text`=true → offered as a WebVTT ; image subs (PGS/VobSub) are text=false (hidden). + subtitle_streams: { + ord: number; + label: string; + language?: string | null; + codec?: string; + text: boolean; + }[]; + status: string; + position_seconds: number; + show_title?: string | null; + season_number?: number | null; + episode_number?: number | null; + prev_id?: string | null; + next_id?: string | null; + // `source` buckets the strip so each type can be shown/hidden independently on the info page: + // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart". + collections?: { id: string; title: string; source: string; items: PlexCard[] }[]; +} +export interface PlexCollection { + id: string; + title: string; + summary?: string | null; + thumb?: string | null; + child_count?: number | null; + smart: boolean; + source?: string; + editable: boolean; + can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection +} +export interface PlexPlaylist { + id: number; + title: string; + item_count: number; + thumb?: string | null; + has_item?: boolean; // only when the list was fetched with ?contains= + group_in?: number; // only when fetched with ?contains_group=: how many of the group it holds +} +export interface PlexPlaylistDetail { + id: number; + title: string; + items: PlexCard[]; +} +export interface PlexCastMember { + name: string; + role?: string | null; + thumb?: string | null; // proxied person-image url, or null when Plex has no photo +} +export interface PlexPersonCard { + name: string; + photo?: string | null; // proxied person-image url, or null (placeholder) + count: number; // in-scope movies/shows featuring them +} +// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. +export interface PlexFilters { + genres: string[]; + genreMode?: "any" | "all"; // how multiple genres combine (default any) + contentRatings: string[]; + yearMin?: number | null; + yearMax?: number | null; + ratingMin?: number | null; + durationMin?: number | null; // seconds + durationMax?: number | null; + addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y" + directors: string[]; // AND — titles featuring ALL selected + actors: string[]; // OR (any) by default, or AND (all) via actorMode + actorMode?: "any" | "all"; // how multiple actors combine (default any = OR) + studios: string[]; // OR — from any selected studio + collection?: string | null; // a single Plex collection (rating_key) + collectionTitle?: string | null; // its title, for the active-filter chip + sortDir?: "asc" | "desc"; // sort direction (default desc) +} +export const EMPTY_PLEX_FILTERS: PlexFilters = { + genres: [], + contentRatings: [], + directors: [], + actors: [], + studios: [], +}; +export function plexFilterCount(f: PlexFilters): number { + return ( + f.genres.length + + f.contentRatings.length + + (f.yearMin != null || f.yearMax != null ? 1 : 0) + + (f.ratingMin != null ? 1 : 0) + + (f.durationMin != null || f.durationMax != null ? 1 : 0) + + (f.addedWithin ? 1 : 0) + + f.directors.length + + f.actors.length + + f.studios.length + + (f.collection ? 1 : 0) + ); +} +// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid +// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are +// each caller's own concern and stay out of here). +function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { + 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.yearMin != null) u.set("year_min", String(f.yearMin)); + if (f.yearMax != null) u.set("year_max", String(f.yearMax)); + if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin)); + if (f.durationMin != null) u.set("duration_min", String(f.durationMin)); + if (f.durationMax != null) u.set("duration_max", String(f.durationMax)); + if (f.addedWithin) u.set("added_within", f.addedWithin); + if (f.directors?.length) u.set("directors", f.directors.join(",")); + if (f.actors?.length) u.set("actors", f.actors.join(",")); + if (f.actorMode === "all") u.set("actor_mode", "all"); + if (f.studios?.length) u.set("studios", f.studios.join(",")); + if (f.collection) u.set("collection", f.collection); +} +export interface PlexFacets { + genres: { value: string; count: number }[]; + content_ratings: { value: string; count: number }[]; + year_min: number | null; + year_max: number | null; + rating_max: number | null; + duration_min: number | null; + duration_max: number | null; +} +export interface PlexPlaySession { + mode: "direct" | "hls"; + url: string; + start_s: number; +} + +// Two-way Plex watch-state sync link status (owner account; Phase A). +export interface PlexWatchLink { + linked: boolean; + sync_enabled: boolean; + uses_admin: boolean; + initial_import_done: boolean; + last_watch_sync_at: string | null; +} +export interface PlexWatchImport { + watched: number; + in_progress: number; + scanned: number; +} + +export const plexApi = { + testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), + syncPlex: (): Promise> => req("/api/plex/sync", { method: "POST" }), + plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => + req("/api/plex/libraries"), + plexLibrary: (p: { + scope: string; // movie | show | both + q?: string; + sort?: string; + show?: string; + offset?: number; + limit?: number; + filters?: PlexFilters; + }): Promise => { + const u = new URLSearchParams({ scope: p.scope }); + if (p.q) u.set("q", p.q); + if (p.sort) u.set("sort", p.sort); + if (p.show && p.show !== "all") u.set("show", p.show); + if (p.offset) u.set("offset", String(p.offset)); + if (p.limit) u.set("limit", String(p.limit)); + const f = p.filters; + if (f) { + appendPlexFilters(u, f); + u.set("sort_dir", f.sortDir ?? "asc"); // default ascending + } + return req(`/api/plex/library?${u.toString()}`); + }, + // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group + // narrows the others and the bounds match the visible result set. + plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => { + const u = new URLSearchParams({ scope }); + if (show && show !== "all") u.set("show", show); + if (f) appendPlexFilters(u, f); + return req(`/api/plex/facets?${u.toString()}`); + }, + // With a library plex_key → that library's collections (editor). Without one → the union across all + // enabled libraries (the unified-scope sidebar picker). + plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => { + const u = new URLSearchParams(); + if (library) u.set("library", library); + if (q) u.set("q", q); + const qs = u.toString(); + return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); + }, + // --- Collection editing (admin only; writes back to Plex) --- + plexCreateCollection: ( + library: string, + title: string, + item_rating_key: string, + ): Promise => + req(`/api/plex/collections`, { + method: "POST", + body: JSON.stringify({ library, title, item_rating_key }), + }), + plexCollectionAddItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { + method: "POST", + }), + plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { + method: "DELETE", + }), + plexRenameCollection: (rk: string, title: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}`, { + method: "PATCH", + body: JSON.stringify({ title }), + }), + plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => + req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), + plexSetCollectionEditable: (rk: string, editable: boolean): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { + method: "POST", + body: JSON.stringify({ editable }), + }), + // --- Playlists (Siftlode-native, per-user, ordered) --- + // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole + // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog). + plexPlaylists: ( + contains?: string, + containsGroup?: string[], + ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => { + const p = new URLSearchParams(); + if (contains) p.set("contains", contains); + if (containsGroup) p.set("contains_group", containsGroup.join(",")); + const qs = p.toString(); + return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); + }, + plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), + plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => + req(`/api/plex/playlists`, { + method: "POST", + body: JSON.stringify({ title, item_rating_key }), + }), + plexPlaylistAddBulk: ( + id: number, + itemRks: string[], + ): Promise<{ added: number; item_count: number }> => + req(`/api/plex/playlists/${id}/items/bulk`, { + method: "POST", + body: JSON.stringify({ item_rating_keys: itemRks }), + }), + plexPlaylistRemoveBulk: ( + id: number, + itemRks: string[], + ): Promise<{ removed: number; item_count: number }> => + req(`/api/plex/playlists/${id}/items/remove-bulk`, { + method: "POST", + body: JSON.stringify({ item_rating_keys: itemRks }), + }), + plexRenamePlaylist: (id: number, title: string): Promise => + req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), + plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => + req(`/api/plex/playlists/${id}`, { method: "DELETE" }), + plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => + req(`/api/plex/playlists/${id}/items`, { + method: "POST", + body: JSON.stringify({ item_rating_key: itemRk }), + }), + plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => + req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), + plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> => + req(`/api/plex/playlists/${id}/order`, { + method: "PUT", + body: JSON.stringify({ item_rating_keys: itemRks }), + }), + plexShow: (id: string): Promise => + req(`/api/plex/show/${encodeURIComponent(id)}`), + // Mark a whole show / season watched or unwatched (all its episodes) for the current user. + plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => + req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { + method: "POST", + body: JSON.stringify({ watched }), + }), + plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => + req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { + method: "POST", + body: JSON.stringify({ watched }), + }), + plexItem: (id: string): Promise => + req(`/api/plex/item/${encodeURIComponent(id)}`), + // Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count. + plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> => + req( + `/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, + ), + plexSession: ( + id: string, + start = 0, + audio?: number | null, + aoff = 0, + multi = false, + ): Promise => { + const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) }); + if (audio != null) p.set("audio", String(audio)); + if (aoff) p.set("aoff", String(aoff)); + if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch) + return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { + method: "POST", + }); + }, + // WebVTT URL for a text subtitle track (used directly as a ). Same-origin → cookie-authed. + // `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute + // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek. + plexSubtitleUrl: (id: string, ord: number, offset = 0): string => + `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`, + // `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the + // periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a + // final checkpoint, so a single watch doesn't spray Plex with timeline updates. + plexProgress: ( + id: string, + position_seconds: number, + duration_seconds: number, + final = false, + ): Promise => + req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { + method: "POST", + body: JSON.stringify({ position_seconds, duration_seconds, final }), + }), + // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is + // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position + // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we + // replicate req()'s credentials + account header (no retry — there's no page left to retry on). + plexProgressBeacon: ( + id: string, + position_seconds: number, + duration_seconds: number, + final = false, + ): void => + beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, { + position_seconds, + duration_seconds, + final, + }), + plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise => + req(`/api/plex/item/${encodeURIComponent(id)}/state`, { + method: "POST", + body: JSON.stringify({ status }), + }), + // Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import). + plexWatchLink: (): Promise => req("/api/plex/watch/link"), + plexWatchSetLink: ( + enabled: boolean, + ): Promise => + req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), + plexWatchImport: (): Promise => + req("/api/plex/watch/import", { method: "POST" }), + plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, + plexDownloadUrl: (id: string): string => + `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`, + plexImageUrl: (id: string, variant?: "thumb" | "art"): string => + `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, +};