2026-06-18 23:17:32 +02:00
|
|
|
import { notify, remove } from "./notifications";
|
2026-06-18 01:17:31 +02:00
|
|
|
import i18n from "../i18n";
|
|
|
|
|
import { reportError } from "./errorDialog";
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";
|
2026-06-11 19:26:34 +02:00
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface Me {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
display_name: string | null;
|
|
|
|
|
avatar_url: string | null;
|
|
|
|
|
role: string;
|
2026-06-16 09:17:34 +02:00
|
|
|
is_demo: boolean;
|
2026-06-19 19:52:02 +02:00
|
|
|
has_google: boolean;
|
|
|
|
|
has_password: boolean;
|
2026-06-21 02:06:37 +02:00
|
|
|
google_enabled: boolean;
|
2026-07-05 02:32:00 +02:00
|
|
|
plex_enabled: boolean;
|
2026-06-14 01:11:29 +02:00
|
|
|
can_read: boolean;
|
2026-06-11 23:27:11 +02:00
|
|
|
can_write: boolean;
|
2026-06-12 01:43:07 +02:00
|
|
|
pending_invites: number;
|
2026-06-11 02:19:47 +02:00
|
|
|
preferences: Record<string, any>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:17:34 +02:00
|
|
|
export interface DemoWhitelistEntry {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
note: string | null;
|
|
|
|
|
added_by: string | null;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 01:43:07 +02:00
|
|
|
export interface Invite {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
status: "pending" | "approved" | "denied";
|
|
|
|
|
note: string | null;
|
|
|
|
|
requested_at: string | null;
|
|
|
|
|
decided_at: string | null;
|
|
|
|
|
decided_by: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface Tag {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
color: string | null;
|
|
|
|
|
category: string;
|
|
|
|
|
system: boolean;
|
|
|
|
|
channel_count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Video {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
channel_id: string;
|
|
|
|
|
channel_title: string | null;
|
|
|
|
|
channel_thumbnail: string | null;
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
channel_url: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
published_at: string | null;
|
|
|
|
|
thumbnail_url: string | null;
|
|
|
|
|
duration_seconds: number | null;
|
|
|
|
|
view_count: number | null;
|
|
|
|
|
is_short: boolean;
|
|
|
|
|
live_status: string;
|
|
|
|
|
status: string;
|
2026-06-14 18:40:12 +02:00
|
|
|
position_seconds: number;
|
2026-06-15 15:33:53 +02:00
|
|
|
saved: boolean; // is the video in the user's built-in Watch later playlist
|
2026-06-11 02:19:47 +02:00
|
|
|
watch_url: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:39:01 +02:00
|
|
|
export interface VideoDetail {
|
|
|
|
|
id: string;
|
|
|
|
|
description: string | null;
|
|
|
|
|
like_count: number | null;
|
2026-06-12 17:39:20 +02:00
|
|
|
in_db: boolean;
|
|
|
|
|
channel_id: string | null;
|
|
|
|
|
channel_title: string | null;
|
|
|
|
|
published_at: string | null;
|
|
|
|
|
view_count: number | null;
|
|
|
|
|
duration_seconds: number | null;
|
2026-06-12 17:39:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-30 03:08:52 +02:00
|
|
|
export 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;
|
|
|
|
|
subscribed: boolean;
|
|
|
|
|
explored: boolean;
|
2026-07-01 01:00:32 +02:00
|
|
|
blocked: boolean;
|
2026-06-30 03:08:52 +02:00
|
|
|
stored_videos: number;
|
|
|
|
|
from_explore: boolean;
|
|
|
|
|
details_synced: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExploreResult {
|
|
|
|
|
channel_id: string;
|
|
|
|
|
ingested: number;
|
|
|
|
|
next_page_token: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface FeedResponse {
|
|
|
|
|
items: Video[];
|
|
|
|
|
has_more: boolean;
|
2026-06-25 19:54:40 +02:00
|
|
|
// Opaque keyset cursor for the next page, or null when this is the last page.
|
|
|
|
|
next_cursor: string | null;
|
2026-06-11 02:19:47 +02:00
|
|
|
limit: number;
|
2026-06-29 22:30:03 +02:00
|
|
|
// 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";
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
export interface Playlist {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
kind: string; // "user" | "watch_later"
|
|
|
|
|
source: string; // "local" | "youtube"
|
|
|
|
|
yt_playlist_id: string | null;
|
2026-06-15 21:23:13 +02:00
|
|
|
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
2026-06-15 14:45:18 +02:00
|
|
|
item_count: number;
|
2026-06-15 22:13:32 +02:00
|
|
|
total_duration_seconds: number;
|
2026-06-15 14:45:18 +02:00
|
|
|
cover_thumbnail: string | null;
|
|
|
|
|
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PlaylistDetail {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
kind: string;
|
|
|
|
|
source: string;
|
|
|
|
|
yt_playlist_id: string | null;
|
2026-06-15 21:23:13 +02:00
|
|
|
dirty: boolean;
|
2026-06-15 14:45:18 +02:00
|
|
|
items: Video[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 21:23:13 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export interface FeedFilters {
|
|
|
|
|
tags: number[];
|
|
|
|
|
tagMode: "or" | "and";
|
|
|
|
|
q: string;
|
|
|
|
|
sort: string;
|
2026-06-15 03:08:10 +02:00
|
|
|
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
|
|
|
|
// feed re-queries with a fresh order. Ignored by every other sort.
|
|
|
|
|
seed?: number;
|
2026-06-15 04:06:22 +02:00
|
|
|
// "my" = only your subscriptions (default); "all" = the whole shared catalog.
|
|
|
|
|
scope: "my" | "all";
|
2026-06-11 03:47:51 +02:00
|
|
|
includeNormal: boolean;
|
2026-06-11 02:19:47 +02:00
|
|
|
includeShorts: boolean;
|
|
|
|
|
includeLive: boolean;
|
|
|
|
|
show: string;
|
2026-06-29 02:11:53 +02:00
|
|
|
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
|
|
|
|
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
2026-07-05 02:32:00 +02:00
|
|
|
librarySource?: "organic" | "all" | "search" | "plex";
|
2026-06-11 02:19:47 +02:00
|
|
|
channelId?: string;
|
2026-06-11 03:47:51 +02:00
|
|
|
channelName?: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
maxAgeDays?: number;
|
|
|
|
|
minDuration?: number;
|
|
|
|
|
maxDuration?: number;
|
2026-06-11 04:00:17 +02:00
|
|
|
dateFrom?: string;
|
|
|
|
|
dateTo?: string;
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-01 03:17:36 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 00:06:57 +02:00
|
|
|
export interface VersionInfo {
|
|
|
|
|
app_version: string;
|
|
|
|
|
git_sha: string;
|
|
|
|
|
build_date: string | null;
|
|
|
|
|
db_revision: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
class HttpError extends Error {
|
|
|
|
|
status: number;
|
2026-06-15 02:02:05 +02:00
|
|
|
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
|
|
|
|
constructor(status: number, detail?: string) {
|
|
|
|
|
super(detail || `HTTP ${status}`);
|
2026-06-11 02:19:47 +02:00
|
|
|
this.status = status;
|
2026-06-15 02:02:05 +02:00
|
|
|
this.detail = detail;
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 23:17:32 +02:00
|
|
|
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
|
|
|
|
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
|
|
|
|
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
|
|
|
|
// bursts of concurrent failures from stacking up — there's only ever one live handle.
|
|
|
|
|
let connectivityNotifId: number | null = null;
|
|
|
|
|
function markConnectivityLost(): void {
|
|
|
|
|
if (connectivityNotifId !== null) return;
|
|
|
|
|
connectivityNotifId = notify({
|
|
|
|
|
level: "error",
|
|
|
|
|
title: i18n.t("errors.offline.title"),
|
|
|
|
|
message: i18n.t("errors.offline.body"),
|
|
|
|
|
transient: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function markConnectivityRestored(): void {
|
|
|
|
|
if (connectivityNotifId === null) return;
|
|
|
|
|
remove(connectivityNotifId);
|
|
|
|
|
connectivityNotifId = null;
|
2026-06-11 22:00:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-18 15:13:01 +02:00
|
|
|
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
|
|
|
|
|
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
|
|
|
|
|
// request was processed. Safe to retry an idempotent call once on a fresh connection.
|
|
|
|
|
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
|
|
|
|
|
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
|
|
|
|
|
|
|
|
|
|
// `idempotent`: may this request be replayed after a transient gateway/network failure?
|
|
|
|
|
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
|
|
|
|
|
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
|
|
|
|
interface ReqConfig {
|
|
|
|
|
idempotent?: boolean;
|
2026-06-19 15:15:58 +02:00
|
|
|
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
|
|
|
|
|
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
|
|
|
|
|
quiet?: boolean;
|
2026-06-18 15:13:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 19:52:02 +02:00
|
|
|
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
|
|
|
|
|
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
|
2026-07-04 18:17:24 +02:00
|
|
|
// page. The handler itself guards against firing when we were never signed in, so it's safe to
|
|
|
|
|
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
|
|
|
|
|
// user when logged out — but other protected endpoints still do.)
|
2026-06-19 19:52:02 +02:00
|
|
|
let onUnauthorized: (() => void) | null = null;
|
|
|
|
|
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
|
|
|
|
onUnauthorized = fn;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 01:11:16 +02:00
|
|
|
// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
|
|
|
|
|
// replaces — not merges — its default headers when opts.headers is given, so include both).
|
|
|
|
|
const setupHeaders = (token: string) => ({
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"X-Setup-Token": token,
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-01 23:43:35 +02:00
|
|
|
// --- Per-tab active account --------------------------------------------------------------
|
|
|
|
|
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
|
|
|
|
|
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
|
|
|
|
|
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
|
2026-07-01 23:43:35 +02:00
|
|
|
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
|
|
|
|
|
fix(state): scope all per-account localStorage by account id
Multi-account-in-one-browser (esp. with per-tab accounts) leaked one account's client state
into another via shared localStorage keys. Scope every account-specific key by the tab's active
account (accountKey/readAccount/writeAccount/useAccountPersistedState helpers), so nothing bleeds
across accounts or tabs:
- Real leaks: selected playlist, client notification history + settings, onboarding-dismissed.
- UI position: feed page, channel-manager filter/view + tables, playlist sort, Settings/Stats/
Users/Config tabs.
- Previously DB-adopted caches (theme, hints, performance mode, sidebar layout, nav/filter
collapse) — now the cache is per-account too, so there's no flash of the other account's value
on login.
Kept intentionally global: siftlode.lang (needed pre-login on the Welcome page; the DB pref still
scopes it per-account after sign-in) and siftlode.seenVersion (a per-browser 'new version' banner).
E2EE private keys (IndexedDB) and the chat-dock key were already per-user.
2026-07-02 01:45:16 +02:00
|
|
|
export const getActiveAccount = activeAccountId;
|
2026-07-01 23:43:35 +02:00
|
|
|
export function setActiveAccount(id: number): void {
|
|
|
|
|
try {
|
|
|
|
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
export function clearActiveAccount(): void {
|
|
|
|
|
try {
|
|
|
|
|
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 15:13:01 +02:00
|
|
|
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
|
2026-06-11 19:26:34 +02:00
|
|
|
const method = opts.method ?? "GET";
|
2026-06-18 15:13:01 +02:00
|
|
|
const canRetry = cfg.idempotent ?? method === "GET";
|
|
|
|
|
let attempt = 0;
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
let r: Response;
|
2026-06-15 02:02:05 +02:00
|
|
|
try {
|
2026-07-01 23:43:35 +02:00
|
|
|
const active = getActiveAccount();
|
2026-06-18 15:13:01 +02:00
|
|
|
r = await fetch(url, {
|
|
|
|
|
credentials: "include",
|
2026-07-01 23:43:35 +02:00
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
// Per-tab identity override (only setup calls pass their own headers, and those are
|
|
|
|
|
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
|
|
|
|
|
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
|
|
|
|
},
|
2026-06-18 15:13:01 +02:00
|
|
|
...opts,
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
|
|
|
|
|
if (canRetry && attempt === 0) {
|
|
|
|
|
attempt++;
|
|
|
|
|
await delay(250);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-18 23:17:32 +02:00
|
|
|
markConnectivityLost();
|
2026-06-18 15:13:01 +02:00
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A gateway error usually means the upstream connection was reset before our request
|
|
|
|
|
// was processed — retry idempotent calls once on a fresh connection before surfacing it.
|
|
|
|
|
if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
|
|
|
|
|
attempt++;
|
|
|
|
|
await delay(250);
|
|
|
|
|
continue;
|
2026-06-15 02:02:05 +02:00
|
|
|
}
|
2026-06-18 15:13:01 +02:00
|
|
|
|
2026-06-18 23:17:32 +02:00
|
|
|
// The server answered (anything that isn't a gateway error means it's reachable) —
|
|
|
|
|
// clear a standing "connection lost" status if one is showing.
|
|
|
|
|
if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();
|
|
|
|
|
|
2026-06-18 15:13:01 +02:00
|
|
|
if (!r.ok) {
|
|
|
|
|
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
|
|
|
|
let detail: string | undefined;
|
|
|
|
|
try {
|
|
|
|
|
const body = await r.json();
|
|
|
|
|
if (body && typeof body.detail === "string") detail = body.detail;
|
|
|
|
|
} catch {
|
|
|
|
|
/* no JSON body */
|
|
|
|
|
}
|
|
|
|
|
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
|
|
|
|
|
// not a request the app refused — treat them like a network blip with the soft,
|
|
|
|
|
// self-clearing toast rather than a blocking modal the user must acknowledge.
|
|
|
|
|
// A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
|
|
|
|
|
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
|
|
|
|
|
if (RETRIABLE_GATEWAY.has(r.status)) {
|
2026-06-18 23:17:32 +02:00
|
|
|
markConnectivityLost();
|
2026-06-19 19:52:02 +02:00
|
|
|
} else if (r.status === 401) {
|
|
|
|
|
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
|
|
|
|
|
// app shell decide what to do (drop to the login page if we were signed in). Not a modal.
|
|
|
|
|
onUnauthorized?.();
|
2026-06-19 15:15:58 +02:00
|
|
|
} else if (cfg.quiet) {
|
|
|
|
|
/* caller handles the error inline — no global modal */
|
2026-06-18 15:13:01 +02:00
|
|
|
} else if (r.status >= 500) {
|
|
|
|
|
reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
|
|
|
|
|
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
|
|
|
|
reportError(detail);
|
|
|
|
|
}
|
|
|
|
|
throw new HttpError(r.status, detail);
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
2026-06-18 15:13:01 +02:00
|
|
|
return r.status === 204 ? null : r.json();
|
2026-06-11 19:26:34 +02:00
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 12:06:02 +02:00
|
|
|
// The filter half of the query (everything except paging/sort), shared by the feed and
|
|
|
|
|
// the facet-count endpoint so both see exactly the same filter context.
|
|
|
|
|
function filterParams(f: FeedFilters): URLSearchParams {
|
2026-06-11 02:19:47 +02:00
|
|
|
const p = new URLSearchParams();
|
|
|
|
|
f.tags.forEach((t) => p.append("tags", String(t)));
|
|
|
|
|
p.set("tag_mode", f.tagMode);
|
|
|
|
|
if (f.q) p.set("q", f.q);
|
2026-06-15 04:06:22 +02:00
|
|
|
p.set("scope", f.scope);
|
2026-06-11 03:47:51 +02:00
|
|
|
p.set("show_normal", String(f.includeNormal));
|
2026-06-11 02:19:47 +02:00
|
|
|
p.set("include_shorts", String(f.includeShorts));
|
|
|
|
|
p.set("include_live", String(f.includeLive));
|
|
|
|
|
p.set("show", f.show);
|
|
|
|
|
if (f.channelId) p.set("channel_id", f.channelId);
|
|
|
|
|
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
2026-06-11 04:00:17 +02:00
|
|
|
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
|
|
|
|
if (f.dateTo) p.set("published_before", f.dateTo);
|
2026-06-11 02:19:47 +02:00
|
|
|
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
|
|
|
|
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
2026-06-29 02:11:53 +02:00
|
|
|
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
|
|
|
|
|
p.set("library_source", f.librarySource ?? "organic");
|
2026-06-15 12:06:02 +02:00
|
|
|
return p;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 19:54:40 +02:00
|
|
|
function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
|
2026-06-15 12:06:02 +02:00
|
|
|
const p = filterParams(f);
|
|
|
|
|
p.set("sort", f.sort);
|
|
|
|
|
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
2026-06-25 19:54:40 +02:00
|
|
|
if (cursor) p.set("cursor", cursor);
|
2026-06-11 02:19:47 +02:00
|
|
|
p.set("limit", String(limit));
|
|
|
|
|
return p.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
export interface SyncStatus {
|
|
|
|
|
subscriptions: number;
|
|
|
|
|
channels_total: number;
|
|
|
|
|
channels_backfilling: number;
|
|
|
|
|
videos_total: number;
|
|
|
|
|
pending_enrich: number;
|
|
|
|
|
quota_used_today: number;
|
|
|
|
|
quota_remaining_today: number;
|
|
|
|
|
paused: boolean;
|
|
|
|
|
is_admin: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
export interface MyStatus {
|
|
|
|
|
channels_total: number;
|
|
|
|
|
channels_details_synced: number;
|
|
|
|
|
channels_recent_synced: number;
|
|
|
|
|
channels_deep_done: number;
|
|
|
|
|
channels_recent_pending: number;
|
|
|
|
|
channels_deep_pending: number;
|
2026-06-11 23:07:09 +02:00
|
|
|
channels_deep_requested: number;
|
|
|
|
|
deep_pending_count: number;
|
|
|
|
|
deep_eta_seconds: number;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
my_videos: number;
|
2026-06-14 18:42:55 +02:00
|
|
|
total_videos: number;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
quota_used_today: number;
|
|
|
|
|
quota_remaining_today: number;
|
|
|
|
|
paused: boolean;
|
2026-06-19 02:57:45 +02:00
|
|
|
sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
export interface MyUsage {
|
|
|
|
|
today: number;
|
|
|
|
|
last_7d: number;
|
|
|
|
|
last_30d: number;
|
|
|
|
|
all_time: number;
|
|
|
|
|
by_action: Record<string, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AdminQuotaRow {
|
|
|
|
|
user_id: number | null;
|
|
|
|
|
email: string;
|
|
|
|
|
total: number;
|
|
|
|
|
by_action: Record<string, number>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AdminQuota {
|
|
|
|
|
range_days: number;
|
|
|
|
|
rows: AdminQuotaRow[];
|
|
|
|
|
daily: { day: string; total: number }[];
|
|
|
|
|
}
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
export interface ManagedChannel {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
handle: string | null;
|
|
|
|
|
thumbnail_url: string | null;
|
|
|
|
|
subscriber_count: number | null;
|
|
|
|
|
video_count: number | null;
|
|
|
|
|
stored_videos: number;
|
2026-06-17 23:24:25 +02:00
|
|
|
last_video_at: string | null;
|
|
|
|
|
total_duration_seconds: number;
|
|
|
|
|
count_normal: number;
|
|
|
|
|
count_short: number;
|
|
|
|
|
count_live: number;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
priority: number;
|
|
|
|
|
hidden: boolean;
|
2026-06-11 23:07:09 +02:00
|
|
|
deep_requested: boolean;
|
2026-06-12 02:29:10 +02:00
|
|
|
deep_in_queue: boolean;
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
tag_ids: number[];
|
|
|
|
|
details_synced: boolean;
|
|
|
|
|
recent_synced: boolean;
|
|
|
|
|
backfill_done: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 02:16:42 +02:00
|
|
|
// 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;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 14:38:51 +02:00
|
|
|
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;
|
2026-06-18 04:01:10 +02:00
|
|
|
// 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;
|
2026-06-16 14:38:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
2026-06-18 04:37:08 +02:00
|
|
|
maintenance: {
|
|
|
|
|
revalidate_batch: number;
|
|
|
|
|
revalidate_batch_default: number;
|
|
|
|
|
min: number;
|
|
|
|
|
max: number;
|
|
|
|
|
};
|
2026-06-16 14:38:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-16 02:05:38 +02:00
|
|
|
export interface Account {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
display_name: string | null;
|
|
|
|
|
avatar_url: string | null;
|
|
|
|
|
active: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 03:20:17 +02:00
|
|
|
// 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, any> | null;
|
|
|
|
|
read: boolean;
|
|
|
|
|
dismissed: boolean;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 22:05:35 +02:00
|
|
|
export interface MessageUser {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
avatar_url: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Message {
|
|
|
|
|
id: number;
|
|
|
|
|
kind: "user" | "system";
|
|
|
|
|
sender_id: number | null;
|
|
|
|
|
recipient_id: number;
|
|
|
|
|
body: string | null; // plaintext, system messages only
|
|
|
|
|
ciphertext: string | null; // base64, E2EE user messages only
|
|
|
|
|
iv: string | null;
|
|
|
|
|
read_at: string | null;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Conversation {
|
|
|
|
|
partner: MessageUser;
|
|
|
|
|
last_message: Message;
|
|
|
|
|
unread: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
|
|
|
|
|
export interface MyKeyBundle {
|
|
|
|
|
configured: boolean;
|
|
|
|
|
public_key?: string;
|
|
|
|
|
wrapped_private_key?: string;
|
|
|
|
|
salt?: string;
|
|
|
|
|
wrap_iv?: string;
|
|
|
|
|
key_check?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface KeySetup {
|
|
|
|
|
public_key: string;
|
|
|
|
|
wrapped_private_key: string;
|
|
|
|
|
salt: string;
|
|
|
|
|
wrap_iv: string;
|
|
|
|
|
key_check: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:23:00 +02:00
|
|
|
// 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)
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:35:08 +02:00
|
|
|
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
|
|
|
|
export interface PlexSection {
|
|
|
|
|
key: string;
|
|
|
|
|
title: string;
|
|
|
|
|
type: string; // "movie" | "show"
|
|
|
|
|
count?: number;
|
|
|
|
|
}
|
|
|
|
|
export interface PlexTestResult {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
server_name: string;
|
|
|
|
|
version?: string;
|
|
|
|
|
sections: PlexSection[];
|
2026-07-05 06:58:34 +02:00
|
|
|
// Whether a sample media file resolved + was readable through the local mount (bind-mount +
|
|
|
|
|
// path-map check). checked=false when the library is empty (nothing to probe yet).
|
|
|
|
|
media_check?: {
|
|
|
|
|
checked: boolean;
|
|
|
|
|
ok?: boolean;
|
|
|
|
|
plex_path?: string;
|
|
|
|
|
local_path?: string;
|
|
|
|
|
};
|
2026-07-05 01:35:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 02:32:00 +02:00
|
|
|
// Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards).
|
|
|
|
|
export interface PlexLibrary {
|
|
|
|
|
key: string;
|
|
|
|
|
title: string;
|
|
|
|
|
kind: "movie" | "show";
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
export interface PlexCard {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "movie" | "show" | "episode";
|
|
|
|
|
title: string;
|
|
|
|
|
year?: number | null;
|
|
|
|
|
duration_seconds?: number | null;
|
|
|
|
|
thumb: string;
|
|
|
|
|
playable?: string; // direct | remux | transcode
|
|
|
|
|
status?: string; // new | watched | hidden
|
|
|
|
|
position_seconds?: number;
|
|
|
|
|
season_count?: number | null; // show
|
|
|
|
|
season_number?: number | null; // episode
|
|
|
|
|
episode_number?: number | null; // episode
|
|
|
|
|
summary?: string | null; // episode
|
|
|
|
|
}
|
|
|
|
|
export interface PlexBrowseResult {
|
|
|
|
|
kind: "movie" | "show";
|
|
|
|
|
total: number;
|
|
|
|
|
offset: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
items: PlexCard[];
|
|
|
|
|
}
|
|
|
|
|
export interface PlexSeasonDetail {
|
|
|
|
|
id: string;
|
|
|
|
|
season_number: number | null;
|
|
|
|
|
title: string;
|
|
|
|
|
thumb: string;
|
|
|
|
|
episodes: PlexCard[];
|
|
|
|
|
}
|
|
|
|
|
export interface PlexShowDetail {
|
|
|
|
|
show: {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
summary?: string | null;
|
|
|
|
|
year?: number | null;
|
|
|
|
|
thumb: string;
|
|
|
|
|
art: string;
|
|
|
|
|
};
|
|
|
|
|
seasons: PlexSeasonDetail[];
|
|
|
|
|
}
|
|
|
|
|
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
export interface PlexMarker {
|
|
|
|
|
type: "intro" | "credits";
|
|
|
|
|
start_s: number;
|
|
|
|
|
end_s: number;
|
|
|
|
|
}
|
|
|
|
|
export interface PlexItemDetail {
|
|
|
|
|
id: string;
|
|
|
|
|
kind: "movie" | "episode";
|
|
|
|
|
title: string;
|
|
|
|
|
summary?: string | null;
|
|
|
|
|
year?: number | null;
|
|
|
|
|
duration_seconds: number | null;
|
|
|
|
|
playable: string; // direct | remux | transcode
|
|
|
|
|
thumb: string;
|
|
|
|
|
art: string;
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
cast: PlexCastMember[];
|
|
|
|
|
imdb_rating?: number | null;
|
|
|
|
|
imdb_id?: string | null;
|
|
|
|
|
imdb_url?: string | null;
|
|
|
|
|
content_rating?: string | null;
|
|
|
|
|
genres: string[];
|
|
|
|
|
directors: string[];
|
|
|
|
|
studio?: string | null;
|
|
|
|
|
tagline?: string | null;
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
markers: PlexMarker[];
|
2026-07-05 06:08:44 +02:00
|
|
|
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
|
|
|
|
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
status: string;
|
|
|
|
|
position_seconds: number;
|
|
|
|
|
show_title?: string | null;
|
|
|
|
|
season_number?: number | null;
|
|
|
|
|
episode_number?: number | null;
|
|
|
|
|
prev_id?: string | null;
|
|
|
|
|
next_id?: string | null;
|
2026-07-06 02:25:48 +02:00
|
|
|
collections?: { id: string; title: string; items: PlexCard[] }[];
|
|
|
|
|
}
|
|
|
|
|
export interface PlexCollection {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
summary?: string | null;
|
|
|
|
|
thumb?: string | null;
|
|
|
|
|
child_count?: number | null;
|
|
|
|
|
smart: boolean;
|
|
|
|
|
editable: boolean;
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
}
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
export interface PlexCastMember {
|
|
|
|
|
name: string;
|
|
|
|
|
role?: string | null;
|
|
|
|
|
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
|
|
|
|
|
}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
|
|
|
|
export interface PlexFilters {
|
|
|
|
|
genres: string[];
|
2026-07-06 00:15:31 +02:00
|
|
|
genreMode?: "any" | "all"; // how multiple genres combine (default any)
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
contentRatings: string[];
|
|
|
|
|
yearMin?: number | null;
|
|
|
|
|
yearMax?: number | null;
|
|
|
|
|
ratingMin?: number | null;
|
|
|
|
|
durationMin?: number | null; // seconds
|
|
|
|
|
durationMax?: number | null;
|
|
|
|
|
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
|
2026-07-06 00:15:31 +02:00
|
|
|
directors: string[]; // AND — titles featuring ALL selected
|
|
|
|
|
actors: string[]; // AND — titles featuring ALL selected
|
|
|
|
|
studios: string[]; // OR — from any selected studio
|
2026-07-06 02:25:48 +02:00
|
|
|
collection?: string | null; // a single Plex collection (rating_key)
|
|
|
|
|
collectionTitle?: string | null; // its title, for the active-filter chip
|
2026-07-06 00:15:31 +02:00
|
|
|
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
|
|
|
|
}
|
|
|
|
|
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
|
|
|
|
genres: [],
|
|
|
|
|
contentRatings: [],
|
|
|
|
|
directors: [],
|
|
|
|
|
actors: [],
|
|
|
|
|
studios: [],
|
|
|
|
|
};
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
export function plexFilterCount(f: PlexFilters): number {
|
|
|
|
|
return (
|
|
|
|
|
f.genres.length +
|
|
|
|
|
f.contentRatings.length +
|
|
|
|
|
(f.yearMin != null || f.yearMax != null ? 1 : 0) +
|
|
|
|
|
(f.ratingMin != null ? 1 : 0) +
|
|
|
|
|
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
|
|
|
|
|
(f.addedWithin ? 1 : 0) +
|
2026-07-06 00:15:31 +02:00
|
|
|
f.directors.length +
|
|
|
|
|
f.actors.length +
|
2026-07-06 02:25:48 +02:00
|
|
|
f.studios.length +
|
|
|
|
|
(f.collection ? 1 : 0)
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
);
|
|
|
|
|
}
|
2026-07-06 00:56:01 +02:00
|
|
|
export interface PlexPerson {
|
|
|
|
|
name: string;
|
|
|
|
|
kind: "actor" | "director";
|
|
|
|
|
count: number;
|
|
|
|
|
photo?: string | null;
|
|
|
|
|
}
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
export interface PlexFacets {
|
|
|
|
|
genres: { value: string; count: number }[];
|
|
|
|
|
content_ratings: { value: string; count: number }[];
|
|
|
|
|
year_min: number | null;
|
|
|
|
|
year_max: number | null;
|
|
|
|
|
rating_max: number | null;
|
|
|
|
|
duration_min: number | null;
|
|
|
|
|
duration_max: number | null;
|
|
|
|
|
}
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
export interface PlexPlaySession {
|
|
|
|
|
mode: "direct" | "hls";
|
|
|
|
|
url: string;
|
|
|
|
|
start_s: number;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 14:16:48 +02:00
|
|
|
// Admin Users & roles page.
|
|
|
|
|
export interface AdminUserRow {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
display_name: string | null;
|
|
|
|
|
role: string; // "user" | "admin"
|
|
|
|
|
is_active: boolean;
|
2026-06-19 19:52:02 +02:00
|
|
|
is_suspended: boolean;
|
2026-06-19 14:16:48 +02:00
|
|
|
email_verified: boolean;
|
|
|
|
|
is_demo: boolean;
|
|
|
|
|
has_password: boolean;
|
|
|
|
|
has_google: boolean;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
// --- download center ---
|
|
|
|
|
export interface DownloadSpec {
|
|
|
|
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
|
|
|
|
max_height: number | null;
|
|
|
|
|
container: string | null;
|
|
|
|
|
audio_format: string | null;
|
|
|
|
|
vcodec: string | null;
|
|
|
|
|
embed_subs: boolean;
|
|
|
|
|
embed_chapters: boolean;
|
|
|
|
|
embed_thumbnail: boolean;
|
|
|
|
|
sponsorblock: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DownloadProfile {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
spec: DownloadSpec;
|
|
|
|
|
is_builtin: boolean;
|
|
|
|
|
sort_order: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface DownloadAsset {
|
|
|
|
|
id: number;
|
|
|
|
|
status: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
uploader: string | null;
|
|
|
|
|
upload_date: string | null;
|
|
|
|
|
duration_s: number | null;
|
|
|
|
|
size_bytes: number | null;
|
|
|
|
|
width: number | null;
|
|
|
|
|
height: number | null;
|
|
|
|
|
container: string | null;
|
|
|
|
|
thumbnail_url: string | null;
|
|
|
|
|
expires_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type DownloadStatus =
|
|
|
|
|
| "queued"
|
|
|
|
|
| "running"
|
|
|
|
|
| "paused"
|
|
|
|
|
| "done"
|
|
|
|
|
| "error"
|
|
|
|
|
| "canceled";
|
|
|
|
|
|
2026-07-04 03:26:33 +02:00
|
|
|
// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
|
|
|
|
|
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
|
2026-07-04 01:10:42 +02:00
|
|
|
export interface EditSpec {
|
|
|
|
|
trim?: { start_s: number; end_s?: number };
|
2026-07-04 03:26:33 +02:00
|
|
|
segments?: { start_s: number; end_s: number }[];
|
2026-07-04 01:10:42 +02:00
|
|
|
crop?: { x: number; y: number; w: number; h: number };
|
|
|
|
|
accurate?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StoryboardMeta {
|
|
|
|
|
available: boolean;
|
|
|
|
|
cols?: number;
|
|
|
|
|
rows?: number;
|
|
|
|
|
count?: number;
|
|
|
|
|
interval_s?: number;
|
|
|
|
|
url?: string;
|
|
|
|
|
}
|
|
|
|
|
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
export interface DownloadJob {
|
|
|
|
|
id: number;
|
|
|
|
|
status: DownloadStatus;
|
|
|
|
|
progress: number;
|
|
|
|
|
speed_bps: number | null;
|
|
|
|
|
eta_s: number | null;
|
|
|
|
|
phase: string | null;
|
|
|
|
|
error: string | null;
|
|
|
|
|
queue_pos: number;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
source_kind: string;
|
|
|
|
|
source_ref: string;
|
2026-07-04 21:25:37 +02:00
|
|
|
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
profile_id: number | null;
|
|
|
|
|
spec: DownloadSpec;
|
|
|
|
|
display_name: string | null;
|
2026-07-04 01:10:42 +02:00
|
|
|
job_kind?: string; // "download" (default) | "edit"
|
|
|
|
|
source_job_id?: number | null; // parent download an edit was cut from
|
|
|
|
|
edit_spec?: EditSpec | null;
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
can_download: boolean;
|
|
|
|
|
expired: boolean;
|
|
|
|
|
asset: DownloadAsset | null;
|
|
|
|
|
shared?: boolean;
|
|
|
|
|
user_email?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 04:32:31 +02:00
|
|
|
export interface ShareRecipient {
|
|
|
|
|
id: number;
|
|
|
|
|
email: string;
|
|
|
|
|
display_name: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ShareLink {
|
|
|
|
|
id: number;
|
|
|
|
|
token: string;
|
|
|
|
|
url: string; // path like "/watch/<token>" — prepend window.location.origin to share
|
|
|
|
|
allow_download: boolean;
|
|
|
|
|
has_password: boolean;
|
|
|
|
|
expires_at: string | null;
|
|
|
|
|
view_count: number;
|
|
|
|
|
created_at: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
export interface DownloadUsage {
|
|
|
|
|
footprint_bytes: number;
|
|
|
|
|
active_jobs: number;
|
|
|
|
|
running_jobs: number;
|
|
|
|
|
max_bytes: number;
|
|
|
|
|
max_concurrent: number;
|
|
|
|
|
max_jobs: number;
|
|
|
|
|
unlimited: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AdminStorage {
|
|
|
|
|
ready_files: number;
|
|
|
|
|
total_bytes: number;
|
|
|
|
|
total_assets: number;
|
|
|
|
|
total_cap_bytes: number;
|
|
|
|
|
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AdminDownloadQuota {
|
|
|
|
|
user_id: number;
|
|
|
|
|
custom: boolean;
|
|
|
|
|
max_bytes: number;
|
|
|
|
|
max_concurrent: number;
|
|
|
|
|
max_jobs: number;
|
|
|
|
|
unlimited: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
export const api = {
|
2026-07-04 18:17:24 +02:00
|
|
|
// 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> => {
|
|
|
|
|
const r = await req("/api/me");
|
|
|
|
|
return r && r.authenticated ? (r as Me) : null;
|
|
|
|
|
},
|
2026-06-16 02:05:38 +02:00
|
|
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
2026-06-19 15:46:49 +02:00
|
|
|
deleteAccount: (): Promise<{ deleted: boolean }> =>
|
|
|
|
|
req("/api/me/account", { method: "DELETE" }),
|
2026-06-16 02:05:38 +02:00
|
|
|
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
|
|
|
|
|
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
|
2026-07-01 23:43:35 +02:00
|
|
|
// 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" }),
|
2026-06-15 00:06:57 +02:00
|
|
|
version: (): Promise<VersionInfo> => req("/api/version"),
|
2026-06-11 02:19:47 +02:00
|
|
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
2026-06-11 04:15:25 +02:00
|
|
|
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
2026-06-25 19:54:40 +02:00
|
|
|
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
|
|
|
|
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
2026-06-11 04:15:25 +02:00
|
|
|
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
2026-06-25 19:54:40 +02:00
|
|
|
req(`/api/feed/count?${filterParams(f).toString()}`),
|
2026-06-29 02:01:44 +02:00
|
|
|
// 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
|
2026-06-29 22:30:03 +02:00
|
|
|
// 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.
|
2026-07-01 01:00:32 +02:00
|
|
|
searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
|
2026-06-29 02:01:44 +02:00
|
|
|
const p = new URLSearchParams({ q });
|
|
|
|
|
if (cursor) p.set("cursor", cursor);
|
2026-07-01 01:00:32 +02:00
|
|
|
if (limit) p.set("limit", String(limit));
|
2026-06-29 02:01:44 +02:00
|
|
|
return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
|
|
|
|
|
},
|
2026-07-01 01:00:32 +02:00
|
|
|
// 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 }) }),
|
2026-06-15 12:06:02 +02:00
|
|
|
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
|
|
|
|
req(`/api/facets?${filterParams(f).toString()}`),
|
2026-06-18 15:13:01 +02:00
|
|
|
// 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()).
|
2026-06-11 02:19:47 +02:00
|
|
|
setState: (id: string, status: string) =>
|
2026-06-18 15:13:01 +02:00
|
|
|
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 }),
|
2026-06-14 18:40:12 +02:00
|
|
|
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
|
2026-06-18 15:13:01 +02:00
|
|
|
req(
|
|
|
|
|
`/api/videos/${id}/progress`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
position_seconds: Math.floor(positionSeconds),
|
|
|
|
|
duration_seconds: Math.floor(durationSeconds),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
{ idempotent: true }
|
|
|
|
|
),
|
2026-06-12 17:39:01 +02:00
|
|
|
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
2026-06-11 04:15:25 +02:00
|
|
|
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
|
|
|
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
2026-06-18 23:59:40 +02:00
|
|
|
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
|
2026-06-11 02:19:47 +02:00
|
|
|
savePrefs: (p: Record<string, any>) =>
|
2026-06-18 23:59:40 +02:00
|
|
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
|
|
|
|
|
// --- channel manager ---
|
|
|
|
|
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
|
|
|
|
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
2026-06-11 23:07:09 +02:00
|
|
|
updateChannel: (
|
|
|
|
|
id: string,
|
|
|
|
|
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
|
|
|
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
2026-06-17 19:16:23 +02:00
|
|
|
resetChannelBackfill: (id: string) =>
|
|
|
|
|
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
2026-06-11 23:07:09 +02:00
|
|
|
deepAll: (on = true) =>
|
|
|
|
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
attachChannelTag: (id: string, tagId: number) =>
|
|
|
|
|
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
|
|
|
|
detachChannelTag: (id: string, tagId: number) =>
|
|
|
|
|
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
2026-06-11 23:27:11 +02:00
|
|
|
unsubscribeChannel: (id: string) =>
|
|
|
|
|
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
2026-06-19 02:16:42 +02:00
|
|
|
discoveredChannels: (): Promise<DiscoveredChannel[]> =>
|
|
|
|
|
req("/api/channels/discovery"),
|
|
|
|
|
subscribeChannel: (id: string) =>
|
|
|
|
|
req(`/api/channels/${id}/subscribe`, { method: "POST" }),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
2026-06-30 03:08:52 +02:00
|
|
|
// --- 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 }
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-07-01 01:00:32 +02:00
|
|
|
// --- 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" }),
|
2026-06-12 02:47:55 +02:00
|
|
|
|
2026-06-15 14:45:18 +02:00
|
|
|
// --- playlists ---
|
|
|
|
|
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
|
|
|
|
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
|
|
|
|
|
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
|
|
|
|
|
createPlaylist: (name: string): Promise<Playlist> =>
|
|
|
|
|
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
|
|
|
|
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
|
|
|
|
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
2026-06-15 21:23:13 +02:00
|
|
|
deletePlaylist: (id: number, onYoutube = false) =>
|
|
|
|
|
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
2026-06-15 14:45:18 +02:00
|
|
|
addToPlaylist: (id: number, videoId: string) =>
|
|
|
|
|
req(`/api/playlists/${id}/items`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ video_id: videoId }),
|
|
|
|
|
}),
|
|
|
|
|
removeFromPlaylist: (id: number, videoId: string) =>
|
|
|
|
|
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
|
|
|
|
reorderPlaylist: (id: number, videoIds: string[]) =>
|
|
|
|
|
req(`/api/playlists/${id}/order`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
body: JSON.stringify({ video_ids: videoIds }),
|
|
|
|
|
}),
|
2026-06-15 15:33:53 +02:00
|
|
|
// Bookmark shortcut for the built-in Watch later playlist.
|
|
|
|
|
watchLaterAdd: (videoId: string) =>
|
|
|
|
|
req("/api/playlists/watch-later", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ video_id: videoId }),
|
|
|
|
|
}),
|
|
|
|
|
watchLaterRemove: (videoId: string) =>
|
|
|
|
|
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
2026-06-15 19:37:17 +02:00
|
|
|
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
|
|
|
|
req("/api/playlists/sync-youtube", { method: "POST" }),
|
2026-06-15 22:28:16 +02:00
|
|
|
revertPlaylist: (id: number): Promise<{ items: number }> =>
|
|
|
|
|
req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
|
2026-06-15 21:23:13 +02:00
|
|
|
playlistPushPlan: (id: number): Promise<PushPlan> =>
|
|
|
|
|
req(`/api/playlists/${id}/push-plan`),
|
|
|
|
|
pushPlaylist: (id: number): Promise<PushResult> =>
|
|
|
|
|
req(`/api/playlists/${id}/push`, { method: "POST" }),
|
2026-06-15 14:45:18 +02:00
|
|
|
|
2026-06-12 02:47:55 +02:00
|
|
|
// --- quota usage ---
|
|
|
|
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
|
|
|
|
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
2026-06-19 12:23:00 +02:00
|
|
|
// --- 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" }),
|
2026-07-05 01:35:08 +02:00
|
|
|
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
|
2026-07-05 02:32:00 +02:00
|
|
|
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
|
|
|
|
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
|
|
|
|
req("/api/plex/libraries"),
|
|
|
|
|
plexBrowse: (p: {
|
|
|
|
|
library: string;
|
|
|
|
|
q?: string;
|
|
|
|
|
sort?: string;
|
2026-07-05 03:29:20 +02:00
|
|
|
show?: string;
|
2026-07-05 02:32:00 +02:00
|
|
|
offset?: number;
|
|
|
|
|
limit?: number;
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
filters?: PlexFilters;
|
2026-07-05 02:32:00 +02:00
|
|
|
}): Promise<PlexBrowseResult> => {
|
|
|
|
|
const u = new URLSearchParams({ library: p.library });
|
|
|
|
|
if (p.q) u.set("q", p.q);
|
|
|
|
|
if (p.sort) u.set("sort", p.sort);
|
2026-07-05 03:29:20 +02:00
|
|
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
2026-07-05 02:32:00 +02:00
|
|
|
if (p.offset) u.set("offset", String(p.offset));
|
|
|
|
|
if (p.limit) u.set("limit", String(p.limit));
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
const f = p.filters;
|
|
|
|
|
if (f) {
|
|
|
|
|
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
2026-07-06 00:15:31 +02:00
|
|
|
if (f.genreMode === "all") u.set("genre_mode", "all");
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
|
|
|
|
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
|
|
|
|
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
|
|
|
|
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
|
|
|
|
|
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
|
|
|
|
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
|
|
|
|
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
2026-07-06 00:15:31 +02:00
|
|
|
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
|
|
|
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
|
|
|
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
2026-07-06 02:25:48 +02:00
|
|
|
if (f.collection) u.set("collection", f.collection);
|
2026-07-06 00:15:31 +02:00
|
|
|
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
}
|
2026-07-05 02:32:00 +02:00
|
|
|
return req(`/api/plex/browse?${u.toString()}`);
|
|
|
|
|
},
|
feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).
Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
2026-07-05 23:45:55 +02:00
|
|
|
plexFacets: (library: string): Promise<PlexFacets> =>
|
|
|
|
|
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
2026-07-06 00:56:01 +02:00
|
|
|
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
|
|
|
|
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
2026-07-06 02:25:48 +02:00
|
|
|
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
|
|
|
|
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
2026-07-05 02:32:00 +02:00
|
|
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
|
|
|
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
|
|
|
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
2026-07-05 06:08:44 +02:00
|
|
|
plexSession: (
|
|
|
|
|
id: string,
|
|
|
|
|
start = 0,
|
|
|
|
|
audio?: number | null,
|
|
|
|
|
subtitle?: number | null,
|
|
|
|
|
): Promise<PlexPlaySession> => {
|
|
|
|
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
|
|
|
|
if (audio != null) p.set("audio", String(audio));
|
|
|
|
|
if (subtitle != null) p.set("subtitle", String(subtitle));
|
|
|
|
|
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
|
|
|
|
},
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
|
|
|
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ position_seconds, duration_seconds }),
|
|
|
|
|
}),
|
|
|
|
|
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
|
|
|
|
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ status }),
|
|
|
|
|
}),
|
|
|
|
|
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
|
2026-07-05 05:26:16 +02:00
|
|
|
plexDownloadUrl: (id: string): string =>
|
|
|
|
|
`/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,
|
2026-07-05 02:32:00 +02:00
|
|
|
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
|
|
|
|
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
2026-06-19 14:16:48 +02:00
|
|
|
// --- admin: users & roles ---
|
|
|
|
|
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 }) }),
|
2026-06-19 19:52:02 +02:00
|
|
|
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" }),
|
2026-06-16 14:38:51 +02:00
|
|
|
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
2026-06-16 15:58:23 +02:00
|
|
|
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 }),
|
|
|
|
|
}),
|
2026-06-18 04:01:10 +02:00
|
|
|
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" }),
|
2026-06-18 04:37:08 +02:00
|
|
|
updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
|
|
|
|
|
req("/api/admin/scheduler/maintenance", {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: JSON.stringify({ revalidate_batch: revalidateBatch }),
|
|
|
|
|
}),
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
|
2026-06-20 20:04:23 +02:00
|
|
|
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
2026-07-01 12:46:50 +02:00
|
|
|
authConfig: (): Promise<{
|
|
|
|
|
google_enabled: boolean;
|
|
|
|
|
allow_registration: boolean;
|
|
|
|
|
operator_contact: string | null;
|
|
|
|
|
}> => req("/auth/config"),
|
2026-06-21 01:11:16 +02:00
|
|
|
|
|
|
|
|
// --- 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 }),
|
2026-06-19 15:15:58 +02:00
|
|
|
// --- 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 }),
|
2026-06-19 19:52:02 +02:00
|
|
|
// 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 }
|
|
|
|
|
),
|
2026-06-12 01:43:07 +02:00
|
|
|
// --- onboarding / admin ---
|
|
|
|
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
|
|
|
|
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
2026-06-16 09:17:34 +02:00
|
|
|
// 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" }),
|
2026-06-12 01:43:07 +02:00
|
|
|
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 }) }),
|
|
|
|
|
|
2026-06-18 03:20:17 +02:00
|
|
|
// --- 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" }),
|
|
|
|
|
|
2026-07-01 03:17:36 +02:00
|
|
|
// --- 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 }) }),
|
|
|
|
|
|
2026-06-25 22:05:35 +02:00
|
|
|
// --- direct messages (end-to-end encrypted) ---
|
|
|
|
|
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
|
|
|
|
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
|
|
|
|
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
|
|
|
|
|
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
|
|
|
|
|
req(`/api/messages/keys/${userId}`),
|
|
|
|
|
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
|
|
|
|
|
conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
|
|
|
|
|
thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
|
|
|
|
|
req(`/api/messages/conversations/${partnerId}`),
|
|
|
|
|
sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
|
|
|
|
|
req("/api/messages", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
|
|
|
|
|
}),
|
|
|
|
|
messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),
|
|
|
|
|
|
feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags),
user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts).
Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in
tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status +
actions + admin pause/resume, Account); a channel manager with priority, hide,
per-channel user tags, sync badges and 'view in feed'. Notifications now honor the
configurable sound + auto-dismiss settings.
2026-06-11 20:45:48 +02:00
|
|
|
// --- user tags ---
|
|
|
|
|
createTag: (t: { name: string; color?: string; category?: string }) =>
|
|
|
|
|
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
|
|
|
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
|
|
|
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
|
|
|
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
|
|
|
|
|
// --- download center ---
|
|
|
|
|
downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
|
|
|
|
|
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
|
|
|
|
|
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
|
|
|
|
|
updateDownloadProfile: (
|
|
|
|
|
id: number,
|
|
|
|
|
patch: { name?: string; spec?: DownloadSpec }
|
|
|
|
|
): Promise<DownloadProfile> =>
|
|
|
|
|
req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
|
|
|
deleteDownloadProfile: (id: number) =>
|
|
|
|
|
req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
|
|
|
|
|
|
|
|
|
|
enqueueDownload: (body: {
|
|
|
|
|
source: string;
|
|
|
|
|
profile_id?: number;
|
|
|
|
|
spec?: DownloadSpec;
|
|
|
|
|
display_name?: string;
|
|
|
|
|
}): Promise<DownloadJob> =>
|
|
|
|
|
req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
|
2026-07-04 01:10:42 +02:00
|
|
|
enqueueEdit: (body: {
|
|
|
|
|
source_job_id: number;
|
|
|
|
|
edit_spec: EditSpec;
|
|
|
|
|
display_name?: string;
|
|
|
|
|
}): Promise<DownloadJob> =>
|
|
|
|
|
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
|
|
|
|
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
|
|
|
|
req(`/api/downloads/${id}/storyboard`),
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
|
|
|
|
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
|
2026-07-04 05:21:50 +02:00
|
|
|
// Remove a shared-with-me item from your list (deletes only your share grant, not the file).
|
|
|
|
|
removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
|
|
|
|
|
req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
|
|
|
|
|
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
|
|
|
|
|
pauseDownload: (id: number): Promise<DownloadJob> =>
|
|
|
|
|
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
|
|
|
|
|
resumeDownload: (id: number): Promise<DownloadJob> =>
|
|
|
|
|
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
|
|
|
|
|
cancelDownload: (id: number): Promise<DownloadJob> =>
|
|
|
|
|
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
|
|
|
|
|
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
|
|
|
|
|
renameDownload: (id: number, display_name: string): Promise<DownloadJob> =>
|
|
|
|
|
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }),
|
|
|
|
|
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
|
|
|
|
|
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
|
|
|
|
|
unshareDownload: (id: number, email: string) =>
|
|
|
|
|
req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
|
2026-07-04 04:32:31 +02:00
|
|
|
// Registered users this user can share with (for the internal picker).
|
|
|
|
|
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
|
|
|
|
|
// Public watch links (share by link).
|
|
|
|
|
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
|
|
|
|
createDownloadLink: (
|
|
|
|
|
jobId: number,
|
|
|
|
|
body: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
|
|
|
|
): Promise<ShareLink> =>
|
|
|
|
|
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
|
|
|
|
updateDownloadLink: (
|
|
|
|
|
linkId: number,
|
|
|
|
|
patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
|
|
|
|
|
): Promise<ShareLink> =>
|
|
|
|
|
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
|
|
|
|
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
|
|
|
|
|
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
2026-07-03 02:08:10 +02:00
|
|
|
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
|
|
|
|
|
// account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
|
|
|
|
|
// download resolves to the session-default account and 404s for a per-tab account's file.
|
|
|
|
|
downloadFileUrl: (id: number): string => {
|
|
|
|
|
const a = getActiveAccount();
|
|
|
|
|
return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
|
|
|
|
|
},
|
2026-07-04 01:10:42 +02:00
|
|
|
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
|
|
|
|
|
storyboardImageUrl: (id: number): string => {
|
|
|
|
|
const a = getActiveAccount();
|
|
|
|
|
return a != null
|
|
|
|
|
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
|
|
|
|
|
: `/api/downloads/${id}/storyboard.jpg`;
|
|
|
|
|
},
|
feat(downloads): M5 — Download Center frontend
- api.ts: download types + methods (profiles, enqueue, list/manage, file url, share,
usage, index, admin storage/quota)
- format.ts: formatBytes / formatSpeed
- i18n: en/hu/de downloads.json (trilingual)
- Downloads nav module (Download icon, active-count badge, hidden for demo) placed after
Messages; urlState page + App render + Header title
- DownloadButton: self-contained per-card/player affordance (demo-hidden, reflects the
per-user download index: downloaded / in-queue), opens the preset dialog
- DownloadDialog: preset picker + optional display name -> enqueue + 'View' toast
- ProfileEditor: manage built-in + custom formats (full spec form incl. SponsorBlock)
- DownloadCenter: Queue / Library / Shared / (admin) System tabs, live progress via
useLiveQuery, usage bar, pause/resume/cancel/delete, save-to-device, rename, share,
admin storage dashboard + per-user quota editor
- wired DownloadButton into VideoCard + PlayerModal
- lib/nav.ts: decoupled navigator so the download toast can jump to the page
tsc + vite build clean. Verified in a headless authed browser: page + tabs render, Library
shows a real download with usage bar + actions, no console errors.
2026-07-03 01:52:58 +02:00
|
|
|
|
|
|
|
|
// admin
|
|
|
|
|
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
|
|
|
|
|
adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
|
|
|
|
|
adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
|
|
|
|
req(`/api/admin/downloads/quota/${userId}`),
|
|
|
|
|
adminSetDownloadQuota: (
|
|
|
|
|
userId: number,
|
|
|
|
|
patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
|
|
|
|
|
): Promise<AdminDownloadQuota> =>
|
|
|
|
|
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
|
|
|
|
|
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
|
|
|
|
|
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
|
2026-06-11 02:19:47 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export { HttpError };
|