import { notify, remove } from "./notifications"; import i18n from "../i18n"; import { reportError } from "./errorDialog"; export interface Me { id: number; email: string; display_name: string | null; avatar_url: string | null; role: string; is_demo: 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 FeedResponse { items: Video[]; has_more: boolean; offset: number; limit: number; } 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; channelId?: string; channelName?: string; maxAgeDays?: number; minDuration?: number; maxDuration?: number; dateFrom?: string; dateTo?: string; } 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; } 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 { r = await fetch(url, { credentials: "include", headers: { "Content-Type": "application/json" }, ...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 >= 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)); return p; } function feedQuery(f: FeedFilters, offset: number, limit: number): string { const p = filterParams(f); p.set("sort", f.sort); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("offset", String(offset)); 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; } 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; } 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 const api = { me: (): Promise => req("/api/me"), accounts: (): Promise => req("/api/me/accounts"), switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), version: (): Promise => req("/api/version"), tags: (): Promise => req("/api/tags"), status: (): Promise => req("/api/sync/status"), feed: (f: FeedFilters, offset: number, limit: number): Promise => req(`/api/feed?${feedQuery(f, offset, limit)}`), feedCount: (f: FeedFilters): Promise<{ count: number }> => req(`/api/feed/count?${feedQuery(f, 0, 0)}`), 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" }), savePrefs: (p: Record) => req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }), // --- 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" }), syncSubscriptions: () => req("/api/sync/subscriptions", { 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}`), 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 }), }), // --- 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" }), // --- 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" }), }; export { HttpError };