Merge: promote dev to prod

This commit is contained in:
2026-07-29 18:33:57 +02:00
27 changed files with 1084 additions and 840 deletions
+5 -3
View File
@@ -5,7 +5,7 @@ channels you follow into one clean, filterable feed — no algorithm deciding wh
Shorts or livestream noise unless you want it. Self-hosted, multi-user, and private: your data
stays on your own server.
![Siftlode feed](frontend/public/welcome/feed.png)
![Siftlode feed](frontend/public/welcome/feed.webp)
Everything expensive (channels, videos, metadata) is fetched from YouTube once and stored locally,
so filtering, searching and sorting are instant and don't burn API quota. Click a video to watch it
@@ -16,8 +16,8 @@ blocker and SponsorBlock keep working.
- **A readable subscription feed** — sort and filter by channel, tag, language, topic, length,
upload date or watch state; hide channels without unsubscribing; save filter setups as named views.
- **Search all of YouTube** from the feed — results play, save and add to playlists like any other
video, and are materialised into your catalog.
- **Search all of YouTube** from the feed — live results you can play, save or add to playlists like
any other video; they stay temporary and out of your catalog unless you keep one.
- **Channel pages & a channel manager** — per-channel stats and uploads, priorities, and your own
tags to slice the feed by.
- **Playlists with two-way YouTube sync** — build them locally, keep them in sync in both directions.
@@ -25,6 +25,8 @@ blocker and SponsorBlock keep working.
- **Download Center** — save videos to the server with yt-dlp in a Plex-friendly layout (format
presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save
to your device, share with another user, or hand out a public watch link.
- **Plex library** *(optional)* — point Siftlode at your own Plex server to browse and play its
movies and shows alongside your feed, with watch state kept in sync.
- **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard.
- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English
and Hungarian**.
+1 -1
View File
@@ -1 +1 @@
0.57.2
0.58.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+28 -14
View File
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Clapperboard,
Code2,
Download,
Expand,
Filter,
Inbox,
@@ -115,20 +117,19 @@ export default function Welcome() {
</div>
</section>
{/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview
src="/welcome/channels.webp"
label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
<Preview
src="/welcome/playlists.webp"
label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
{/* Gallery — one shot per module; click any to view it full size. */}
<section className="mt-12">
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{GALLERY.map((g) => (
<Preview
key={g.key}
src={g.src}
label={t(`welcome.preview.${g.key}`)}
className="aspect-video"
onOpen={open}
/>
))}
</div>
</section>
</main>
@@ -159,12 +160,25 @@ export default function Welcome() {
);
}
// The screenshot gallery below the hero — one shot per module, each opening full-size in the
// lightbox. Order roughly follows the nav rail; the feed leads as the hero above the fold.
const GALLERY: { key: string; src: string }[] = [
{ key: "channels", src: "/welcome/channels.webp" },
{ key: "playlists", src: "/welcome/playlists.webp" },
{ key: "search", src: "/welcome/search.webp" },
{ key: "player", src: "/welcome/player.webp" },
{ key: "downloads", src: "/welcome/downloads.webp" },
{ key: "plex", src: "/welcome/plex.webp" },
];
const FEATURES: { key: string; icon: LucideIcon }[] = [
{ key: "readable", icon: Filter },
{ key: "neverMiss", icon: Inbox },
{ key: "search", icon: Search },
{ key: "playlists", icon: ListVideo },
{ key: "player", icon: PlayCircle },
{ key: "downloads", icon: Download },
{ key: "plex", icon: Clapperboard },
{ key: "private", icon: Lock },
];
+23 -11
View File
@@ -1,24 +1,28 @@
{
"hero": {
"title": "Your YouTube subscriptions, the way a feed should work.",
"subtitle": "Every upload from the channels you follow in one clean, filterable feed — no algorithm deciding what you see, and no Shorts or livestream noise unless you want it.",
"trust": "Self-hosted and private. Sign in with email, or continue with Google."
"title": "Your subscriptions, in one quiet feed.",
"subtitle": "Every upload from the channels you follow, in one place you can actually filter, search and sort. No algorithm deciding what you see and no Shorts or livestreams unless you want them.",
"trust": "Self-hosted and private: your data stays on your own server. Sign in with email, or continue with Google."
},
"openSource": {
"title": "Free and open source",
"body": "Siftlode's code is public — see how it works, run your own private instance, or open an issue.",
"body": "The whole thing is open — read the code, run your own private copy, or open an issue.",
"link": "View the source"
},
"preview": {
"feed": "The feed — filter by channel, language, topic, length and date.",
"channels": "Channel manager",
"playlists": "Playlists"
"playlists": "Playlists",
"player": "In-app player",
"downloads": "Download Center",
"search": "Search results",
"plex": "Your Plex library"
},
"features": {
"heading": "Why Siftlode",
"heading": "What it does",
"readable": {
"title": "Your subscriptions, actually readable",
"body": "Sort and filter by channel, tag, language, topic, length or date, and save your setups as views. You decide what surfaces, not an algorithm."
"body": "Sort and filter by channel, tag, language, topic, length or date, and save your setups as views."
},
"neverMiss": {
"title": "Never miss an upload",
@@ -26,19 +30,27 @@
},
"search": {
"title": "Search all of YouTube",
"body": "Search YouTube right from your feed — results play, save and add to playlists like any other video."
"body": "Search right from your feed — results play, save and add to playlists like any other video."
},
"playlists": {
"title": "Playlists that sync both ways",
"body": "Build playlists locally and keep them in two-way sync with YouTube."
"body": "Build them locally and keep them in two-way sync with YouTube."
},
"player": {
"title": "Watch in-app, resume anywhere",
"body": "An in-app player picks up each video exactly where you left off — no recommendation rabbit-hole."
"body": "A built-in player picks each video up where you left off, with keyboard and scroll controls."
},
"downloads": {
"title": "Download Center",
"body": "Save videos to your server in a Plex-friendly layout, trim or join them, then share a link."
},
"plex": {
"title": "Your Plex library too",
"body": "Browse and play what your Plex server already holds, right alongside your feed."
},
"private": {
"title": "Self-hosted and private",
"body": "Run it yourself, multi-user, in English and Hungarian. Your data stays yours."
"body": "Run it yourself, multi-user, in English and Hungarian — your data stays yours."
}
},
"auth": {
+24 -12
View File
@@ -1,24 +1,28 @@
{
"hero": {
"title": "A YouTube-feliratkozásaid úgy, ahogy egy hírfolyamnak működnie kéne.",
"subtitle": "Minden feltöltés a követett csatornáidról egyetlen tiszta, szűrhető hírfolyamban — nincs algoritmus, ami eldönti, mit látsz, és nincs Shorts- vagy élő-zaj, hacsak nem kéred.",
"trust": "Self-hosted és privát. Lépj be e-maillel, vagy folytasd Google-fiókkal."
"title": "A feliratkozásaid egyetlen nyugodt hírfolyamban.",
"subtitle": "Minden feltöltés a követett csatornáidról egy helyen, amit tényleg szűrhetsz, kereshetsz és rendezhetsz. Nincs algoritmus, ami eldönti, mit látsz és nincs Shorts vagy élő adás, hacsak nem kéred.",
"trust": "Self-hosted és privát: az adataid a saját szervereden maradnak. Lépj be e-maillel, vagy folytasd Google-fiókkal."
},
"openSource": {
"title": "Ingyenes és nyílt forráskódú",
"body": "A Siftlode kódja nyilvános — nézd meg, hogyan működik, futtass saját privát példányt, vagy nyiss egy hibajegyet.",
"body": "Az egész nyílt — nézd meg a kódot, futtass saját privát példányt, vagy nyiss egy hibajegyet.",
"link": "Forráskód megtekintése"
},
"preview": {
"feed": "A hírfolyam — szűrés csatorna, nyelv, téma, hossz és dátum szerint.",
"channels": "Csatorna-kezelő",
"playlists": "Lejátszási listák"
"playlists": "Lejátszási listák",
"player": "Beépített lejátszó",
"downloads": "Letöltésközpont",
"search": "Keresési találatok",
"plex": "A Plex-könyvtárad"
},
"features": {
"heading": "Miért a Siftlode",
"heading": "Mit tud",
"readable": {
"title": "A feliratkozásaid végre olvashatóan",
"body": "Rendezz és szűrj csatorna, címke, nyelv, téma, hossz vagy dátum szerint, és mentsd el a beállításaidat nézetként. Te döntöd el, mi kerül elő, nem egy algoritmus."
"body": "Rendezz és szűrj csatorna, címke, nyelv, téma, hossz vagy dátum szerint, és mentsd el a beállításaidat nézetként."
},
"neverMiss": {
"title": "Egy feltöltés se vesszen el",
@@ -26,19 +30,27 @@
},
"search": {
"title": "Keress az egész YouTube-on",
"body": "Keress a YouTube-on közvetlenül a hírfolyamból — a találatok ugyanúgy lejátszhatók, menthetők és listához adhatók, mint bármely más videó."
"body": "Keress közvetlenül a hírfolyamból — a találatok ugyanúgy lejátszhatók, menthetők és listához adhatók, mint bármely más videó."
},
"playlists": {
"title": "Kétirányban szinkronizált lejátszási listák",
"body": "Építs helyi lejátszási listákat, és tartsd őket kétirányú szinkronban a YouTube-bal."
"title": "Kétirányban szinkronizált listák",
"body": "Építsd helyben, és tartsd kétirányú szinkronban a YouTube-bal."
},
"player": {
"title": "Nézd appon belül, folytasd bárhonnan",
"body": "Az appon belüli lejátszó pontosan onnan folytatja a videót, ahol abbahagytad — nincs ajánló-örvény."
"body": "A beépített lejátszó onnan folytatja a videót, ahol abbahagytad — billentyű- és görgetés-vezérléssel."
},
"downloads": {
"title": "Letöltésközpont",
"body": "Ments videókat a szerveredre Plex-barát elrendezésben, vágd vagy fűzd össze őket, majd oszd meg egy linkkel."
},
"plex": {
"title": "A Plex-könyvtárad is",
"body": "Böngészd és játszd le, amit a Plex-szervered már tárol, a hírfolyamod mellett."
},
"private": {
"title": "Self-hosted és privát",
"body": "Futtasd magad, többfelhasználós, angolul és magyarul. Az adataid a tieid maradnak."
"body": "Futtasd magad, többfelhasználós, angolul és magyarul — az adataid a tieid maradnak."
}
},
"auth": {
+46 -799
View File
@@ -1,7 +1,12 @@
// The API façade. This file owns NO endpoint logic and NO types of its own — every domain lives in
// its own api/<domain>.ts module (types + method slice together). This barrel only:
// 1. re-exports the shared HTTP machinery from api/core,
// 2. re-exports each domain's TYPES (`export type *`) so consumers import them from "./api"
// unchanged,
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated R7.5 cannot re-grow here. (An automated guard for that invariant lands in S3.)
import {
req,
beacon,
setupHeaders,
getActiveAccount,
HttpError,
setActiveAccount,
@@ -18,810 +23,52 @@ export {
setUnauthorizedHandler,
};
// Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing
// them from "./api" unchanged; their method slices stay internal (`export type *`, not `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 { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { accountApi } from "./api/account";
export type * from "./api/account";
import { authApi } from "./api/auth";
export type * from "./api/auth";
import { setupApi } from "./api/setup";
export type * from "./api/setup";
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 { adminApi } from "./api/admin";
export type * from "./api/admin";
import { plexApi } from "./api/plex";
export type * from "./api/plex";
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
export interface Me {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
role: string;
is_demo: boolean;
has_google: boolean;
has_password: boolean;
google_enabled: boolean;
plex_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
preferences: Record<string, unknown>;
}
export interface DemoWhitelistEntry {
id: number;
email: string;
note: string | null;
added_by: string | null;
created_at: string | null;
}
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;
}
// 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=<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 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;
build_date: string | null;
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<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;
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;
display_name: string | null;
avatar_url: string | null;
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<string, unknown> | 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;
type: "int" | "str" | "bool";
group: string;
secret: boolean;
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
groups: Record<string, ConfigItem[]>;
secrets_manageable: boolean;
}
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
email: string;
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
is_suspended: boolean;
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
has_google: boolean;
created_at: string | null;
}
export interface AuditRow {
id: number;
created_at: string | null;
actor: string | null; // email of the acting admin; null = System (scheduler/background)
action: string; // canonical key, localized on the client via auditActions.<action>
target_type: string | null;
target_id: string | null;
summary: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
}
export interface AuditListResult {
items: AuditRow[];
total: number; // total rows in the log (may exceed `items` if capped)
returned: number;
}
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
export const api = {
// 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.
me: async (): Promise<Me | null> => {
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"),
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}`),
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<string, unknown>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
// --- 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) }),
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" }),
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<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" }),
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
purgeDiscovery: (): Promise<Record<string, number>> =>
req("/api/admin/purge-discovery", { method: "POST" }),
// --- 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, 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" }),
// --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
// --- admin: system configuration ---
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
resetConfig: (key: string): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
...accountApi,
...authApi,
...setupApi,
...feedApi,
...channelsApi,
...syncApi,
...playlistsApi,
...tagsApi,
...notificationsApi,
...savedViewsApi,
...quotaApi,
...adminApi,
...plexApi,
// --- admin: users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (
jobId: string,
intervalMinutes: number,
): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
}),
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }),
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
req("/api/admin/scheduler/maintenance", {
method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{
google_enabled: boolean;
allow_registration: boolean;
operator_contact: string | null;
}> => req("/auth/config"),
// --- first-run install wizard (token from the setup URL printed to the container logs) ---
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/admin",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
{ quiet: true },
),
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
req(
"/api/setup/config",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
{ quiet: true },
),
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{ quiet: true },
),
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(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req(
"/auth/password-reset/request",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/confirm",
{ method: "POST", body: JSON.stringify({ token, password }) },
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
// reveals whether the email has a pending account.
resendVerification: (email: string): Promise<{ status: string }> =>
req(
"/auth/verify/resend",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true },
),
// --- onboarding / admin ---
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
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
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 }) }),
// --- 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<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 }) }),
...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,
};
+58
View File
@@ -0,0 +1,58 @@
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
// app version, and preference save. Only touches core's req.
import { req } from "./core";
export interface Me {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
role: string;
is_demo: boolean;
has_google: boolean;
has_password: boolean;
google_enabled: boolean;
plex_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
preferences: Record<string, unknown>;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
build_date: string | null;
db_revision: string | null;
}
export const accountApi = {
// 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.
me: async (): Promise<Me | null> => {
// Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`.
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, unknown>) =>
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
};
+172
View File
@@ -0,0 +1,172 @@
// The admin surface: discovery purge, system configuration, users & roles, the audit log, the
// scheduler + maintenance controls, the demo-account whitelist, and access invites. Only touches
// core's req. (Grouped by sub-area below; kept in one module as the cohesive "admin" domain.)
import { req } from "./core";
export interface DemoWhitelistEntry {
id: number;
email: string;
note: string | null;
added_by: string | null;
created_at: string | null;
}
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;
}
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
key: string;
type: "int" | "str" | "bool";
group: string;
secret: boolean;
min: number | null;
max: number | null;
is_set: boolean; // a DB override row exists (else using the env/config default)
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
value?: string | number | boolean; // non-secret: effective value
default?: string | number | boolean; // non-secret: env/config default
default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
groups: Record<string, ConfigItem[]>;
secrets_manageable: boolean;
}
// Admin Users & roles page.
export interface AdminUserRow {
id: number;
email: string;
display_name: string | null;
role: string; // "user" | "admin"
is_active: boolean;
is_suspended: boolean;
email_verified: boolean;
is_demo: boolean;
has_password: boolean;
has_google: boolean;
created_at: string | null;
}
export interface AuditRow {
id: number;
created_at: string | null;
actor: string | null; // email of the acting admin; null = System (scheduler/background)
action: string; // canonical key, localized on the client via auditActions.<action>
target_type: string | null;
target_id: string | null;
summary: string | null;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
}
export interface AuditListResult {
items: AuditRow[];
total: number; // total rows in the log (may exceed `items` if capped)
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 adminApi = {
// --- discovery / system configuration ---
// Admin: reclaim all un-kept discovery content (search results + explored channels) now.
purgeDiscovery: (): Promise<Record<string, number>> =>
req("/api/admin/purge-discovery", { method: "POST" }),
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
resetConfig: (key: string): Promise<SystemConfigData> =>
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
// --- users & roles ---
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
// --- scheduler + maintenance ---
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (
jobId: string,
intervalMinutes: number,
): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, {
method: "PATCH",
body: JSON.stringify({ interval_minutes: intervalMinutes }),
}),
runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
req("/api/admin/scheduler/run-all", { method: "POST" }),
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
req("/api/admin/scheduler/maintenance", {
method: "PATCH",
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
}),
// --- demo whitelist ---
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> => req("/api/admin/demo/whitelist"),
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
removeDemoWhitelist: (id: number) => req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
// --- access invites ---
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 }) }),
};
+67
View File
@@ -0,0 +1,67 @@
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
// session management, access requests, and the hidden demo login. Errors on the interactive paths
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req } from "./core";
export const authApi = {
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
authConfig: (): Promise<{
google_enabled: boolean;
allow_registration: boolean;
operator_contact: string | null;
}> => req("/auth/config"),
// --- email + password (errors handled inline via quiet) ---
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ quiet: true },
),
requestPasswordReset: (email: string): Promise<{ status: string }> =>
req(
"/auth/password-reset/request",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/confirm",
{ method: "POST", body: JSON.stringify({ token, password }) },
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
// reveals whether the email has a pending account.
resendVerification: (email: string): Promise<{ status: string }> =>
req(
"/auth/verify/resend",
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// SA4: sign every OTHER session for this account out (keeps the current one).
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true },
),
// --- onboarding: access requests + demo entry ---
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
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
};
+138
View File
@@ -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" }),
};
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll } from "vitest";
// The R7.5 guard: lib/api.ts is a spread-only façade over per-domain slice modules. These tests
// mechanise the two invariants that make that structure safe — so a future edit that reintroduces
// the interleave (a method dropped into two slices → a silent last-wins spread override) or bloats
// the barrel (a method defined inline instead of in a domain module) fails CI instead of shipping.
//
// Scope, on purpose: this covers RUNTIME method-key collisions — the only *silent* failure mode,
// since tsc does not flag overlapping spread keys. It deliberately does NOT cover exported-TYPE-name
// clashes across slices (two slices exporting the same interface name): types are erased at runtime
// so they're invisible here, and that case is already build-caught — the barrel's `export type *`
// makes the ambiguous name a downstream tsc "no exported member" error, so nothing ships. Left to
// tsc rather than re-checked by a fragile source parse.
type Slice = Record<string, unknown>;
// Names line up with the modules below; kept as a list so the wiring stays a single source.
const SLICE_MODULES = [
["accountApi", () => import("./account")],
["authApi", () => import("./auth")],
["setupApi", () => import("./setup")],
["feedApi", () => import("./feed")],
["channelsApi", () => import("./channels")],
["syncApi", () => import("./sync")],
["playlistsApi", () => import("./playlists")],
["tagsApi", () => import("./tags")],
["notificationsApi", () => import("./notifications")],
["savedViewsApi", () => import("./savedViews")],
["quotaApi", () => import("./quota")],
["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")],
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")],
] as const;
let api: Slice;
const slices: Record<string, Slice> = {};
beforeAll(async () => {
// The runtime api graph pulls i18n at module-load, which reads localStorage/navigator. Under
// vitest's node env (no jsdom — see vitest.config.ts) stub the minimum so the import succeeds.
if (typeof globalThis.localStorage === "undefined") {
const store = new Map<string, string>();
globalThis.localStorage = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: (i: number) => [...store.keys()][i] ?? null,
get length() {
return store.size;
},
} as Storage;
}
if (typeof globalThis.navigator === "undefined") {
globalThis.navigator = { language: "en" } as Navigator;
}
if (typeof globalThis.document === "undefined") {
globalThis.document = { documentElement: {} } as Document;
}
for (const [name, load] of SLICE_MODULES) {
const mod = (await load()) as Record<string, Slice>;
const slice = mod[name];
if (!slice) throw new Error(`slice module did not export ${name}`);
slices[name] = slice;
}
api = ((await import("../api")) as { api: Slice }).api;
});
describe("api façade is a collision-free spread of domain slices", () => {
it("defines no method name in more than one slice (object spread would silently drop one)", () => {
const owner = new Map<string, string>();
const collisions: string[] = [];
for (const [sliceName, slice] of Object.entries(slices)) {
for (const key of Object.keys(slice)) {
const prev = owner.get(key);
if (prev) collisions.push(`"${key}" is defined in both ${prev} and ${sliceName}`);
else owner.set(key, sliceName);
}
}
expect(collisions).toEqual([]);
});
it("exposes exactly the union of the slices — nothing added inline in the barrel, nothing lost", () => {
const union = new Set<string>();
for (const slice of Object.values(slices)) {
for (const key of Object.keys(slice)) union.add(key);
}
expect(new Set(Object.keys(api))).toEqual(union);
});
});
+163
View File
@@ -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}`),
};
+28
View File
@@ -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" }),
};
+91
View File
@@ -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" }),
};
+29
View File
@@ -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}`),
};
+28
View File
@@ -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 }) }),
};
+30
View File
@@ -0,0 +1,30 @@
// First-run install wizard: probes install state and drives admin/config/email setup with the
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
// the wizard renders errors inline. Only touches core's req + setupHeaders.
import { req, setupHeaders } from "./core";
export const setupApi = {
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/admin",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) },
{ quiet: true },
),
setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
req(
"/api/setup/config",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) },
{ quiet: true },
),
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
req(
"/api/setup/test-email",
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
{ quiet: true },
),
setupFinish: (token: string): Promise<{ ok: boolean }> =>
req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
};
+30
View File
@@ -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" }),
};
+20
View File
@@ -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" }),
};
+11
View File
@@ -14,6 +14,17 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.58.0",
date: "2026-07-29",
summary:
"Housekeeping: a refreshed public landing page and internal frontend cleanup — nothing changes for signed-in users.",
chores: [
"Landing: the signed-out welcome page has a new screenshot gallery (feed, channels, playlists, player, search, downloads and Plex) and refreshed, plainer copy describing what the app does.",
"Frontend: finished splitting the data-fetching layer into per-domain modules behind a thin façade (no behaviour change).",
"Docs: the README is brought up to date (feature list, Plex, search behaviour) and its landing screenshot fixed.",
],
},
{
version: "0.57.2",
date: "2026-07-28",