- 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.
742 lines
32 KiB
TypeScript
742 lines
32 KiB
TypeScript
// 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" : ""}`,
|
||
};
|