import { notify, remove } from "./notifications"; import i18n from "../i18n"; import { reportError } from "./errorDialog"; import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage"; export interface Me { id: number; email: string; display_name: string | null; avatar_url: string | null; role: string; is_demo: boolean; has_google: boolean; has_password: boolean; google_enabled: boolean; plex_enabled: boolean; can_read: boolean; can_write: boolean; pending_invites: number; preferences: Record; } export interface DemoWhitelistEntry { id: number; email: string; note: string | null; added_by: string | null; created_at: string | null; } export interface Invite { id: number; email: string; status: "pending" | "approved" | "denied"; note: string | null; requested_at: string | null; decided_at: string | null; decided_by: string | null; } export interface Tag { id: number; name: string; color: string | null; category: string; system: boolean; channel_count: number; } export interface Video { id: string; title: string | null; channel_id: string; channel_title: string | null; channel_thumbnail: string | null; channel_url: string; published_at: string | null; thumbnail_url: string | null; duration_seconds: number | null; view_count: number | null; is_short: boolean; live_status: string; status: string; position_seconds: number; saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; } export interface VideoDetail { id: string; description: string | null; like_count: number | null; in_db: boolean; channel_id: string | null; channel_title: string | null; published_at: string | null; view_count: number | null; duration_seconds: number | null; } export interface ChannelLink { title: string | null; url: string; } export interface ChannelDetail { id: string; title: string | null; handle: string | null; description: string | null; thumbnail_url: string | null; banner_url: string | null; subscriber_count: number | null; video_count: number | null; total_view_count: number | null; published_at: string | null; external_links: ChannelLink[]; country: string | null; subscribed: boolean; explored: boolean; blocked: boolean; stored_videos: number; from_explore: boolean; details_synced: boolean; } export interface ExploreResult { channel_id: string; ingested: number; next_page_token: string | null; } export interface FeedResponse { items: Video[]; has_more: boolean; // Opaque keyset cursor for the next page, or null when this is the last page. next_cursor: string | null; limit: number; // Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api" // (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode. source?: "scrape" | "api"; } export interface Playlist { id: number; name: string; kind: string; // "user" | "watch_later" source: string; // "local" | "youtube" yt_playlist_id: string | null; dirty: boolean; // linked local playlist has edits not yet pushed to YouTube item_count: number; total_duration_seconds: number; cover_thumbnail: string | null; has_video?: boolean; // only set when listed with ?contains= } export interface PlaylistDetail { id: number; name: string; kind: string; source: string; yt_playlist_id: string | null; dirty: boolean; items: Video[]; } export interface PushPlan { action: "create" | "update"; to_insert: number; to_delete: number; to_reorder: number; yt_extra: number; // items on YouTube that the push would remove (divergence) units_estimate: number; remaining_today: number; affordable: boolean; } export interface PushResult { created: number; inserted: number; deleted: number; reordered: number; failures: string[]; yt_playlist_id: string | null; } export interface FeedFilters { tags: number[]; tagMode: "or" | "and"; q: string; sort: string; // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the // feed re-queries with a fresh order. Ignored by every other sort. seed?: number; // "my" = only your subscriptions (default); "all" = the whole shared catalog. scope: "my" | "all"; includeNormal: boolean; includeShorts: boolean; includeLive: boolean; show: string; // Library (scope "all") only — provenance filter: "organic" (default) hides search- // discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my". librarySource?: "organic" | "all" | "search" | "plex"; channelId?: string; channelName?: string; maxAgeDays?: number; minDuration?: number; maxDuration?: number; dateFrom?: string; dateTo?: string; } // A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget). export interface SavedView { id: number; name: string; filters: FeedFilters; position: number; is_default: boolean; } export interface VersionInfo { app_version: string; git_sha: string; build_date: string | null; db_revision: string | null; } class HttpError extends Error { status: number; detail?: string; // server-provided reason (FastAPI's `detail`), when present constructor(status: number, detail?: string) { super(detail || `HTTP ${status}`); this.status = status; this.detail = detail; } } // A single, self-resolving "connection lost" status: while the server is unreachable we // show one sticky, transient (non-persisted) notice; the moment any request reaches the // server again we remove it. This makes good on its "clears once it's back" copy and keeps // bursts of concurrent failures from stacking up — there's only ever one live handle. let connectivityNotifId: number | null = null; function markConnectivityLost(): void { if (connectivityNotifId !== null) return; connectivityNotifId = notify({ level: "error", title: i18n.t("errors.offline.title"), message: i18n.t("errors.offline.body"), transient: true, }); } function markConnectivityRestored(): void { if (connectivityNotifId === null) return; remove(connectivityNotifId); connectivityNotifId = null; } // Gateway statuses that mean "the upstream connection failed", not "the app rejected the // request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the // request was processed. Safe to retry an idempotent call once on a fresh connection. const RETRIABLE_GATEWAY = new Set([502, 503, 504]); const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms)); // `idempotent`: may this request be replayed after a transient gateway/network failure? // GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which // are safe to repeat). Used to recover from the keepalive race without surfacing a 502. interface ReqConfig { idempotent?: boolean; // Suppress the global error modal for 4xx/5xx so the caller can show the error inline // (used by the auth forms — a wrong password shouldn't pop a blocking dialog). quiet?: boolean; } // Set by the app shell. Invoked whenever any request returns 401 so a session that ended // server-side (account suspended/deleted while the tab was open) can drop the user to the login // page. The handler itself guards against firing when we were never signed in, so it's safe to // call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null // user when logged out — but other protected endpoints still do.) let onUnauthorized: (() => void) | null = null; export function setUnauthorizedHandler(fn: (() => void) | null): void { onUnauthorized = fn; } // Headers for install-wizard calls: the one-time setup token plus the JSON content type (req // replaces — not merges — its default headers when opts.headers is given, so include both). const setupHeaders = (token: string) => ({ "Content-Type": "application/json", "X-Setup-Token": token, }); // --- Per-tab active account -------------------------------------------------------------- // The signed session cookie is the browser's account "wallet"; which account a given TAB acts // as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in // sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. // The storage key + reader live in storage.ts (so its per-account helpers can use them too). const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; export const getActiveAccount = activeAccountId; export function setActiveAccount(id: number): void { try { sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); } catch { /* ignore */ } } export function clearActiveAccount(): void { try { sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); } catch { /* ignore */ } } async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; let attempt = 0; for (;;) { let r: Response; try { const active = getActiveAccount(); r = await fetch(url, { credentials: "include", headers: { "Content-Type": "application/json", // Per-tab identity override (only setup calls pass their own headers, and those are // pre-auth, so this is safe to put in the defaults that `...opts` may replace). ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), }, ...opts, }); } catch (e) { // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError). if (canRetry && attempt === 0) { attempt++; await delay(250); continue; } markConnectivityLost(); throw e; } // A gateway error usually means the upstream connection was reset before our request // was processed — retry idempotent calls once on a fresh connection before surfacing it. if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) { attempt++; await delay(250); continue; } // The server answered (anything that isn't a gateway error means it's reachable) — // clear a standing "connection lost" status if one is showing. if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored(); if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; try { const body = await r.json(); if (body && typeof body.detail === "string") detail = body.detail; } catch { /* no JSON body */ } // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), // not a request the app refused — treat them like a network blip with the soft, // self-clearing toast rather than a blocking modal the user must acknowledge. // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. if (RETRIABLE_GATEWAY.has(r.status)) { markConnectivityLost(); } else if (r.status === 401) { // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the // app shell decide what to do (drop to the login page if we were signed in). Not a modal. onUnauthorized?.(); } else if (cfg.quiet) { /* caller handles the error inline — no global modal */ } else if (r.status >= 500) { reportError(detail || `${i18n.t("errors.server")} (${r.status})`); } else if (r.status === 400 || r.status === 409 || r.status === 422) { reportError(detail); } throw new HttpError(r.status, detail); } return r.status === 204 ? null : r.json(); } } // The filter half of the query (everything except paging/sort), shared by the feed and // the facet-count endpoint so both see exactly the same filter context. function filterParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); f.tags.forEach((t) => p.append("tags", String(t))); p.set("tag_mode", f.tagMode); if (f.q) p.set("q", f.q); p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive)); p.set("show", f.show); if (f.channelId) p.set("channel_id", f.channelId); if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays)); if (f.dateFrom) p.set("published_after", f.dateFrom); if (f.dateTo) p.set("published_before", f.dateTo); if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); // Only relevant in Library scope; the backend ignores it for "my". Default = hide search. p.set("library_source", f.librarySource ?? "organic"); return p; } function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string { const p = filterParams(f); p.set("sort", f.sort); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); if (cursor) p.set("cursor", cursor); p.set("limit", String(limit)); return p.toString(); } export interface SyncStatus { subscriptions: number; channels_total: number; channels_backfilling: number; videos_total: number; pending_enrich: number; quota_used_today: number; quota_remaining_today: number; paused: boolean; is_admin: boolean; } export interface MyStatus { channels_total: number; channels_details_synced: number; channels_recent_synced: number; channels_deep_done: number; channels_recent_pending: number; channels_deep_pending: number; channels_deep_requested: number; deep_pending_count: number; deep_eta_seconds: number; my_videos: number; total_videos: number; quota_used_today: number; quota_remaining_today: number; paused: boolean; sync_active: boolean; // a channel-sync job (backfill/rss) is running right now } export interface MyUsage { today: number; last_7d: number; last_30d: number; all_time: number; by_action: Record; } export interface AdminQuotaRow { user_id: number | null; email: string; total: number; by_action: Record; } export interface AdminQuota { range_days: number; rows: AdminQuotaRow[]; daily: { day: string; total: number }[]; } export interface ManagedChannel { id: string; title: string | null; handle: string | null; thumbnail_url: string | null; subscriber_count: number | null; video_count: number | null; stored_videos: number; last_video_at: string | null; total_duration_seconds: number; count_normal: number; count_short: number; count_live: number; priority: number; hidden: boolean; deep_requested: boolean; deep_in_queue: boolean; tag_ids: number[]; details_synced: boolean; recent_synced: boolean; backfill_done: boolean; } // A channel that shows up in the user's playlists but that they don't subscribe to — // surfaced in the Channel manager's Discovery tab so they can subscribe in one click. export interface DiscoveredChannel { id: string; title: string | null; handle: string | null; thumbnail_url: string | null; subscriber_count: number | null; video_count: number | null; playlist_video_count: number; // how many of the user's playlist videos are from here playlist_count: number; // across how many of their playlists details_synced: boolean; } export interface SchedulerJob { id: string; interval_minutes: number; next_run: string | null; running: boolean; status: "ok" | "error" | "skipped" | null; last_started: string | null; last_finished: string | null; last_result: string | null; last_error: string | null; // Live progress while running (null when idle or for jobs that don't report it). // total is null for indeterminate progress (e.g. enrichment drains an unknown queue). progress: { current: number; total: number | null; phase: string | null } | null; } export interface SchedulerStatus { running_here: boolean; enabled: boolean; paused: boolean; jobs: SchedulerJob[]; queue: { channels_recent_pending: number; channels_deep_pending: number; deep_eta_seconds: number; videos_pending_enrich: number; videos_pending_shorts: number; videos_live_refresh: number; }; quota: { used_today: number; remaining_today: number; daily_budget: number; backfill_reserve: number; }; maintenance: { revalidate_batch: number; revalidate_batch_default: number; min: number; max: number; }; } export interface Account { id: number; email: string; display_name: string | null; avatar_url: string | null; active: boolean; } // A durable, server-backed notification (the inbox center). Distinct from the client-side // transient toast/bell, which lives only in localStorage. export interface AppNotification { id: number; type: string; title: string; body: string | null; data: Record | null; read: boolean; dismissed: boolean; created_at: string | null; } export interface MessageUser { id: number; name: string; avatar_url: string | null; } export interface Message { id: number; kind: "user" | "system"; sender_id: number | null; recipient_id: number; body: string | null; // plaintext, system messages only ciphertext: string | null; // base64, E2EE user messages only iv: string | null; read_at: string | null; created_at: string | null; } export interface Conversation { partner: MessageUser; last_message: Message; unread: number; } // My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock. export interface MyKeyBundle { configured: boolean; public_key?: string; wrapped_private_key?: string; salt?: string; wrap_iv?: string; key_check?: string; } export interface KeySetup { public_key: string; wrapped_private_key: string; salt: string; wrap_iv: string; key_check: string; } // Admin Configuration page: one DB-overridable setting (registry entry on the backend). export interface ConfigItem { key: string; type: "int" | "str" | "bool"; group: string; secret: boolean; min: number | null; max: number | null; is_set: boolean; // a DB override row exists (else using the env/config default) value?: string | number | boolean; // non-secret: effective value default?: string | number | boolean; // non-secret: env/config default default_is_set?: boolean; // secret: whether an env default exists } export interface SystemConfigData { groups: Record; secrets_manageable: boolean; } // Admin: result of testing the Plex server connection (also drives the library-picker). export interface PlexSection { key: string; title: string; type: string; // "movie" | "show" count?: number; } export interface PlexTestResult { ok: boolean; server_name: string; version?: string; sections: PlexSection[]; // Whether a sample media file resolved + was readable through the local mount (bind-mount + // path-map check). checked=false when the library is empty (nothing to probe yet). media_check?: { checked: boolean; ok?: boolean; plex_path?: string; local_path?: string; }; } // Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards). export interface PlexLibrary { key: string; title: string; kind: "movie" | "show"; count: number; } export interface PlexCard { id: string; type: "movie" | "show" | "episode"; title: string; year?: number | null; duration_seconds?: number | null; thumb: string; playable?: string; // direct | remux | transcode status?: string; // new | watched | hidden position_seconds?: number; season_count?: number | null; // show season_number?: number | null; // episode episode_number?: number | null; // episode show_title?: string | null; // episode (in a mixed playlist) show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) summary?: string | null; // episode } export interface PlexBrowseResult { kind: "movie" | "show"; total: number; offset: number; limit: number; items: PlexCard[]; } // Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that // also matches episodes (the grouped "Episodes" section). export interface PlexUnifiedResult { scope: string; // movie | show | both total: number; offset: number; limit: number; items: PlexCard[]; episodes: PlexCard[]; } export interface PlexSeasonDetail { id: string; // season rating_key season_number: number | null; title: string; thumb: string; episode_count: number; status: string; // aggregate: new | in_progress | watched resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched) first?: PlexCard | null; // first episode (Play from the start) episodes: PlexCard[]; } export interface PlexShowDetail { show: { id: string; title: string; summary?: string | null; year?: number | null; thumb: string; art: string; library?: string | null; // Plex section key (for the admin collection editor) content_rating?: string | null; rating?: number | null; genres: string[]; studio?: string | null; season_count?: number | null; imdb_rating?: number | null; imdb_id?: string | null; imdb_url?: string | null; cast: PlexCastMember[]; status: string; // aggregate across all episodes resume?: PlexCard | null; first?: PlexCard | null; episode_count: number; collection_keys: string[]; }; seasons: PlexSeasonDetail[]; related: PlexCard[]; } export interface PlexMarker { type: "intro" | "credits"; start_s: number; end_s: number; } export interface PlexItemDetail { id: string; kind: "movie" | "episode"; title: string; summary?: string | null; year?: number | null; duration_seconds: number | null; playable: string; // direct | remux | transcode thumb: string; art: string; library?: string | null; // Plex section key (for the admin collection editor) cast: PlexCastMember[]; imdb_rating?: number | null; imdb_id?: string | null; imdb_url?: string | null; content_rating?: string | null; genres: string[]; directors: string[]; studio?: string | null; tagline?: string | null; markers: PlexMarker[]; audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; // `text`=true → offered as a WebVTT ; image subs (PGS/VobSub) are text=false (hidden). subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[]; status: string; position_seconds: number; show_title?: string | null; season_number?: number | null; episode_number?: number | null; prev_id?: string | null; next_id?: string | null; // `source` buckets the strip so each type can be shown/hidden independently on the info page: // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart". collections?: { id: string; title: string; source: string; items: PlexCard[] }[]; } export interface PlexCollection { id: string; title: string; summary?: string | null; thumb?: string | null; child_count?: number | null; smart: boolean; source?: string; editable: boolean; can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection } export interface PlexPlaylist { id: number; title: string; item_count: number; thumb?: string | null; has_item?: boolean; // only when the list was fetched with ?contains= group_in?: number; // only when fetched with ?contains_group=: how many of the group it holds } export interface PlexPlaylistDetail { id: number; title: string; items: PlexCard[]; } export interface PlexCastMember { name: string; role?: string | null; thumb?: string | null; // proxied person-image url, or null when Plex has no photo } // 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[]; // AND — titles featuring ALL selected studios: string[]; // OR — from any selected studio collection?: string | null; // a single Plex collection (rating_key) collectionTitle?: string | null; // its title, for the active-filter chip sortDir?: "asc" | "desc"; // sort direction (default desc) } export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], contentRatings: [], directors: [], actors: [], studios: [], }; export function plexFilterCount(f: PlexFilters): number { return ( f.genres.length + f.contentRatings.length + (f.yearMin != null || f.yearMax != null ? 1 : 0) + (f.ratingMin != null ? 1 : 0) + (f.durationMin != null || f.durationMax != null ? 1 : 0) + (f.addedWithin ? 1 : 0) + f.directors.length + f.actors.length + f.studios.length + (f.collection ? 1 : 0) ); } // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid // and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are // each caller's own concern and stay out of here). export 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.studios?.length) u.set("studios", f.studios.join(",")); if (f.collection) u.set("collection", f.collection); } export interface PlexFacets { genres: { value: string; count: number }[]; content_ratings: { value: string; count: number }[]; year_min: number | null; year_max: number | null; rating_max: number | null; duration_min: number | null; duration_max: number | null; } export interface PlexPlaySession { mode: "direct" | "hls"; url: string; start_s: number; } // Two-way Plex watch-state sync link status (owner account; Phase A). export interface PlexWatchLink { linked: boolean; sync_enabled: boolean; uses_admin: boolean; initial_import_done: boolean; last_watch_sync_at: string | null; } export interface PlexWatchImport { watched: number; in_progress: number; scanned: number; } // Admin Users & roles page. export interface AdminUserRow { id: number; email: string; display_name: string | null; role: string; // "user" | "admin" is_active: boolean; is_suspended: boolean; email_verified: boolean; is_demo: boolean; has_password: boolean; has_google: boolean; created_at: string | null; } // --- download center --- export interface DownloadSpec { mode: "av" | "v" | "a"; // audio+video | video-only | audio-only max_height: number | null; container: string | null; audio_format: string | null; vcodec: string | null; embed_subs: boolean; embed_chapters: boolean; embed_thumbnail: boolean; sponsorblock: boolean; } export interface DownloadProfile { id: number; name: string; spec: DownloadSpec; is_builtin: boolean; sort_order: number; } export interface DownloadAsset { id: number; status: string; title: string | null; uploader: string | null; uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit) upload_date: string | null; duration_s: number | null; size_bytes: number | null; width: number | null; height: number | null; container: string | null; thumbnail_url: string | null; expires_at: string | null; } export type DownloadStatus = | "queued" | "running" | "paused" | "done" | "error" | "canceled"; // Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into // one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes). export interface EditSpec { trim?: { start_s: number; end_s?: number }; segments?: { start_s: number; end_s: number }[]; crop?: { x: number; y: number; w: number; h: number }; accurate?: boolean; } export interface StoryboardMeta { available: boolean; cols?: number; rows?: number; count?: number; interval_s?: number; url?: string; } export interface DownloadJob { id: number; status: DownloadStatus; progress: number; speed_bps: number | null; eta_s: number | null; phase: string | null; error: string | null; queue_pos: number; created_at: string | null; source_kind: string; source_ref: string; source_url: string | null; // clean "downloaded from" URL (null for edit clips) poster_url: string | null; // locally-generated poster, fallback when the source had no thumbnail profile_id: number | null; spec: DownloadSpec; display_name: string | null; display_uploader: string | null; // user override of the channel name display_uploader_url: string | null; // user override of the channel link extra_links: string[]; // extra reference URLs (beyond source_url) job_kind?: string; // "download" (default) | "edit" source_job_id?: number | null; // parent download an edit was cut from edit_spec?: EditSpec | null; can_download: boolean; expired: boolean; asset: DownloadAsset | null; shared?: boolean; user_email?: string; } export interface ShareRecipient { id: number; email: string; display_name: string | null; } export interface ShareLink { id: number; token: string; url: string; // path like "/watch/" — prepend window.location.origin to share allow_download: boolean; has_password: boolean; expires_at: string | null; view_count: number; created_at: string | null; } export interface DownloadUsage { footprint_bytes: number; active_jobs: number; running_jobs: number; max_bytes: number; max_concurrent: number; max_jobs: number; unlimited: boolean; } export interface AdminStorage { ready_files: number; total_bytes: number; total_assets: number; total_cap_bytes: number; per_user: { user_id: number; email: string | null; footprint_bytes: number }[]; } export interface AdminDownloadQuota { user_id: number; custom: boolean; max_bytes: number; max_concurrent: number; max_jobs: number; unlimited: boolean; } export const api = { // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never // logs a failed request to the console). React Query treats the null as data, not an error. me: async (): Promise => { const r = await req("/api/me"); return r && r.authenticated ? (r as Me) : null; }, accounts: (): Promise => req("/api/me/accounts"), deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }), switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), // Signs the current tab's active account out of the browser wallet (per-tab aware via the // X-Siftlode-Account header that req() attaches). logout: (): Promise<{ ok: boolean; switched: boolean }> => req("/auth/logout", { method: "POST" }), version: (): Promise => req("/api/version"), tags: (): Promise => req("/api/tags"), status: (): Promise => req("/api/sync/status"), feed: (f: FeedFilters, cursor: string | null, limit: number): Promise => req(`/api/feed?${feedQuery(f, cursor, limit)}`), feedCount: (f: FeedFilters): Promise<{ count: number }> => req(`/api/feed/count?${filterParams(f).toString()}`), // Live YouTube search: results are materialised into the catalog and returned as feed cards. // `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of // popping the global modal. `cursor` is the next-page token (an InnerTube continuation token // in scrape mode, or a Data API pageToken in api mode); the response's `source` says which. searchYoutube: (q: string, cursor: string | null, limit?: number): Promise => { const p = new URLSearchParams({ q }); if (cursor) p.set("cursor", cursor); if (limit) p.set("limit", String(limit)); return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true }); }, // Discard a set of live-search results "as if never added" (drops your search-finds + deletes // the now-orphaned, un-kept videos). Returns how many videos were removed. clearSearch: (videoIds: string[]): Promise<{ removed: number }> => req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }), facets: (f: FeedFilters): Promise<{ counts: Record }> => req(`/api/facets?${filterParams(f).toString()}`), // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // recover from a transient gateway/keepalive 502 instead of surfacing it (see req()). setState: (id: string, status: string) => req( `/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }, { idempotent: true } ), clearState: (id: string) => req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }), saveProgress: (id: string, positionSeconds: number, durationSeconds: number) => req( `/api/videos/${id}/progress`, { method: "POST", body: JSON.stringify({ position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }), }, { idempotent: true } ), videoDetail: (id: string): Promise => req(`/api/videos/${id}`), pauseSync: () => req("/api/sync/pause", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }), // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. savePrefs: (p: Record) => req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), // --- channel manager --- myStatus: (): Promise => req("/api/sync/my-status"), channels: (): Promise => req("/api/channels"), updateChannel: ( id: string, patch: { priority?: number; hidden?: boolean; deep_requested?: boolean } ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), resetChannelBackfill: (id: string) => req(`/api/channels/${id}/reset-backfill`, { method: "POST" }), deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), attachChannelTag: (id: string, tagId: number) => req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), detachChannelTag: (id: string, tagId: number) => req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }), unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }), discoveredChannels: (): Promise => req("/api/channels/discovery"), subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), // --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- channelDetail: (id: string): Promise => req(`/api/channels/${id}`), // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first, // marked via_explore) + record the exploration. `pageToken` continues into older uploads // ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page. exploreChannel: (id: string, pageToken?: string | null): Promise => { const p = new URLSearchParams(); if (pageToken) p.set("page_token", pageToken); const qs = p.toString(); return req( `/api/channels/${id}/explore${qs ? `?${qs}` : ""}`, { method: "POST" }, { quiet: true } ); }, // --- per-user channel blocklist (excluded from live search + feed/explore) --- blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> => req("/api/channels/blocked/list"), blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }), unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }), // Admin: reclaim all un-kept discovery content (search results + explored channels) now. purgeDiscovery: (): Promise> => req("/api/admin/purge-discovery", { method: "POST" }), // --- playlists --- playlists: (containsVideoId?: string): Promise => req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`), playlist: (id: number): Promise => req(`/api/playlists/${id}`), createPlaylist: (name: string): Promise => req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }), renamePlaylist: (id: number, name: string): Promise => req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), deletePlaylist: (id: number, onYoutube = false) => req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }), addToPlaylist: (id: number, videoId: string) => req(`/api/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ video_id: videoId }), }), removeFromPlaylist: (id: number, videoId: string) => req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }), reorderPlaylist: (id: number, videoIds: string[]) => req(`/api/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ video_ids: videoIds }), }), // Bookmark shortcut for the built-in Watch later playlist. watchLaterAdd: (videoId: string) => req("/api/playlists/watch-later", { method: "POST", body: JSON.stringify({ video_id: videoId }), }), watchLaterRemove: (videoId: string) => req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => req("/api/playlists/sync-youtube", { method: "POST" }), revertPlaylist: (id: number): Promise<{ items: number }> => req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }), playlistPushPlan: (id: number): Promise => req(`/api/playlists/${id}/push-plan`), pushPlaylist: (id: number): Promise => req(`/api/playlists/${id}/push`, { method: "POST" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), // --- admin: system configuration --- adminConfig: (): Promise => req("/api/admin/config"), setConfig: (key: string, value: string | number | boolean): Promise => req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }), resetConfig: (key: string): Promise => req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), syncPlex: (): Promise> => req("/api/plex/sync", { method: "POST" }), plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => req("/api/plex/libraries"), plexLibrary: (p: { scope: string; // movie | show | both q?: string; sort?: string; show?: string; offset?: number; limit?: number; filters?: PlexFilters; }): Promise => { const u = new URLSearchParams({ scope: p.scope }); if (p.q) u.set("q", p.q); if (p.sort) u.set("sort", p.sort); if (p.show && p.show !== "all") u.set("show", p.show); if (p.offset) u.set("offset", String(p.offset)); if (p.limit) u.set("limit", String(p.limit)); const f = p.filters; if (f) { appendPlexFilters(u, f); u.set("sort_dir", f.sortDir ?? "asc"); // default ascending } return req(`/api/plex/library?${u.toString()}`); }, // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group // narrows the others and the bounds match the visible result set. plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => { const u = new URLSearchParams({ scope }); if (show && show !== "all") u.set("show", show); if (f) appendPlexFilters(u, f); return req(`/api/plex/facets?${u.toString()}`); }, // With a library plex_key → that library's collections (editor). Without one → the union across all // enabled libraries (the unified-scope sidebar picker). plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => { const u = new URLSearchParams(); if (library) u.set("library", library); if (q) u.set("q", q); const qs = u.toString(); return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); }, // --- Collection editing (admin only; writes back to Plex) --- plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise => req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }), plexCollectionAddItem: (rk: string, itemRk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }), plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), plexRenameCollection: (rk: string, title: string): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }) }), plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), plexSetCollectionEditable: (rk: string, editable: boolean): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }), // --- Playlists (Siftlode-native, per-user, ordered) --- // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog). plexPlaylists: ( contains?: string, containsGroup?: string[], ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => { const p = new URLSearchParams(); if (contains) p.set("contains", contains); if (containsGroup) p.set("contains_group", containsGroup.join(",")); const qs = p.toString(); return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); }, plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }), plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> => req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }), plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> => req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }), plexRenamePlaylist: (id: number, title: string): Promise => req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => req(`/api/plex/playlists/${id}`, { method: "DELETE" }), plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }), plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> => req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }), plexShow: (id: string): Promise => req(`/api/plex/show/${encodeURIComponent(id)}`), // Mark a whole show / season watched or unwatched (all its episodes) for the current user. plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }), plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }), plexItem: (id: string): Promise => req(`/api/plex/item/${encodeURIComponent(id)}`), 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 => { const active = getActiveAccount(); try { void fetch(`/api/plex/item/${encodeURIComponent(id)}/progress`, { method: "POST", keepalive: true, credentials: "include", headers: { "Content-Type": "application/json", ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), }, body: JSON.stringify({ position_seconds, duration_seconds, final }), }).catch(() => {}); } catch { /* ignore */ } }, 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" : ""}`, // --- admin: users & roles --- adminUsers: (): Promise => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), setUserSuspended: (id: number, suspended: boolean): Promise => req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }), adminDeleteUser: (id: number): Promise<{ deleted: number }> => req(`/api/admin/users/${id}`, { method: "DELETE" }), schedulerStatus: (): Promise => req("/api/admin/scheduler"), updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => req(`/api/admin/scheduler/jobs/${jobId}`, { method: "PATCH", body: JSON.stringify({ interval_minutes: intervalMinutes }), }), runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> => req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }), runAllSchedulerJobs: (): Promise<{ started: string[] }> => req("/api/admin/scheduler/run-all", { method: "POST" }), updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> => req("/api/admin/scheduler/maintenance", { method: "PATCH", body: JSON.stringify({ revalidate_batch: revalidateBatch }), }), // Public: which sign-in options this instance offers (e.g. hide Google when not configured). authConfig: (): Promise<{ google_enabled: boolean; allow_registration: boolean; operator_contact: string | null; }> => req("/auth/config"), // --- first-run install wizard (token from the setup URL printed to the container logs) --- setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"), setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> => req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }), setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> => req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }), setupConfig: (token: string, values: Record): Promise<{ saved: string[] }> => req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }), setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> => req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }), setupFinish: (token: string): Promise<{ ok: boolean }> => req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }), // --- auth: email + password (errors handled inline via quiet) --- register: (email: string, password: string): Promise<{ status: string }> => req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }), passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> => req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }), requestPasswordReset: (email: string): Promise<{ status: string }> => req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), // Set or change the signed-in account's password (errors shown inline via quiet). setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> => req( "/auth/set-password", { method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) }, { quiet: true } ), // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), // Hidden demo entry: a whitelisted email logs into the shared demo account, no Google // sign-in. Returns whether a session was established. demoLogin: (email: string): Promise<{ authenticated: boolean }> => req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }), adminDemoWhitelist: (): Promise => req("/api/admin/demo/whitelist"), addDemoWhitelist: (email: string): Promise => req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }), removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }), resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> => req("/api/admin/demo/reset", { method: "POST" }), adminInvites: (status?: string): Promise => req(`/api/admin/invites${status ? `?status=${status}` : ""}`), approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }), denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }), addInvite: (email: string) => req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }), // --- notification inbox (durable, server-backed) --- notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> => req(`/api/me/notifications?include_dismissed=${includeDismissed}`), notificationUnreadCount: (): Promise<{ count: number }> => req("/api/me/notifications/unread_count"), markNotificationRead: (id: number) => req(`/api/me/notifications/${id}/read`, { method: "POST" }), markAllNotificationsRead: () => req("/api/me/notifications/read_all", { method: "POST" }), dismissNotification: (id: number) => req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }), clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }), // --- saved smart views (named FeedFilters snapshots) --- savedViews: (): Promise => req("/api/saved-views"), createView: (name: string, filters: FeedFilters): Promise => req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }), updateView: ( id: number, patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>, ): Promise => req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteView: (id: number): Promise<{ deleted: number }> => req(`/api/saved-views/${id}`, { method: "DELETE" }), reorderViews: (ids: number[]): Promise<{ ok: boolean }> => req("/api/saved-views/reorder", { method: "POST", body: JSON.stringify({ ids }) }), // --- direct messages (end-to-end encrypted) --- messageMyKey: (): Promise => req("/api/messages/keys/me"), setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> => req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }), messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> => req(`/api/messages/keys/${userId}`), messageUsers: (): Promise => req("/api/messages/users"), conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"), thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> => req(`/api/messages/conversations/${partnerId}`), sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise => req("/api/messages", { method: "POST", body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }), }), messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"), // --- user tags --- createTag: (t: { name: string; color?: string; category?: string }) => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), updateTag: (id: number, patch: { name?: string; color?: string }) => req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }), // --- download center --- downloadProfiles: (): Promise => req("/api/downloads/profiles"), createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise => req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }), updateDownloadProfile: ( id: number, patch: { name?: string; spec?: DownloadSpec } ): Promise => req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteDownloadProfile: (id: number) => req(`/api/downloads/profiles/${id}`, { method: "DELETE" }), enqueueDownload: (body: { source: string; profile_id?: number; spec?: DownloadSpec; display_name?: string; }): Promise => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }), enqueueEdit: (body: { source_job_id: number; edit_spec: EditSpec; display_name?: string; }): Promise => req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), downloadStoryboard: (id: number): Promise => req(`/api/downloads/${id}/storyboard`), downloads: (): Promise => req("/api/downloads"), downloadsShared: (): Promise => req("/api/downloads/shared"), // Remove a shared-with-me item from your list (deletes only your share grant, not the file). removeSharedDownload: (jobId: number): Promise<{ removed: number }> => req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }), downloadUsage: (): Promise => req("/api/downloads/usage"), downloadIndex: (): Promise> => req("/api/downloads/index"), pauseDownload: (id: number): Promise => req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), resumeDownload: (id: number): Promise => req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }), cancelDownload: (id: number): Promise => req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }), deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }), // Update a download's display metadata (title / channel name+link / extra reference URLs). updateDownloadMeta: ( id: number, meta: { display_name?: string; display_uploader?: string; display_uploader_url?: string; extra_links?: string[]; } ): Promise => req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }), shareDownload: (id: number, email: string): Promise<{ shared_with: string }> => req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), unshareDownload: (id: number, email: string) => req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }), // Registered users this user can share with (for the internal picker). shareRecipients: (): Promise => req("/api/downloads/recipients"), // Public watch links (share by link). downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), createDownloadLink: ( jobId: number, body: { allow_download?: boolean; expires_days?: number | null; password?: string } ): Promise => req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }), updateDownloadLink: ( linkId: number, patch: { allow_download?: boolean; expires_days?: number | null; password?: string } ): Promise => req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }), revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> => req(`/api/downloads/links/${linkId}`, { method: "DELETE" }), // A plain navigation can't send the X-Siftlode-Account header, so pass the active // account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the // download resolves to the session-default account and 404s for a per-tab account's file. downloadFileUrl: (id: number): string => { const a = getActiveAccount(); return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`; }, // Filmstrip sprite (an /background src → direct nav, so it needs the ?account= wallet gate). storyboardImageUrl: (id: number): string => { const a = getActiveAccount(); return a != null ? `/api/downloads/${id}/storyboard.jpg?account=${a}` : `/api/downloads/${id}/storyboard.jpg`; }, // admin adminDownloads: (): Promise => req("/api/admin/downloads"), adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"), adminGetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`), adminSetDownloadQuota: ( userId: number, patch: Partial> ): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }), adminResetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), }; export { HttpError };