refactor(api): extract 8 content domains into api/*.ts slices (R7.5 S1)
Move feed, channels, sync, playlists, tags, notifications, saved-views and quota out of the lib/api.ts god object into per-domain slice modules, each owning its own types + method slice and spread into the façade. The `api` object shape and all 54 call sites are unchanged; account/auth/setup/admin stay inline until S2. Reunites the interleaved tags read/CRUD that motivated the epic.
This commit is contained in:
@@ -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<MyStatus> => req("/api/sync/my-status"),
|
||||
channels: (): Promise<ManagedChannel[]> => 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<DiscoveredChannel[]> => 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<ChannelDetail> => 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<ExploreResult> => {
|
||||
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" }),
|
||||
};
|
||||
@@ -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<FeedResponse> =>
|
||||
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<FeedResponse> => {
|
||||
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<string, number> }> =>
|
||||
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<VideoDetail> => req(`/api/videos/${id}`),
|
||||
};
|
||||
@@ -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<string, unknown> | 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" }),
|
||||
};
|
||||
@@ -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=<video_id>
|
||||
}
|
||||
|
||||
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<Playlist[]> =>
|
||||
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
|
||||
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
|
||||
createPlaylist: (name: string): Promise<Playlist> =>
|
||||
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
||||
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
||||
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<PushPlan> => req(`/api/playlists/${id}/push-plan`),
|
||||
pushPlaylist: (id: number): Promise<PushResult> =>
|
||||
req(`/api/playlists/${id}/push`, { method: "POST" }),
|
||||
};
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
export interface AdminQuotaRow {
|
||||
user_id: number | null;
|
||||
email: string;
|
||||
total: number;
|
||||
by_action: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AdminQuota {
|
||||
range_days: number;
|
||||
rows: AdminQuotaRow[];
|
||||
daily: { day: string; total: number }[];
|
||||
}
|
||||
|
||||
export const quotaApi = {
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
};
|
||||
@@ -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<SavedView[]> => req("/api/saved-views"),
|
||||
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
|
||||
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
|
||||
updateView: (
|
||||
id: number,
|
||||
patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
|
||||
): Promise<SavedView> =>
|
||||
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 }) }),
|
||||
};
|
||||
@@ -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<SyncStatus> => 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" }),
|
||||
};
|
||||
@@ -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<Tag[]> => 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" }),
|
||||
};
|
||||
Reference in New Issue
Block a user