- Narrow the title hover target to the actual text, not the whole row. - Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a new tab. Blank lines are stripped so the popover isn't mostly whitespace. - YouTube links play in the inline player: a link to the current video seeks (honoring t=), a link to another video navigates the player to it, with a Back button to the original. While on a linked video the title/author come from the player and its views/date/duration + a clickable channel come from the detail endpoint, which falls back to the YouTube API (videos.list, attributed to the user) for videos not in our DB.
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
import { notify } from "./notifications";
|
|
|
|
export interface Me {
|
|
id: number;
|
|
email: string;
|
|
display_name: string | null;
|
|
avatar_url: string | null;
|
|
role: string;
|
|
can_write: boolean;
|
|
pending_invites: number;
|
|
preferences: Record<string, any>;
|
|
}
|
|
|
|
export interface Invite {
|
|
id: number;
|
|
email: string;
|
|
status: "pending" | "approved" | "denied";
|
|
note: string | null;
|
|
requested_at: string | null;
|
|
decided_at: string | null;
|
|
decided_by: string | null;
|
|
}
|
|
|
|
export interface Tag {
|
|
id: number;
|
|
name: string;
|
|
color: string | null;
|
|
category: string;
|
|
system: boolean;
|
|
channel_count: number;
|
|
}
|
|
|
|
export interface Video {
|
|
id: string;
|
|
title: string | null;
|
|
channel_id: string;
|
|
channel_title: string | null;
|
|
channel_thumbnail: string | null;
|
|
channel_url: string;
|
|
published_at: string | null;
|
|
thumbnail_url: string | null;
|
|
duration_seconds: number | null;
|
|
view_count: number | null;
|
|
is_short: boolean;
|
|
live_status: string;
|
|
status: string;
|
|
watch_url: string;
|
|
}
|
|
|
|
export interface VideoDetail {
|
|
id: string;
|
|
description: string | null;
|
|
like_count: number | null;
|
|
in_db: boolean;
|
|
channel_id: string | null;
|
|
channel_title: string | null;
|
|
published_at: string | null;
|
|
view_count: number | null;
|
|
duration_seconds: number | null;
|
|
}
|
|
|
|
export interface FeedResponse {
|
|
items: Video[];
|
|
has_more: boolean;
|
|
offset: number;
|
|
limit: number;
|
|
}
|
|
|
|
export interface FeedFilters {
|
|
tags: number[];
|
|
tagMode: "or" | "and";
|
|
q: string;
|
|
sort: string;
|
|
includeNormal: boolean;
|
|
includeShorts: boolean;
|
|
includeLive: boolean;
|
|
show: string;
|
|
channelId?: string;
|
|
channelName?: string;
|
|
maxAgeDays?: number;
|
|
minDuration?: number;
|
|
maxDuration?: number;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
}
|
|
|
|
class HttpError extends Error {
|
|
status: number;
|
|
constructor(status: number) {
|
|
super(`HTTP ${status}`);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
// Collapse bursts of connection failures (e.g. while the server restarts) into a
|
|
// single notification rather than one per failed request.
|
|
let lastErrorNotifiedAt = 0;
|
|
function notifyErrorThrottled(title: string, message: string): void {
|
|
const now = Date.now();
|
|
if (now - lastErrorNotifiedAt < 30_000) return;
|
|
lastErrorNotifiedAt = now;
|
|
notify({ level: "error", title, message });
|
|
}
|
|
|
|
async function req(url: string, opts: RequestInit = {}): Promise<any> {
|
|
const method = opts.method ?? "GET";
|
|
let r: Response;
|
|
try {
|
|
r = await fetch(url, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
...opts,
|
|
});
|
|
} catch (e) {
|
|
notifyErrorThrottled(
|
|
"Connection lost",
|
|
"Couldn't reach the server — it may be restarting. This will clear once it's back."
|
|
);
|
|
throw e;
|
|
}
|
|
if (!r.ok) {
|
|
// Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
|
|
if (r.status >= 500) {
|
|
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`);
|
|
}
|
|
throw new HttpError(r.status);
|
|
}
|
|
return r.status === 204 ? null : r.json();
|
|
}
|
|
|
|
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
|
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("sort", f.sort);
|
|
p.set("show_normal", String(f.includeNormal));
|
|
p.set("include_shorts", String(f.includeShorts));
|
|
p.set("include_live", String(f.includeLive));
|
|
p.set("show", f.show);
|
|
if (f.channelId) p.set("channel_id", f.channelId);
|
|
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
|
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
|
if (f.dateTo) p.set("published_before", f.dateTo);
|
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
|
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
|
p.set("offset", String(offset));
|
|
p.set("limit", String(limit));
|
|
return p.toString();
|
|
}
|
|
|
|
export interface SyncStatus {
|
|
subscriptions: number;
|
|
channels_total: number;
|
|
channels_backfilling: number;
|
|
videos_total: number;
|
|
pending_enrich: number;
|
|
quota_used_today: number;
|
|
quota_remaining_today: number;
|
|
paused: boolean;
|
|
is_admin: boolean;
|
|
}
|
|
|
|
export interface MyStatus {
|
|
channels_total: number;
|
|
channels_details_synced: number;
|
|
channels_recent_synced: number;
|
|
channels_deep_done: number;
|
|
channels_recent_pending: number;
|
|
channels_deep_pending: number;
|
|
channels_deep_requested: number;
|
|
deep_pending_count: number;
|
|
deep_eta_seconds: number;
|
|
my_videos: number;
|
|
quota_used_today: number;
|
|
quota_remaining_today: number;
|
|
paused: boolean;
|
|
}
|
|
|
|
export interface MyUsage {
|
|
today: number;
|
|
last_7d: number;
|
|
last_30d: number;
|
|
all_time: number;
|
|
by_action: Record<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 interface ManagedChannel {
|
|
id: string;
|
|
title: string | null;
|
|
handle: string | null;
|
|
thumbnail_url: string | null;
|
|
subscriber_count: number | null;
|
|
video_count: number | null;
|
|
stored_videos: number;
|
|
priority: number;
|
|
hidden: boolean;
|
|
deep_requested: boolean;
|
|
deep_in_queue: boolean;
|
|
tag_ids: number[];
|
|
details_synced: boolean;
|
|
recent_synced: boolean;
|
|
backfill_done: boolean;
|
|
}
|
|
|
|
export const api = {
|
|
me: (): Promise<Me> => req("/api/me"),
|
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
|
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
|
|
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
|
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
|
setState: (id: string, status: string) =>
|
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
|
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
|
savePrefs: (p: Record<string, any>) =>
|
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
|
|
|
// --- channel manager ---
|
|
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) }),
|
|
deepAll: (on = true) =>
|
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
|
attachChannelTag: (id: string, tagId: number) =>
|
|
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
|
detachChannelTag: (id: string, tagId: number) =>
|
|
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
|
unsubscribeChannel: (id: string) =>
|
|
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
|
|
|
// --- quota usage ---
|
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
|
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
|
|
|
// --- 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 }) }),
|
|
|
|
// --- user tags ---
|
|
createTag: (t: { name: string; color?: string; category?: string }) =>
|
|
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
|
};
|
|
|
|
export { HttpError };
|