2026-06-11 19:26:34 +02:00
|
|
|
import { notify } from "./notifications";
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface Me {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
display_name: string | null;
|
|
|
|
|
avatar_url: string | null;
|
|
|
|
|
role: string;
|
2026-06-14 01:11:29 +02:00
|
|
|
can_read: boolean;
|
2026-06-11 23:27:11 +02:00
|
|
|
can_write: boolean;
|
2026-06-12 01:43:07 +02:00
|
|
|
pending_invites: number;
|
2026-06-11 02:19:47 +02:00
|
|
|
preferences: Record<string, any>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface Tag {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
color: string | null;
|
|
|
|
|
category: string;
|
|
|
|
|
system: boolean;
|
|
|
|
|
channel_count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Video {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
channel_id: string;
|
|
|
|
|
channel_title: string | null;
|
|
|
|
|
channel_thumbnail: string | null;
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
channel_url: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
published_at: string | null;
|
|
|
|
|
thumbnail_url: string | null;
|
|
|
|
|
duration_seconds: number | null;
|
|
|
|
|
view_count: number | null;
|
|
|
|
|
is_short: boolean;
|
|
|
|
|
live_status: string;
|
|
|
|
|
status: string;
|
2026-06-14 18:40:12 +02:00
|
|
|
position_seconds: number;
|
2026-06-15 15:33:53 +02:00
|
|
|
saved: boolean; // is the video in the user's built-in Watch later playlist
|
2026-06-11 02:19:47 +02:00
|
|
|
watch_url: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:39:01 +02:00
|
|
|
export interface VideoDetail {
|
|
|
|
|
id: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
like_count: number | null;
|
2026-06-12 17:39:20 +02:00
|
|
|
in_db: boolean;
|
|
|
|
|
channel_id: string | null;
|
|
|
|
|
channel_title: string | null;
|
|
|
|
|
published_at: string | null;
|
|
|
|
|
view_count: number | null;
|
|
|
|
|
duration_seconds: number | null;
|
2026-06-12 17:39:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface FeedResponse {
|
|
|
|
|
items: Video[];
|
|
|
|
|
has_more: boolean;
|
|
|
|
|
offset: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
export interface Playlist {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
kind: string; // "user" | "watch_later"
|
|
|
|
|
source: string; // "local" | "youtube"
|
|
|
|
|
yt_playlist_id: string | null;
|
|
|
|
|
item_count: number;
|
|
|
|
|
cover_thumbnail: string | null;
|
|
|
|
|
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PlaylistDetail {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
kind: string;
|
|
|
|
|
source: string;
|
|
|
|
|
yt_playlist_id: string | null;
|
|
|
|
|
items: Video[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface FeedFilters {
|
|
|
|
|
tags: number[];
|
|
|
|
|
tagMode: "or" | "and";
|
|
|
|
|
q: string;
|
|
|
|
|
sort: string;
|
2026-06-15 03:08:10 +02:00
|
|
|
// 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;
|
2026-06-15 04:06:22 +02:00
|
|
|
// "my" = only your subscriptions (default); "all" = the whole shared catalog.
|
|
|
|
|
scope: "my" | "all";
|
2026-06-11 03:47:51 +02:00
|
|
|
includeNormal: boolean;
|
2026-06-11 02:19:47 +02:00
|
|
|
includeShorts: boolean;
|
|
|
|
|
includeLive: boolean;
|
|
|
|
|
show: string;
|
|
|
|
|
channelId?: string;
|
2026-06-11 03:47:51 +02:00
|
|
|
channelName?: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
maxAgeDays?: number;
|
|
|
|
|
minDuration?: number;
|
|
|
|
|
maxDuration?: number;
|
2026-06-11 04:00:17 +02:00
|
|
|
dateFrom?: string;
|
|
|
|
|
dateTo?: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 00:06:57 +02:00
|
|
|
export interface VersionInfo {
|
|
|
|
|
app_version: string;
|
|
|
|
|
git_sha: string;
|
|
|
|
|
build_date: string | null;
|
|
|
|
|
db_revision: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
class HttpError extends Error {
|
|
|
|
|
status: number;
|
2026-06-15 02:02:05 +02:00
|
|
|
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
|
|
|
|
constructor(status: number, detail?: string) {
|
|
|
|
|
super(detail || `HTTP ${status}`);
|
2026-06-11 02:19:47 +02:00
|
|
|
this.status = status;
|
2026-06-15 02:02:05 +02:00
|
|
|
this.detail = detail;
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 22:00:57 +02:00
|
|
|
// Collapse bursts of connection failures (e.g. while the server restarts) into a
|
|
|
|
|
// single notification rather than one per failed request.
|
|
|
|
|
let lastErrorNotifiedAt = 0;
|
|
|
|
|
function notifyErrorThrottled(title: string, message: string): void {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastErrorNotifiedAt < 30_000) return;
|
|
|
|
|
lastErrorNotifiedAt = now;
|
|
|
|
|
notify({ level: "error", title, message });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
2026-06-11 19:26:34 +02:00
|
|
|
const method = opts.method ?? "GET";
|
|
|
|
|
let r: Response;
|
|
|
|
|
try {
|
|
|
|
|
r = await fetch(url, {
|
|
|
|
|
credentials: "include",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
...opts,
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
2026-06-11 22:00:57 +02:00
|
|
|
notifyErrorThrottled(
|
|
|
|
|
"Connection lost",
|
|
|
|
|
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
|
|
|
|
);
|
2026-06-11 19:26:34 +02:00
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
if (!r.ok) {
|
2026-06-15 02:02:05 +02:00
|
|
|
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
|
|
|
|
let detail: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
const body = await r.json();
|
|
|
|
|
if (body && typeof body.detail === "string") detail = body.detail;
|
|
|
|
|
} catch {
|
|
|
|
|
/* no JSON body */
|
|
|
|
|
}
|
2026-06-11 22:00:57 +02:00
|
|
|
// Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
|
2026-06-11 19:26:34 +02:00
|
|
|
if (r.status >= 500) {
|
2026-06-11 22:00:57 +02:00
|
|
|
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
2026-06-15 02:02:05 +02:00
|
|
|
throw new HttpError(r.status, detail);
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
return r.status === 204 ? null : r.json();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:06:02 +02:00
|
|
|
// 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 {
|
2026-06-11 02:19:47 +02:00
|
|
|
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);
|
2026-06-15 04:06:22 +02:00
|
|
|
p.set("scope", f.scope);
|
2026-06-11 03:47:51 +02:00
|
|
|
p.set("show_normal", String(f.includeNormal));
|
2026-06-11 02:19:47 +02:00
|
|
|
p.set("include_shorts", String(f.includeShorts));
|
|
|
|
|
p.set("include_live", String(f.includeLive));
|
|
|
|
|
p.set("show", f.show);
|
|
|
|
|
if (f.channelId) p.set("channel_id", f.channelId);
|
|
|
|
|
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
2026-06-11 04:00:17 +02:00
|
|
|
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
|
|
|
|
if (f.dateTo) p.set("published_before", f.dateTo);
|
2026-06-11 02:19:47 +02:00
|
|
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
|
|
|
|
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
2026-06-15 12:06:02 +02:00
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
|
|
|
|
const p = filterParams(f);
|
|
|
|
|
p.set("sort", f.sort);
|
|
|
|
|
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
2026-06-11 02:19:47 +02:00
|
|
|
p.set("offset", String(offset));
|
|
|
|
|
p.set("limit", String(limit));
|
|
|
|
|
return p.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
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;
|
2026-06-11 23:07:09 +02:00
|
|
|
channels_deep_requested: number;
|
|
|
|
|
deep_pending_count: number;
|
|
|
|
|
deep_eta_seconds: number;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
my_videos: number;
|
2026-06-14 18:42:55 +02:00
|
|
|
total_videos: number;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
quota_used_today: number;
|
|
|
|
|
quota_remaining_today: number;
|
|
|
|
|
paused: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
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 }[];
|
|
|
|
|
}
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
export interface ManagedChannel {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
handle: string | null;
|
|
|
|
|
thumbnail_url: string | null;
|
|
|
|
|
subscriber_count: number | null;
|
|
|
|
|
video_count: number | null;
|
|
|
|
|
stored_videos: number;
|
|
|
|
|
priority: number;
|
|
|
|
|
hidden: boolean;
|
2026-06-11 23:07:09 +02:00
|
|
|
deep_requested: boolean;
|
2026-06-12 02:29:10 +02:00
|
|
|
deep_in_queue: boolean;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
tag_ids: number[];
|
|
|
|
|
details_synced: boolean;
|
|
|
|
|
recent_synced: boolean;
|
|
|
|
|
backfill_done: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export const api = {
|
|
|
|
|
me: (): Promise<Me> => req("/api/me"),
|
2026-06-15 00:06:57 +02:00
|
|
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
2026-06-11 02:19:47 +02:00
|
|
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
2026-06-11 04:15:25 +02:00
|
|
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
2026-06-11 02:19:47 +02:00
|
|
|
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
|
|
|
|
|
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
2026-06-11 04:15:25 +02:00
|
|
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
|
|
|
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
2026-06-15 12:06:02 +02:00
|
|
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
|
|
|
|
req(`/api/facets?${filterParams(f).toString()}`),
|
2026-06-11 02:19:47 +02:00
|
|
|
setState: (id: string, status: string) =>
|
|
|
|
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
2026-06-14 18:40:12 +02:00
|
|
|
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),
|
|
|
|
|
}),
|
|
|
|
|
}),
|
2026-06-12 17:39:01 +02:00
|
|
|
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
2026-06-11 04:15:25 +02:00
|
|
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
|
|
|
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
2026-06-11 02:19:47 +02:00
|
|
|
savePrefs: (p: Record<string, any>) =>
|
|
|
|
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
|
|
|
|
|
// --- channel manager ---
|
|
|
|
|
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
|
|
|
|
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
2026-06-11 23:07:09 +02:00
|
|
|
updateChannel: (
|
|
|
|
|
id: string,
|
|
|
|
|
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
|
|
|
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
|
|
|
deepAll: (on = true) =>
|
|
|
|
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
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" }),
|
2026-06-11 23:27:11 +02:00
|
|
|
unsubscribeChannel: (id: string) =>
|
|
|
|
|
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
2026-06-12 02:47:55 +02:00
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
// --- playlists ---
|
|
|
|
|
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) => req(`/api/playlists/${id}`, { 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 }),
|
|
|
|
|
}),
|
2026-06-15 15:33:53 +02:00
|
|
|
// 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" }),
|
2026-06-15 19:37:17 +02:00
|
|
|
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
|
|
|
|
req("/api/playlists/sync-youtube", { method: "POST" }),
|
2026-06-15 14:45:18 +02:00
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
// --- quota usage ---
|
|
|
|
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
|
|
|
|
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
// --- onboarding / admin ---
|
|
|
|
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
|
|
|
|
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
|
|
|
|
adminInvites: (status?: string): Promise<Invite[]> =>
|
|
|
|
|
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 }) }),
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
// --- 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" }),
|
2026-06-11 02:19:47 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export { HttpError };
|