Merge: promote dev to prod
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
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**.
|
||||
|
||||
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 234 KiB |
@@ -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">
|
||||
{/* 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
|
||||
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]"
|
||||
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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
// Channel manager (subscribed + discovery), the channel About/detail page, ephemeral exploration
|
||||
// of un-subscribed channels, and the per-user channel blocklist. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
|
||||
interface ChannelLink {
|
||||
title: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ChannelDetail {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle: string | null;
|
||||
description: string | null;
|
||||
thumbnail_url: string | null;
|
||||
banner_url: string | null;
|
||||
subscriber_count: number | null;
|
||||
video_count: number | null;
|
||||
total_view_count: number | null;
|
||||
published_at: string | null;
|
||||
external_links: ChannelLink[];
|
||||
country: string | null;
|
||||
default_language: string | null;
|
||||
topic_categories: string[];
|
||||
keywords: string | null;
|
||||
subscribed: boolean;
|
||||
explored: boolean;
|
||||
blocked: boolean;
|
||||
stored_videos: number;
|
||||
deep_requested: boolean;
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
}
|
||||
|
||||
export interface ExploreResult {
|
||||
channel_id: string;
|
||||
ingested: number;
|
||||
next_page_token: string | null;
|
||||
}
|
||||
|
||||
export interface MyStatus {
|
||||
channels_total: number;
|
||||
channels_details_synced: number;
|
||||
channels_recent_synced: number;
|
||||
channels_deep_done: number;
|
||||
channels_recent_pending: number;
|
||||
channels_deep_pending: number;
|
||||
channels_deep_requested: number;
|
||||
deep_pending_count: number;
|
||||
deep_eta_seconds: number;
|
||||
my_videos: number;
|
||||
total_videos: number;
|
||||
quota_used_today: number;
|
||||
quota_remaining_today: number;
|
||||
paused: boolean;
|
||||
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
|
||||
}
|
||||
|
||||
export interface ManagedChannel {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle: string | null;
|
||||
thumbnail_url: string | null;
|
||||
subscriber_count: number | null;
|
||||
video_count: number | null;
|
||||
description: string | null;
|
||||
stored_videos: number;
|
||||
last_video_at: string | null;
|
||||
total_duration_seconds: number;
|
||||
count_normal: number;
|
||||
count_short: number;
|
||||
count_live: number;
|
||||
priority: number;
|
||||
hidden: boolean;
|
||||
deep_requested: boolean;
|
||||
deep_in_queue: boolean;
|
||||
tag_ids: number[];
|
||||
details_synced: boolean;
|
||||
recent_synced: boolean;
|
||||
backfill_done: boolean;
|
||||
}
|
||||
|
||||
// A channel that shows up in the user's playlists but that they don't subscribe to —
|
||||
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
|
||||
export interface DiscoveredChannel {
|
||||
id: string;
|
||||
title: string | null;
|
||||
handle: string | null;
|
||||
thumbnail_url: string | null;
|
||||
subscriber_count: number | null;
|
||||
video_count: number | null;
|
||||
description: string | null;
|
||||
playlist_video_count: number; // how many of the user's playlist videos are from here
|
||||
playlist_count: number; // across how many of their playlists
|
||||
details_synced: boolean;
|
||||
}
|
||||
|
||||
export const channelsApi = {
|
||||
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
||||
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
||||
updateChannel: (
|
||||
id: string,
|
||||
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean },
|
||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
resetChannelBackfill: (id: string) =>
|
||||
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
||||
deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> =>
|
||||
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||
attachChannelTag: (id: string, tagId: number) =>
|
||||
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
||||
detachChannelTag: (id: string, tagId: number) =>
|
||||
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
||||
unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||
discoveredChannels: (): Promise<DiscoveredChannel[]> => req("/api/channels/discovery"),
|
||||
subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
||||
// --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
|
||||
channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
|
||||
// Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
|
||||
// marked via_explore) + record the exploration. `pageToken` continues into older uploads
|
||||
// ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
|
||||
exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
|
||||
const p = new URLSearchParams();
|
||||
if (pageToken) p.set("page_token", pageToken);
|
||||
const qs = p.toString();
|
||||
return req(
|
||||
`/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
|
||||
{ method: "POST" },
|
||||
{ quiet: true },
|
||||
);
|
||||
},
|
||||
// --- per-user channel blocklist (excluded from live search + feed/explore) ---
|
||||
blockedChannels: (): Promise<
|
||||
{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]
|
||||
> => req("/api/channels/blocked/list"),
|
||||
blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
|
||||
unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// Feed, video watch-state/progress, and live YouTube search. `filterParams`/`feedQuery` translate
|
||||
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
|
||||
// module-local (no consumer names them). Only touches core's req/beacon.
|
||||
import { req, beacon } from "./core";
|
||||
|
||||
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
|
||||
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
|
||||
// so they never appear on the wire.
|
||||
export type VideoStatus = "new" | "watched" | "hidden";
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
title: string | null;
|
||||
channel_id: string;
|
||||
channel_title: string | null;
|
||||
channel_thumbnail: string | null;
|
||||
channel_url: string;
|
||||
published_at: string | null;
|
||||
thumbnail_url: string | null;
|
||||
duration_seconds: number | null;
|
||||
view_count: number | null;
|
||||
is_short: boolean;
|
||||
live_status: string;
|
||||
status: VideoStatus;
|
||||
position_seconds: number;
|
||||
saved: boolean; // is the video in the user's built-in Watch later playlist
|
||||
watch_url: string;
|
||||
}
|
||||
|
||||
export interface VideoDetail {
|
||||
id: string;
|
||||
description: string | null;
|
||||
like_count: number | null;
|
||||
in_db: boolean;
|
||||
channel_id: string | null;
|
||||
channel_title: string | null;
|
||||
published_at: string | null;
|
||||
view_count: number | null;
|
||||
duration_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: Video[];
|
||||
has_more: boolean;
|
||||
// Opaque keyset cursor for the next page, or null when this is the last page.
|
||||
next_cursor: string | null;
|
||||
// Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api"
|
||||
// (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode.
|
||||
source?: "scrape" | "api";
|
||||
// NOTE: /api/feed also returns an echoed `limit`, but /api/search/youtube does not and nothing
|
||||
// in the app reads it, so it's deliberately omitted rather than typed required (a lie for search)
|
||||
// or optional (dead surface). Re-add if a consumer ever needs it.
|
||||
}
|
||||
|
||||
export interface FeedFilters {
|
||||
tags: number[];
|
||||
tagMode: "or" | "and";
|
||||
q: string;
|
||||
sort: string;
|
||||
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
||||
// feed re-queries with a fresh order. Ignored by every other sort.
|
||||
seed?: number;
|
||||
// "my" = only your subscriptions (default); "all" = the whole shared catalog.
|
||||
scope: "my" | "all";
|
||||
includeNormal: boolean;
|
||||
includeShorts: boolean;
|
||||
includeLive: boolean;
|
||||
show: string;
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||
channelIds: string[];
|
||||
maxAgeDays?: number;
|
||||
minDuration?: number;
|
||||
maxDuration?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||
// the facet-count endpoint so both see exactly the same filter context.
|
||||
function filterParams(f: FeedFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||
p.set("tag_mode", f.tagMode);
|
||||
if (f.q) p.set("q", f.q);
|
||||
p.set("scope", f.scope);
|
||||
p.set("show_normal", String(f.includeNormal));
|
||||
p.set("include_shorts", String(f.includeShorts));
|
||||
p.set("include_live", String(f.includeLive));
|
||||
p.set("show", f.show);
|
||||
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
||||
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
|
||||
p.set("library_source", f.librarySource ?? "organic");
|
||||
return p;
|
||||
}
|
||||
|
||||
function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
|
||||
const p = filterParams(f);
|
||||
p.set("sort", f.sort);
|
||||
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
||||
if (cursor) p.set("cursor", cursor);
|
||||
p.set("limit", String(limit));
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
export const feedApi = {
|
||||
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
||||
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
||||
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||
req(`/api/feed/count?${filterParams(f).toString()}`),
|
||||
// Live YouTube search: results are materialised into the catalog and returned as feed cards.
|
||||
// `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
|
||||
// popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
|
||||
// in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
|
||||
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
|
||||
const p = new URLSearchParams({ q });
|
||||
if (cursor) p.set("cursor", cursor);
|
||||
if (limit) p.set("limit", String(limit));
|
||||
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
||||
},
|
||||
// Discard a set of live-search results "as if never added" (drops your search-finds + deletes
|
||||
// the now-orphaned, un-kept videos). Returns how many videos were removed.
|
||||
clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
|
||||
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
|
||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||
req(`/api/facets?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||
setState: (id: string, status: string) =>
|
||||
req(
|
||||
`/api/videos/${id}/state`,
|
||||
{ method: "POST", body: JSON.stringify({ status }) },
|
||||
{ idempotent: true },
|
||||
),
|
||||
clearState: (id: string) =>
|
||||
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
|
||||
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
||||
req(
|
||||
`/api/videos/${id}/progress`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
position_seconds: Math.floor(positionSeconds),
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
},
|
||||
{ idempotent: true },
|
||||
),
|
||||
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
|
||||
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
|
||||
beacon(`/api/videos/${encodeURIComponent(id)}/progress`, {
|
||||
position_seconds: Math.floor(positionSeconds),
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// The durable, server-backed notification inbox (distinct from the client-side transient
|
||||
// toast/bell, which lives only in localStorage). Only touches core's req.
|
||||
import { req } from "./core";
|
||||
|
||||
// A durable, server-backed notification (the inbox center). Distinct from the client-side
|
||||
// transient toast/bell, which lives only in localStorage.
|
||||
export interface AppNotification {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
data: Record<string, unknown> | null;
|
||||
read: boolean;
|
||||
dismissed: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> =>
|
||||
req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
|
||||
notificationUnreadCount: (): Promise<{ count: number }> =>
|
||||
req("/api/me/notifications/unread_count"),
|
||||
markNotificationRead: (id: number) => req(`/api/me/notifications/${id}/read`, { method: "POST" }),
|
||||
markAllNotificationsRead: () => req("/api/me/notifications/read_all", { method: "POST" }),
|
||||
dismissNotification: (id: number) =>
|
||||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
// Playlists: local + YouTube-linked, item add/remove/reorder, the built-in Watch later shortcut,
|
||||
// and the push-to-YouTube plan/apply flow. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
import type { Video } from "./feed";
|
||||
|
||||
// Not exported: consumers compare `playlist.kind`/`.source` against string literals, they never
|
||||
// name these types — keeping them local satisfies the no-unused-exports gate (knip).
|
||||
type PlaylistKind = "user" | "watch_later";
|
||||
type PlaylistSource = "local" | "youtube";
|
||||
|
||||
export interface Playlist {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: PlaylistKind;
|
||||
source: PlaylistSource;
|
||||
yt_playlist_id: string | null;
|
||||
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
||||
item_count: number;
|
||||
total_duration_seconds: number;
|
||||
cover_thumbnail: string | null;
|
||||
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
||||
}
|
||||
|
||||
export interface PlaylistDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: PlaylistKind;
|
||||
source: PlaylistSource;
|
||||
yt_playlist_id: string | null;
|
||||
dirty: boolean;
|
||||
items: Video[];
|
||||
}
|
||||
|
||||
export interface PushPlan {
|
||||
action: "create" | "update";
|
||||
to_insert: number;
|
||||
to_delete: number;
|
||||
to_reorder: number;
|
||||
yt_extra: number; // items on YouTube that the push would remove (divergence)
|
||||
units_estimate: number;
|
||||
remaining_today: number;
|
||||
affordable: boolean;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
created: number;
|
||||
inserted: number;
|
||||
deleted: number;
|
||||
reordered: number;
|
||||
failures: string[];
|
||||
yt_playlist_id: string | null;
|
||||
}
|
||||
|
||||
export const playlistsApi = {
|
||||
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
||||
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
|
||||
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
|
||||
createPlaylist: (name: string): Promise<Playlist> =>
|
||||
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
||||
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
||||
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
||||
deletePlaylist: (id: number, onYoutube = false) =>
|
||||
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
||||
addToPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_id: videoId }),
|
||||
}),
|
||||
removeFromPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||
reorderPlaylist: (id: number, videoIds: string[]) =>
|
||||
req(`/api/playlists/${id}/order`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ video_ids: videoIds }),
|
||||
}),
|
||||
// Bookmark shortcut for the built-in Watch later playlist.
|
||||
watchLaterAdd: (videoId: string) =>
|
||||
req("/api/playlists/watch-later", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_id: videoId }),
|
||||
}),
|
||||
watchLaterRemove: (videoId: string) =>
|
||||
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
||||
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
||||
req("/api/playlists/sync-youtube", { method: "POST" }),
|
||||
revertPlaylist: (id: number): Promise<{ items: number }> =>
|
||||
req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
|
||||
playlistPushPlan: (id: number): Promise<PushPlan> => req(`/api/playlists/${id}/push-plan`),
|
||||
pushPlaylist: (id: number): Promise<PushResult> =>
|
||||
req(`/api/playlists/${id}/push`, { method: "POST" }),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
// YouTube API quota usage: the signed-in user's own totals and the admin cross-user view.
|
||||
// Only touches core's req.
|
||||
import { req } from "./core";
|
||||
|
||||
export interface MyUsage {
|
||||
today: number;
|
||||
last_7d: number;
|
||||
last_30d: number;
|
||||
all_time: number;
|
||||
by_action: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AdminQuotaRow {
|
||||
user_id: number | null;
|
||||
email: string;
|
||||
total: number;
|
||||
by_action: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AdminQuota {
|
||||
range_days: number;
|
||||
rows: AdminQuotaRow[];
|
||||
daily: { day: string; total: number }[];
|
||||
}
|
||||
|
||||
export const quotaApi = {
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget).
|
||||
// Only touches core's req; the FeedFilters shape is owned by feed.ts.
|
||||
import { req } from "./core";
|
||||
import type { FeedFilters } from "./feed";
|
||||
|
||||
// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget).
|
||||
export interface SavedView {
|
||||
id: number;
|
||||
name: string;
|
||||
filters: FeedFilters;
|
||||
position: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export const savedViewsApi = {
|
||||
savedViews: (): Promise<SavedView[]> => req("/api/saved-views"),
|
||||
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
|
||||
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
|
||||
updateView: (
|
||||
id: number,
|
||||
patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
|
||||
): Promise<SavedView> =>
|
||||
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteView: (id: number): Promise<{ deleted: number }> =>
|
||||
req(`/api/saved-views/${id}`, { method: "DELETE" }),
|
||||
reorderViews: (ids: number[]): Promise<{ ok: boolean }> =>
|
||||
req("/api/saved-views/reorder", { method: "POST", body: JSON.stringify({ ids }) }),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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 }),
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
// The global sync engine: overall status, pause/resume, and a manual subscriptions pull.
|
||||
// (Per-channel management + my-status/deep-all live in channels.ts.) Only touches core's req.
|
||||
import { req } from "./core";
|
||||
|
||||
export interface SyncStatus {
|
||||
subscriptions: number;
|
||||
channels_total: number;
|
||||
channels_backfilling: number;
|
||||
videos_total: number;
|
||||
pending_enrich: number;
|
||||
quota_used_today: number;
|
||||
quota_remaining_today: number;
|
||||
paused: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export const syncApi = {
|
||||
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||
syncSubscriptions: (): Promise<{
|
||||
subscriptions: number;
|
||||
channels_new: number;
|
||||
channels_detailed: number;
|
||||
removed_stale: number;
|
||||
prune_skipped: boolean;
|
||||
quota_used_estimate: number;
|
||||
quota_remaining_today: number;
|
||||
}> => req("/api/sync/subscriptions", { method: "POST" }),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
// User channel-tags: list + CRUD. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
category: string;
|
||||
system: boolean;
|
||||
channel_count: number;
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -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",
|
||||
|
||||