Files
siftlode/frontend/src/lib/api/plex.ts
T
peter 1b946113c6 feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)
- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
2026-09-26 05:56:07 +02:00

742 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
import { type SortState } from "../columnSort";
import { type PlexSearchField } from "../plexSearch";
// 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;
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
playable?: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
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; // movie/show hover-overview synopsis (trimmed) + episode summary
}
// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail.
// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row,
// facts sidebar, keyword chips (→ filter), and the full crew.
export interface PlexTmdbCrew {
name: string;
job: string;
tmdb_id?: number | null;
profile_path?: string | null; // raw TMDB path — we don't proxy crew headshots, so name+job only
}
export interface PlexTmdb {
tmdb_id?: number | null;
rating?: number | null; // TMDb user score (0–10)
vote_count?: number | null;
budget?: number | null; // USD, movies only
revenue?: number | null; // USD, movies only
keywords: string[];
crew: PlexTmdbCrew[];
studios: string[];
production_countries: string[];
recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[];
backdrops: string[];
}
// 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[];
tmdb?: PlexTmdb | null;
};
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
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
video_bit_depth?: number | null; // e.g. 8 / 10 (info-chip subtitle)
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;
rating?: number | null; // Plex audience score — shown when there's no IMDb score
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[] }[];
tmdb?: PlexTmdb | null; // movies only (episodes aren't enriched)
}
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
art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only)
total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only)
}
/** A collection Plex regenerates outside a user's control — a smart collection or an external auto-list
* (imdb/tmdb/tvdb/trakt/…) — can never be edited. The single source of truth for the lock badge shown
* on both the browse tile and the info page, so the two surfaces can't drift. */
export function isIntrinsicReadOnly(c: Pick<PlexCollection, "smart" | "source">): boolean {
return !!c.smart || (!!c.source && c.source !== "collection");
}
// The Collection info page: the card fields + hero aggregates + the ordered members.
export interface PlexCollectionDetail extends PlexCollection {
content_rating?: string | null;
year_min?: number | null;
year_max?: number | null;
items: PlexCard[];
}
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
}
export interface PlexPersonDetail {
name: string;
photo?: string | null; // cached headshot, or null (placeholder)
// TMDB bio (E5 S6), lazily fetched on first open, then served from the row. Null when the person has
// no linked tmdb_person_id (never appeared in a TMDB-enriched title) or TMDB has no data.
biography?: string | null;
birthday?: string | null; // "YYYY-MM-DD"
deathday?: string | null;
place_of_birth?: string | null;
known_for_department?: string | null;
also_known_as: string[];
known_for: PlexCard[]; // their most-popular credits that are IN our library (deep-linkable)
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
shows: PlexCard[]; // enabled-library shows, same
}
// 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
playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
collectionTitles?: Record<string, string>; // rating_key → title, for the active-filter chips
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
genres: [],
contentRatings: [],
directors: [],
actors: [],
studios: [],
playable: [],
keywords: [],
collections: [],
};
// Coerce an opaque persisted/saved filter blob to a full PlexFilters: fill defaults for any missing
// array/scalar, and migrate the legacy single `collection`/`collectionTitle` into the multi-select
// `collections`/`collectionTitles`. Shared by PlexProvider (localStorage seed) and the saved-views
// provider (a DB view blob). Not a migration — an unknown field a newer build dropped just falls away.
export function coercePlexFilters(raw: Record<string, unknown>): PlexFilters {
const {
sortDir: _drop,
collection,
collectionTitle,
...rest
} = raw as Record<string, unknown> & {
collection?: string | null;
collectionTitle?: string | null;
};
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial<PlexFilters>) };
if (collection && !merged.collections.length) {
merged.collections = [collection];
if (collectionTitle) merged.collectionTitles = { [collection]: collectionTitle };
}
return merged;
}
// A stable signature of a PlexFilters set (only non-default keys; display-only titles excluded), so a
// saved view can be matched against the live filters regardless of key order — the Plex analogue of the
// feed's filtersToParams. Reuses appendPlexFilters so the signature can't drift from the wire params.
export function plexFiltersSignature(f: PlexFilters): string {
const u = new URLSearchParams();
appendPlexFilters(u, f);
return u.toString();
}
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.playable.length +
f.keywords.length +
f.collections.length
);
}
// 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.playable?.length) u.set("playable", f.playable.join(","));
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all");
}
// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the
// filter set through the SAME single source the API request uses — the two can never drift. Display-only
// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature.
export function plexFiltersToParams(f: PlexFilters): URLSearchParams {
const u = new URLSearchParams();
appendPlexFilters(u, f);
return u;
}
// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing
// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only.
export function parsePlexFilters(p: URLSearchParams): PlexFilters {
const csv = (k: string): string[] => {
const v = p.get(k);
return v ? v.split(",").filter(Boolean) : [];
};
const num = (k: string): number | undefined => {
if (!p.has(k)) return undefined;
const n = Number(p.get(k));
return Number.isFinite(n) ? n : undefined;
};
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS };
f.genres = csv("genres");
if (p.get("genre_mode") === "all") f.genreMode = "all";
f.contentRatings = csv("content_ratings");
f.yearMin = num("year_min");
f.yearMax = num("year_max");
f.ratingMin = num("rating_min");
f.durationMin = num("duration_min");
f.durationMax = num("duration_max");
const aw = p.get("added_within");
if (aw) f.addedWithin = aw;
f.directors = csv("directors");
f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios");
f.playable = csv("playable");
f.keywords = csv("keywords");
f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all";
return f;
}
export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
playable: { 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;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the
// dedicated Discover landing renders as a horizontal Carousel; `because_you_watched` is null when the
// user has no TMDB-enriched watch history (rail hidden). Served from DB — no live Plex/TMDB call.
export interface PlexDiscover {
scope: string;
continue_watching: PlexCard[];
recently_watched: PlexCard[]; // last fully-watched movies + episodes, newest first
recently_added: PlexCard[];
// top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage
genres: { value: string; count: number; art: string[] }[];
because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null;
}
// 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;
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
sort?: SortState;
show?: string;
lang?: string; // active UI language: "hu" serves the HU overlay twin, else EN (default)
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.field && p.field !== "all") u.set("field", p.field);
if (p.sort) {
u.set("sort", p.sort.key);
u.set("sort_dir", p.sort.dir);
}
if (p.show && p.show !== "all") u.set("show", p.show);
if (p.lang === "hu") u.set("lang", "hu"); // en is the server default; omit to keep URLs stable
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
if (p.filters) appendPlexFilters(u, p.filters);
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. `lang` picks the genre vocabulary.
plexFacets: (
scope: string,
f?: PlexFilters,
show?: string,
lang?: string,
): Promise<PlexFacets> => {
const u = new URLSearchParams({ scope });
if (show && show !== "all") u.set("show", show);
if (lang === "hu") u.set("lang", "hu");
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,
keys?: string[],
): Promise<{ collections: PlexCollection[] }> => {
const u = new URLSearchParams();
if (library) u.set("library", library);
if (q) u.set("q", q);
// `keys` resolves specific rating_keys to their titles — a shared Plex link carries the collection
// filter as bare ids, so the sidebar token and the filter chip have nothing else to render.
if (keys) u.set("keys", keys.join(","));
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// One collection's hero + its members in the requested order (the Collection info page). The sort
// vocabulary is the browse grid's, so the same Sort control means the same thing on both.
plexCollection: (rk: string, sort?: SortState): Promise<PlexCollectionDetail> => {
const u = new URLSearchParams();
if (sort) {
u.set("sort", sort.key);
u.set("sort_dir", sort.dir);
}
const qs = u.toString();
return req(`/api/plex/collections/${encodeURIComponent(rk)}${qs ? `?${qs}` : ""}`);
},
// --- Collection editing (admin only; writes back to Plex) ---
// `quiet`: the editor reports a failure itself, as a toast beside the name being typed (a taken
// name comes back structured and localized — see localizeDetail). Not quiet, it was reported twice:
// the global error dialog in English AND the editor's toast.
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 }) },
{ quiet: true },
),
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 }) },
{ quiet: true }, // as plexCreateCollection
),
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 }),
}),
/** `positions` (one per key, an index over the ordered rows) puts removed rows back where they
* stood — the list page's Undo, in one request; without it the keys are appended. */
plexPlaylistAddBulk: (
id: number,
itemRks: string[],
positions?: number[],
): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, {
method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks, ...(positions ? { positions } : {}) }),
}),
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 }),
}),
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the
// active sidebar `filters` narrow every rail (so Discover behaves like the Browse grid).
plexDiscover: (scope: string, lang?: string, filters?: PlexFilters): Promise<PlexDiscover> => {
const u = new URLSearchParams({ scope });
if (lang === "hu") u.set("lang", "hu");
if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/discover?${u.toString()}`);
},
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
// the active filters like the other rails.
plexSurprise: (
scope: string,
limit?: number,
filters?: PlexFilters,
lang?: string,
): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope });
if (limit) u.set("limit", String(limit));
if (lang === "hu") u.set("lang", "hu");
if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/surprise?${u.toString()}`);
},
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// 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, lang?: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// 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(","))}`,
),
// One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors).
plexPerson: (name: string): Promise<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}`),
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.
// `gen` is the cached track's generation: extraction publishes a window around the playhead first
// and widens it in the background, so the URL has to change when it grows or the browser would keep
// serving the first, short VTT from its own cache. It deliberately carries NO position: the fetch
// is a pure read of what is cached — scheduling is the status poll's job, since only it knows `at`.
plexSubtitleUrl: (id: string, ord: number, offset = 0, gen = 0): string => {
const p = new URLSearchParams();
if (offset) p.set("offset", String(offset));
if (gen) p.set("gen", String(gen));
const q = p.toString();
return `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${q ? `?${q}` : ""}`;
},
// Whether a subtitle track can be shown yet. Extracting one out of a big MKV costs a full read of
// the container (measured: 154 s for a 17 GB film), so the server does it in the background — around
// `at`, the viewer's position, first — and the player polls this instead of mounting a <track> that
// would sit empty for minutes. `partial` = a window around `at` is ready and still growing;
// `unavailable` = nothing for `at` right now (`code` says why) but ask again — cues elsewhere, when
// `gen` > 0, are still there; `failed` = the track can never be shown (the only final answer).
plexSubtitleStatus: (
id: string,
ord: number,
at = 0,
): Promise<{
state: "ready" | "partial" | "preparing" | "unavailable" | "failed";
code: string | null;
gen: number;
/** On `partial`/`preparing`: whether the track still fills in on its own. */
growing?: boolean;
}> =>
// `quiet`: the player reports a failed poll inline, so no global error dialog on top of it.
req(
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}/status?at=${Math.max(0, Math.floor(at))}`,
undefined,
{ quiet: true },
),
// `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,
}),
// Inline union (matches api.ts's VideoStatus by design) rather than importing VideoStatus — that
// lives in the api.ts barrel, which imports this module, so reusing it would make a type cycle.
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" : ""}`,
};