import { req, beacon, setupHeaders, getActiveAccount, HttpError, setActiveAccount, clearActiveAccount, setUnauthorizedHandler, } from "./api/core"; // Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. export { HttpError, getActiveAccount, setActiveAccount, clearActiveAccount, setUnauthorizedHandler, }; // Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing // them from "./api" unchanged; their method slices stay internal (`export type *`, not `export *`) // and reach consumers only through the `api` façade they're spread into below. Runtime value // exports a domain genuinely owns (e.g. plex's EMPTY_PLEX_FILTERS/plexFilterCount) are re-exported // explicitly. import { downloadsApi } from "./api/downloads"; export type * from "./api/downloads"; import { plexApi } from "./api/plex"; export type * from "./api/plex"; export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; 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; } // Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and // "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position), // so they never appear on the wire. export type VideoStatus = "new" | "watched" | "hidden"; 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: VideoStatus; 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; } 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; default_language: string | null; topic_categories: string[]; keywords: 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; // 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"; // NOTE: /api/feed also returns an echoed `limit`, but /api/search/youtube does not and nothing // in the app reads it, so it's deliberately omitted rather than typed required (a lie for search) // or optional (dead surface). Re-add if a consumer ever needs it. } export type PlaylistKind = "user" | "watch_later"; export type PlaylistSource = "local" | "youtube"; export interface Playlist { id: number; name: string; kind: PlaylistKind; source: PlaylistSource; 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: PlaylistKind; source: PlaylistSource; 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"; /** OR-ed together: a video from ANY of these passes, and every other filter still applies. */ channelIds: 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; } // 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); f.channelIds.forEach((id) => p.append("channel_ids", id)); 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; description: string | 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; description: string | 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; } // 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) allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting 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 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; } export interface AuditRow { id: number; created_at: string | null; actor: string | null; // email of the acting admin; null = System (scheduler/background) action: string; // canonical key, localized on the client via auditActions. target_type: string | null; target_id: string | null; summary: string | null; before: Record | null; after: Record | null; } export interface AuditListResult { items: AuditRow[]; total: number; // total rows in the log (may exceed `items` if capped) returned: number; } 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 => { // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. const r = await req<{ authenticated: boolean } & Partial>("/api/me"); return 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 }, ), // Fire-and-forget YT progress save that survives page unload (F5/close/navigate). saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void => beacon(`/api/videos/${encodeURIComponent(id)}/progress`, { position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }), 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): Promise<{ updated: number; deep_requested: boolean }> => 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: (): Promise<{ subscriptions: number; channels_new: number; channels_detailed: number; removed_stale: number; prune_skipped: boolean; quota_used_estimate: number; quota_remaining_today: number; }> => 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" }), ...plexApi, // --- admin: users & roles --- adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), 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 }, ), // Confirm an email-verification token the SPA read from the URL fragment (SB3). verifyEmail: (token: string): Promise<{ ok: boolean }> => req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }), // Re-issue a verification link (the first one expired, or was lost). Uniform response — never // reveals whether the email has a pending account. resendVerification: (email: string): Promise<{ status: string }> => req( "/auth/verify/resend", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }, ), // SA4: sign every OTHER session for this account out (keeps the current one). logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }), // 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 }) }), ...messagingApi, // --- 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" }), ...downloadsApi, };