// 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 ; 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): 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= group_in?: number; // only when fetched with ?contains_group=: how many of the group it holds } export interface PlexPlaylistDetail { id: number; title: string; items: PlexCard[]; } export interface PlexCastMember { name: string; role?: string | null; thumb?: string | null; // proxied person-image url, or null when Plex has no photo } export interface PlexPersonCard { name: string; photo?: string | null; // proxied person-image url, or null (placeholder) count: number; // in-scope movies/shows featuring them } 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; // 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): PlexFilters { const { sortDir: _drop, collection, collectionTitle, ...rest } = raw as Record & { collection?: string | null; collectionTitle?: string | null; }; const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial) }; 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) } // 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 => req("/api/plex/test", { method: "POST" }), syncPlex: (): Promise> => req("/api/plex/sync", { method: "POST" }), plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => req("/api/plex/libraries"), plexLibrary: (p: { scope: string; // movie | show | both q?: string; 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 => { 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 => { 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): 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}` : ""}`); }, // One collection's hero + ordered members (the Collection info page). plexCollection: (rk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}`), // --- Collection editing (admin only; writes back to Plex) --- plexCreateCollection: ( library: string, title: string, item_rating_key: string, ): Promise => req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }), }), plexCollectionAddItem: (rk: string, itemRk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST", }), plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE", }), plexRenameCollection: (rk: string, title: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }), }), plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), plexSetCollectionEditable: (rk: string, editable: boolean): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }), }), // --- Playlists (Siftlode-native, per-user, ordered) --- // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog). plexPlaylists: ( contains?: string, containsGroup?: string[], ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => { const p = new URLSearchParams(); if (contains) p.set("contains", contains); if (containsGroup) p.set("contains_group", containsGroup.join(",")); const qs = p.toString(); return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); }, plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }), }), plexPlaylistAddBulk: ( id: number, itemRks: string[], ): Promise<{ added: number; item_count: number }> => req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }), }), plexPlaylistRemoveBulk: ( id: number, itemRks: string[], ): Promise<{ removed: number; item_count: number }> => req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }), }), plexRenamePlaylist: (id: number, title: string): Promise => req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => req(`/api/plex/playlists/${id}`, { method: "DELETE" }), plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }), }), plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }), }), // 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 => { 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 => 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 => 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 => req(`/api/plex/person/${encodeURIComponent(name)}`), plexSession: ( id: string, start = 0, audio?: number | null, aoff = 0, multi = false, ): Promise => { const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) }); if (audio != null) p.set("audio", String(audio)); if (aoff) p.set("aoff", String(aoff)); if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch) return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST", }); }, // WebVTT URL for a text subtitle track (used directly as a ). Same-origin → cookie-authed. // `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek. plexSubtitleUrl: (id: string, ord: number, offset = 0): string => `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`, // `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the // periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a // final checkpoint, so a single watch doesn't spray Plex with timeline updates. plexProgress: ( id: string, position_seconds: number, duration_seconds: number, final = false, ): Promise => req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { method: "POST", body: JSON.stringify({ position_seconds, duration_seconds, final }), }), // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we // replicate req()'s credentials + account header (no retry — there's no page left to retry on). plexProgressBeacon: ( id: string, position_seconds: number, duration_seconds: number, final = false, ): void => beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, { position_seconds, duration_seconds, final, }), // 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 => req(`/api/plex/item/${encodeURIComponent(id)}/state`, { method: "POST", body: JSON.stringify({ status }), }), // Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import). plexWatchLink: (): Promise => req("/api/plex/watch/link"), plexWatchSetLink: ( enabled: boolean, ): Promise => req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), plexWatchImport: (): Promise => req("/api/plex/watch/import", { method: "POST" }), plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, plexDownloadUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`, plexImageUrl: (id: string, variant?: "thumb" | "art"): string => `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, };