diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9e5cc11..7a33513 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,5 @@ import { req, - beacon, setupHeaders, getActiveAccount, HttpError, @@ -23,6 +22,22 @@ 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 { 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 { downloadsApi } from "./api/downloads"; export type * from "./api/downloads"; import { plexApi } from "./api/plex"; @@ -31,6 +46,12 @@ export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; +// --------------------------------------------------------------------------- +// The domains still living inline here — account, auth, setup, admin — are +// extracted in R7.5 S2. Their types and methods stay together in this file +// until then. +// --------------------------------------------------------------------------- + export interface Me { id: number; email: string; @@ -66,184 +87,6 @@ export interface Invite { 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; @@ -251,170 +94,6 @@ export interface VersionInfo { 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; @@ -423,19 +102,6 @@ export interface Account { 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; @@ -487,7 +153,50 @@ export interface AuditListResult { 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 api = { + // --- account & session --- // 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. @@ -505,157 +214,14 @@ export const api = { 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: discovery / system configuration --- // 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 }) }), @@ -663,7 +229,6 @@ export const api = { req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), - ...plexApi, // --- admin: users & roles --- adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), @@ -693,6 +258,7 @@ export const api = { body: JSON.stringify({ revalidate_batch: revalidateBatch }), }), + // --- auth: public config --- // Public: which sign-in options this instance offers (e.g. hide Google when not configured). authConfig: (): Promise<{ google_enabled: boolean; @@ -724,6 +290,7 @@ export const api = { ), 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( @@ -769,7 +336,8 @@ export const api = { { method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) }, { quiet: true }, ), - // --- onboarding / admin --- + + // --- onboarding / admin: access requests, demo --- 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 @@ -789,39 +357,16 @@ export const api = { 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 }) }), - + // --- extracted domain slices (their methods reach consumers through this façade) --- + ...feedApi, + ...channelsApi, + ...syncApi, + ...playlistsApi, + ...tagsApi, + ...notificationsApi, + ...savedViewsApi, + ...quotaApi, + ...plexApi, ...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/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/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/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" }), +};