refactor(api): extract the plex domain to api/plex.ts (R7 S3b·3)
All Plex types (browse cards, show/season/item details, collections, native playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/ appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved from the original 1793); no runtime change. Gate green: typecheck, eslint, prettier, 74 vitest.
This commit is contained in:
+3
-473
@@ -22,6 +22,8 @@ export {
|
|||||||
// importable from "./api". Their method slices are spread into `api` below.
|
// importable from "./api". Their method slices are spread into `api` below.
|
||||||
import { downloadsApi } from "./api/downloads";
|
import { downloadsApi } from "./api/downloads";
|
||||||
export * from "./api/downloads";
|
export * from "./api/downloads";
|
||||||
|
import { plexApi } from "./api/plex";
|
||||||
|
export * from "./api/plex";
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -485,269 +487,6 @@ export interface SystemConfigData {
|
|||||||
secrets_manageable: boolean;
|
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 <track>; 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=<rating_key>
|
|
||||||
group_in?: number; // only when fetched with ?contains_group=<rk,rk,…>: 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.
|
// Admin Users & roles page.
|
||||||
export interface AdminUserRow {
|
export interface AdminUserRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -956,216 +695,7 @@ export const api = {
|
|||||||
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
||||||
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
||||||
req("/api/admin/config/test-email", { method: "POST" }),
|
req("/api/admin/config/test-email", { method: "POST" }),
|
||||||
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
|
...plexApi,
|
||||||
syncPlex: (): Promise<Record<string, unknown>> => 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<PlexUnifiedResult> => {
|
|
||||||
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<PlexFacets> => {
|
|
||||||
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<PlexCollection> =>
|
|
||||||
req(`/api/plex/collections`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ library, title, item_rating_key }),
|
|
||||||
}),
|
|
||||||
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
|
||||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
|
||||||
method: "POST",
|
|
||||||
}),
|
|
||||||
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
|
||||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
}),
|
|
||||||
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
|
|
||||||
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<PlexCollection> =>
|
|
||||||
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<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
|
|
||||||
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
|
|
||||||
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<PlexPlaylist> =>
|
|
||||||
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<PlexShowDetail> =>
|
|
||||||
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<PlexItemDetail> =>
|
|
||||||
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<PlexPlaySession> => {
|
|
||||||
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 <track src>). 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<unknown> =>
|
|
||||||
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<unknown> =>
|
|
||||||
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<PlexWatchLink> => req("/api/plex/watch/link"),
|
|
||||||
plexWatchSetLink: (
|
|
||||||
enabled: boolean,
|
|
||||||
): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
|
|
||||||
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
|
|
||||||
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
|
|
||||||
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" : ""}`,
|
|
||||||
// --- admin: users & roles ---
|
// --- admin: users & roles ---
|
||||||
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||||
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||||
|
|||||||
@@ -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 <track>; 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=<rating_key>
|
||||||
|
group_in?: number; // only when fetched with ?contains_group=<rk,rk,…>: 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<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"),
|
||||||
|
plexLibrary: (p: {
|
||||||
|
scope: string; // movie | show | both
|
||||||
|
q?: string;
|
||||||
|
sort?: string;
|
||||||
|
show?: string;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
filters?: PlexFilters;
|
||||||
|
}): Promise<PlexUnifiedResult> => {
|
||||||
|
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<PlexFacets> => {
|
||||||
|
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<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ library, title, item_rating_key }),
|
||||||
|
}),
|
||||||
|
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
|
||||||
|
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<PlexCollection> =>
|
||||||
|
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<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
|
||||||
|
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
|
||||||
|
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<PlexPlaylist> =>
|
||||||
|
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<PlexShowDetail> =>
|
||||||
|
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<PlexItemDetail> =>
|
||||||
|
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<PlexPlaySession> => {
|
||||||
|
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 <track src>). 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<unknown> =>
|
||||||
|
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<unknown> =>
|
||||||
|
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<PlexWatchLink> => req("/api/plex/watch/link"),
|
||||||
|
plexWatchSetLink: (
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
|
||||||
|
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
|
||||||
|
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
|
||||||
|
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" : ""}`,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user