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