diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9e5cc11..3293216 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,12 @@ +// The API façade. This file owns NO endpoint logic and NO types of its own — every domain lives in +// its own api/.ts module (types + method slice together). This barrel only: +// 1. re-exports the shared HTTP machinery from api/core, +// 2. re-exports each domain's TYPES (`export type *`) so consumers import them from "./api" +// unchanged, +// 3. spreads each domain's method slice into the single `api` object consumers call. +// A new endpoint therefore has nowhere to land except a domain module — the interleave that +// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.) import { - req, - beacon, - setupHeaders, getActiveAccount, HttpError, setActiveAccount, @@ -18,810 +23,52 @@ export { 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 { accountApi } from "./api/account"; +export type * from "./api/account"; +import { authApi } from "./api/auth"; +export type * from "./api/auth"; +import { setupApi } from "./api/setup"; +export type * from "./api/setup"; +import { feedApi } from "./api/feed"; +export type * from "./api/feed"; +import { channelsApi } from "./api/channels"; +export type * from "./api/channels"; +import { syncApi } from "./api/sync"; +export type * from "./api/sync"; +import { playlistsApi } from "./api/playlists"; +export type * from "./api/playlists"; +import { tagsApi } from "./api/tags"; +export type * from "./api/tags"; +import { notificationsApi } from "./api/notifications"; +export type * from "./api/notifications"; +import { savedViewsApi } from "./api/savedViews"; +export type * from "./api/savedViews"; +import { quotaApi } from "./api/quota"; +export type * from "./api/quota"; +import { adminApi } from "./api/admin"; +export type * from "./api/admin"; 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; - deep_requested: boolean; - backfill_done: boolean; - 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. -} - -// Not exported: consumers compare `playlist.kind`/`.source` against string literals, they never -// name these types — keeping them local satisfies the no-unused-exports gate (knip). -type PlaylistKind = "user" | "watch_later"; -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; -} +import { downloadsApi } from "./api/downloads"; +export type * from "./api/downloads"; 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" }), + ...accountApi, + ...authApi, + ...setupApi, + ...feedApi, + ...channelsApi, + ...syncApi, + ...playlistsApi, + ...tagsApi, + ...notificationsApi, + ...savedViewsApi, + ...quotaApi, + ...adminApi, ...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, }; diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts new file mode 100644 index 0000000..7dda353 --- /dev/null +++ b/frontend/src/lib/api/account.ts @@ -0,0 +1,58 @@ +// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out, +// app version, and preference save. Only touches core's req. +import { req } from "./core"; + +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 Account { + id: number; + email: string; + display_name: string | null; + avatar_url: string | null; + active: boolean; +} + +export interface VersionInfo { + app_version: string; + git_sha: string; + build_date: string | null; + db_revision: string | null; +} + +export const accountApi = { + // 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"), + // 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 }), +}; diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts new file mode 100644 index 0000000..6258390 --- /dev/null +++ b/frontend/src/lib/api/admin.ts @@ -0,0 +1,172 @@ +// The admin surface: discovery purge, system configuration, users & roles, the audit log, the +// scheduler + maintenance controls, the demo-account whitelist, and access invites. Only touches +// core's req. (Grouped by sub-area below; kept in one module as the cohesive "admin" domain.) +import { req } from "./core"; + +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; +} + +// 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 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 const adminApi = { + // --- discovery / system configuration --- + // Admin: reclaim all un-kept discovery content (search results + explored channels) now. + purgeDiscovery: (): Promise> => + req("/api/admin/purge-discovery", { method: "POST" }), + 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" }), + // --- 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" }), + // --- scheduler + maintenance --- + 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 }), + }), + // --- demo whitelist --- + 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" }), + // --- access invites --- + 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 }) }), +}; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..5567837 --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,67 @@ +// The sign-in funnel: public auth config, email+password register/login/reset, email verification, +// session management, access requests, and the hidden demo login. Errors on the interactive paths +// are surfaced inline (quiet) rather than via the global modal. Only touches core's req. +import { req } from "./core"; + +export const authApi = { + // 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"), + + // --- 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: access requests + demo entry --- + 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 }) }), +}; diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts new file mode 100644 index 0000000..0e49fe8 --- /dev/null +++ b/frontend/src/lib/api/channels.ts @@ -0,0 +1,138 @@ +// Channel manager (subscribed + discovery), the channel About/detail page, ephemeral exploration +// of un-subscribed channels, and the per-user channel blocklist. Only touches core's req. +import { req } from "./core"; + +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; + deep_requested: boolean; + backfill_done: boolean; + from_explore: boolean; + details_synced: boolean; +} + +export interface ExploreResult { + channel_id: string; + ingested: number; + next_page_token: string | null; +} + +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 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 const channelsApi = { + 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" }), + // --- 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" }), +}; diff --git a/frontend/src/lib/api/facade.test.ts b/frontend/src/lib/api/facade.test.ts new file mode 100644 index 0000000..4ac8b5e --- /dev/null +++ b/frontend/src/lib/api/facade.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +// The R7.5 guard: lib/api.ts is a spread-only façade over per-domain slice modules. These tests +// mechanise the two invariants that make that structure safe — so a future edit that reintroduces +// the interleave (a method dropped into two slices → a silent last-wins spread override) or bloats +// the barrel (a method defined inline instead of in a domain module) fails CI instead of shipping. +// +// Scope, on purpose: this covers RUNTIME method-key collisions — the only *silent* failure mode, +// since tsc does not flag overlapping spread keys. It deliberately does NOT cover exported-TYPE-name +// clashes across slices (two slices exporting the same interface name): types are erased at runtime +// so they're invisible here, and that case is already build-caught — the barrel's `export type *` +// makes the ambiguous name a downstream tsc "no exported member" error, so nothing ships. Left to +// tsc rather than re-checked by a fragile source parse. + +type Slice = Record; + +// Names line up with the modules below; kept as a list so the wiring stays a single source. +const SLICE_MODULES = [ + ["accountApi", () => import("./account")], + ["authApi", () => import("./auth")], + ["setupApi", () => import("./setup")], + ["feedApi", () => import("./feed")], + ["channelsApi", () => import("./channels")], + ["syncApi", () => import("./sync")], + ["playlistsApi", () => import("./playlists")], + ["tagsApi", () => import("./tags")], + ["notificationsApi", () => import("./notifications")], + ["savedViewsApi", () => import("./savedViews")], + ["quotaApi", () => import("./quota")], + ["adminApi", () => import("./admin")], + ["plexApi", () => import("./plex")], + ["messagingApi", () => import("./messaging")], + ["downloadsApi", () => import("./downloads")], +] as const; + +let api: Slice; +const slices: Record = {}; + +beforeAll(async () => { + // The runtime api graph pulls i18n at module-load, which reads localStorage/navigator. Under + // vitest's node env (no jsdom — see vitest.config.ts) stub the minimum so the import succeeds. + if (typeof globalThis.localStorage === "undefined") { + const store = new Map(); + globalThis.localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: (i: number) => [...store.keys()][i] ?? null, + get length() { + return store.size; + }, + } as Storage; + } + if (typeof globalThis.navigator === "undefined") { + globalThis.navigator = { language: "en" } as Navigator; + } + if (typeof globalThis.document === "undefined") { + globalThis.document = { documentElement: {} } as Document; + } + + for (const [name, load] of SLICE_MODULES) { + const mod = (await load()) as Record; + const slice = mod[name]; + if (!slice) throw new Error(`slice module did not export ${name}`); + slices[name] = slice; + } + api = ((await import("../api")) as { api: Slice }).api; +}); + +describe("api façade is a collision-free spread of domain slices", () => { + it("defines no method name in more than one slice (object spread would silently drop one)", () => { + const owner = new Map(); + const collisions: string[] = []; + for (const [sliceName, slice] of Object.entries(slices)) { + for (const key of Object.keys(slice)) { + const prev = owner.get(key); + if (prev) collisions.push(`"${key}" is defined in both ${prev} and ${sliceName}`); + else owner.set(key, sliceName); + } + } + expect(collisions).toEqual([]); + }); + + it("exposes exactly the union of the slices — nothing added inline in the barrel, nothing lost", () => { + const union = new Set(); + for (const slice of Object.values(slices)) { + for (const key of Object.keys(slice)) union.add(key); + } + expect(new Set(Object.keys(api))).toEqual(union); + }); +}); diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts new file mode 100644 index 0000000..3479ed0 --- /dev/null +++ b/frontend/src/lib/api/feed.ts @@ -0,0 +1,163 @@ +// Feed, video watch-state/progress, and live YouTube search. `filterParams`/`feedQuery` translate +// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay +// module-local (no consumer names them). Only touches core's req/beacon. +import { req, beacon } from "./core"; + +// 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; +} + +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 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; +} + +// 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 const feedApi = { + 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}`), +}; diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts new file mode 100644 index 0000000..8d11fa7 --- /dev/null +++ b/frontend/src/lib/api/notifications.ts @@ -0,0 +1,28 @@ +// The durable, server-backed notification inbox (distinct from the client-side transient +// toast/bell, which lives only in localStorage). Only touches core's req. +import { req } from "./core"; + +// 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 notificationsApi = { + 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" }), +}; diff --git a/frontend/src/lib/api/playlists.ts b/frontend/src/lib/api/playlists.ts new file mode 100644 index 0000000..46fb911 --- /dev/null +++ b/frontend/src/lib/api/playlists.ts @@ -0,0 +1,91 @@ +// Playlists: local + YouTube-linked, item add/remove/reorder, the built-in Watch later shortcut, +// and the push-to-YouTube plan/apply flow. Only touches core's req. +import { req } from "./core"; +import type { Video } from "./feed"; + +// Not exported: consumers compare `playlist.kind`/`.source` against string literals, they never +// name these types — keeping them local satisfies the no-unused-exports gate (knip). +type PlaylistKind = "user" | "watch_later"; +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 const playlistsApi = { + 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" }), +}; diff --git a/frontend/src/lib/api/quota.ts b/frontend/src/lib/api/quota.ts new file mode 100644 index 0000000..964ef7f --- /dev/null +++ b/frontend/src/lib/api/quota.ts @@ -0,0 +1,29 @@ +// YouTube API quota usage: the signed-in user's own totals and the admin cross-user view. +// Only touches core's req. +import { req } from "./core"; + +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 const quotaApi = { + myUsage: (): Promise => req("/api/quota/my-usage"), + adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), +}; diff --git a/frontend/src/lib/api/savedViews.ts b/frontend/src/lib/api/savedViews.ts new file mode 100644 index 0000000..cb57ff5 --- /dev/null +++ b/frontend/src/lib/api/savedViews.ts @@ -0,0 +1,28 @@ +// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget). +// Only touches core's req; the FeedFilters shape is owned by feed.ts. +import { req } from "./core"; +import type { FeedFilters } from "./feed"; + +// 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 const savedViewsApi = { + 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 }) }), +}; diff --git a/frontend/src/lib/api/setup.ts b/frontend/src/lib/api/setup.ts new file mode 100644 index 0000000..c47d7b3 --- /dev/null +++ b/frontend/src/lib/api/setup.ts @@ -0,0 +1,30 @@ +// First-run install wizard: probes install state and drives admin/config/email setup with the +// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so +// the wizard renders errors inline. Only touches core's req + setupHeaders. +import { req, setupHeaders } from "./core"; + +export const setupApi = { + 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 }), +}; diff --git a/frontend/src/lib/api/sync.ts b/frontend/src/lib/api/sync.ts new file mode 100644 index 0000000..4c6a680 --- /dev/null +++ b/frontend/src/lib/api/sync.ts @@ -0,0 +1,30 @@ +// The global sync engine: overall status, pause/resume, and a manual subscriptions pull. +// (Per-channel management + my-status/deep-all live in channels.ts.) Only touches core's req. +import { req } from "./core"; + +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 const syncApi = { + status: (): Promise => req("/api/sync/status"), + pauseSync: () => req("/api/sync/pause", { method: "POST" }), + resumeSync: () => req("/api/sync/resume", { 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" }), +}; diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts new file mode 100644 index 0000000..f9eb575 --- /dev/null +++ b/frontend/src/lib/api/tags.ts @@ -0,0 +1,20 @@ +// User channel-tags: list + CRUD. Only touches core's req. +import { req } from "./core"; + +export interface Tag { + id: number; + name: string; + color: string | null; + category: string; + system: boolean; + channel_count: number; +} + +export const tagsApi = { + tags: (): Promise => req("/api/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" }), +};