refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b)

Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every
remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in
36 files. All ~200 React Query keys across the app now route through the
factory; zero raw key literals remain outside queryKeys.ts.

The heterogeneous composite plex keys (plex-library/facets/collections/
playlists carry a filters object, a ratingKeys array, or a union/group
discriminator) take a variadic pass-through — the factory centralizes the
ROOT string; single-id keys stay precisely typed. Nullable keys (thread,
playlist, ytSearch) accept null so a null segment is preserved.

Pure substitution — byte-identical key arrays, so TanStack prefix matching
is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err),
prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex
modules load, feed/facets/tags/my-status/plex-library/plex-facets/
plex-collections/plex-playlists all 200, full UI renders.
This commit is contained in:
2026-07-27 22:10:03 +02:00
parent ad86d6d126
commit 34297b696f
37 changed files with 235 additions and 142 deletions
+5 -4
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,7 +224,7 @@ 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;
+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][] = [
[
+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") }),
+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);
@@ -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,
});
@@ -57,7 +57,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 },
);
@@ -72,8 +72,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 });
+2 -2
View File
@@ -381,7 +381,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;
},
@@ -393,7 +393,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,
);
};
+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?.();
};
+5 -6
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";
@@ -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;
@@ -513,12 +514,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("");
+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() });
},
});
+6 -5
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 { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
@@ -387,7 +388,7 @@ 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 }),
@@ -516,7 +517,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 +530,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 +540,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);
},
});
+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: () =>
+2 -2
View File
@@ -57,7 +57,7 @@ function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
const s = status.data;
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
@@ -200,7 +200,7 @@ 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 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()),
+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();
},
+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), []);
+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";
+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,
);
};
+58
View File
@@ -41,4 +41,62 @@ export const qk = {
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,
};
+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;
}