import { notify } from "./notifications"; 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; } } // Collapse bursts of connection failures (e.g. while the server restarts) into a // single notification rather than one per failed request. let lastErrorNotifiedAt = 0; function notifyErrorThrottled(title: string, message: string): void { const now = Date.now(); if (now - lastErrorNotifiedAt < 30_000) return; lastErrorNotifiedAt = now; notify({ level: "error", title, message }); } async function req(url: string, opts: RequestInit = {}): Promise { const method = opts.method ?? "GET"; let r: Response; try { r = await fetch(url, { credentials: "include", headers: { "Content-Type": "application/json" }, ...opts, }); } catch (e) { notifyErrorThrottled( "Connection lost", "Couldn't reach the server — it may be restarting. This will clear once it's back." ); throw e; } 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 */ } // Server faults are worth surfacing; 401/403/404 etc. are handled by callers. if (r.status >= 500) { notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`); } 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; 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 Account { id: number; email: string; display_name: string | null; avatar_url: string | null; active: boolean; } 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()}`), setState: (id: string, status: string) => req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), 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), }), }), 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) }), 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}`), // --- 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 }) }), // --- 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 };