Merge: promote dev to prod

This commit is contained in:
2026-07-28 00:25:52 +02:00
70 changed files with 1786 additions and 1336 deletions
+1 -1
View File
@@ -1 +1 @@
0.55.0
0.56.0
+5 -4
View File
@@ -37,10 +37,11 @@ export default tseslint.config(
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
"@typescript-eslint/no-unused-vars": "off",
"no-undef": "off",
// The 19 `any`s all live in the api.ts god-module, which R7 (Frontend API layer) rewrites.
// Turning the rule on now would either block the gate on pre-existing debt or scatter 19
// suppressions — R7 flips this back to "error" when it types that layer. Tracked, not ignored.
"@typescript-eslint/no-explicit-any": "off",
// R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
// catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
"@typescript-eslint/no-explicit-any": "error",
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [
+11 -10
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
@@ -135,7 +136,7 @@ export default function App() {
// We check this before anything else and render the wizard; `me` is held back until we know the
// instance is set up (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({
queryKey: ["setup-status"],
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
});
@@ -145,7 +146,7 @@ export default function App() {
// Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there.
const meQuery = useQuery({
queryKey: ["me"],
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
@@ -173,7 +174,7 @@ export default function App() {
// can't loop the public landing.
useEffect(() => {
setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) {
if (queryClient.getQueryData(qk.me())) {
queryClient.clear();
window.location.reload();
}
@@ -223,13 +224,12 @@ export default function App() {
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
// and the feed view by FeedViewProvider — each via its own passive ["me"] observer.
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
const prefsRec = prefs as Record<string, unknown>;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
const raw = prefs[PREF_KEY[p]];
if (raw) {
const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
@@ -244,11 +244,12 @@ export default function App() {
setFilterCollapsedState(prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
}
if (typeof prefsRec.playlistsCollapsed === "boolean") {
setPlaylistsCollapsedState(prefsRec.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed);
if (typeof prefs.playlistsCollapsed === "boolean") {
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
}
if (isSupported(prefs.language)) setLanguage(prefs.language);
const lang = prefs.language;
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// dedicated effect above, which also covers accounts that have no stored preferences.)
// eslint-disable-next-line react-hooks/exhaustive-deps
+6 -1
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
@@ -21,7 +22,11 @@ export default function About({
onOpenReleaseNotes: () => void;
}) {
const { t } = useTranslation();
const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 });
const { data } = useQuery({
queryKey: qk.version(),
queryFn: api.version,
staleTime: 5 * 60_000,
});
const rows: [string, string][] = [
[
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
@@ -24,7 +25,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const { data: ch } = useQuery({
queryKey: ["channel", channelId],
queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId),
enabled: open && !!channelId,
staleTime: 5 * 60_000,
+5 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
@@ -28,7 +29,7 @@ export default function AddToPlaylist({
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: ["playlist-membership", videoId],
queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId),
enabled: open,
});
@@ -75,11 +76,11 @@ export default function AddToPlaylist({
}, [open]);
function refresh() {
qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] });
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
// Also invalidate every open/cached playlist detail (["playlist", id]) so the
// Playlists page reflects the add/remove when navigated to, not only after F5.
qc.invalidateQueries({ queryKey: ["playlist"] });
qc.invalidateQueries({ queryKey: qk.playlist() });
}
async function toggle(pl: Playlist) {
+11 -10
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
@@ -70,7 +71,7 @@ function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
@@ -86,7 +87,7 @@ function UsersRoles({ me }: { me: Me }) {
api.setUserRole(id, role),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
@@ -98,7 +99,7 @@ function UsersRoles({ me }: { me: Me }) {
api.setUserSuspended(id, suspended),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({
level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
@@ -113,7 +114,7 @@ function UsersRoles({ me }: { me: Me }) {
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
onSettled: (_d, _e, vars) => endPending(vars.id),
@@ -267,11 +268,11 @@ function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin-invites"] });
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
qc.invalidateQueries({ queryKey: qk.adminInvites() });
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
};
const approve = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
@@ -383,14 +384,14 @@ function AdminDemo() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
const list = useQuery({ queryKey: qk.demoWhitelist(), queryFn: () => api.adminDemoWhitelist() });
const [newEmail, setNewEmail] = useState("");
const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: (_d, email) => {
setNewEmail("");
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.added", { email }) });
},
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
@@ -398,7 +399,7 @@ function AdminDemo() {
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
+3 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
@@ -18,7 +19,7 @@ export default function AuditLog() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() });
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
const data = q.data;
const rows = useMemo(() => data?.items ?? [], [data]);
@@ -31,7 +32,7 @@ export default function AuditLog() {
const clear = useMutation({
mutationFn: () => api.clearAudit(),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ["admin-audit"] });
qc.invalidateQueries({ queryKey: qk.adminAudit() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
},
onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
@@ -52,7 +53,7 @@ export default function ChannelDiscovery({
};
const query = useQuery({
queryKey: ["discovered-channels"],
queryKey: qk.discoveredChannels(),
queryFn: api.discoveredChannels,
});
@@ -61,9 +62,9 @@ export default function ChannelDiscovery({
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
+15 -14
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider";
import {
ArrowLeft,
@@ -73,7 +74,7 @@ export default function ChannelPage({
const [bannerAttempt, setBannerAttempt] = useState(0);
const detail = useQuery({
queryKey: ["channel", channelId],
queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId),
});
const ch = detail.data;
@@ -91,10 +92,10 @@ export default function ChannelPage({
.exploreChannel(channelId, token ?? undefined)
.then((r) => {
setNextToken(r.next_page_token);
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.feed() });
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
// first visit (the GET that drove this render predated the explore that just ran).
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
})
.finally(() => setExploring(false));
},
@@ -116,9 +117,9 @@ export default function ChannelPage({
const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
},
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
@@ -128,12 +129,12 @@ export default function ChannelPage({
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["discovered-channels"] });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
@@ -143,9 +144,9 @@ export default function ChannelPage({
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
@@ -25,7 +26,7 @@ export default function ChannelPicker({
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade();
const channelsQuery = useQuery({
queryKey: ["channels"],
queryKey: qk.channels(),
queryFn: api.channels,
staleTime: 5 * 60_000,
});
+27 -26
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
ArrowDown,
ArrowUp,
@@ -106,27 +107,27 @@ export default function Channels() {
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels });
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["blocked-channels"] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() });
},
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
// already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedCount() });
};
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
@@ -139,10 +140,10 @@ export default function Channels() {
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedCount() });
},
});
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
@@ -150,18 +151,18 @@ export default function Channels() {
// lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => {
const next = current + delta;
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
);
api
.updateChannel(id, { priority: next })
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
@@ -172,7 +173,7 @@ export default function Channels() {
),
);
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
@@ -188,8 +189,8 @@ export default function Channels() {
const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
},
onError: (e) =>
@@ -198,8 +199,8 @@ export default function Channels() {
const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
@@ -207,8 +208,8 @@ export default function Channels() {
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({
level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -555,7 +556,7 @@ export default function Channels() {
resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the ["channels"] query
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
// otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
// handled by the hook's clamp instead.
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react";
@@ -34,10 +35,10 @@ export default function ChatDock({ meId }: { meId: number }) {
useEffect(
() =>
onMessage(({ message: m, from }) => {
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id;
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
// Auto-open/flash only for messages from someone else (not my own echo) and only for
// real user conversations.
if (m.kind === "user" && m.sender_id !== meId && from) {
@@ -51,8 +52,8 @@ export default function ChatDock({ meId }: { meId: number }) {
useEffect(
() =>
onUnread(() => {
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
qc.invalidateQueries({ queryKey: qk.conversations() });
}),
[qc],
);
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
@@ -35,7 +36,7 @@ export default function ChatThread({
const needsKey = !isSystem && !ready;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
["thread", partnerId],
qk.thread(partnerId),
() => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey },
);
@@ -102,8 +103,8 @@ export default function ChatThread({
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: ["message-unread"] });
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, partnerId, meId, qc]);
@@ -123,8 +124,8 @@ export default function ChatThread({
},
onSuccess: () => {
setDraft("");
qc.invalidateQueries({ queryKey: ["thread", partnerId] });
qc.invalidateQueries({ queryKey: ["conversations"] });
qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
qc.invalidateQueries({ queryKey: qk.conversations() });
},
onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already
+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react";
@@ -34,7 +35,7 @@ const GROUP_ORDER = [
export default function ConfigPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
const data = q.data;
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
@@ -98,7 +99,7 @@ export default function ConfigPanel() {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await qc.invalidateQueries({ queryKey: ["admin-config"] });
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
+3 -2
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
@@ -22,11 +23,11 @@ export default function DownloadButton({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const me = qc.getQueryData<Me>(["me"]);
const me = qc.getQueryData<Me>(qk.me());
const isDemo = !!me?.is_demo;
const indexQ = useQuery({
queryKey: ["download-index"],
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: !isDemo,
staleTime: 10000,
+12 -11
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -281,7 +282,7 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: qk.downloads() });
onClose();
},
});
@@ -365,7 +366,7 @@ function UsageBar() {
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload.
const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 });
const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 });
const u = usage.data;
if (!u) return null;
const pct =
@@ -408,7 +409,7 @@ function QuotaModal({
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({
queryKey: ["dl-quota", userId],
queryKey: qk.dlQuota(userId),
queryFn: () => api.adminGetDownloadQuota(userId),
});
const [form, setForm] = useState<{
@@ -429,8 +430,8 @@ function QuotaModal({
}
: null);
const done = () => {
qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
qc.invalidateQueries({ queryKey: ["admin-storage"] });
qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: qk.adminStorage() });
onClose();
};
const save = useMutation({
@@ -516,7 +517,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
const list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
@@ -565,8 +566,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
function AdminSystem() {
const { t } = useTranslation();
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data;
return (
@@ -662,9 +663,9 @@ export default function DownloadCenter() {
const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = useState<DownloadJob | null>(null);
const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 });
const sharedQ = useQuery({
queryKey: ["downloads-shared"],
queryKey: qk.downloadsShared(),
queryFn: api.downloadsShared,
enabled: tab === "shared",
});
@@ -698,7 +699,7 @@ export default function DownloadCenter() {
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
});
const confirmThen = async (title: string, body: string, fn: () => void) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal";
@@ -24,7 +25,7 @@ export default function DownloadDialog({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
const [profileId, setProfileId] = useState<number | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
+16 -15
View File
@@ -6,6 +6,7 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
ArrowDown,
ArrowUp,
@@ -21,7 +22,7 @@ import {
Youtube,
type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
@@ -69,7 +70,7 @@ const VIEW_ICON: Record<FeedView, LucideIcon> = {
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
function matchesView(status: string, show: string): boolean {
function matchesView(status: VideoStatus, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
@@ -125,7 +126,7 @@ export default function Feed({
const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
@@ -155,7 +156,7 @@ export default function Feed({
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({
queryKey: ["feed", queryFilters],
queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
@@ -168,7 +169,7 @@ export default function Feed({
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch, ytCount],
queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null,
@@ -225,7 +226,7 @@ export default function Feed({
}, [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: ["feed-count", queryFilters],
queryKey: qk.feedCount(queryFilters),
queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
@@ -240,7 +241,7 @@ export default function Feed({
const loadedRef = useRef<Video[]>([]);
const onState = useCallback(
(id: string, status: string) => {
(id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status }));
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
@@ -250,7 +251,7 @@ export default function Feed({
api
.setState(id, status)
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.feed() });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
@@ -307,7 +308,7 @@ export default function Feed({
setOverrides((o) => ({ ...o, [id]: "new" }));
api
.clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch(() =>
setOverrides((o) => {
const next = { ...o };
@@ -324,8 +325,8 @@ export default function Feed({
setSavedOverrides((o) => ({ ...o, [id]: saved }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist"] });
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
},
@@ -355,9 +356,9 @@ export default function Feed({
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: qk.feedCount() });
qc.removeQueries({ queryKey: qk.facets() });
};
// --- Live YouTube search mode -------------------------------------------------------------
@@ -435,7 +436,7 @@ export default function Feed({
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
qc.removeQueries({ queryKey: ["yt-search"] });
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>
@@ -7,6 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
@@ -95,10 +96,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
hasFilterParams(new URLSearchParams(window.location.search)),
);
// Passive observer: App owns the fetching `["me"]` query (gated on setup state); enabled:false
// Passive observer: App owns the fetching `qk.me()` query (gated on setup state); enabled:false
// here reads its cache and re-renders when it lands, without triggering a second fetch (which
// could 503 in setup mode). We only need id + is_demo, which don't change on background refetch.
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
+3 -2
View File
@@ -7,6 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { parseFeedView, type FeedView } from "../lib/feedView";
@@ -19,7 +20,7 @@ import { LS, readAccount, writeAccount } from "../lib/storage";
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
// render synchronously; the passive ["me"] observer adopts the server value once the account resolves.
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
interface FeedViewActions {
setView: (v: FeedView) => void;
@@ -40,7 +41,7 @@ export function FeedViewProvider({ children }: { children: ReactNode }) {
parseFeedView(readAccount(LS.feedView, null)),
);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
const setView = useCallback((next: FeedView) => {
+6 -5
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
@@ -16,15 +17,15 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
const { t } = useTranslation();
const qc = useQueryClient();
const keyQ = useQuery({
queryKey: ["message-key"],
queryKey: qk.messageKey(),
queryFn: api.messageMyKey,
staleTime: 60_000,
});
const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
// have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled:
// have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled:
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const hasPassword = meQ.data?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate");
@@ -46,7 +47,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload);
}
qc.invalidateQueries({ queryKey: ["message-key"] });
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
} finally {
@@ -77,7 +78,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
await e2ee.resetLocal(meId);
setCurPass("");
setView("gate");
qc.invalidateQueries({ queryKey: ["message-key"] });
qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch (e) {
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
} finally {
+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
@@ -74,7 +75,7 @@ function ConversationList({
const { t } = useTranslation();
const keyState = useKeyState();
const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, {
const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
intervalMs: POLL_MS,
});
const items = q.data?.items ?? [];
@@ -204,7 +205,7 @@ function ConversationList({
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
const { t } = useTranslation();
const [filter, setFilter] = useState("");
const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers });
const q = useQuery({ queryKey: qk.messageUsers(), queryFn: api.messageUsers });
const users = (q.data ?? []).filter((u) =>
u.name.toLowerCase().includes(filter.trim().toLowerCase()),
);
+5 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
@@ -114,7 +115,7 @@ export default function NavSidebar({
// Accounts that have signed in on this browser (loaded only while the popover is open).
const accountsQuery = useQuery({
queryKey: ["accounts"],
queryKey: qk.accounts(),
queryFn: api.accounts,
enabled: acctOpen,
});
@@ -138,7 +139,7 @@ export default function NavSidebar({
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(["notif-unread"], api.notificationUnreadCount, {
const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, {
intervalMs: 30000,
});
// One indicator for both layers: durable server notifications + the client-side transient
@@ -146,14 +147,14 @@ export default function NavSidebar({
const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message).
const msgUnreadQuery = useLiveQuery(["message-unread"], api.messageUnreadCount, {
const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, {
intervalMs: 30000,
enabled: !me.is_demo,
});
const msgUnread = msgUnreadQuery.data?.count ?? 0;
// Download center badge = items still working (queued/running/paused). Reuses the same
// lightweight per-user index the feed cards poll, so no extra request shape.
const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, {
intervalMs: 30000,
enabled: !me.is_demo,
});
+16 -11
View File
@@ -1,6 +1,8 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
Activity,
Bell,
@@ -56,7 +58,7 @@ export default function NotificationsPanel() {
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
qk.notifications(),
() => api.notifications(),
{ intervalMs: POLL_MS },
);
@@ -71,8 +73,8 @@ export default function NotificationsPanel() {
}, []);
const refresh = () => {
qc.invalidateQueries({ queryKey: ["notifications"] });
qc.invalidateQueries({ queryKey: ["notif-unread"] });
qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() });
};
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
@@ -107,8 +109,8 @@ export default function NotificationsPanel() {
api
.setState(meta.videoId, "new")
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedCount() });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId);
})
@@ -222,7 +224,7 @@ function NotificationRow({
onOpenScheduler,
}: {
n: AppNotification;
t: (k: string, o?: any) => string;
t: TFunction;
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
@@ -238,15 +240,18 @@ function NotificationRow({
let title = n.title;
let body = n.body;
if (isMaintenance) {
// Guarded above (typeof count === "number"); shape the JSON `data` bag for typed access.
const d = n.data as { count: number };
title = t("inbox.maintenance.title");
body = t("inbox.maintenance.body", { count: n.data!.count });
body = t("inbox.maintenance.body", { count: d.count });
} else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body.
const job = t(`scheduler.jobs.${n.data!.job_id}`, n.data!.job_id);
title = t(n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
const d = n.data as { job_id: string; status?: string; summary?: string };
const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job,
});
body = n.data!.summary || n.body;
body = d.summary || n.body;
}
return (
<div
@@ -319,7 +324,7 @@ function ClientActivityRow({
onRemove,
}: {
n: ClientNotif;
t: (k: string, o?: any) => string;
t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void;
+6 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
@@ -63,7 +64,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
// Once we have read access, find out whether any subscriptions are imported yet.
const myStatus = useQuery({
queryKey: ["my-status"],
queryKey: qk.myStatus(),
queryFn: api.myStatus,
enabled: me.can_read,
});
@@ -72,10 +73,10 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
mutationFn: () => api.syncSubscriptions(),
onSuccess: () => {
// Feed/channels/status now have content — refresh the app behind the wizard.
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["feed-count"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedCount() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
},
});
+18 -15
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
AlertTriangle,
ArrowLeft,
@@ -28,7 +29,8 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
@@ -81,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
}
// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null;
function loadYouTubeApi(): Promise<any> {
let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => {
const w = window as any;
apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev();
resolve(w.YT);
// The API-ready callback only fires once the script has installed window.YT.
resolve(w.YT!);
};
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
@@ -117,7 +120,7 @@ export default function PlayerModal({
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
// Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour.
}) {
@@ -157,7 +160,7 @@ export default function PlayerModal({
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null);
const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
// run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
@@ -380,7 +383,7 @@ export default function PlayerModal({
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = useMemo(
() =>
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
@@ -392,7 +395,7 @@ export default function PlayerModal({
modeRef.current = { autoMode, loopMode };
const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
@@ -479,7 +482,7 @@ export default function PlayerModal({
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
};
const detail = useQuery({
queryKey: ["video-detail", currentVideoId],
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
// On hover (for the description) and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result.
@@ -705,7 +708,7 @@ export default function PlayerModal({
focusModal();
applyVolume(volumeRef.current, false);
},
onStateChange: (e: any) => {
onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
@@ -724,7 +727,7 @@ export default function PlayerModal({
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => {
onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
// never runs and an auto-advancing queue would freeze here. Skip forward instead — but
@@ -767,8 +770,8 @@ export default function PlayerModal({
window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["playlist"] });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.playlist() });
});
const p = playerRef.current;
if (p && typeof p.destroy === "function") {
+12 -11
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
DndContext,
KeyboardSensor,
@@ -171,8 +172,8 @@ export default function Playlists() {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
// and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
})
.catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
@@ -182,7 +183,7 @@ export default function Playlists() {
// the arriving detail is judged "same list" and the rejected order stays on screen — and
// becomes the base for the next drag.
lastSetRef.current = "";
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
});
},
});
@@ -201,14 +202,14 @@ export default function Playlists() {
const [playingId, setPlayingId] = useState<string | null>(null);
const listQuery = useQuery({
queryKey: ["playlists"],
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? [];
const detailQuery = useQuery({
queryKey: ["playlist", selectedId],
queryKey: qk.playlist(selectedId),
queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device)
@@ -331,8 +332,8 @@ export default function Playlists() {
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}
async function revertToYoutube() {
@@ -499,14 +500,14 @@ export default function Playlists() {
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: ["playlist", deletedId] });
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
}
function playerState(id: string, status: string) {
api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: qk.feed() });
});
}
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format";
@@ -54,7 +55,7 @@ export default function PlaylistsRail({
const scrolledRef = useRef(false);
const listQuery = useQuery({
queryKey: ["playlists"],
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
@@ -114,8 +115,8 @@ export default function PlaylistsRail({
setSyncing(true);
try {
const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist"] });
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed"));
@@ -128,7 +129,7 @@ export default function PlaylistsRail({
mutationFn: (name: string) => api.createPlaylist(name),
onSuccess: (pl: Playlist) => {
setNewName("");
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: qk.playlists() });
setSelectedId(pl.id);
},
});
+20 -19
View File
@@ -7,6 +7,7 @@ import {
useState,
type CSSProperties,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
@@ -138,7 +139,7 @@ export default function PlexBrowse() {
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-library", scope, dq, sort, show, filters],
queryKey: qk.plexLibrary(scope, dq, sort, show, filters),
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
@@ -162,7 +163,7 @@ export default function PlexBrowse() {
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
// grid so you SEE who you're exploring, not just chips in the rail.
const peopleCardsQ = useQuery({
queryKey: ["plex-people-cards", scope, filters.actors],
queryKey: qk.plexPeopleCards(scope, filters.actors),
queryFn: () => api.plexPeopleCards(filters.actors, scope),
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
staleTime: 60_000,
@@ -206,7 +207,7 @@ export default function PlexBrowse() {
// and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
// BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
function optimisticStatus(id: string, next: string) {
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) =>
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
old
? {
...old,
@@ -223,7 +224,7 @@ export default function PlexBrowse() {
const next = card.status === "watched" ? "new" : "watched";
optimisticStatus(card.id, next);
await api.plexSetState(card.id, next).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
// Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
// Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
@@ -587,7 +588,7 @@ function PlexInfoView({
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
@@ -614,7 +615,7 @@ function PlexInfoView({
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
// No onStateChange: PlexInfo.setState already invalidates ["plex-item", id] (this very
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
</Suspense>
@@ -624,7 +625,7 @@ function PlexInfoView({
}
// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached ["plex-show", showId] payload (the
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
@@ -989,8 +990,8 @@ function PlexShowView({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
const d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
@@ -1009,13 +1010,13 @@ function PlexShowView({
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markSeason(seasonRk: string, w: boolean) {
await api.plexSeasonState(seasonRk, w).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
@@ -1208,7 +1209,7 @@ function PlexShowView({
library={showLib}
memberOf={show.collection_keys}
onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })}
onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
/>
)}
</div>
@@ -1228,7 +1229,7 @@ function PlexSeasonView({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
const d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
@@ -1245,13 +1246,13 @@ function PlexSeasonView({
} finally {
setBusy(false);
}
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
async function markEpisode(ep: PlexCard) {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
}
return (
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
@@ -35,7 +36,7 @@ export default function PlexCollectionEditor({
const [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
const listQ = useQuery({
queryKey: ["plex-collections", library],
queryKey: qk.plexCollections(library),
queryFn: () => api.plexCollections(library),
});
const editable = useMemo(
@@ -54,9 +55,9 @@ export default function PlexCollectionEditor({
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
const refresh = () => {
qc.invalidateQueries({ queryKey: ["plex-collections"] });
qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: qk.plexCollections() });
qc.invalidateQueries({ queryKey: qk.plexItem(item.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
onChanged();
};
+6 -5
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
@@ -52,7 +53,7 @@ export default function PlexInfo({
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
@@ -66,7 +67,7 @@ export default function PlexInfo({
return next;
});
};
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin";
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false);
@@ -77,9 +78,9 @@ export default function PlexInfo({
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: ["plex-show"] });
qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
onStateChange?.();
};
+10 -10
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
type WheelEvent as ReactWheelEvent,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -33,7 +34,7 @@ import {
VolumeX,
X,
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import {
exitFullscreen,
@@ -196,7 +197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
tRef.current = t;
const qc = useQueryClient();
const [id, setId] = useState(itemId);
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
const detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0;
@@ -313,14 +314,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// selected client-side after the manifest loads.
const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m);
} catch (e: any) {
} catch (e) {
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError(
e?.status === 501
status === 501
? tRef.current("plex.player.errTranscode")
: e?.status === 404
: status === 404
? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"),
);
@@ -513,12 +515,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
qc.setQueryData(["plex-item", id], (old) =>
old ? { ...old, position_seconds: pos } : old,
);
qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
}
qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: ["plex-show"] });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
};
}, [id, qc]);
+4 -3
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
@@ -35,15 +36,15 @@ export default function PlexPlaylistAdd({
const listQ = useQuery({
queryKey: isGroup
? ["plex-playlists", "group", target.ratingKeys]
: ["plex-playlists", "contains", target.ratingKey],
? qk.plexPlaylists("group", target.ratingKeys)
: qk.plexPlaylists("contains", target.ratingKey),
queryFn: () =>
isGroup
? api.plexPlaylists(undefined, target.ratingKeys)
: api.plexPlaylists(target.ratingKey),
});
const playlists = listQ.data?.playlists ?? [];
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
const refresh = () => qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
// How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number {
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -375,7 +376,7 @@ export default function PlexPlaylistView({
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
const q = useQuery({
queryKey: ["plex-playlist", playlistId],
queryKey: qk.plexPlaylist(playlistId),
queryFn: () => api.plexPlaylist(playlistId),
});
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
@@ -387,8 +388,8 @@ export default function PlexPlaylistView({
const order = useMemo(() => items.map((i) => i.id), [items]);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] });
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
};
function commit(next: PlexCard[]) {
@@ -398,7 +399,7 @@ export default function PlexPlaylistView({
playlistId,
next.map((i) => i.id),
)
.then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] }));
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }));
}
async function onRemove(keys: string[]) {
@@ -431,7 +432,7 @@ export default function PlexPlaylistView({
)
return;
await api.plexDeletePlaylist(playlistId);
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onBack();
}
+5 -4
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
@@ -84,7 +85,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
setPlaylistOpen: onOpenPlaylist,
} = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
const qc = useQueryClient();
@@ -93,12 +94,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
if (!title) return;
const pl = await api.plexCreatePlaylist(title);
setNewPlaylist("");
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
}
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({
queryKey: ["plex-facets", scope, show, facetKey],
queryKey: qk.plexFacets(scope, show, facetKey),
queryFn: () => api.plexFacets(scope, filters, show),
enabled: !!scope,
placeholderData: (prev) => prev,
@@ -108,7 +109,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: ["plex-collections", "union", collSearchDeb],
queryKey: qk.plexCollections("union", collSearchDeb),
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
});
+2 -1
View File
@@ -8,6 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import {
@@ -68,7 +69,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
// stores too); persistence to the server is the auto-save effect below.
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
@@ -42,7 +43,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
const profilesQ = useQuery({ queryKey: qk.downloadProfiles(), queryFn: api.downloadProfiles });
const profiles = profilesQ.data ?? [];
const builtins = profiles.filter((p) => p.is_builtin);
const mine = profiles.filter((p) => !p.is_builtin);
@@ -52,7 +53,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => {
setEditing(null);
setName("");
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
@@ -56,7 +57,7 @@ export default function SavedViewsWidget({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const viewsQuery = useQuery({ queryKey: ["saved-views"], queryFn: api.savedViews });
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = viewsQuery.data ?? [];
const [naming, setNaming] = useState(false);
const [draftName, setDraftName] = useState("");
@@ -69,7 +70,7 @@ export default function SavedViewsWidget({
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]);
const refresh = () => qc.invalidateQueries({ queryKey: ["saved-views"] });
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
const currentKey = keyOf(filters);
async function save() {
@@ -124,7 +125,7 @@ export default function SavedViewsWidget({
const next = arrayMove(ids, oldIndex, newIndex);
// Optimistic reorder so the drop sticks visually before the round-trip.
qc.setQueryData<SavedView[]>(
["saved-views"],
qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!),
);
await api.reorderViews(next);
+8 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import i18n from "../i18n";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -340,7 +341,7 @@ export default function Scheduler() {
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when
// idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
const q = useLiveQuery<SchedulerStatus>(qk.scheduler(), api.schedulerStatus, {
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
});
const data = q.data;
@@ -376,14 +377,14 @@ export default function Scheduler() {
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
});
const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
});
@@ -396,7 +397,7 @@ export default function Scheduler() {
level: "success",
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
});
qc.invalidateQueries({ queryKey: ["scheduler"] });
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
const runAllMut = useMutation({
@@ -406,12 +407,12 @@ export default function Scheduler() {
level: "success",
message: t("scheduler.triggeredAll", { count: res.started.length }),
});
qc.invalidateQueries({ queryKey: ["scheduler"] });
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
});
const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(),
@@ -419,7 +420,7 @@ export default function Scheduler() {
const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) });
qc.invalidateQueries({ queryKey: ["scheduler"] });
qc.invalidateQueries({ queryKey: qk.scheduler() });
},
});
+11 -8
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { api, HttpError, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
@@ -387,13 +388,15 @@ function SignInMethods({ me }: { me: Me }) {
setCurrent("");
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password".
await qc.invalidateQueries({ queryKey: ["me"] });
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
message: t("settings.account.password.saved", { email: me.email }),
});
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} catch (e) {
setErr(
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
);
} finally {
setBusy(false);
}
@@ -516,7 +519,7 @@ function SignInMethods({ me }: { me: Me }) {
function PlexWatchSync() {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const link = useQuery({ queryKey: ["plex-watch-link"], queryFn: () => api.plexWatchLink() });
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
const enabled = link.data?.sync_enabled ?? false;
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => {
@@ -529,7 +532,7 @@ function PlexWatchSync() {
}),
});
// The browse grid's watch badges read plex_states — refresh them.
qc.invalidateQueries({ queryKey: ["plex"] });
qc.invalidateQueries({ queryKey: qk.plex() });
}
};
@@ -539,14 +542,14 @@ function PlexWatchSync() {
const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res);
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
},
});
const reimport = useMutation({
mutationFn: () => api.plexWatchImport(),
onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res);
qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import);
},
});
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api } from "../lib/api";
import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
@@ -103,8 +103,8 @@ export default function SetupWizard() {
return;
}
go(1);
} catch (e: any) {
setError(e?.detail ?? t("setup.genericError"));
} catch (e) {
setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
} finally {
setBusy(false);
}
+4 -3
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
@@ -17,7 +18,7 @@ function UserShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const recipients = useQuery({ queryKey: ["share-recipients"], queryFn: api.shareRecipients });
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
const list = recipients.data ?? [];
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
@@ -245,14 +246,14 @@ function LinkShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({
queryKey: ["download-links", job.id],
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
});
const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-links", job.id] });
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const create = useMutation({
mutationFn: () =>
+3 -2
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
@@ -80,14 +81,14 @@ export default function Sidebar({
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
const facetsQuery = useQuery({
queryKey: ["facets", facetFilters],
queryKey: qk.facets(facetFilters),
queryFn: () => api.facets(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
+10 -9
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
@@ -55,14 +56,14 @@ export default function Stats() {
function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
@@ -73,8 +74,8 @@ function Overview({ me }: { me: Me }) {
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.channels() });
notify({
level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -199,11 +200,11 @@ function SystemStats() {
const { t } = useTranslation();
const qc = useQueryClient();
const [days, setDays] = useState<number>(30);
const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) });
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
});
const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+3 -2
View File
@@ -1,6 +1,7 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
@@ -27,7 +28,7 @@ export default function SyncStatus({
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
const { data } = useQuery({
queryKey: ["my-status"],
queryKey: qk.myStatus(),
queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and
// refresh immediately on tab focus (the global default disables focus refetch), so the
@@ -47,7 +48,7 @@ export default function SyncStatus({
const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
});
if (!data) return null;
+6 -5
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
@@ -116,8 +117,8 @@ export default function TagManager({
const confirm = useConfirm();
const listFade = useScrollFade();
const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
@@ -125,9 +126,9 @@ export default function TagManager({
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.feed() });
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
+4 -4
View File
@@ -18,7 +18,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
@@ -30,14 +30,14 @@ function Actions({
dense = false,
}: {
video: Video;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
const act = (status: VideoStatus) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
@@ -375,7 +375,7 @@ function VideoCard({
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
+4 -3
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -73,7 +74,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
const sb = useQuery({
queryKey: ["storyboard", job.id],
queryKey: qk.storyboard(job.id),
queryFn: () => api.downloadStoryboard(job.id),
staleTime: Infinity,
retry: false,
@@ -307,8 +308,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: N };
},
onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
notify({ level: "success", message: t("editor.created", { count: files }) });
onClose();
},
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Video } from "../lib/api";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
import VirtualGrid from "./VirtualGrid";
@@ -21,7 +21,7 @@ const ROW_EST: Record<FeedView, number> = {
export interface VirtualFeedProps {
items: Video[];
view: FeedView;
onState: (id: string, status: string) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
+3 -2
View File
@@ -9,6 +9,7 @@ import {
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
@@ -21,7 +22,7 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
//
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
// Settings / the notify "reconnect" action. It reads `me` as a passive ["me"] observer (App owns the
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (App owns the
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
interface WizardActions {
@@ -43,7 +44,7 @@ export function useWizard(): WizardActions {
export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const openWizard = useCallback(() => setOpen(true), []);
const closeWizard = useCallback(() => setOpen(false), []);
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.",
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
"server": "Server error",
"ok": "OK",
"offline": {
+1
View File
@@ -1,6 +1,7 @@
{
"title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
"server": "Szerverhiba",
"ok": "OK",
"offline": {
+70 -1004
View File
File diff suppressed because it is too large Load Diff
+232
View File
@@ -0,0 +1,232 @@
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { notify, remove } from "../notifications";
import i18n from "../../i18n";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../storage";
export class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
this.detailData = detailData;
}
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as { code?: string; reason?: string; limit?: number } | null | undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
(o.limit || 0) / 1_073_741_824,
);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
return t("errors.generic");
}
// 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;
}
// 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.
export interface ReqConfig {
idempotent?: boolean;
// 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;
}
// 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
// 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.)
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
// 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).
export const setupHeaders = (token: string) => ({
"Content-Type": "application/json",
"X-Setup-Token": token,
});
// --- 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.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
export const getActiveAccount = activeAccountId;
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
export function beacon(url: string, body: Record<string, unknown>): void {
const active = getActiveAccount();
try {
void fetch(url, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify(body),
}).catch(() => {});
} catch {
/* ignore */
}
}
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 */
}
}
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
cfg: ReqConfig = {},
): Promise<T> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
let attempt = 0;
for (;;) {
let r: Response;
try {
const active = getActiveAccount();
r = await fetch(url, {
credentials: "include",
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) } : {}),
},
...opts,
});
} catch (e) {
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
if (canRetry && attempt === 0) {
attempt++;
await delay(250);
continue;
}
markConnectivityLost();
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;
}
// 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();
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
let detailData: unknown;
try {
const body = await r.json();
if (body && typeof body.detail === "string") detail = body.detail;
else if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} 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)) {
markConnectivityLost();
} 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?.();
} else if (cfg.quiet) {
/* caller handles the error inline — no global modal */
} 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, detailData);
}
// The single audited cast in this layer: the server contract is asserted here, once, so
// every typed api.* method (T comes from its declared return type) is checked from here up.
return (r.status === 204 ? null : await r.json()) as T;
}
}
+242
View File
@@ -0,0 +1,242 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, getActiveAccount } from "./core";
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;
}
interface DownloadAsset {
id: number;
status: string;
title: string | null;
uploader: string | null;
uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit)
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";
// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job).
// Local (not exported): consumers compare `job.job_kind` against literals, never name the type.
type DownloadJobKind = "download" | "edit";
// 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).
export interface EditSpec {
trim?: { start_s: number; end_s?: number };
segments?: { start_s: number; end_s: number }[];
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;
}
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;
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
poster_url: string | null; // locally-generated poster, fallback when the source had no thumbnail
profile_id: number | null;
spec: DownloadSpec;
display_name: string | null;
display_uploader: string | null; // user override of the channel name
display_uploader_url: string | null; // user override of the channel link
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: DownloadJobKind; // "download" (default) | "edit" (a per-user trim/crop derivative)
source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null;
can_download: boolean;
expired: boolean;
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
}
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;
}
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;
}
export const downloadsApi = {
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) }),
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`),
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
// 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" }),
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 }),
// Force a fresh re-download of a finished/failed job — keeps the job (and its share links),
// deletes the old file, and rebuilds it under the current format/naming rules.
redownloadDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
// Update a download's display metadata (title / channel name+link / extra reference URLs).
updateDownloadMeta: (
id: number,
meta: {
display_name?: string;
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
},
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
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" }),
// 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" }),
// 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`;
},
// 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`;
},
// 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" }),
};
+72
View File
@@ -0,0 +1,72 @@
// Direct-messaging domain: the E2EE key bundle setup/reset, the conversation list, threads, and
// send/unread. Self-contained — types reference only each other, methods only touch core's req.
// (The WebCrypto layer that encrypts/decrypts message bodies lives in e2ee.ts, not here.)
import { req } from "./core";
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;
}
export const messagingApi = {
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
// Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new
// passphrase can be set. Password accounts must pass their current password; errors shown inline.
resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> =>
req(
"/api/messages/keys/reset",
{ method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) },
{ quiet: true },
),
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"),
};
+483
View File
@@ -0,0 +1,483 @@
// Plex domain: browse/search/drill-down of the mirrored catalog, the admin collection editor,
// Siftlode-native Plex playlists, HLS/direct playback sessions + progress mirroring, and the
// two-way watch-sync link. Self-contained — its types reference only each other, and its methods
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
import { req, beacon } from "./core";
// Admin: result of testing the Plex server connection (also drives the library-picker).
interface PlexSection {
key: string;
title: string;
type: string; // "movie" | "show"
count?: number;
}
export interface PlexTestResult {
ok: boolean;
server_name: string;
version?: string;
sections: PlexSection[];
// 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;
};
}
// 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
show_title?: string | null; // episode (in a mixed playlist)
show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
summary?: string | null; // episode
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult {
scope: string; // movie | show | both
total: number;
offset: number;
limit: number;
items: PlexCard[];
episodes: PlexCard[];
}
export interface PlexSeasonDetail {
id: string; // season rating_key
season_number: number | null;
title: string;
thumb: string;
episode_count: number;
status: string; // aggregate: new | in_progress | watched
resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched)
first?: PlexCard | null; // first episode (Play from the start)
episodes: PlexCard[];
}
export interface PlexShowDetail {
show: {
id: string;
title: string;
summary?: string | null;
year?: number | null;
thumb: string;
art: string;
library?: string | null; // Plex section key (for the admin collection editor)
content_rating?: string | null;
rating?: number | null;
genres: string[];
studio?: string | null;
season_count?: number | null;
imdb_rating?: number | null;
imdb_id?: string | null;
imdb_url?: string | null;
cast: PlexCastMember[];
status: string; // aggregate across all episodes
resume?: PlexCard | null;
first?: PlexCard | null;
episode_count: number;
collection_keys: string[];
};
seasons: PlexSeasonDetail[];
related: PlexCard[];
}
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;
library?: string | null; // Plex section key (for the admin collection editor)
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;
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
subtitle_streams: {
ord: number;
label: string;
language?: string | null;
codec?: string;
text: boolean;
}[];
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;
// `source` buckets the strip so each type can be shown/hidden independently on the info page:
// "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart".
collections?: { id: string; title: string; source: string; items: PlexCard[] }[];
}
export interface PlexCollection {
id: string;
title: string;
summary?: string | null;
thumb?: string | null;
child_count?: number | null;
smart: boolean;
source?: string;
editable: boolean;
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
}
export interface PlexPlaylist {
id: number;
title: string;
item_count: number;
thumb?: string | null;
has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
group_in?: number; // only when fetched with ?contains_group=<rk,rk,…>: how many of the group it holds
}
export interface PlexPlaylistDetail {
id: number;
title: string;
items: PlexCard[];
}
export interface PlexCastMember {
name: string;
role?: string | null;
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
export interface PlexPersonCard {
name: string;
photo?: string | null; // proxied person-image url, or null (placeholder)
count: number; // in-scope movies/shows featuring them
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
genres: string[];
genreMode?: "any" | "all"; // how multiple genres combine (default any)
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"
directors: string[]; // AND — titles featuring ALL selected
actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
sortDir?: "asc" | "desc"; // sort direction (default desc)
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
genres: [],
contentRatings: [],
directors: [],
actors: [],
studios: [],
};
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) +
f.directors.length +
f.actors.length +
f.studios.length +
(f.collection ? 1 : 0)
);
}
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
// each caller's own concern and stay out of here).
function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.genres?.length) u.set("genres", f.genres.join(","));
if (f.genreMode === "all") u.set("genre_mode", "all");
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);
if (f.directors?.length) u.set("directors", f.directors.join(","));
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
}
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;
}
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
start_s: number;
}
// Two-way Plex watch-state sync link status (owner account; Phase A).
export interface PlexWatchLink {
linked: boolean;
sync_enabled: boolean;
uses_admin: boolean;
initial_import_done: boolean;
last_watch_sync_at: string | null;
}
export interface PlexWatchImport {
watched: number;
in_progress: number;
scanned: number;
}
export const plexApi = {
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
req("/api/plex/libraries"),
plexLibrary: (p: {
scope: string; // movie | show | both
q?: string;
sort?: string;
show?: string;
offset?: number;
limit?: number;
filters?: PlexFilters;
}): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q);
if (p.sort) u.set("sort", p.sort);
if (p.show && p.show !== "all") u.set("show", p.show);
if (p.offset) u.set("offset", String(p.offset));
if (p.limit) u.set("limit", String(p.limit));
const f = p.filters;
if (f) {
appendPlexFilters(u, f);
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
}
return req(`/api/plex/library?${u.toString()}`);
},
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
// narrows the others and the bounds match the visible result set.
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
const u = new URLSearchParams({ scope });
if (show && show !== "all") u.set("show", show);
if (f) appendPlexFilters(u, f);
return req(`/api/plex/facets?${u.toString()}`);
},
// With a library plex_key → that library's collections (editor). Without one → the union across all
// enabled libraries (the unified-scope sidebar picker).
plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
const u = new URLSearchParams();
if (library) u.set("library", library);
if (q) u.set("q", q);
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
},
// --- Collection editing (admin only; writes back to Plex) ---
plexCreateCollection: (
library: string,
title: string,
item_rating_key: string,
): Promise<PlexCollection> =>
req(`/api/plex/collections`, {
method: "POST",
body: JSON.stringify({ library, title, item_rating_key }),
}),
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
method: "POST",
}),
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, {
method: "DELETE",
}),
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, {
method: "PATCH",
body: JSON.stringify({ title }),
}),
plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, {
method: "POST",
body: JSON.stringify({ editable }),
}),
// --- Playlists (Siftlode-native, per-user, ordered) ---
// `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole
// season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog).
plexPlaylists: (
contains?: string,
containsGroup?: string[],
): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => {
const p = new URLSearchParams();
if (contains) p.set("contains", contains);
if (containsGroup) p.set("contains_group", containsGroup.join(","));
const qs = p.toString();
return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`);
},
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists`, {
method: "POST",
body: JSON.stringify({ title, item_rating_key }),
}),
plexPlaylistAddBulk: (
id: number,
itemRks: string[],
): Promise<{ added: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/bulk`, {
method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexPlaylistRemoveBulk: (
id: number,
itemRks: string[],
): Promise<{ removed: number; item_count: number }> =>
req(`/api/plex/playlists/${id}/items/remove-bulk`, {
method: "POST",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ item_rating_key: itemRk }),
}),
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
req(`/api/plex/playlists/${id}/order`, {
method: "PUT",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, {
method: "POST",
body: JSON.stringify({ watched }),
}),
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, {
method: "POST",
body: JSON.stringify({ watched }),
}),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
req(
`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`,
),
plexSession: (
id: string,
start = 0,
audio?: number | null,
aoff = 0,
multi = false,
): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));
if (aoff) p.set("aoff", String(aoff));
if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, {
method: "POST",
});
},
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
// `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the
// periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a
// final checkpoint, so a single watch doesn't spray Plex with timeline updates.
plexProgress: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
method: "POST",
body: JSON.stringify({ position_seconds, duration_seconds, final }),
}),
// Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
plexProgressBeacon: (
id: string,
position_seconds: number,
duration_seconds: number,
final = false,
): void =>
beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
position_seconds,
duration_seconds,
final,
}),
// Inline union (matches api.ts's VideoStatus by design) rather than importing VideoStatus — that
// lives in the api.ts barrel, which imports this module, so reusing it would make a type cycle.
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
method: "POST",
body: JSON.stringify({ status }),
}),
// Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import).
plexWatchLink: (): Promise<PlexWatchLink> => req("/api/plex/watch/link"),
plexWatchSetLink: (
enabled: boolean,
): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
req("/api/plex/watch/import", { method: "POST" }),
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
plexDownloadUrl: (id: string): string =>
`/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
};
+4 -3
View File
@@ -1,9 +1,10 @@
import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
// The download queue, the per-card download index, and the storage-usage meter all move together
// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: ["downloads"] });
qc.invalidateQueries({ queryKey: ["download-index"] });
qc.invalidateQueries({ queryKey: ["download-usage"] });
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
}
+2 -1
View File
@@ -1,5 +1,6 @@
// Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react";
import { qk } from "./queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "./api";
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
@@ -57,7 +58,7 @@ function useUnlocked(): boolean {
export type KeyState = "loading" | "setup" | "unlock" | "ready";
export function useKeyState(): KeyState {
const unlocked = useUnlocked();
const q = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 });
const q = useQuery({ queryKey: qk.messageKey(), queryFn: api.messageMyKey, staleTime: 60_000 });
if (q.isLoading && !q.data) return "loading";
if (!(q.data?.configured ?? false)) return "setup";
if (!unlocked) return "unlock";
+29 -4
View File
@@ -26,6 +26,9 @@ export type ModuleDef = {
id: Page;
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
labelKey: string;
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
titleKey?: string;
icon: LucideIcon;
// Admin/system modules render in their own section (below a divider) in the nav rail.
system?: boolean;
@@ -44,7 +47,12 @@ const notDemo = (me: Me) => !me.is_demo;
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home },
{ id: "channels", labelKey: "header.account.channels", icon: Tv },
{
id: "channels",
labelKey: "header.account.channels",
titleKey: "header.channelManager",
icon: Tv,
},
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
@@ -62,10 +70,11 @@ const MODULES: readonly ModuleDef[] = [
hasBadge: true,
gate: notDemo,
},
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
{ id: "stats", labelKey: "header.account.stats", titleKey: "header.usageStats", icon: BarChart3 },
{
id: "scheduler",
labelKey: "header.account.scheduler",
titleKey: "header.scheduler",
icon: Activity,
system: true,
gate: isAdmin,
@@ -73,12 +82,27 @@ const MODULES: readonly ModuleDef[] = [
{
id: "config",
labelKey: "header.account.config",
titleKey: "header.configuration",
icon: SlidersHorizontal,
system: true,
gate: isAdmin,
},
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
{
id: "users",
labelKey: "header.account.users",
titleKey: "header.users",
icon: Users,
system: true,
gate: isAdmin,
},
{
id: "audit",
labelKey: "header.account.audit",
titleKey: "audit.title",
icon: ScrollText,
system: true,
gate: isAdmin,
},
];
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
@@ -86,6 +110,7 @@ const MODULES: readonly ModuleDef[] = [
const SETTINGS_MODULE: ModuleDef = {
id: "settings",
labelKey: "header.account.settings",
titleKey: "settings.title",
icon: Settings,
};
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { pageTitleKey } from "./pageMeta";
import type { Page } from "./urlState";
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
// rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = {
feed: "header.account.feed",
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
messages: "messages.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
scheduler: "header.scheduler",
config: "header.configuration",
users: "header.users",
audit: "audit.title",
settings: "settings.title",
};
describe("pageTitleKey", () => {
it("returns the exact title key for every page (registry-derived)", () => {
for (const [page, key] of Object.entries(EXPECTED)) {
expect(pageTitleKey(page as Page)).toBe(key);
}
});
});
+8 -32
View File
@@ -1,36 +1,12 @@
import type { Page } from "./urlState";
import { moduleDef } from "./modules";
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title
// so the two never drift. Keep in sync with the nav modules in NavSidebar / App's page switch.
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title so
// the two never drift. Derived from the single ModuleDef registry (modules.ts): a module's optional
// `titleKey` is the longer, descriptive tab title (e.g. "Channel manager"), falling back to its
// short nav `labelKey` when the two are the same. Registering a new Page is then one touch (add it
// to MODULES) instead of also editing a switch here.
export function pageTitleKey(page: Page): string {
switch (page) {
case "feed":
return "header.account.feed";
case "channels":
return "header.channelManager";
case "playlists":
return "header.account.playlists";
case "notifications":
return "inbox.navLabel";
case "messages":
return "messages.navLabel";
case "downloads":
return "downloads.navLabel";
case "stats":
return "header.usageStats";
case "plex":
return "plex.navLabel";
case "scheduler":
return "header.scheduler";
case "config":
return "header.configuration";
case "users":
return "header.users";
case "audit":
return "audit.title";
case "settings":
return "settings.title";
default:
return "header.account.feed";
}
const def = moduleDef(page);
return def.titleKey ?? def.labelKey;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { qk } from "./queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
@@ -12,7 +13,7 @@ import { api } from "./api";
export function useDetailPrefs() {
const qc = useQueryClient();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as {
plexInfoArtBg?: boolean;
plexInfoCast?: boolean;
@@ -23,7 +24,7 @@ export function useDetailPrefs() {
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
const save = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { qk } from "./queryKeys";
import type { FeedFilters } from "./api";
// The factory is the single source of truth for ~200 React Query keys; these tests lock the exact
// array shapes it produces, because TanStack invalidation is prefix-MATCHING on those arrays — a
// silent shape change (a flipped guard, a dropped spread) would break refetching with no type error.
const FILTERS = { scope: "my", tags: [] } as unknown as FeedFilters;
describe("qk — bare vs parameterized branch", () => {
it("collapses to the bare prefix key only when no arg is given", () => {
expect(qk.feed()).toEqual(["feed"]);
expect(qk.feed(FILTERS)).toEqual(["feed", FILTERS]);
expect(qk.feedCount()).toEqual(["feed-count"]);
expect(qk.feedCount(FILTERS)).toEqual(["feed-count", FILTERS]);
expect(qk.facets()).toEqual(["facets"]);
expect(qk.facets(FILTERS)).toEqual(["facets", FILTERS]);
expect(qk.playlist()).toEqual(["playlist"]);
expect(qk.plexShow()).toEqual(["plex-show"]);
expect(qk.plexShow("s1")).toEqual(["plex-show", "s1"]);
expect(qk.ytSearch()).toEqual(["yt-search"]);
});
it("preserves a NULL segment instead of collapsing it to the prefix", () => {
// `["playlist", null]` must stay 2-element — a null id is a real (distinct) key, not "no arg".
expect(qk.playlist(null)).toEqual(["playlist", null]);
expect(qk.playlist(7)).toEqual(["playlist", 7]);
expect(qk.ytSearch(null, 3)).toEqual(["yt-search", null, 3]);
expect(qk.ytSearch("cats", 3)).toEqual(["yt-search", "cats", 3]);
expect(qk.thread(null)).toEqual(["thread", null]);
expect(qk.thread(42)).toEqual(["thread", 42]);
});
});
describe("qk — single-id and scalar keys", () => {
it("returns [root, id]", () => {
expect(qk.channel("c1")).toEqual(["channel", "c1"]);
expect(qk.videoDetail("v1")).toEqual(["video-detail", "v1"]);
expect(qk.playlistMembership("v1")).toEqual(["playlist-membership", "v1"]);
expect(qk.plexItem("i1")).toEqual(["plex-item", "i1"]);
expect(qk.plexPlaylist(9)).toEqual(["plex-playlist", 9]);
expect(qk.downloadLinks(5)).toEqual(["download-links", 5]);
expect(qk.dlQuota(3)).toEqual(["dl-quota", 3]);
expect(qk.storyboard(5)).toEqual(["storyboard", 5]);
expect(qk.adminQuota(30)).toEqual(["admin-quota", 30]);
expect(qk.plexPeopleCards("both", ["Actor"])).toEqual(["plex-people-cards", "both", ["Actor"]]);
});
it("returns the bare root for no-arg keys", () => {
expect(qk.me()).toEqual(["me"]);
expect(qk.myStatus()).toEqual(["my-status"]);
expect(qk.channels()).toEqual(["channels"]);
expect(qk.playlists()).toEqual(["playlists"]);
expect(qk.conversations()).toEqual(["conversations"]);
expect(qk.downloads()).toEqual(["downloads"]);
expect(qk.adminUsers()).toEqual(["admin-users"]);
expect(qk.plex()).toEqual(["plex"]);
});
});
describe("qk — variadic composite plex keys", () => {
it("spreads the rest args after the root, and is bare with none", () => {
expect(qk.plexLibrary()).toEqual(["plex-library"]);
expect(qk.plexLibrary("movie", "q", "title", "s", FILTERS)).toEqual([
"plex-library",
"movie",
"q",
"title",
"s",
FILTERS,
]);
expect(qk.plexCollections()).toEqual(["plex-collections"]);
expect(qk.plexCollections("lib")).toEqual(["plex-collections", "lib"]);
expect(qk.plexCollections("union", "term")).toEqual(["plex-collections", "union", "term"]);
expect(qk.plexPlaylists()).toEqual(["plex-playlists"]);
expect(qk.plexPlaylists("group", ["1", "2"])).toEqual(["plex-playlists", "group", ["1", "2"]]);
expect(qk.plexFacets("both", "s", { genres: [] })).toEqual([
"plex-facets",
"both",
"s",
{ genres: [] },
]);
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { FeedFilters } from "./api";
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
// (the R7 driver: `["feed"]` was hand-written in 15+ files). CONTRACT: each function returns the
// SAME array shape the call sites used before, so TanStack's partial (prefix) matching is
// unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`).
// Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site
// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
// Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots).
export const qk = {
// --- shared roots (invalidated from more than one domain) ---
me: () => ["me"] as const,
myStatus: () => ["my-status"] as const,
tags: () => ["tags"] as const,
// --- feed / video ---
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
// key" is the ONE rule across the factory. It also keeps a nullable segment intact: `q`/`id`
// accept null because some call sites key on nullable state (`selectedId`, the search string),
// and a NULL is a real key segment that must be preserved (`["playlist", null]`) rather than
// silently collapsed to the prefix.
feed: (filters?: FeedFilters) =>
filters !== undefined ? (["feed", filters] as const) : (["feed"] as const),
feedCount: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
facets: (filters?: FeedFilters) =>
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
// --- channels ---
channels: () => ["channels"] as const,
channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const,
blockedChannels: () => ["blocked-channels"] as const,
// --- playlists ---
playlists: () => ["playlists"] as const,
playlist: (id?: number | null) =>
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
savedViews: () => ["saved-views"] as const,
// --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above)
accounts: () => ["accounts"] as const,
version: () => ["version"] as const,
setupStatus: () => ["setup-status"] as const,
myUsage: () => ["my-usage"] as const,
scheduler: () => ["scheduler"] as const,
// --- notifications ---
notifications: () => ["notifications"] as const,
notifUnread: () => ["notif-unread"] as const,
// --- messaging ---
conversations: () => ["conversations"] as const,
thread: (partnerId: number | null) => ["thread", partnerId] as const,
messageKey: () => ["message-key"] as const,
messageUnread: () => ["message-unread"] as const,
messageUsers: () => ["message-users"] as const,
shareRecipients: () => ["share-recipients"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
downloadsShared: () => ["downloads-shared"] as const,
downloadIndex: () => ["download-index"] as const,
downloadUsage: () => ["download-usage"] as const,
downloadProfiles: () => ["download-profiles"] as const,
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
dlQuota: (userId: number) => ["dl-quota", userId] as const,
storyboard: (jobId: number) => ["storyboard", jobId] as const,
// --- admin ---
adminUsers: () => ["admin-users"] as const,
adminInvites: () => ["admin-invites"] as const,
adminConfig: () => ["admin-config"] as const,
adminAudit: () => ["admin-audit"] as const,
adminStorage: () => ["admin-storage"] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const,
// --- plex ---
// The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys
// array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through:
// the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]`
// anyway. Single-id plex keys stay precisely typed.
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
plexShow: (showId?: string) =>
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
plexItem: (id: string) => ["plex-item", id] as const,
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const,
plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest],
// facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too.
plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest],
plexPeopleCards: (scope: string, actors: string[]) =>
["plex-people-cards", scope, actors] as const,
};
+11
View File
@@ -14,6 +14,17 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.56.0",
date: "2026-07-28",
summary:
"Internal frontend hardening — the whole client API layer is now type-checked end to end and the largest module was broken up for maintainability. No changes to normal use.",
chores: [
"Type safety: the fetch wrapper and every one of the ~150 API calls now carry real types, closed-value fields (watch status, playlist kind/source, download job kind) are strict value sets, and every loosely-typed `any` was removed and is now blocked going forward — a whole class of mistakes is caught at build time instead of in the browser.",
"Cache correctness: all ~200 React Query cache keys now come from one typed source of truth, so a mistyped key can no longer silently fail to refresh a list after an action.",
"Code structure: the ~1800-line API module was split — the shared request machinery and the downloads, Plex, and messaging areas each moved to their own file behind the exact same interface, with no behaviour change. Plus a validation message and browser-tab titles now read from a single source so they can't drift.",
],
},
{
version: "0.55.0",
date: "2026-07-26",
+3 -2
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
@@ -45,9 +46,9 @@ export function useActiveFilters(
setFilters: (f: FeedFilters) => void,
): ActiveFilter[] {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({
queryKey: ["channels"],
queryKey: qk.channels(),
queryFn: api.channels,
enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000,
+3 -2
View File
@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api";
// The signed-in account, read from the ["me"] query cache. App owns the FETCHING query (gated on
// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
// instead of having it prop-drilled through App.
export function useMe(): Me {
const { data } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const { data } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
return data as Me;
}
+59
View File
@@ -0,0 +1,59 @@
// Minimal typings for the YouTube IFrame Player API surface this app actually uses. The API is
// loaded at runtime from youtube.com/iframe_api and ships no official npm types; rather than pull in
// @types/youtube we declare just the methods/events PlayerModal calls, so tsc flags any new call
// that isn't covered here. See [[reference-yt-iframe-api-remote-control-map]] for what's driveable.
interface YTVideoData {
title: string;
author: string;
video_id?: string;
}
export interface YTPlayer {
getPlayerState(): number;
getCurrentTime(): number;
getDuration(): number;
getVideoData(): YTVideoData;
getIframe(): HTMLIFrameElement;
getVolume(): number;
isMuted(): boolean;
setVolume(volume: number): void;
mute(): void;
unMute(): void;
playVideo(): void;
pauseVideo(): void;
loadVideoById(opts: { videoId: string; startSeconds?: number }): void;
seekTo(seconds: number, allowSeekAhead: boolean): void;
destroy(): void;
}
// The IFrame API fires events carrying a numeric `data` — a player-state code (onStateChange) or an
// error code (onError).
export interface YTPlayerEvent {
data: number;
target: YTPlayer;
}
interface YTPlayerOptions {
width?: string | number;
height?: string | number;
videoId?: string;
playerVars?: Record<string, string | number | undefined>;
events?: {
onReady?: (e: YTPlayerEvent) => void;
onStateChange?: (e: YTPlayerEvent) => void;
onError?: (e: YTPlayerEvent) => void;
};
}
export interface YTNamespace {
Player: new (el: HTMLElement, opts: YTPlayerOptions) => YTPlayer;
}
// The globals the IFrame API script installs on `window` once loaded.
declare global {
interface Window {
YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void;
}
}