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;
|
|
|
|
|
preferences: Record<string, any>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
watch_url: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FeedResponse {
|
|
|
|
|
items: Video[];
|
|
|
|
|
has_more: boolean;
|
|
|
|
|
offset: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FeedFilters {
|
|
|
|
|
tags: number[];
|
|
|
|
|
tagMode: "or" | "and";
|
|
|
|
|
q: string;
|
|
|
|
|
sort: string;
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class HttpError extends Error {
|
|
|
|
|
status: number;
|
|
|
|
|
constructor(status: number) {
|
|
|
|
|
super(`HTTP ${status}`);
|
|
|
|
|
this.status = status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
// Network/connection failure — surface it in the notification center.
|
|
|
|
|
notify({
|
|
|
|
|
level: "error",
|
|
|
|
|
title: "Network error",
|
|
|
|
|
message: `Couldn't reach the server (${method} ${url}).`,
|
|
|
|
|
});
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
if (!r.ok) {
|
|
|
|
|
// Server faults are worth logging; 401/403/404 etc. are handled by callers.
|
|
|
|
|
if (r.status >= 500) {
|
|
|
|
|
notify({
|
|
|
|
|
level: "error",
|
|
|
|
|
title: `Server error ${r.status}`,
|
|
|
|
|
message: `${method} ${url}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
throw new HttpError(r.status);
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
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);
|
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));
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export const api = {
|
|
|
|
|
me: (): Promise<Me> => req("/api/me"),
|
|
|
|
|
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-11 02:19:47 +02:00
|
|
|
setState: (id: string, status: string) =>
|
|
|
|
|
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
|
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) }),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export { HttpError };
|