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. // finding is reported once, by one tool, and this config stays about hooks + refresh safety.
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"no-undef": "off", "no-undef": "off",
// The 19 `any`s all live in the api.ts god-module, which R7 (Frontend API layer) rewrites. // R7 S3a eliminated every explicit `any` (api.ts prefs/data bags → Record<string, unknown>;
// Turning the rule on now would either block the gate on pre-existing debt or scatter 19 // catch clauses → unknown + HttpError narrowing; the YouTube IFrame boundary → lib/youtube.ts
// suppressions — R7 flips this back to "error" when it types that layer. Tracked, not ignored. // types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
"@typescript-eslint/no-explicit-any": "off", // 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()`, // 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. // `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [ "@typescript-eslint/no-unused-expressions": [
+11 -10
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; 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 // 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). // instance is set up (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({ const setupQuery = useQuery({
queryKey: ["setup-status"], queryKey: qk.setupStatus(),
queryFn: api.setupStatus, queryFn: api.setupStatus,
staleTime: Infinity, 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 // Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there. // its periodic refetch caused a visible flicker there.
const meQuery = useQuery({ const meQuery = useQuery({
queryKey: ["me"], queryKey: qk.me(),
queryFn: api.me, queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup, enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false), refetchInterval: (query) => (query.state.data ? 60_000 : false),
@@ -173,7 +174,7 @@ export default function App() {
// can't loop the public landing. // can't loop the public landing.
useEffect(() => { useEffect(() => {
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) { if (queryClient.getQueryData(qk.me())) {
queryClient.clear(); queryClient.clear();
window.location.reload(); window.location.reload();
} }
@@ -223,13 +224,12 @@ export default function App() {
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, // 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 // 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(() => { useEffect(() => {
const prefs = meQuery.data?.preferences; const prefs = meQuery.data?.preferences;
if (!prefs) return; if (!prefs) return;
const prefsRec = prefs as Record<string, unknown>;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]]; const raw = prefs[PREF_KEY[p]];
if (raw) { if (raw) {
const l = normalizeLayout(p, raw); const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l })); setPanelLayouts((prev) => ({ ...prev, [p]: l }));
@@ -244,11 +244,12 @@ export default function App() {
setFilterCollapsedState(prefs.filterCollapsed); setFilterCollapsedState(prefs.filterCollapsed);
writeAccount(LS.filterCollapsed, prefs.filterCollapsed); writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
} }
if (typeof prefsRec.playlistsCollapsed === "boolean") { if (typeof prefs.playlistsCollapsed === "boolean") {
setPlaylistsCollapsedState(prefsRec.playlistsCollapsed); setPlaylistsCollapsedState(prefs.playlistsCollapsed);
writeAccount(LS.playlistsCollapsed, prefsRec.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 // (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.) // dedicated effect above, which also covers accounts that have no stored preferences.)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+6 -1
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react"; import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
@@ -21,7 +22,11 @@ export default function About({
onOpenReleaseNotes: () => void; onOpenReleaseNotes: () => void;
}) { }) {
const { t } = useTranslation(); 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][] = [ const rows: [string, string][] = [
[ [
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api"; import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout"; 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 [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const { data: ch } = useQuery({ const { data: ch } = useQuery({
queryKey: ["channel", channelId], queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId), queryFn: () => api.channelDetail(channelId),
enabled: open && !!channelId, enabled: open && !!channelId,
staleTime: 5 * 60_000, 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 { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@@ -28,7 +29,7 @@ export default function AddToPlaylist({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const membership = useQuery({ const membership = useQuery({
queryKey: ["playlist-membership", videoId], queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId), queryFn: () => api.playlists(videoId),
enabled: open, enabled: open,
}); });
@@ -75,11 +76,11 @@ export default function AddToPlaylist({
}, [open]); }, [open]);
function refresh() { function refresh() {
qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
// Also invalidate every open/cached playlist detail (["playlist", id]) so the // Also invalidate every open/cached playlist detail (["playlist", id]) so the
// Playlists page reflects the add/remove when navigated to, not only after F5. // 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) { async function toggle(pl: Playlist) {
+11 -10
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
@@ -70,7 +71,7 @@ function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 // 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. // with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set()); const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
@@ -86,7 +87,7 @@ function UsersRoles({ me }: { me: Me }) {
api.setUserRole(id, role), api.setUserRole(id, role),
onMutate: (vars) => startPending(vars.id), onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
@@ -98,7 +99,7 @@ function UsersRoles({ me }: { me: Me }) {
api.setUserSuspended(id, suspended), api.setUserSuspended(id, suspended),
onMutate: (vars) => startPending(vars.id), onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ notify({
level: "success", level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", { 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), mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onMutate: (vars) => startPending(vars.id), onMutate: (vars) => startPending(vars.id),
onSuccess: (_d, vars) => { onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: qk.adminUsers() });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
}, },
onSettled: (_d, _e, vars) => endPending(vars.id), onSettled: (_d, _e, vars) => endPending(vars.id),
@@ -267,11 +268,11 @@ function AdminInvites() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 [newEmail, setNewEmail] = useState("");
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: ["admin-invites"] }); qc.invalidateQueries({ queryKey: qk.adminInvites() });
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
}; };
const approve = useMutation({ const approve = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id), mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
@@ -383,14 +384,14 @@ function AdminDemo() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 [newEmail, setNewEmail] = useState("");
const add = useMutation({ const add = useMutation({
mutationFn: (email: string) => api.addDemoWhitelist(email), mutationFn: (email: string) => api.addDemoWhitelist(email),
onSuccess: (_d, email) => { onSuccess: (_d, email) => {
setNewEmail(""); setNewEmail("");
qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.added", { email }) }); notify({ level: "success", message: t("settings.demo.added", { email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
@@ -398,7 +399,7 @@ function AdminDemo() {
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id), mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); qc.invalidateQueries({ queryKey: qk.demoWhitelist() });
notify({ level: "success", message: t("settings.demo.removed") }); notify({ level: "success", message: t("settings.demo.removed") });
}, },
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
+3 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
@@ -18,7 +19,7 @@ export default function AuditLog() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 data = q.data;
const rows = useMemo(() => data?.items ?? [], [data]); const rows = useMemo(() => data?.items ?? [], [data]);
@@ -31,7 +32,7 @@ export default function AuditLog() {
const clear = useMutation({ const clear = useMutation({
mutationFn: () => api.clearAudit(), mutationFn: () => api.clearAudit(),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ["admin-audit"] }); qc.invalidateQueries({ queryKey: qk.adminAudit() });
notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) });
}, },
onError: () => notify({ level: "error", message: t("audit.clearFailed") }), onError: () => notify({ level: "error", message: t("audit.clearFailed") }),
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { UserPlus } from "lucide-react"; import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api"; import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; import { accountKey, LS, readAccount, writeAccount } from "../lib/storage";
@@ -52,7 +53,7 @@ export default function ChannelDiscovery({
}; };
const query = useQuery({ const query = useQuery({
queryKey: ["discovered-channels"], queryKey: qk.discoveredChannels(),
queryFn: api.discoveredChannels, queryFn: api.discoveredChannels,
}); });
@@ -61,9 +62,9 @@ export default function ChannelDiscovery({
onSuccess: (_data, c) => { onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start // The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status. // arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: ["discovered-channels"] }); qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
const name = c.title ?? c.id; const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the // 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 // 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 { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { import {
ArrowLeft, ArrowLeft,
@@ -73,7 +74,7 @@ export default function ChannelPage({
const [bannerAttempt, setBannerAttempt] = useState(0); const [bannerAttempt, setBannerAttempt] = useState(0);
const detail = useQuery({ const detail = useQuery({
queryKey: ["channel", channelId], queryKey: qk.channel(channelId),
queryFn: () => api.channelDetail(channelId), queryFn: () => api.channelDetail(channelId),
}); });
const ch = detail.data; const ch = detail.data;
@@ -91,10 +92,10 @@ export default function ChannelPage({
.exploreChannel(channelId, token ?? undefined) .exploreChannel(channelId, token ?? undefined)
.then((r) => { .then((r) => {
setNextToken(r.next_page_token); 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 // 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). // 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)); .finally(() => setExploring(false));
}, },
@@ -116,9 +117,9 @@ export default function ChannelPage({
const block = useMutation({ const block = useMutation({
mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)), mutationFn: () => (ch?.blocked ? api.unblockChannel(channelId) : api.blockChannel(channelId)),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
}, },
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case), // 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) — // 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({ const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId), mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["discovered-channels"] }); qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); 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 // 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({ const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId), mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channel", channelId] }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
}, },
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api"; import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade"; 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. // Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade(); const fade = useScrollFade();
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ["channels"], queryKey: qk.channels(),
queryFn: api.channels, queryFn: api.channels,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
+27 -26
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -106,27 +107,27 @@ export default function Channels() {
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) }); notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}; };
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const blockedQuery = useQuery({ queryKey: ["blocked-channels"], queryFn: api.blockedChannels }); const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({ const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id), mutationFn: (id: string) => api.unblockChannel(id),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["blocked-channels"] }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}, },
}); });
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
// FB2: syncing subscriptions can change the feed immediately — newly-followed channels may // 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). // already have videos in the shared catalog, so refresh the feed too (not just the manager).
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
}; };
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system); 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 // 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. // refresh the per-user status and feed too — not only the channel list.
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
}, },
}); });
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list // 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. // lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => { const bumpPriority = (id: string, current: number, delta: number) => {
const next = current + delta; 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)), (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
); );
api api
.updateChannel(id, { priority: next }) .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 // 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. // made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => { const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(["channels"], (old) => qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) => (old ?? []).map((ch) =>
ch.id === id ch.id === id
? { ? {
@@ -172,7 +173,7 @@ export default function Channels() {
), ),
); );
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); 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({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
@@ -188,8 +189,8 @@ export default function Channels() {
const unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id), mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
}, },
onError: (e) => onError: (e) =>
@@ -198,8 +199,8 @@ export default function Channels() {
const resetBackfill = useMutation({ const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) }); notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
}, },
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard), onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
@@ -207,8 +208,8 @@ export default function Channels() {
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ notify({
level: "success", level: "success",
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -555,7 +556,7 @@ export default function Channels() {
resetPage, resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards); } = useCardPager(sortedChannels, LS.channelCardSize, isCards);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own // 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 // 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 // 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. // handled by the hook's clamp instead.
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, X } from "lucide-react"; import { ChevronDown, ChevronUp, X } from "lucide-react";
@@ -34,10 +35,10 @@ export default function ChatDock({ meId }: { meId: number }) {
useEffect( useEffect(
() => () =>
onMessage(({ message: m, from }) => { onMessage(({ message: m, from }) => {
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: ["message-unread"] }); qc.invalidateQueries({ queryKey: qk.messageUnread() });
const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id; 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 // Auto-open/flash only for messages from someone else (not my own echo) and only for
// real user conversations. // real user conversations.
if (m.kind === "user" && m.sender_id !== meId && from) { if (m.kind === "user" && m.sender_id !== meId && from) {
@@ -51,8 +52,8 @@ export default function ChatDock({ meId }: { meId: number }) {
useEffect( useEffect(
() => () =>
onUnread(() => { onUnread(() => {
qc.invalidateQueries({ queryKey: ["message-unread"] }); qc.invalidateQueries({ queryKey: qk.messageUnread() });
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: qk.conversations() });
}), }),
[qc], [qc],
); );
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
@@ -35,7 +36,7 @@ export default function ChatThread({
const needsKey = !isSystem && !ready; const needsKey = !isSystem && !ready;
const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>(
["thread", partnerId], qk.thread(partnerId),
() => api.thread(partnerId), () => api.thread(partnerId),
{ intervalMs: POLL_MS, enabled: !needsKey }, { 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; const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) { if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming; lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: ["message-unread"] }); qc.invalidateQueries({ queryKey: qk.messageUnread() });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, partnerId, meId, qc]); }, [q.dataUpdatedAt, partnerId, meId, qc]);
@@ -123,8 +124,8 @@ export default function ChatThread({
}, },
onSuccess: () => { onSuccess: () => {
setDraft(""); setDraft("");
qc.invalidateQueries({ queryKey: ["thread", partnerId] }); qc.invalidateQueries({ queryKey: qk.thread(partnerId) });
qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: qk.conversations() });
}, },
onError: (e) => { onError: (e) => {
// The draft is kept (only cleared on success) so the user can retry. 400/409/422/500 already // 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 { useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react"; import { Plug, RotateCcw, Send } from "lucide-react";
@@ -34,7 +35,7 @@ const GROUP_ORDER = [
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); 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 data = q.data;
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [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 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 setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved"); setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
+3 -2
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react"; import { Download } from "lucide-react";
@@ -22,11 +23,11 @@ export default function DownloadButton({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const me = qc.getQueryData<Me>(["me"]); const me = qc.getQueryData<Me>(qk.me());
const isDemo = !!me?.is_demo; const isDemo = !!me?.is_demo;
const indexQ = useQuery({ const indexQ = useQuery({
queryKey: ["download-index"], queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex, queryFn: api.downloadIndex,
enabled: !isDemo, enabled: !isDemo,
staleTime: 10000, staleTime: 10000,
+12 -11
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -281,7 +282,7 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void
extra_links: links.map((l) => l.trim()).filter(Boolean), extra_links: links.map((l) => l.trim()).filter(Boolean),
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: qk.downloads() });
onClose(); onClose();
}, },
}); });
@@ -365,7 +366,7 @@ function UsageBar() {
// Poll live (like the library list) so the footprint updates the moment the worker finishes an // 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 // 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. // 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; const u = usage.data;
if (!u) return null; if (!u) return null;
const pct = const pct =
@@ -408,7 +409,7 @@ function QuotaModal({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const q = useQuery({ const q = useQuery({
queryKey: ["dl-quota", userId], queryKey: qk.dlQuota(userId),
queryFn: () => api.adminGetDownloadQuota(userId), queryFn: () => api.adminGetDownloadQuota(userId),
}); });
const [form, setForm] = useState<{ const [form, setForm] = useState<{
@@ -429,8 +430,8 @@ function QuotaModal({
} }
: null); : null);
const done = () => { const done = () => {
qc.invalidateQueries({ queryKey: ["dl-quota", userId] }); qc.invalidateQueries({ queryKey: qk.dlQuota(userId) });
qc.invalidateQueries({ queryKey: ["admin-storage"] }); qc.invalidateQueries({ queryKey: qk.adminStorage() });
onClose(); onClose();
}; };
const save = useMutation({ const save = useMutation({
@@ -516,7 +517,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
const { t } = useTranslation(); const { t } = useTranslation();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [open, setOpen] = useState(false); 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 list = (users.data ?? []).filter((u) => !u.is_demo);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const s = q.trim().toLowerCase(); const s = q.trim().toLowerCase();
@@ -565,8 +566,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
function AdminSystem() { function AdminSystem() {
const { t } = useTranslation(); const { t } = useTranslation();
const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 }); const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
const s = storage.data; const s = storage.data;
return ( return (
@@ -662,9 +663,9 @@ export default function DownloadCenter() {
const [shareJob, setShareJob] = useState<DownloadJob | null>(null); const [shareJob, setShareJob] = useState<DownloadJob | null>(null);
const [editJob, setEditJob] = 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({ const sharedQ = useQuery({
queryKey: ["downloads-shared"], queryKey: qk.downloadsShared(),
queryFn: api.downloadsShared, queryFn: api.downloadsShared,
enabled: tab === "shared", 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). // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({ const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id), 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) => { const confirmThen = async (title: string, body: string, fn: () => void) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal"; import Modal from "./Modal";
@@ -24,7 +25,7 @@ export default function DownloadDialog({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); 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 [profileId, setProfileId] = useState<number | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
+16 -15
View File
@@ -6,6 +6,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -21,7 +22,7 @@ import {
Youtube, Youtube,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } 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 i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; 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. // (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. // "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) { switch (show) {
case "hidden": case "hidden":
return status === "hidden"; return status === "hidden";
@@ -125,7 +126,7 @@ export default function Feed({
const sortKeys = channelScoped const sortKeys = channelScoped
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
: SORT_KEYS; : SORT_KEYS;
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({}); const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
// The open player: which video and where to start (null = resume from saved position). // The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( 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 queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", queryFilters], queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE), queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null, initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined, 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 // retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending. // revisited search from re-spending.
const ytQuery = useInfiniteQuery({ const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch, ytCount], queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount), api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null, initialPageParam: null as string | null,
@@ -225,7 +226,7 @@ export default function Feed({
}, [query.dataUpdatedAt]); }, [query.dataUpdatedAt]);
const countQuery = useQuery({ const countQuery = useQuery({
queryKey: ["feed-count", queryFilters], queryKey: qk.feedCount(queryFilters),
queryFn: () => api.feedCount(queryFilters), queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000, staleTime: 30_000,
enabled: !ytActive, enabled: !ytActive,
@@ -240,7 +241,7 @@ export default function Feed({
const loadedRef = useRef<Video[]>([]); const loadedRef = useRef<Video[]>([]);
const onState = useCallback( const onState = useCallback(
(id: string, status: string) => { (id: string, status: VideoStatus) => {
setOverrides((o) => ({ ...o, [id]: status })); setOverrides((o) => ({ ...o, [id]: status }));
// Refetch once the server has the change so other views (e.g. Hidden) are in sync. // 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" // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
@@ -250,7 +251,7 @@ export default function Feed({
api api
.setState(id, status) .setState(id, status)
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
if (status === "hidden") { if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id); const v = loadedRef.current.find((x) => x.id === id);
notify({ notify({
@@ -307,7 +308,7 @@ export default function Feed({
setOverrides((o) => ({ ...o, [id]: "new" })); setOverrides((o) => ({ ...o, [id]: "new" }));
api api
.clearState(id) .clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch(() => .catch(() =>
setOverrides((o) => { setOverrides((o) => {
const next = { ...o }; const next = { ...o };
@@ -324,8 +325,8 @@ export default function Feed({
setSavedOverrides((o) => ({ ...o, [id]: saved })); setSavedOverrides((o) => ({ ...o, [id]: saved }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
}) })
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); .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 // 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. // pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => { const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: qk.feedCount() });
qc.removeQueries({ queryKey: ["facets"] }); qc.removeQueries({ queryKey: qk.facets() });
}; };
// --- Live YouTube search mode ------------------------------------------------------------- // --- Live YouTube search mode -------------------------------------------------------------
@@ -435,7 +436,7 @@ export default function Feed({
setFilters({ ...filters, librarySource: "organic", sort: "newest" }); setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch(); onExitYtSearch();
dropFeedCaches(); 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" 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, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api"; import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { hasFilterParams, paramsToFilters } from "../lib/urlState"; import { hasFilterParams, paramsToFilters } from "../lib/urlState";
@@ -95,10 +96,10 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
hasFilterParams(new URLSearchParams(window.location.search)), 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 // 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. // 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) => { const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next); setFiltersState(next);
+3 -2
View File
@@ -7,6 +7,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { parseFeedView, type FeedView } from "../lib/feedView"; 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) // 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 // 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 // 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 { interface FeedViewActions {
setView: (v: FeedView) => void; setView: (v: FeedView) => void;
@@ -40,7 +41,7 @@ export function FeedViewProvider({ children }: { children: ReactNode }) {
parseFeedView(readAccount(LS.feedView, null)), parseFeedView(readAccount(LS.feedView, null)),
); );
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). // 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. // A user pick persists both to the localStorage mirror (instant reload) and to the server.
const setView = useCallback((next: FeedView) => { const setView = useCallback((next: FeedView) => {
+6 -5
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react"; 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const keyQ = useQuery({ const keyQ = useQuery({
queryKey: ["message-key"], queryKey: qk.messageKey(),
queryFn: api.messageMyKey, queryFn: api.messageMyKey,
staleTime: 60_000, staleTime: 60_000,
}); });
const configured = keyQ.data?.configured ?? false; const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts // 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. // 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 hasPassword = meQ.data?.has_password ?? false;
const [view, setView] = useState<"gate" | "reset">("gate"); 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); const payload = await e2ee.setup(meId, pass);
await api.setupMessageKey(payload); await api.setupMessageKey(payload);
} }
qc.invalidateQueries({ queryKey: ["message-key"] }); qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch { } catch {
setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed")); setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed"));
} finally { } finally {
@@ -77,7 +78,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
await e2ee.resetLocal(meId); await e2ee.resetLocal(meId);
setCurPass(""); setCurPass("");
setView("gate"); setView("gate");
qc.invalidateQueries({ queryKey: ["message-key"] }); qc.invalidateQueries({ queryKey: qk.messageKey() });
} catch (e) { } catch (e) {
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed")); setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
} finally { } finally {
+3 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react"; import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
@@ -74,7 +75,7 @@ function ConversationList({
const { t } = useTranslation(); const { t } = useTranslation();
const keyState = useKeyState(); const keyState = useKeyState();
const ready = keyState === "ready"; const ready = keyState === "ready";
const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { const q = useLiveQuery<{ items: Conversation[] }>(qk.conversations(), api.conversations, {
intervalMs: POLL_MS, intervalMs: POLL_MS,
}); });
const items = q.data?.items ?? []; const items = q.data?.items ?? [];
@@ -204,7 +205,7 @@ function ConversationList({
function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState(""); 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) => const users = (q.data ?? []).filter((u) =>
u.name.toLowerCase().includes(filter.trim().toLowerCase()), u.name.toLowerCase().includes(filter.trim().toLowerCase()),
); );
+5 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; 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). // Accounts that have signed in on this browser (loaded only while the popover is open).
const accountsQuery = useQuery({ const accountsQuery = useQuery({
queryKey: ["accounts"], queryKey: qk.accounts(),
queryFn: api.accounts, queryFn: api.accounts,
enabled: acctOpen, enabled: acctOpen,
}); });
@@ -138,7 +139,7 @@ export default function NavSidebar({
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // 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. // 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, intervalMs: 30000,
}); });
// One indicator for both layers: durable server notifications + the client-side transient // 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 clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const unread = (unreadQuery.data?.count ?? 0) + clientUnread; const unread = (unreadQuery.data?.count ?? 0) + clientUnread;
// Direct messages are their own module with their own unread badge (demo can't message). // 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, intervalMs: 30000,
enabled: !me.is_demo, enabled: !me.is_demo,
}); });
const msgUnread = msgUnreadQuery.data?.count ?? 0; const msgUnread = msgUnreadQuery.data?.count ?? 0;
// Download center badge = items still working (queued/running/paused). Reuses the same // 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. // 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, intervalMs: 30000,
enabled: !me.is_demo, enabled: !me.is_demo,
}); });
+16 -11
View File
@@ -1,6 +1,8 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
Activity, Activity,
Bell, Bell,
@@ -56,7 +58,7 @@ export default function NotificationsPanel() {
const { focusChannel: onFocusChannel } = useChannelsActions(); const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient(); const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>( const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"], qk.notifications(),
() => api.notifications(), () => api.notifications(),
{ intervalMs: POLL_MS }, { intervalMs: POLL_MS },
); );
@@ -71,8 +73,8 @@ export default function NotificationsPanel() {
}, []); }, []);
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: ["notifications"] }); qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: ["notif-unread"] }); qc.invalidateQueries({ queryKey: qk.notifUnread() });
}; };
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
@@ -107,8 +109,8 @@ export default function NotificationsPanel() {
api api
.setState(meta.videoId, "new") .setState(meta.videoId, "new")
.then(() => { .then(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
// Quietly resolve the now-stale notice (no confirmation toast — the change is visible). // Quietly resolve the now-stale notice (no confirmation toast — the change is visible).
resolveVideo(meta.videoId); resolveVideo(meta.videoId);
}) })
@@ -222,7 +224,7 @@ function NotificationRow({
onOpenScheduler, onOpenScheduler,
}: { }: {
n: AppNotification; n: AppNotification;
t: (k: string, o?: any) => string; t: TFunction;
onRead: () => void; onRead: () => void;
onDismiss: () => void; onDismiss: () => void;
onOpenScheduler: () => void; onOpenScheduler: () => void;
@@ -238,15 +240,18 @@ function NotificationRow({
let title = n.title; let title = n.title;
let body = n.body; let body = n.body;
if (isMaintenance) { 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"); 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) { } else if (isScheduler) {
// "<Job label> finished/failed", with the raw result summary as the (technical) body. // "<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); const d = n.data as { job_id: string; status?: string; summary?: string };
title = t(n.data!.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", { const job = t(`scheduler.jobs.${d.job_id}`, d.job_id);
title = t(d.status === "error" ? "inbox.jobDone.titleError" : "inbox.jobDone.titleOk", {
job, job,
}); });
body = n.data!.summary || n.body; body = d.summary || n.body;
} }
return ( return (
<div <div
@@ -319,7 +324,7 @@ function ClientActivityRow({
onRemove, onRemove,
}: { }: {
n: ClientNotif; n: ClientNotif;
t: (k: string, o?: any) => string; t: TFunction;
onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void;
onFocusChannel: (name: string) => void; onFocusChannel: (name: string) => void;
+6 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding"; 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. // Once we have read access, find out whether any subscriptions are imported yet.
const myStatus = useQuery({ const myStatus = useQuery({
queryKey: ["my-status"], queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
enabled: me.can_read, enabled: me.can_read,
}); });
@@ -72,10 +73,10 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: () => { onSuccess: () => {
// Feed/channels/status now have content — refresh the app behind the wizard. // Feed/channels/status now have content — refresh the app behind the wizard.
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["feed-count"] }); qc.invalidateQueries({ queryKey: qk.feedCount() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
}, },
}); });
+18 -15
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
@@ -28,7 +29,8 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal"; import { modalCount } from "./Modal";
import DownloadButton from "./DownloadButton"; 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 { import {
channelYouTubeUrl, channelYouTubeUrl,
formatDate, formatDate,
@@ -81,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
} }
// --- IFrame Player API loader (singleton) --- // --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null; let apiPromise: Promise<YTNamespace> | null = null;
function loadYouTubeApi(): Promise<any> { function loadYouTubeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise; if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve) => { apiPromise = new Promise<YTNamespace>((resolve) => {
const w = window as any; const w = window;
if (w.YT && w.YT.Player) return resolve(w.YT); if (w.YT && w.YT.Player) return resolve(w.YT);
const prev = w.onYouTubeIframeAPIReady; const prev = w.onYouTubeIframeAPIReady;
w.onYouTubeIframeAPIReady = () => { w.onYouTubeIframeAPIReady = () => {
if (typeof prev === "function") prev(); 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"); const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api"; tag.src = "https://www.youtube.com/iframe_api";
@@ -117,7 +120,7 @@ export default function PlayerModal({
queue?: Video[]; queue?: Video[];
startIndex?: number; startIndex?: number;
onClose: () => void; 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 // 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. // icon next to the name keeps the open-on-YouTube behaviour.
}) { }) {
@@ -157,7 +160,7 @@ export default function PlayerModal({
const resumeAt = const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<YTPlayer | null>(null);
const autoMarkedRef = useRef(false); const autoMarkedRef = useRef(false);
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a // 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. // 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. // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = useMemo( const savedPrefs = useMemo(
() => () =>
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as { (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
playerAutoAdvance?: AutoMode; playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode; playerLoop?: LoopMode;
}, },
@@ -392,7 +395,7 @@ export default function PlayerModal({
modeRef.current = { autoMode, loopMode }; modeRef.current = { autoMode, loopMode };
const persistPref = (patch: Record<string, string>) => { const persistPref = (patch: Record<string, string>) => {
api.savePrefs(patch).catch(() => {}); 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, m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
); );
}; };
@@ -479,7 +482,7 @@ export default function PlayerModal({
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
}; };
const detail = useQuery({ const detail = useQuery({
queryKey: ["video-detail", currentVideoId], queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId),
// On hover (for the description) and eagerly when navigated, so we can link the // On hover (for the description) and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result. // linked video's channel. Both share the one cached result.
@@ -705,7 +708,7 @@ export default function PlayerModal({
focusModal(); focusModal();
applyVolume(volumeRef.current, false); applyVolume(volumeRef.current, false);
}, },
onStateChange: (e: any) => { onStateChange: (e: YTPlayerEvent) => {
// 1 === playing → sync the navigated-to video's title/author for display. // 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) { if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run 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…). // The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch. // Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => { onError: (e: YTPlayerEvent) => {
setPlayerError(typeof e?.data === "number" ? e.data : -1); setPlayerError(typeof e?.data === "number" ? e.data : -1);
// The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd() // 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 // 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); window.removeEventListener("pagehide", onHide);
// Final flush, then refresh the feed + playlists so resume bars reflect this session. // Final flush, then refresh the feed + playlists so resume bars reflect this session.
Promise.resolve(persist()).finally(() => { Promise.resolve(persist()).finally(() => {
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
}); });
const p = playerRef.current; const p = playerRef.current;
if (p && typeof p.destroy === "function") { 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 { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -171,8 +172,8 @@ export default function Playlists() {
// Refresh the sidebar (cover may change) and the detail (so the now-dirty state, // 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 // and the Reset/Unsynced indicators, show without an F5). The membership guard
// keeps the refetch from wiping the undo history on a pure reorder. // keeps the refetch from wiping the undo history on a pure reorder.
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
}) })
.catch((e) => { .catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had // 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 // the arriving detail is judged "same list" and the rejected order stays on screen — and
// becomes the base for the next drag. // becomes the base for the next drag.
lastSetRef.current = ""; 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 [playingId, setPlayingId] = useState<string | null>(null);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["playlists"], queryKey: qk.playlists(),
queryFn: () => api.playlists(), queryFn: () => api.playlists(),
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const playlists = listQuery.data ?? []; const playlists = listQuery.data ?? [];
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: ["playlist", selectedId], queryKey: qk.playlist(selectedId),
queryFn: () => api.playlist(selectedId as number), queryFn: () => api.playlist(selectedId as number),
enabled: selectedId != null, enabled: selectedId != null,
// Refetch when returning to the tab so a change made elsewhere (another tab/device) // 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); const plName = (p: { kind: string; name: string }) => playlistName(p, t);
function refreshAll() { function refreshAll() {
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
} }
async function revertToYoutube() { async function revertToYoutube() {
@@ -499,14 +500,14 @@ export default function Playlists() {
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")), (onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
}); });
} }
qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
} }
function playerState(id: string, status: string) { function playerState(id: string, status: string) {
api.setState(id, status).then(() => { api.setState(id, status).then(() => {
qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}); });
} }
+5 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
@@ -54,7 +55,7 @@ export default function PlaylistsRail({
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["playlists"], queryKey: qk.playlists(),
queryFn: () => api.playlists(), queryFn: () => api.playlists(),
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
@@ -114,8 +115,8 @@ export default function PlaylistsRail({
setSyncing(true); setSyncing(true);
try { try {
const r = await api.syncYoutubePlaylists(); const r = await api.syncYoutubePlaylists();
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: ["playlist"] }); qc.invalidateQueries({ queryKey: qk.playlist() });
notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) });
} catch (e) { } catch (e) {
notifyYouTubeActionError(e, t("playlists.syncFailed")); notifyYouTubeActionError(e, t("playlists.syncFailed"));
@@ -128,7 +129,7 @@ export default function PlaylistsRail({
mutationFn: (name: string) => api.createPlaylist(name), mutationFn: (name: string) => api.createPlaylist(name),
onSuccess: (pl: Playlist) => { onSuccess: (pl: Playlist) => {
setNewName(""); setNewName("");
qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: qk.playlists() });
setSelectedId(pl.id); setSelectedId(pl.id);
}, },
}); });
+20 -19
View File
@@ -7,6 +7,7 @@ import {
useState, useState,
type CSSProperties, type CSSProperties,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -138,7 +139,7 @@ export default function PlexBrowse() {
}, [sub]); }, [sub]);
const browseQ = useInfiniteQuery({ 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", enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0, initialPageParam: 0,
queryFn: ({ pageParam }) => 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 // 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. // grid so you SEE who you're exploring, not just chips in the rail.
const peopleCardsQ = useQuery({ const peopleCardsQ = useQuery({
queryKey: ["plex-people-cards", scope, filters.actors], queryKey: qk.plexPeopleCards(scope, filters.actors),
queryFn: () => api.plexPeopleCards(filters.actors, scope), queryFn: () => api.plexPeopleCards(filters.actors, scope),
enabled: sub.view.kind === "grid" && filters.actors.length > 0, enabled: sub.view.kind === "grid" && filters.actors.length > 0,
staleTime: 60_000, 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 // 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. // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
function optimisticStatus(id: string, next: string) { function optimisticStatus(id: string, next: string) {
qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: ["plex-library"] }, (old) => qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
old old
? { ? {
...old, ...old,
@@ -223,7 +224,7 @@ export default function PlexBrowse() {
const next = card.status === "watched" ? "new" : "watched"; const next = card.status === "watched" ? "new" : "watched";
optimisticStatus(card.id, next); optimisticStatus(card.id, next);
await api.plexSetState(card.id, next).catch(() => {}); 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. // 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 // 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; onFilter: (patch: Partial<PlexFilters>) => void;
}) { }) {
const { t } = useTranslation(); 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 ( return (
<div className="w-[90%] max-w-[1600px] mx-auto"> <div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0"> <div className="p-4 pb-0">
@@ -614,7 +615,7 @@ function PlexInfoView({
onPlay={onPlay} onPlay={onPlay}
onPlayItem={onPlayItem} onPlayItem={onPlayItem}
onFilter={onFilter} 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. // query), so q refetches automatically — a manual refetch here just double-fetched.
/> />
</Suspense> </Suspense>
@@ -624,7 +625,7 @@ function PlexInfoView({
} }
// --- Series drill-down: show detail page → season subpage → player ------------------------------- // --- 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 // 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), // 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 — // 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin"; const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
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 d = q.data;
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null); const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false); const [collOpen, setCollOpen] = useState(false);
@@ -1009,13 +1010,13 @@ function PlexShowView({
} finally { } finally {
setBusy(false); setBusy(false);
} }
qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
async function markSeason(seasonRk: string, w: boolean) { async function markSeason(seasonRk: string, w: boolean) {
await api.plexSeasonState(seasonRk, w).catch(() => {}); await api.plexSeasonState(seasonRk, w).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
return ( return (
@@ -1208,7 +1209,7 @@ function PlexShowView({
library={showLib} library={showLib}
memberOf={show.collection_keys} memberOf={show.collection_keys}
onClose={() => setCollOpen(false)} onClose={() => setCollOpen(false)}
onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })} onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
/> />
)} )}
</div> </div>
@@ -1228,7 +1229,7 @@ function PlexSeasonView({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); 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 d = q.data;
const se = d?.seasons.find((s) => s.id === seasonId); const se = d?.seasons.find((s) => s.id === seasonId);
const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null); const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
@@ -1245,13 +1246,13 @@ function PlexSeasonView({
} finally { } finally {
setBusy(false); setBusy(false);
} }
qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
async function markEpisode(ep: PlexCard) { async function markEpisode(ep: PlexCard) {
await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {}); await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-show", showId] }); qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
} }
return ( return (
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react"; 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 [busy, setBusy] = useState<string | null>(null); // collection id currently mutating
const listQ = useQuery({ const listQ = useQuery({
queryKey: ["plex-collections", library], queryKey: qk.plexCollections(library),
queryFn: () => api.plexCollections(library), queryFn: () => api.plexCollections(library),
}); });
const editable = useMemo( const editable = useMemo(
@@ -54,9 +55,9 @@ export default function PlexCollectionEditor({
const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable; const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable;
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: ["plex-collections"] }); qc.invalidateQueries({ queryKey: qk.plexCollections() });
qc.invalidateQueries({ queryKey: ["plex-item", item.id] }); qc.invalidateQueries({ queryKey: qk.plexItem(item.id) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
onChanged(); onChanged();
}; };
+6 -5
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; 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 // artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref. // is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs(); 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[] }; {}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled // 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. // per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
@@ -66,7 +67,7 @@ export default function PlexInfo({
return next; 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 [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = 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 inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => { const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {}); await api.plexSetState(detail.id, status).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); qc.invalidateQueries({ queryKey: qk.plexItem(detail.id) });
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: ["plex-show"] }); qc.invalidateQueries({ queryKey: qk.plexShow() });
onStateChange?.(); onStateChange?.();
}; };
+10 -10
View File
@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
type WheelEvent as ReactWheelEvent, type WheelEvent as ReactWheelEvent,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -33,7 +34,7 @@ import {
VolumeX, VolumeX,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type PlexMarker } from "../lib/api"; import { api, HttpError, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { import {
exitFullscreen, exitFullscreen,
@@ -196,7 +197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
tRef.current = t; tRef.current = t;
const qc = useQueryClient(); const qc = useQueryClient();
const [id, setId] = useState(itemId); 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 detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0; 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. // selected client-side after the manifest loads.
const m = multiAudioRef.current; const m = multiAudioRef.current;
sess = await api.plexSession(id, startAt, m ? null : audioRef.current, aoffRef.current, m); 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 // 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 // isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
// needs full transcoding (a later phase). // needs full transcoding (a later phase).
const status = e instanceof HttpError ? e.status : undefined;
setLoadError( setLoadError(
e?.status === 501 status === 501
? tRef.current("plex.player.errTranscode") ? tRef.current("plex.player.errTranscode")
: e?.status === 404 : status === 404
? tRef.current("plex.player.errNotFound") ? tRef.current("plex.player.errNotFound")
: tRef.current("plex.player.errGeneric"), : 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. // unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play.
if (absRef.current > 0 && durationRef.current > 0) { if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current); const pos = Math.floor(absRef.current);
qc.setQueryData(["plex-item", id], (old) => qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old));
old ? { ...old, position_seconds: pos } : old,
);
} }
qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: ["plex-show"] }); qc.invalidateQueries({ queryKey: qk.plexShow() });
}; };
}, [id, qc]); }, [id, qc]);
+4 -3
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react"; import { Check, Minus, Plus } from "lucide-react";
@@ -35,15 +36,15 @@ export default function PlexPlaylistAdd({
const listQ = useQuery({ const listQ = useQuery({
queryKey: isGroup queryKey: isGroup
? ["plex-playlists", "group", target.ratingKeys] ? qk.plexPlaylists("group", target.ratingKeys)
: ["plex-playlists", "contains", target.ratingKey], : qk.plexPlaylists("contains", target.ratingKey),
queryFn: () => queryFn: () =>
isGroup isGroup
? api.plexPlaylists(undefined, target.ratingKeys) ? api.plexPlaylists(undefined, target.ratingKeys)
: api.plexPlaylists(target.ratingKey), : api.plexPlaylists(target.ratingKey),
}); });
const playlists = listQ.data?.playlists ?? []; 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). // How many of the target a playlist currently holds (0..size).
function inCount(p: PlexPlaylist): number { function inCount(p: PlexPlaylist): number {
+6 -5
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -375,7 +376,7 @@ export default function PlexPlaylistView({
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set()); const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
const q = useQuery({ const q = useQuery({
queryKey: ["plex-playlist", playlistId], queryKey: qk.plexPlaylist(playlistId),
queryFn: () => api.plexPlaylist(playlistId), queryFn: () => api.plexPlaylist(playlistId),
}); });
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. // 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 order = useMemo(() => items.map((i) => i.id), [items]);
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
}; };
function commit(next: PlexCard[]) { function commit(next: PlexCard[]) {
@@ -398,7 +399,7 @@ export default function PlexPlaylistView({
playlistId, playlistId,
next.map((i) => i.id), next.map((i) => i.id),
) )
.then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] })); .then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }));
} }
async function onRemove(keys: string[]) { async function onRemove(keys: string[]) {
@@ -431,7 +432,7 @@ export default function PlexPlaylistView({
) )
return; return;
await api.plexDeletePlaylist(playlistId); await api.plexDeletePlaylist(playlistId);
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onBack(); onBack();
} }
+5 -4
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
@@ -84,7 +85,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
setPlaylistOpen: onOpenPlaylist, setPlaylistOpen: onOpenPlaylist,
} = usePlexActions(); } = usePlexActions();
const collFade = useScrollFade(); 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 [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const qc = useQueryClient(); const qc = useQueryClient();
@@ -93,12 +94,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
if (!title) return; if (!title) return;
const pl = await api.plexCreatePlaylist(title); const pl = await api.plexCreatePlaylist(title);
setNewPlaylist(""); setNewPlaylist("");
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id); onOpenPlaylist(pl.id);
} }
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({ const facetsQ = useQuery({
queryKey: ["plex-facets", scope, show, facetKey], queryKey: qk.plexFacets(scope, show, facetKey),
queryFn: () => api.plexFacets(scope, filters, show), queryFn: () => api.plexFacets(scope, filters, show),
enabled: !!scope, enabled: !!scope,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
@@ -108,7 +109,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
const [collSearch, setCollSearch] = useState(""); const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300); const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({ const collectionsQ = useQuery({
queryKey: ["plex-collections", "union", collSearchDeb], queryKey: qk.plexCollections("union", collSearchDeb),
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection, enabled: !filters.collection,
}); });
+2 -1
View File
@@ -8,6 +8,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { import {
@@ -68,7 +69,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
const saveMsgTimer = useRef<number | undefined>(undefined); const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = 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). // 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 // 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. // 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 { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react"; import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
@@ -42,7 +43,7 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 profiles = profilesQ.data ?? [];
const builtins = profiles.filter((p) => p.is_builtin); const builtins = profiles.filter((p) => p.is_builtin);
const mine = 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 [spec, setSpec] = useState<DownloadSpec>(BLANK);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] }); const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadProfiles() });
const startNew = () => { const startNew = () => {
setEditing(null); setEditing(null);
setName(""); setName("");
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react";
import { import {
closestCenter, closestCenter,
@@ -56,7 +57,7 @@ export default function SavedViewsWidget({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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 views = viewsQuery.data ?? [];
const [naming, setNaming] = useState(false); const [naming, setNaming] = useState(false);
const [draftName, setDraftName] = useState(""); const [draftName, setDraftName] = useState("");
@@ -69,7 +70,7 @@ export default function SavedViewsWidget({
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]); }, [viewsQuery.data]);
const refresh = () => qc.invalidateQueries({ queryKey: ["saved-views"] }); const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
const currentKey = keyOf(filters); const currentKey = keyOf(filters);
async function save() { async function save() {
@@ -124,7 +125,7 @@ export default function SavedViewsWidget({
const next = arrayMove(ids, oldIndex, newIndex); const next = arrayMove(ids, oldIndex, newIndex);
// Optimistic reorder so the drop sticks visually before the round-trip. // Optimistic reorder so the drop sticks visually before the round-trip.
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
["saved-views"], qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!), next.map((id) => views.find((v) => v.id === id)!),
); );
await api.reorderViews(next); await api.reorderViews(next);
+8 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import i18n from "../i18n"; import i18n from "../i18n";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -340,7 +341,7 @@ export default function Scheduler() {
const confirm = useConfirm(); const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when // 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). // 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), intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
}); });
const data = q.data; const data = q.data;
@@ -376,14 +377,14 @@ export default function Scheduler() {
const pauseResume = useMutation({ const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), 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") }), onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }),
}); });
const intervalMut = useMutation({ const intervalMut = useMutation({
mutationFn: (v: { jobId: string; minutes: number }) => mutationFn: (v: { jobId: string; minutes: number }) =>
api.updateSchedulerJob(v.jobId, v.minutes), 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") }), onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }),
}); });
@@ -396,7 +397,7 @@ export default function Scheduler() {
level: "success", level: "success",
message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }), message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }),
}); });
qc.invalidateQueries({ queryKey: ["scheduler"] }); qc.invalidateQueries({ queryKey: qk.scheduler() });
}, },
}); });
const runAllMut = useMutation({ const runAllMut = useMutation({
@@ -406,12 +407,12 @@ export default function Scheduler() {
level: "success", level: "success",
message: t("scheduler.triggeredAll", { count: res.started.length }), message: t("scheduler.triggeredAll", { count: res.started.length }),
}); });
qc.invalidateQueries({ queryKey: ["scheduler"] }); qc.invalidateQueries({ queryKey: qk.scheduler() });
}, },
}); });
const batchMut = useMutation({ const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v), mutationFn: (v: number) => api.updateMaintenanceBatch(v),
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
}); });
const purgeMut = useMutation({ const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(), mutationFn: () => api.purgeDiscovery(),
@@ -419,7 +420,7 @@ export default function Scheduler() {
const removed = const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0); (res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) }); 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 { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; 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 { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
@@ -387,13 +388,15 @@ function SignInMethods({ me }: { me: Me }) {
setCurrent(""); setCurrent("");
setNext(""); setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password". // 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({ notify({
level: "success", level: "success",
message: t("settings.account.password.saved", { email: me.email }), message: t("settings.account.password.saved", { email: me.email }),
}); });
} catch (e: any) { } catch (e) {
setErr(e?.detail ?? t("settings.account.password.failed")); setErr(
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -516,7 +519,7 @@ function SignInMethods({ me }: { me: Me }) {
function PlexWatchSync() { function PlexWatchSync() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); 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 enabled = link.data?.sync_enabled ?? false;
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => { 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. // 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({ const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next), mutationFn: (next: boolean) => api.plexWatchSetLink(next),
onSuccess: (res) => { onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res); qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import); announce(res.import);
}, },
}); });
const reimport = useMutation({ const reimport = useMutation({
mutationFn: () => api.plexWatchImport(), mutationFn: () => api.plexWatchImport(),
onSuccess: (res) => { onSuccess: (res) => {
qc.setQueryData(["plex-watch-link"], res); qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import); announce(res.import);
}, },
}); });
+3 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react"; 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 { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
@@ -103,8 +103,8 @@ export default function SetupWizard() {
return; return;
} }
go(1); go(1);
} catch (e: any) { } catch (e) {
setError(e?.detail ?? t("setup.genericError")); setError((e instanceof HttpError ? e.detail : undefined) ?? t("setup.genericError"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react"; import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
@@ -17,7 +18,7 @@ function UserShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [open, setOpen] = useState(false); 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 list = recipients.data ?? [];
const filtered = useMemo(() => { const filtered = useMemo(() => {
const s = q.trim().toLowerCase(); const s = q.trim().toLowerCase();
@@ -245,14 +246,14 @@ function LinkShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const links = useQuery({ const links = useQuery({
queryKey: ["download-links", job.id], queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id), queryFn: () => api.downloadLinks(job.id),
}); });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false); const [allowDownload, setAllowDownload] = useState(false);
const [expiryDays, setExpiryDays] = useState<number | null>(null); const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState(""); 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({ const create = useMutation({
mutationFn: () => mutationFn: () =>
+3 -2
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react"; import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
@@ -80,14 +81,14 @@ export default function Sidebar({
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions(); 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 ?? []; const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so // 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. // typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
const facetsQuery = useQuery({ const facetsQuery = useQuery({
queryKey: ["facets", facetFilters], queryKey: qk.facets(facetFilters),
queryFn: () => api.facets(facetFilters), queryFn: () => api.facets(facetFilters),
staleTime: 30_000, staleTime: 30_000,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
+10 -9
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { History, Pause, Play, RefreshCw } from "lucide-react"; import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
@@ -55,14 +56,14 @@ export default function Stats() {
function Overview({ me }: { me: Me }) { function Overview({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); 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 s = status.data;
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => { onSuccess: (r: { subscriptions?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
notify({ notify({
level: "success", level: "success",
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }), message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
@@ -73,8 +74,8 @@ function Overview({ me }: { me: Me }) {
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
notify({ notify({
level: "success", level: "success",
message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }), message: t("stats.sync.fullHistoryRequested", { count: r.updated ?? 0 }),
@@ -199,11 +200,11 @@ function SystemStats() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [days, setDays] = useState<number>(30); 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: ["my-status"], queryFn: api.myStatus }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const pauseResume = useMutation({ const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), 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 data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); 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 { useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; 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 }); const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]); useDismiss(open, () => setOpen(false), [chipRef, popRef]);
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["my-status"], queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
// Track the scheduler near-real-time: poll even when the tab is backgrounded, and // 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 // refresh immediately on tab focus (the global default disables focus refetch), so the
@@ -47,7 +48,7 @@ export default function SyncStatus({
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()), mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
}); });
if (!data) return null; if (!data) return null;
+6 -5
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api"; import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -116,8 +117,8 @@ export default function TagManager({
const confirm = useConfirm(); const confirm = useConfirm();
const listFade = useScrollFade(); const listFade = useScrollFade();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) => const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? []) (channelsQuery.data ?? [])
@@ -125,9 +126,9 @@ export default function TagManager({
.map((c) => ({ id: c.id, title: c.title ?? c.id })); .map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["tags"] }); qc.invalidateQueries({ queryKey: qk.tags() });
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: qk.feed() });
}; };
const create = useMutation({ const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }), 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 AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import clsx from "clsx"; 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 { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
@@ -30,14 +30,14 @@ function Actions({
dense = false, dense = false,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */ /** Tighter buttons for the small card, whose column bottoms out at 180px. */
dense?: boolean; dense?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => { const act = (status: VideoStatus) => (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onState(video.id, video.status === status ? "new" : status); onState(video.id, video.status === status ? "new" : status);
@@ -375,7 +375,7 @@ function VideoCard({
view: FeedView; view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */ /** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number; width: number;
onState: (id: string, status: string) => void; onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -73,7 +74,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const aspect = const aspect =
(job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9; (job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
const sb = useQuery({ const sb = useQuery({
queryKey: ["storyboard", job.id], queryKey: qk.storyboard(job.id),
queryFn: () => api.downloadStoryboard(job.id), queryFn: () => api.downloadStoryboard(job.id),
staleTime: Infinity, staleTime: Infinity,
retry: false, retry: false,
@@ -307,8 +308,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: N }; return { files: N };
}, },
onSuccess: ({ files }) => { onSuccess: ({ files }) => {
qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: ["download-usage"] }); qc.invalidateQueries({ queryKey: qk.downloadUsage() });
notify({ level: "success", message: t("editor.created", { count: files }) }); notify({ level: "success", message: t("editor.created", { count: files }) });
onClose(); 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 { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard"; import VideoCard from "./VideoCard";
import VirtualGrid from "./VirtualGrid"; import VirtualGrid from "./VirtualGrid";
@@ -21,7 +21,7 @@ const ROW_EST: Record<FeedView, number> = {
export interface VirtualFeedProps { export interface VirtualFeedProps {
items: Video[]; items: Video[];
view: FeedView; view: FeedView;
onState: (id: string, status: string) => void; onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void; onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void; onOpen: (v: Video, startAt?: number | null) => void;
+3 -2
View File
@@ -9,6 +9,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { shouldAutoOpenOnboarding } from "../lib/onboarding"; 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 // 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 // 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. // fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
interface WizardActions { interface WizardActions {
@@ -43,7 +44,7 @@ export function useWizard(): WizardActions {
export function WizardProvider({ children }: { children: ReactNode }) { export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). // 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 openWizard = useCallback(() => setOpen(true), []);
const closeWizard = useCallback(() => setOpen(false), []); const closeWizard = useCallback(() => setOpen(false), []);
+1
View File
@@ -1,6 +1,7 @@
{ {
"title": "Couldn't complete that", "title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.", "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", "server": "Server error",
"ok": "OK", "ok": "OK",
"offline": { "offline": {
+1
View File
@@ -1,6 +1,7 @@
{ {
"title": "Nem sikerült", "title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", "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", "server": "Szerverhiba",
"ok": "OK", "ok": "OK",
"offline": { "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 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 // 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. // whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) { export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: ["download-index"] }); qc.invalidateQueries({ queryKey: qk.downloadIndex() });
qc.invalidateQueries({ queryKey: ["download-usage"] }); 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. // Shared messaging helpers used by both the Messages page and the floating chat dock.
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { qk } from "./queryKeys";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "./api"; import { api } from "./api";
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee"; import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
@@ -57,7 +58,7 @@ function useUnlocked(): boolean {
export type KeyState = "loading" | "setup" | "unlock" | "ready"; export type KeyState = "loading" | "setup" | "unlock" | "ready";
export function useKeyState(): KeyState { export function useKeyState(): KeyState {
const unlocked = useUnlocked(); 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.isLoading && !q.data) return "loading";
if (!(q.data?.configured ?? false)) return "setup"; if (!(q.data?.configured ?? false)) return "setup";
if (!unlocked) return "unlock"; if (!unlocked) return "unlock";
+29 -4
View File
@@ -26,6 +26,9 @@ export type ModuleDef = {
id: Page; id: Page;
// i18n key for the SHORT display name — the same label the nav rail and the header pill show. // i18n key for the SHORT display name — the same label the nav rail and the header pill show.
labelKey: string; 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; icon: LucideIcon;
// Admin/system modules render in their own section (below a divider) in the nav rail. // Admin/system modules render in their own section (below a divider) in the nav rail.
system?: boolean; 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. // Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [ const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home }, { 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: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled }, { id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true }, { id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
@@ -62,10 +70,11 @@ const MODULES: readonly ModuleDef[] = [
hasBadge: true, hasBadge: true,
gate: notDemo, gate: notDemo,
}, },
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 }, { id: "stats", labelKey: "header.account.stats", titleKey: "header.usageStats", icon: BarChart3 },
{ {
id: "scheduler", id: "scheduler",
labelKey: "header.account.scheduler", labelKey: "header.account.scheduler",
titleKey: "header.scheduler",
icon: Activity, icon: Activity,
system: true, system: true,
gate: isAdmin, gate: isAdmin,
@@ -73,12 +82,27 @@ const MODULES: readonly ModuleDef[] = [
{ {
id: "config", id: "config",
labelKey: "header.account.config", labelKey: "header.account.config",
titleKey: "header.configuration",
icon: SlidersHorizontal, icon: SlidersHorizontal,
system: true, system: true,
gate: isAdmin, 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 // 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 = { const SETTINGS_MODULE: ModuleDef = {
id: "settings", id: "settings",
labelKey: "header.account.settings", labelKey: "header.account.settings",
titleKey: "settings.title",
icon: Settings, 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 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 // The i18n key for a page's human title — shared by the top-bar header and the browser tab title so
// so the two never drift. Keep in sync with the nav modules in NavSidebar / App's page switch. // 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 { export function pageTitleKey(page: Page): string {
switch (page) { const def = moduleDef(page);
case "feed": return def.titleKey ?? def.labelKey;
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";
}
} }
+3 -2
View File
@@ -1,4 +1,5 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { qk } from "./queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
@@ -12,7 +13,7 @@ import { api } from "./api";
export function useDetailPrefs() { export function useDetailPrefs() {
const qc = useQueryClient(); 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 { {}) as {
plexInfoArtBg?: boolean; plexInfoArtBg?: boolean;
plexInfoCast?: boolean; plexInfoCast?: boolean;
@@ -23,7 +24,7 @@ export function useDetailPrefs() {
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false); const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
const save = (patch: Record<string, unknown>) => { const save = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {}); 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, 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[] = [ 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", version: "0.55.0",
date: "2026-07-26", date: "2026-07-26",
+3 -2
View File
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api"; import { api, type FeedFilters } from "./api";
import { parseSort } from "./feedSort"; import { parseSort } from "./feedSort";
@@ -45,9 +46,9 @@ export function useActiveFilters(
setFilters: (f: FeedFilters) => void, setFilters: (f: FeedFilters) => void,
): ActiveFilter[] { ): ActiveFilter[] {
const { t } = useTranslation(); const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const channelsQuery = useQuery({ const channelsQuery = useQuery({
queryKey: ["channels"], queryKey: qk.channels(),
queryFn: api.channels, queryFn: api.channels,
enabled: filters.channelIds.length > 0, enabled: filters.channelIds.length > 0,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
+3 -2
View File
@@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api"; 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 // 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 // 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 // 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 // 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. // instead of having it prop-drilled through App.
export function useMe(): Me { 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; 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;
}
}