From 16585d868def26c390d05570e6bb38ad0b5bd60f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 26 Jul 2026 21:51:24 +0200 Subject: [PATCH 01/15] refactor(api): type req() and tighten the error layer (R7 S1) req() returned Promise, so every api.* return annotation was an unchecked assertion and the 17 action endpoints leaked `any` to callers. Make req generic with a single audited cast at the boundary; T flows from each method's declared return type via contextual inference, so no call site needs a type arg except where none existed: - me(): type the /api/me probe ({ authenticated } + Partial) - deepAll / syncSubscriptions: real backend-verified response shapes - HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in from R6 S1b review): now that malformed requests return 422, surface the offending field via a localized errors.validation key instead of falling through to the generic toast. No runtime change on normal flows (req is await-identical); the 422 branch only fires on genuinely malformed input. Gate green: typecheck, eslint, prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console errors. --- frontend/src/i18n/locales/en/errors.json | 1 + frontend/src/i18n/locales/hu/errors.json | 1 + frontend/src/lib/api.ts | 57 +++++++++++++++++------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 56f38c7..8a8ec37 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -1,6 +1,7 @@ { "title": "Couldn't complete that", "generic": "The server couldn't carry out that action. Please try again.", + "validation": "Invalid value for “{{field}}”. Please check it and try again.", "server": "Server error", "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 43f5311..4bd1342 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -1,6 +1,7 @@ { "title": "Nem sikerült", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", + "validation": "Érvénytelen érték: „{{field}}”. Ellenőrizd, majd próbáld újra.", "server": "Szerverhiba", "ok": "OK", "offline": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0240262..509285f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -212,8 +212,8 @@ export interface VersionInfo { class HttpError extends Error { status: number; detail?: string; // server-provided reason (FastAPI's `detail`), when present - detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object - constructor(status: number, detail?: string, detailData?: any) { + 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; @@ -225,20 +225,31 @@ class HttpError extends Error { // 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: any): string { +function localizeDetail(d: unknown): string { const t = i18n.t.bind(i18n); - if (d?.code === "quota") { - if (d.reason === "max_bytes") { + // 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 — surface the offending + // field name (localized) rather than FastAPI's raw English `msg`, which reads wrong in a HU UI. + if (Array.isArray(d)) { + const loc = (d[0] as { loc?: unknown } | undefined)?.loc; + const field = Array.isArray(loc) + ? loc.filter((s): s is string => typeof s === "string" && s !== "body").pop() + : undefined; + return field ? t("errors.validation", { field }) : t("errors.generic"); + } + 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( - (d.limit || 0) / 1_073_741_824, + (o.limit || 0) / 1_073_741_824, ); return t("downloads.errors.quota.max_bytes", { size: gb }); } - if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit }); + if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit }); return t("downloads.errors.quota.generic"); } - if (d?.code === "edit") - return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic")); + if (o?.code === "edit") + return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic")); return t("errors.generic"); } @@ -341,7 +352,11 @@ export function clearActiveAccount(): void { } } -async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { +async function req( + url: string, + opts: RequestInit = {}, + cfg: ReqConfig = {}, +): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; let attempt = 0; @@ -386,7 +401,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; - let detailData: any; + let detailData: unknown; try { const body = await r.json(); if (body && typeof body.detail === "string") detail = body.detail; @@ -417,7 +432,9 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr } throw new HttpError(r.status, detail, detailData); } - return r.status === 204 ? null : r.json(); + // 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; } } @@ -1098,7 +1115,8 @@ export const api = { // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never // logs a failed request to the console). React Query treats the null as data, not an error. me: async (): Promise => { - const r = await req("/api/me"); + // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. + const r = await req<{ authenticated: boolean } & Partial>("/api/me"); return r && r.authenticated ? (r as Me) : null; }, accounts: (): Promise => req("/api/me/accounts"), @@ -1176,7 +1194,8 @@ export const api = { ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), resetChannelBackfill: (id: string) => req(`/api/channels/${id}/reset-backfill`, { method: "POST" }), - deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), + deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> => + req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), attachChannelTag: (id: string, tagId: number) => req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), detachChannelTag: (id: string, tagId: number) => @@ -1184,7 +1203,15 @@ export const api = { unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }), discoveredChannels: (): Promise => req("/api/channels/discovery"), subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), - syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + syncSubscriptions: (): Promise<{ + subscriptions: number; + channels_new: number; + channels_detailed: number; + removed_stale: number; + prune_skipped: boolean; + quota_used_estimate: number; + quota_remaining_today: number; + }> => req("/api/sync/subscriptions", { method: "POST" }), // --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- channelDetail: (id: string): Promise => req(`/api/channels/${id}`), // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first, From d0aa1cf9a7e564f786e1833707f9599207cec6ba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 26 Jul 2026 22:17:04 +0200 Subject: [PATCH 02/15] refactor(api): code-review fixes (R7 S1) - localizeDetail: don't leak FastAPI's snake_case `loc` field names into a translated 422 toast; return a clean, fully localized validation message (drops the loc filter/pop parsing entirely) - me(): drop the dead `r &&` guard now that req types r non-nullable --- frontend/src/i18n/locales/en/errors.json | 2 +- frontend/src/i18n/locales/hu/errors.json | 2 +- frontend/src/lib/api.ts | 15 +++++---------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 8a8ec37..3a61b69 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -1,7 +1,7 @@ { "title": "Couldn't complete that", "generic": "The server couldn't carry out that action. Please try again.", - "validation": "Invalid value for “{{field}}”. Please check it and try again.", + "validation": "Some of the values you entered weren't valid. Please check them and try again.", "server": "Server error", "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 4bd1342..a8847c5 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -1,7 +1,7 @@ { "title": "Nem sikerült", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", - "validation": "Érvénytelen érték: „{{field}}”. Ellenőrizd, majd 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": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 509285f..497bbd6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -228,15 +228,10 @@ class HttpError extends Error { 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 — surface the offending - // field name (localized) rather than FastAPI's raw English `msg`, which reads wrong in a HU UI. - if (Array.isArray(d)) { - const loc = (d[0] as { loc?: unknown } | undefined)?.loc; - const field = Array.isArray(loc) - ? loc.filter((s): s is string => typeof s === "string" && s !== "body").pop() - : undefined; - return field ? t("errors.validation", { field }) : t("errors.generic"); - } + // 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") { @@ -1117,7 +1112,7 @@ export const api = { me: async (): Promise => { // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. const r = await req<{ authenticated: boolean } & Partial>("/api/me"); - return r && r.authenticated ? (r as Me) : null; + return r.authenticated ? (r as Me) : null; }, accounts: (): Promise => req("/api/me/accounts"), deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }), From ad86d6d126bcddfb3056373e81316b7864839216 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 21:48:35 +0200 Subject: [PATCH 03/15] refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce lib/queryKeys.ts as the single source of truth for React Query keys — the R7 driver was ~200 hand-written key literals across 51 files (`["feed"]` alone in 15+), where one typo silently orphans a key so an invalidate never matches. S2a migrates the three highest-traffic domains (feed/video, channels, playlists) plus the shared me/my-status/tags roots: ~90 literal sites in 18 files now call qk.*(). Pure substitution — each factory fn returns the byte-identical array the call sites used before, so TanStack's prefix matching is unchanged (qk.feed() still invalidates qk.feed(filters)). Co-invalidation clusters are deliberately NOT bundled into helpers (they differ per site), so no query's refetch scope changes. Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null segment is preserved (["playlist", null]) while a no-arg call is the bare prefix key — guard is `!== undefined`. Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app boots, feed/facets/tags/my-status all 200, no console errors. Factory extended to plex/messaging/admin/downloads/notifications in S2b. --- frontend/src/components/AboutCell.tsx | 3 +- frontend/src/components/AddToPlaylist.tsx | 9 ++-- frontend/src/components/ChannelDiscovery.tsx | 9 ++-- frontend/src/components/ChannelPage.tsx | 29 +++++----- frontend/src/components/ChannelPicker.tsx | 3 +- frontend/src/components/Channels.tsx | 53 ++++++++++--------- frontend/src/components/Feed.tsx | 23 ++++---- .../src/components/NotificationsPanel.tsx | 5 +- frontend/src/components/OnboardingWizard.tsx | 11 ++-- frontend/src/components/PlayerModal.tsx | 7 +-- frontend/src/components/Playlists.tsx | 23 ++++---- frontend/src/components/PlaylistsRail.tsx | 9 ++-- frontend/src/components/SavedViewsWidget.tsx | 7 +-- frontend/src/components/Sidebar.tsx | 5 +- frontend/src/components/Stats.tsx | 15 +++--- frontend/src/components/SyncStatus.tsx | 5 +- frontend/src/components/TagManager.tsx | 11 ++-- frontend/src/lib/queryKeys.ts | 44 +++++++++++++++ frontend/src/lib/useActiveFilters.ts | 5 +- 19 files changed, 169 insertions(+), 107 deletions(-) create mode 100644 frontend/src/lib/queryKeys.ts diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index f980814..e889178 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -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, diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 4a84bff..8d8fc58 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -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) { diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 46c69ad..e122a3b 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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 diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 4005cc2..320e800 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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")), }); diff --git a/frontend/src/components/ChannelPicker.tsx b/frontend/src/components/ChannelPicker.tsx index 38bdb1a..1cdc975 100644 --- a/frontend/src/components/ChannelPicker.tsx +++ b/frontend/src/components/ChannelPicker.tsx @@ -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, }); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3784f5b..054befc 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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(["channels"], (old) => + qc.setQueryData(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(["channels"], (old) => + qc.setQueryData(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. diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ad1a3b5..e8e7ff9 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -6,6 +6,7 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { ArrowDown, ArrowUp, @@ -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, @@ -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" > diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 3661ef0..80623b5 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../lib/queryKeys"; import { Activity, Bell, @@ -107,8 +108,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); }) diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index b7678f8..6552291 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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() }); }, }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index a720f13..55ce6ee 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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, @@ -479,7 +480,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. @@ -767,8 +768,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") { diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 2d7ee7f..3cdb381 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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(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() }); }); } diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 01fbdb7..f0c7287 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -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); }, }); diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 76b3ee8..cc2dae4 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -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( - ["saved-views"], + qk.savedViews(), next.map((id) => views.find((v) => v.id === id)!), ); await api.reorderViews(next); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6afbdcd..a4520f7 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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, diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 7ef0543..6a704c0 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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 status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const usage = useQuery({ queryKey: ["my-usage"], 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 }), @@ -200,10 +201,10 @@ function SystemStats() { const qc = useQueryClient(); const [days, setDays] = useState(30); const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) }); - const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); + const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const pauseResume = useMutation({ 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)); diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index fc95e54..19c0d28 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -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; diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 7f4e86b..0fe3dc0 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -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" }), diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts new file mode 100644 index 0000000..47dc048 --- /dev/null +++ b/frontend/src/lib/queryKeys.ts @@ -0,0 +1,44 @@ +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 --- + feed: (filters?: FeedFilters) => (filters ? (["feed", filters] as const) : (["feed"] as const)), + feedCount: (filters?: FeedFilters) => + filters ? (["feed-count", filters] as const) : (["feed-count"] as const), + facets: (filters?: FeedFilters) => + filters ? (["facets", filters] as const) : (["facets"] as const), + // `q`/`id` accept null (not just undefined) because the call sites key on nullable state + // (`selectedId`, the search string): a NULL is a real key segment and must be preserved + // (`["playlist", null]`), while a genuinely absent arg is the bare prefix key. The guard is + // `!== undefined`, so only the no-arg call collapses to the prefix. + 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, +}; diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index 54ae93e..9690a9c 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -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, From 34297b696f11f68fd358c4727e5095033fe017c7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:10:03 +0200 Subject: [PATCH 04/15] refactor(fe): migrate plex/messaging/admin/downloads/notifications to qk (R7 S2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in 36 files. All ~200 React Query keys across the app now route through the factory; zero raw key literals remain outside queryKeys.ts. The heterogeneous composite plex keys (plex-library/facets/collections/ playlists carry a filters object, a ratingKeys array, or a union/group discriminator) take a variadic pass-through — the factory centralizes the ROOT string; single-id keys stay precisely typed. Nullable keys (thread, playlist, ytSearch) accept null so a null segment is preserved. Pure substitution — byte-identical key arrays, so TanStack prefix matching is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err), prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex modules load, feed/facets/tags/my-status/plex-library/plex-facets/ plex-collections/plex-playlists all 200, full UI renders. --- frontend/src/App.tsx | 9 +-- frontend/src/components/About.tsx | 7 ++- frontend/src/components/AdminUsers.tsx | 21 +++---- frontend/src/components/AuditLog.tsx | 5 +- frontend/src/components/ChatDock.tsx | 11 ++-- frontend/src/components/ChatThread.tsx | 11 ++-- frontend/src/components/ConfigPanel.tsx | 5 +- frontend/src/components/DownloadButton.tsx | 5 +- frontend/src/components/DownloadCenter.tsx | 23 ++++---- frontend/src/components/DownloadDialog.tsx | 3 +- .../src/components/FeedFiltersProvider.tsx | 5 +- frontend/src/components/FeedViewProvider.tsx | 5 +- frontend/src/components/KeyGate.tsx | 11 ++-- frontend/src/components/Messages.tsx | 5 +- frontend/src/components/NavSidebar.tsx | 9 +-- .../src/components/NotificationsPanel.tsx | 6 +- frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/components/PlexBrowse.tsx | 39 +++++++------ .../src/components/PlexCollectionEditor.tsx | 9 +-- frontend/src/components/PlexInfo.tsx | 11 ++-- frontend/src/components/PlexPlayer.tsx | 11 ++-- frontend/src/components/PlexPlaylistAdd.tsx | 7 ++- frontend/src/components/PlexPlaylistView.tsx | 11 ++-- frontend/src/components/PlexSidebar.tsx | 9 +-- frontend/src/components/PrefsProvider.tsx | 3 +- frontend/src/components/ProfileEditor.tsx | 5 +- frontend/src/components/Scheduler.tsx | 15 ++--- frontend/src/components/SettingsPanel.tsx | 11 ++-- frontend/src/components/ShareDialog.tsx | 7 ++- frontend/src/components/Stats.tsx | 4 +- frontend/src/components/VideoEditor.tsx | 7 ++- frontend/src/components/WizardProvider.tsx | 5 +- frontend/src/lib/downloads.ts | 7 ++- frontend/src/lib/messaging.ts | 3 +- frontend/src/lib/plexDetailUi.tsx | 5 +- frontend/src/lib/queryKeys.ts | 58 +++++++++++++++++++ frontend/src/lib/useMe.ts | 5 +- 37 files changed, 235 insertions(+), 142 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 934dac3..483cac7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useState } from "react"; +import { qk } from "./lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; @@ -135,7 +136,7 @@ export default function App() { // We check this before anything else and render the wizard; `me` is held back until we know the // instance is set up (otherwise it would 503 against the setup-mode lock). const setupQuery = useQuery({ - queryKey: ["setup-status"], + queryKey: qk.setupStatus(), queryFn: api.setupStatus, staleTime: Infinity, }); @@ -145,7 +146,7 @@ export default function App() { // Only poll while signed in (data present) — polling on the public login page is pointless and // its periodic refetch caused a visible flicker there. const meQuery = useQuery({ - queryKey: ["me"], + queryKey: qk.me(), queryFn: api.me, enabled: setupQuery.isSuccess && !needsSetup, refetchInterval: (query) => (query.state.data ? 60_000 : false), @@ -173,7 +174,7 @@ export default function App() { // can't loop the public landing. useEffect(() => { setUnauthorizedHandler(() => { - if (queryClient.getQueryData(["me"])) { + if (queryClient.getQueryData(qk.me())) { queryClient.clear(); window.location.reload(); } @@ -223,7 +224,7 @@ export default function App() { // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider - // and the feed view by FeedViewProvider — each via its own passive ["me"] observer. + // and the feed view by FeedViewProvider — each via its own passive qk.me() observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 79e6eb4..25c1c3d 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -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][] = [ [ diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 09bca40..acb6992 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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>(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") }), diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index d413e6f..241c4b3 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -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") }), diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index 9d7cba2..9340005 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -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], ); diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index 1eb1a43..fab9595 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -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 diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index b3c3d36..7dae0a2 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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); diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 90e36a9..c58386a 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -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"]); + const me = qc.getQueryData(qk.me()); const isDemo = !!me?.is_demo; const indexQ = useQuery({ - queryKey: ["download-index"], + queryKey: qk.downloadIndex(), queryFn: api.downloadIndex, enabled: !isDemo, staleTime: 10000, diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 92dd99f..84d68db 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -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(null); const [editJob, setEditJob] = useState(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) => { diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 8340ee6..36caadd 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -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(null); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index a3a897f..6ce5921 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -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); diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index 90b55d7..ba51511 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -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) => { diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index caf6dcc..58c3629 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -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 { diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 399f2d0..cfe52d6 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -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()), ); diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index a8a1950..d0ce865 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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, }); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 80623b5..6fee962 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -57,7 +57,7 @@ export default function NotificationsPanel() { const { focusChannel: onFocusChannel } = useChannelsActions(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( - ["notifications"], + qk.notifications(), () => api.notifications(), { intervalMs: POLL_MS }, ); @@ -72,8 +72,8 @@ export default function NotificationsPanel() { }, []); const refresh = () => { - qc.invalidateQueries({ queryKey: ["notifications"] }); - qc.invalidateQueries({ queryKey: ["notif-unread"] }); + qc.invalidateQueries({ queryKey: qk.notifications() }); + qc.invalidateQueries({ queryKey: qk.notifUnread() }); }; const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 55ce6ee..a9f023b 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -381,7 +381,7 @@ export default function PlayerModal({ // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. const savedPrefs = useMemo( () => - (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? {}) as { + (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode; }, @@ -393,7 +393,7 @@ export default function PlayerModal({ modeRef.current = { autoMode, loopMode }; const persistPref = (patch: Record) => { api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, ); }; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 6678fd2..c1e7d32 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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>({ queryKey: ["plex-library"] }, (old) => + qc.setQueriesData>({ 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) => 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 (
@@ -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. /> @@ -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(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) })} /> )}
@@ -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(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 ( diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index d9746a1..2067fb3 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -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(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(); }; diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 49664e4..e1f547f 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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 }>(["me"])?.preferences ?? + const prefs = (qc.getQueryData<{ preferences?: Record }>(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?.(); }; diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 8a373ee..110678f 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -9,6 +9,7 @@ import { type ReactNode, type WheelEvent as ReactWheelEvent, } from "react"; +import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -196,7 +197,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { tRef.current = t; const qc = useQueryClient(); const [id, setId] = useState(itemId); - const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); + const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) }); const detail = detailQ.data; const duration = detail?.duration_seconds ?? 0; @@ -513,12 +514,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // unmount — patching while mounted would retrigger the loadSession effect and reseek mid-play. if (absRef.current > 0 && durationRef.current > 0) { const pos = Math.floor(absRef.current); - qc.setQueryData(["plex-item", id], (old) => - old ? { ...old, position_seconds: pos } : old, - ); + qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old)); } - qc.invalidateQueries({ queryKey: ["plex-library"] }); - qc.invalidateQueries({ queryKey: ["plex-show"] }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + qc.invalidateQueries({ queryKey: qk.plexShow() }); }; }, [id, qc]); diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index 7fabc9f..9e05bf3 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -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 { diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index d9d9448..3cec46a 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -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>(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(); } diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 024c17a..9a3bff9 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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, }); diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 18f3c5a..87229d8 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -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(undefined); const saveTimer = useRef(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. diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx index 9648294..3bc22a6 100644 --- a/frontend/src/components/ProfileEditor.tsx +++ b/frontend/src/components/ProfileEditor.tsx @@ -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(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(""); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index ddd4839..1dedc82 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -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(["scheduler"], api.schedulerStatus, { + const q = useLiveQuery(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() }); }, }); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 58604c9..a75a1f1 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; @@ -387,7 +388,7 @@ function SignInMethods({ me }: { me: Me }) { setCurrent(""); setNext(""); // Refresh `me` so has_password flips and the section switches to "Change password". - await qc.invalidateQueries({ queryKey: ["me"] }); + await qc.invalidateQueries({ queryKey: qk.me() }); notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }), @@ -516,7 +517,7 @@ function SignInMethods({ me }: { me: Me }) { function PlexWatchSync() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); - const link = useQuery({ queryKey: ["plex-watch-link"], queryFn: () => api.plexWatchLink() }); + const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() }); const enabled = link.data?.sync_enabled ?? false; const announce = (imp: { watched: number; in_progress: number } | null | undefined) => { @@ -529,7 +530,7 @@ function PlexWatchSync() { }), }); // The browse grid's watch badges read plex_states — refresh them. - qc.invalidateQueries({ queryKey: ["plex"] }); + qc.invalidateQueries({ queryKey: qk.plex() }); } }; @@ -539,14 +540,14 @@ function PlexWatchSync() { const toggle = useMutation({ mutationFn: (next: boolean) => api.plexWatchSetLink(next), onSuccess: (res) => { - qc.setQueryData(["plex-watch-link"], res); + qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); const reimport = useMutation({ mutationFn: () => api.plexWatchImport(), onSuccess: (res) => { - qc.setQueryData(["plex-watch-link"], res); + qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index 508df8a..396cb32 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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(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: () => diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 6a704c0..a7d4439 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -57,7 +57,7 @@ function Overview({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); - const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); + const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage }); const s = status.data; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), @@ -200,7 +200,7 @@ function SystemStats() { const { t } = useTranslation(); const qc = useQueryClient(); const [days, setDays] = useState(30); - const q = useQuery({ queryKey: ["admin-quota", days], queryFn: () => api.adminQuota(days) }); + const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) }); const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus }); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/components/VideoEditor.tsx index 8f992fc..d3ca5d7 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/components/VideoEditor.tsx @@ -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(); }, diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx index 1c2b81f..c56e7a6 100644 --- a/frontend/src/components/WizardProvider.tsx +++ b/frontend/src/components/WizardProvider.tsx @@ -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), []); diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts index 6fd9351..36dc6cb 100644 --- a/frontend/src/lib/downloads.ts +++ b/frontend/src/lib/downloads.ts @@ -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() }); } diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts index 8bdcb05..88b964d 100644 --- a/frontend/src/lib/messaging.ts +++ b/frontend/src/lib/messaging.ts @@ -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"; diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 6631e79..08e7f4a 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -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 }>(["me"])?.preferences ?? + const prefs = (qc.getQueryData<{ preferences?: Record }>(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) => { api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, ); }; diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 47dc048..c7d3141 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -41,4 +41,62 @@ export const qk = { id !== undefined ? (["playlist", id] as const) : (["playlist"] as const), playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const, savedViews: () => ["saved-views"] as const, + + // --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above) + accounts: () => ["accounts"] as const, + version: () => ["version"] as const, + setupStatus: () => ["setup-status"] as const, + myUsage: () => ["my-usage"] as const, + scheduler: () => ["scheduler"] as const, + + // --- notifications --- + notifications: () => ["notifications"] as const, + notifUnread: () => ["notif-unread"] as const, + + // --- messaging --- + conversations: () => ["conversations"] as const, + thread: (partnerId: number | null) => ["thread", partnerId] as const, + messageKey: () => ["message-key"] as const, + messageUnread: () => ["message-unread"] as const, + messageUsers: () => ["message-users"] as const, + shareRecipients: () => ["share-recipients"] as const, + + // --- downloads --- + downloads: () => ["downloads"] as const, + downloadsShared: () => ["downloads-shared"] as const, + downloadIndex: () => ["download-index"] as const, + downloadUsage: () => ["download-usage"] as const, + downloadProfiles: () => ["download-profiles"] as const, + downloadLinks: (jobId: number) => ["download-links", jobId] as const, + dlQuota: (userId: number) => ["dl-quota", userId] as const, + storyboard: (jobId: number) => ["storyboard", jobId] as const, + + // --- admin --- + adminUsers: () => ["admin-users"] as const, + adminInvites: () => ["admin-invites"] as const, + adminConfig: () => ["admin-config"] as const, + adminAudit: () => ["admin-audit"] as const, + adminStorage: () => ["admin-storage"] as const, + adminDownloads: () => ["admin-downloads"] as const, + adminQuota: (days: number) => ["admin-quota", days] as const, + demoWhitelist: () => ["demo-whitelist"] as const, + + // --- plex --- + // The composite plex keys carry a heterogeneous arg list (a filters object, a ratingKeys + // array, a "union"/"group"/"contains" discriminator), so they take a variadic pass-through: + // the value this factory centralizes is the ROOT string, and TanStack keys are `unknown[]` + // anyway. Single-id plex keys stay precisely typed. + plex: () => ["plex"] as const, + plexWatchLink: () => ["plex-watch-link"] as const, + plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], + plexShow: (showId?: string) => + showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const), + plexItem: (id: string) => ["plex-item", id] as const, + plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], + plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const, + plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest], + // facetKey is a filters-derived OBJECT segment (not a scalar), so this key is heterogeneous too. + plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], + plexPeopleCards: (scope: string, actors: string[]) => + ["plex-people-cards", scope, actors] as const, }; diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts index dd5d780..d5f126b 100644 --- a/frontend/src/lib/useMe.ts +++ b/frontend/src/lib/useMe.ts @@ -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; } From ca0312d1a151986188c8d7e1e707f62d48e6c48b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:24:48 +0200 Subject: [PATCH 05/15] test(fe): lock query-key factory shapes; unify nullable guard (R7 S2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups on the S2 factory: - add queryKeys.test.ts pinning every branch (bare vs param, null-segment preservation, single-id, variadic composite spreads) — the contract the epic exists to protect now has a test (suite 68 -> 73) - unify feed/feedCount/facets on the `!== undefined` guard the rest of the factory already uses (behaviour-identical for the always-truthy FeedFilters, removes the collapse-on-null footgun) Two findings skipped with rationale: variadic composite-key typing (heterogeneous object/array segments make precise tuples high-effort; root centralized + shapes now tested) and a pre-existing thread(null) no-op (unreachable in practice). --- frontend/src/lib/queryKeys.test.ts | 85 ++++++++++++++++++++++++++++++ frontend/src/lib/queryKeys.ts | 16 +++--- 2 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/queryKeys.test.ts diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts new file mode 100644 index 0000000..51841c3 --- /dev/null +++ b/frontend/src/lib/queryKeys.test.ts @@ -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: [] }, + ]); + }); +}); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index c7d3141..611b5cd 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -16,15 +16,17 @@ export const qk = { tags: () => ["tags"] as const, // --- feed / video --- - feed: (filters?: FeedFilters) => (filters ? (["feed", filters] as const) : (["feed"] as const)), + // 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 ? (["feed-count", filters] as const) : (["feed-count"] as const), + filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const), facets: (filters?: FeedFilters) => - filters ? (["facets", filters] as const) : (["facets"] as const), - // `q`/`id` accept null (not just undefined) because the call sites key on nullable state - // (`selectedId`, the search string): a NULL is a real key segment and must be preserved - // (`["playlist", null]`), while a genuinely absent arg is the bare prefix key. The guard is - // `!== undefined`, so only the no-arg call collapses to the prefix. + 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, From 5de6be8fded4e7a9ba795498e79b9e2c5ca311a1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:40:11 +0200 Subject: [PATCH 06/15] =?UTF-8?q?refactor(api):=20literal-union=20the=20cl?= =?UTF-8?q?osed-value=20string=20fields=20(R7=20S3a=C2=B71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind were bare `string`; type them as named unions verified against the backend (VALID_STATES={new,watched,hidden}; kind user|watch_later; source local| youtube; job_kind download|edit). VideoStatus then propagates through the optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a derived filter, never a stored status) at every call site. --- frontend/src/components/Feed.tsx | 8 ++++---- frontend/src/components/PlayerModal.tsx | 4 ++-- frontend/src/components/VideoCard.tsx | 8 ++++---- frontend/src/components/VirtualFeed.tsx | 4 ++-- frontend/src/lib/api.ts | 22 ++++++++++++++++------ 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index e8e7ff9..4573e1c 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -22,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"; @@ -70,7 +70,7 @@ const VIEW_ICON: Record = { // (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"; @@ -126,7 +126,7 @@ export default function Feed({ const sortKeys = channelScoped ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") : SORT_KEYS; - const [overrides, setOverrides] = useState>({}); + const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( @@ -241,7 +241,7 @@ export default function Feed({ const loadedRef = useRef([]); 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" diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index a9f023b..e49f550 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -29,7 +29,7 @@ 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 { channelYouTubeUrl, formatDate, @@ -118,7 +118,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. }) { diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index fdd185b..8e1500e 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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; diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 0410c45..5268ea3 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -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 = { 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; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 497bbd6..66b9895 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,11 @@ export interface Tag { channel_count: number; } +// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and +// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position), +// so they never appear on the wire. +export type VideoStatus = "new" | "watched" | "hidden"; + export interface Video { id: string; title: string | null; @@ -60,7 +65,7 @@ export interface Video { view_count: number | null; is_short: boolean; live_status: string; - status: string; + status: VideoStatus; position_seconds: number; saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; @@ -124,11 +129,14 @@ export interface FeedResponse { source?: "scrape" | "api"; } +export type PlaylistKind = "user" | "watch_later"; +export type PlaylistSource = "local" | "youtube"; + export interface Playlist { id: number; name: string; - kind: string; // "user" | "watch_later" - source: string; // "local" | "youtube" + kind: PlaylistKind; + source: PlaylistSource; yt_playlist_id: string | null; dirty: boolean; // linked local playlist has edits not yet pushed to YouTube item_count: number; @@ -140,8 +148,8 @@ export interface Playlist { export interface PlaylistDetail { id: number; name: string; - kind: string; - source: string; + kind: PlaylistKind; + source: PlaylistSource; yt_playlist_id: string | null; dirty: boolean; items: Video[]; @@ -1012,6 +1020,8 @@ interface DownloadAsset { } export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; +// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job). +export 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). @@ -1051,7 +1061,7 @@ export interface DownloadJob { 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?: string; // "download" (default) | "edit" + 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; From 2d4ecdb6c5607ef898e9a0a2792763f3c1721bf2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:45:30 +0200 Subject: [PATCH 07/15] =?UTF-8?q?refactor(api):=20honest=20FeedResponse;?= =?UTF-8?q?=20derive=20pageTitleKey=20from=20the=20registry=20(R7=20S3a?= =?UTF-8?q?=C2=B72-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedResponse claimed a required `limit`, but /api/search/youtube never returns it (only /api/feed does) and nothing in the app reads it. Drop the field so searchYoutube's Promise stops lying; documented for re-add if a consumer appears. - pageMeta's hand-written 13-case Page->title switch now derives from the ModuleDef registry via a new optional `titleKey` (the longer tab title, e.g. "Channel manager"; falls back to the short nav labelKey). A new page is one touch (add it to MODULES) instead of also editing a switch. A test pins all 13 title keys byte-identical to the old switch (suite 74). --- frontend/src/lib/api.ts | 4 +++- frontend/src/lib/modules.ts | 33 +++++++++++++++++++++---- frontend/src/lib/pageMeta.test.ts | 30 +++++++++++++++++++++++ frontend/src/lib/pageMeta.ts | 40 +++++++------------------------ 4 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/pageMeta.test.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 66b9895..781939f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -123,10 +123,12 @@ export interface FeedResponse { has_more: boolean; // Opaque keyset cursor for the next page, or null when this is the last page. next_cursor: string | null; - limit: number; // Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api" // (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode. source?: "scrape" | "api"; + // NOTE: /api/feed also returns an echoed `limit`, but /api/search/youtube does not and nothing + // in the app reads it, so it's deliberately omitted rather than typed required (a lie for search) + // or optional (dead surface). Re-add if a consumer ever needs it. } export type PlaylistKind = "user" | "watch_later"; diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts index 46b9a66..9952aa5 100644 --- a/frontend/src/lib/modules.ts +++ b/frontend/src/lib/modules.ts @@ -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, }; diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/lib/pageMeta.test.ts new file mode 100644 index 0000000..b9152d1 --- /dev/null +++ b/frontend/src/lib/pageMeta.test.ts @@ -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 = { + 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); + } + }); +}); diff --git a/frontend/src/lib/pageMeta.ts b/frontend/src/lib/pageMeta.ts index 834193e..ae531e5 100644 --- a/frontend/src/lib/pageMeta.ts +++ b/frontend/src/lib/pageMeta.ts @@ -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; } From 90749a9e45e5deff8c16ae1ecefa84c507372ca3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:57:33 +0200 Subject: [PATCH 08/15] =?UTF-8?q?refactor(fe):=20eliminate=20every=20expli?= =?UTF-8?q?cit=20any;=20enforce=20no-explicit-any=20(R7=20S3a=C2=B74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes all 13 remaining `any`s and flips the ESLint rule from off to error so new ones can't creep back: - api.ts prefs/data JSON bags: Record -> Record, narrowed at consumers (App language guard; NotificationsPanel shapes the notification `data` per type behind its existing typeof guards) - catch (e: any) -> catch (e) + `e instanceof HttpError` narrowing (SettingsPanel, SetupWizard, PlexPlayer) - the YouTube IFrame API boundary: new lib/youtube.ts types (YTPlayer/ YTPlayerEvent/YTNamespace + a Window augmentation) replace the 6 player anys; tsc surfaced the exact method surface to declare - NotificationsPanel `t` props: any-options -> i18next TFunction Gate green: typecheck (app+node+e2e), eslint 0 errors (rule now enforced), prettier, 74 vitest. --- frontend/eslint.config.js | 9 +-- frontend/src/App.tsx | 3 +- .../src/components/NotificationsPanel.tsx | 16 +++-- frontend/src/components/PlayerModal.tsx | 18 +++--- frontend/src/components/PlexPlayer.tsx | 9 +-- frontend/src/components/SettingsPanel.tsx | 8 ++- frontend/src/components/SetupWizard.tsx | 6 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/youtube.ts | 59 +++++++++++++++++++ 9 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/youtube.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 6c2cd99..a65aafa 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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; + // 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": [ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 483cac7..454e26b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -249,7 +249,8 @@ export default function App() { setPlaylistsCollapsedState(prefsRec.playlistsCollapsed); writeAccount(LS.playlistsCollapsed, prefsRec.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 diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 6fee962..6b362b7 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,5 +1,6 @@ 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 { @@ -223,7 +224,7 @@ function NotificationRow({ onOpenScheduler, }: { n: AppNotification; - t: (k: string, o?: any) => string; + t: TFunction; onRead: () => void; onDismiss: () => void; onOpenScheduler: () => void; @@ -239,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) { // " 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 (
string; + t: TFunction; onFind: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onRevert: (meta: VideoHiddenMeta | VideoWatchedMeta) => void; onFocusChannel: (name: string) => void; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index e49f550..23c5fd8 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -30,6 +30,7 @@ import AddToPlaylist from "./AddToPlaylist"; import { modalCount } from "./Modal"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; +import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube"; import { channelYouTubeUrl, formatDate, @@ -82,16 +83,17 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { } // --- IFrame Player API loader (singleton) --- -let apiPromise: Promise | null = null; -function loadYouTubeApi(): Promise { +let apiPromise: Promise | null = null; +function loadYouTubeApi(): Promise { if (apiPromise) return apiPromise; - apiPromise = new Promise((resolve) => { - const w = window as any; + apiPromise = new Promise((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"; @@ -158,7 +160,7 @@ export default function PlayerModal({ const resumeAt = startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); - const playerRef = useRef(null); + const playerRef = useRef(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. @@ -706,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 @@ -725,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 diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 110678f..4dcb550 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -34,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, @@ -314,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"), ); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index a75a1f1..d65a5e4 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -4,7 +4,7 @@ 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"; @@ -393,8 +393,10 @@ function SignInMethods({ me }: { me: Me }) { 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); } diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 6236d35..e02e7c3 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -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); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 781939f..0ee8e16 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,7 +17,7 @@ export interface Me { can_read: boolean; can_write: boolean; pending_invites: number; - preferences: Record; + preferences: Record; } export interface DemoWhitelistEntry { @@ -622,7 +622,7 @@ export interface AppNotification { type: string; title: string; body: string | null; - data: Record | null; + data: Record | null; read: boolean; dismissed: boolean; created_at: string | null; @@ -1189,7 +1189,7 @@ export const api = { pauseSync: () => req("/api/sync/pause", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }), // Overwriting preferences is idempotent, so let it retry on a transient gateway blip. - savePrefs: (p: Record) => + savePrefs: (p: Record) => req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }), // --- channel manager --- diff --git a/frontend/src/lib/youtube.ts b/frontend/src/lib/youtube.ts new file mode 100644 index 0000000..82c087c --- /dev/null +++ b/frontend/src/lib/youtube.ts @@ -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. + +export 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; +} + +export interface YTPlayerOptions { + width?: string | number; + height?: string | number; + videoId?: string; + playerVars?: Record; + 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; + } +} From 49f8c57c89eab831f4dedd04ff6dd422d725b49b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:08:48 +0200 Subject: [PATCH 09/15] =?UTF-8?q?refactor(api):=20extract=20the=20shared?= =?UTF-8?q?=20HTTP=20machinery=20to=20api/core.ts=20(R7=20S3b=C2=B71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of splitting the api.ts god module behind its façade. Move req, HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/ beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts imports the machinery it uses and re-exports the public surface, so the 53 call sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 249 +++-------------------------------- frontend/src/lib/api/core.ts | 232 ++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 230 deletions(-) create mode 100644 frontend/src/lib/api/core.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0ee8e16..2f01f17 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,22 @@ -import { notify, remove } from "./notifications"; -import i18n from "../i18n"; -import { reportError } from "./errorDialog"; -import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage"; +import { + req, + beacon, + setupHeaders, + getActiveAccount, + HttpError, + setActiveAccount, + clearActiveAccount, + setUnauthorizedHandler, +} from "./api/core"; + +// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. +export { + HttpError, + getActiveAccount, + setActiveAccount, + clearActiveAccount, + setUnauthorizedHandler, +}; export interface Me { id: number; @@ -219,230 +234,6 @@ export interface VersionInfo { db_revision: string | null; } -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. -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). -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. -function beacon(url: string, body: Record): 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 */ - } -} - -async function req( - url: string, - opts: RequestInit = {}, - cfg: ReqConfig = {}, -): Promise { - 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; - } -} - // The filter half of the query (everything except paging/sort), shared by the feed and // the facet-count endpoint so both see exactly the same filter context. function filterParams(f: FeedFilters): URLSearchParams { @@ -1789,5 +1580,3 @@ export const api = { adminResetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), }; - -export { HttpError }; diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts new file mode 100644 index 0000000..815dae6 --- /dev/null +++ b/frontend/src/lib/api/core.ts @@ -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): 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( + url: string, + opts: RequestInit = {}, + cfg: ReqConfig = {}, +): Promise { + 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; + } +} From cc38d8bdfac6135cf7da62347b62a8713410782c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:17:12 +0200 Subject: [PATCH 10/15] =?UTF-8?q?refactor(api):=20extract=20the=20download?= =?UTF-8?q?s=20domain=20to=20api/downloads.ts=20(R7=20S3b=C2=B72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and the admin storage/quota shapes) + all ~30 download methods move into a self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads the slice into the `api` façade and re-exports the types via `export *`, so the 53 call sites are unchanged. Cleanly separable — the domain's types reference only each other and its methods only touch core's req/getActiveAccount. api.ts 1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 242 +----------------------------- frontend/src/lib/api/downloads.ts | 241 +++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 236 deletions(-) create mode 100644 frontend/src/lib/api/downloads.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2f01f17..8de7b2e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,6 +18,11 @@ export { setUnauthorizedHandler, }; +// Domain modules split out of this file, re-exported so their types + the `api` façade stay +// importable from "./api". Their method slices are spread into `api` below. +import { downloadsApi } from "./api/downloads"; +export * from "./api/downloads"; + export interface Me { id: number; email: string; @@ -775,139 +780,6 @@ export interface AuditListResult { returned: number; } -// --- download center --- -export interface DownloadSpec { - mode: "av" | "v" | "a"; // audio+video | video-only | audio-only - max_height: number | null; - container: string | null; - audio_format: string | null; - vcodec: string | null; - embed_subs: boolean; - embed_chapters: boolean; - embed_thumbnail: boolean; - sponsorblock: boolean; -} - -export interface DownloadProfile { - id: number; - name: string; - spec: DownloadSpec; - is_builtin: boolean; - sort_order: number; -} - -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). -export 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/" — 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 api = { // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never @@ -1476,107 +1348,5 @@ export const api = { req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }), - // --- download center --- - downloadProfiles: (): Promise => req("/api/downloads/profiles"), - createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise => - req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }), - updateDownloadProfile: ( - id: number, - patch: { name?: string; spec?: DownloadSpec }, - ): Promise => - 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 => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }), - enqueueEdit: (body: { - source_job_id: number; - edit_spec: EditSpec; - display_name?: string; - }): Promise => - req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), - downloadStoryboard: (id: number): Promise => - req(`/api/downloads/${id}/storyboard`), - downloads: (): Promise => req("/api/downloads"), - downloadsShared: (): Promise => 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 => req("/api/downloads/usage"), - downloadIndex: (): Promise> => req("/api/downloads/index"), - pauseDownload: (id: number): Promise => - req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), - resumeDownload: (id: number): Promise => - req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }), - cancelDownload: (id: number): Promise => - 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 => - 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 => - 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 => req("/api/downloads/recipients"), - // Public watch links (share by link). - downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), - createDownloadLink: ( - jobId: number, - body: { allow_download?: boolean; expires_days?: number | null; password?: string }, - ): Promise => - 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 => - 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 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 /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 => req("/api/admin/downloads"), - adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"), - adminGetDownloadQuota: (userId: number): Promise => - req(`/api/admin/downloads/quota/${userId}`), - adminSetDownloadQuota: ( - userId: number, - patch: Partial< - Pick - >, - ): Promise => - req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }), - adminResetDownloadQuota: (userId: number): Promise => - req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), + ...downloadsApi, }; diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts new file mode 100644 index 0000000..3cb220b --- /dev/null +++ b/frontend/src/lib/api/downloads.ts @@ -0,0 +1,241 @@ +// 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). +export 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/" — 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 => req("/api/downloads/profiles"), + createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise => + req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }), + updateDownloadProfile: ( + id: number, + patch: { name?: string; spec?: DownloadSpec }, + ): Promise => + 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 => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }), + enqueueEdit: (body: { + source_job_id: number; + edit_spec: EditSpec; + display_name?: string; + }): Promise => + req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), + downloadStoryboard: (id: number): Promise => + req(`/api/downloads/${id}/storyboard`), + downloads: (): Promise => req("/api/downloads"), + downloadsShared: (): Promise => 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 => req("/api/downloads/usage"), + downloadIndex: (): Promise> => req("/api/downloads/index"), + pauseDownload: (id: number): Promise => + req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), + resumeDownload: (id: number): Promise => + req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }), + cancelDownload: (id: number): Promise => + 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 => + 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 => + 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 => req("/api/downloads/recipients"), + // Public watch links (share by link). + downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), + createDownloadLink: ( + jobId: number, + body: { allow_download?: boolean; expires_days?: number | null; password?: string }, + ): Promise => + 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 => + 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 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 /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 => req("/api/admin/downloads"), + adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"), + adminGetDownloadQuota: (userId: number): Promise => + req(`/api/admin/downloads/quota/${userId}`), + adminSetDownloadQuota: ( + userId: number, + patch: Partial< + Pick + >, + ): Promise => + req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }), + adminResetDownloadQuota: (userId: number): Promise => + req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), +}; From 3232a53c46bf355cd0788219265334bc94498132 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:23:31 +0200 Subject: [PATCH 11/15] =?UTF-8?q?refactor(api):=20extract=20the=20plex=20d?= =?UTF-8?q?omain=20to=20api/plex.ts=20(R7=20S3b=C2=B73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All Plex types (browse cards, show/season/item details, collections, native playlists, filters/facets, watch-link) + EMPTY_PLEX_FILTERS/plexFilterCount/ appendPlexFilters + the ~40 plex methods move into a self-contained api/plex.ts exporting a `plexApi` slice (imports only core's req/beacon). api.ts spreads the slice and re-exports the types via `export *`. api.ts 1352 -> 882 lines (halved from the original 1793); no runtime change. Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 476 +--------------------------------- frontend/src/lib/api/plex.ts | 481 +++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 473 deletions(-) create mode 100644 frontend/src/lib/api/plex.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8de7b2e..9d8a06d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,6 +22,8 @@ export { // importable from "./api". Their method slices are spread into `api` below. import { downloadsApi } from "./api/downloads"; export * from "./api/downloads"; +import { plexApi } from "./api/plex"; +export * from "./api/plex"; export interface Me { id: number; @@ -485,269 +487,6 @@ export interface SystemConfigData { secrets_manageable: boolean; } -// 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 ; 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= - group_in?: number; // only when fetched with ?contains_group=: 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; -} - // Admin Users & roles page. export interface AdminUserRow { id: number; @@ -956,216 +695,7 @@ export const api = { req(`/api/admin/config/${key}`, { method: "DELETE" }), testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), - testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), - syncPlex: (): Promise> => 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 => { - 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 => { - 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 => - req(`/api/plex/collections`, { - method: "POST", - body: JSON.stringify({ library, title, item_rating_key }), - }), - plexCollectionAddItem: (rk: string, itemRk: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { - method: "POST", - }), - plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => - req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { - method: "DELETE", - }), - plexRenameCollection: (rk: string, title: string): Promise => - 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 => - 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 => req(`/api/plex/playlists/${id}`), - plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => - 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 => - 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 => - 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 => - 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 => { - 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 ). 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 => - 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, - }), - plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise => - 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 => req("/api/plex/watch/link"), - plexWatchSetLink: ( - enabled: boolean, - ): Promise => - req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), - plexWatchImport: (): Promise => - 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" : ""}`, + ...plexApi, // --- admin: users & roles --- adminAudit: (limit = 500): Promise => req(`/api/admin/audit?limit=${limit}`), clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }), diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts new file mode 100644 index 0000000..e1c8059 --- /dev/null +++ b/frontend/src/lib/api/plex.ts @@ -0,0 +1,481 @@ +// 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 ; 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= + group_in?: number; // only when fetched with ?contains_group=: 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 => req("/api/plex/test", { method: "POST" }), + syncPlex: (): Promise> => 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 => { + 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 => { + 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 => + req(`/api/plex/collections`, { + method: "POST", + body: JSON.stringify({ library, title, item_rating_key }), + }), + plexCollectionAddItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { + method: "POST", + }), + plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { + method: "DELETE", + }), + plexRenameCollection: (rk: string, title: string): Promise => + 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 => + 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 => req(`/api/plex/playlists/${id}`), + plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => + 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 => + 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 => + 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 => + 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 => { + 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 ). 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 => + 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, + }), + plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise => + 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 => req("/api/plex/watch/link"), + plexWatchSetLink: ( + enabled: boolean, + ): Promise => + req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }), + plexWatchImport: (): Promise => + 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" : ""}`, +}; From 926ebe7ffa98899f8c1c742bf173e54bcb5e7146 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:26:23 +0200 Subject: [PATCH 12/15] =?UTF-8?q?refactor(api):=20extract=20the=20messagin?= =?UTF-8?q?g=20domain=20to=20api/messaging.ts=20(R7=20S3b=C2=B74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2EE direct-message types (MessageUser/Message/Conversation/MyKeyBundle/ KeySetup) + the ~9 messaging methods move into a self-contained api/messaging.ts exporting a `messagingApi` slice (imports only core's req). api.ts spreads the slice and re-exports the types. api.ts 882 -> 819 lines; no runtime change. Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 69 ++--------------------------- frontend/src/lib/api/messaging.ts | 72 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 66 deletions(-) create mode 100644 frontend/src/lib/api/messaging.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9d8a06d..79619ba 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -24,6 +24,8 @@ import { downloadsApi } from "./api/downloads"; export * from "./api/downloads"; import { plexApi } from "./api/plex"; export * from "./api/plex"; +import { messagingApi } from "./api/messaging"; +export * from "./api/messaging"; export interface Me { id: number; @@ -426,48 +428,6 @@ export interface AppNotification { created_at: string | null; } -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; -} - // Admin Configuration page: one DB-overridable setting (registry entry on the backend). export interface ConfigItem { key: string; @@ -846,30 +806,7 @@ export const api = { reorderViews: (ids: number[]): Promise<{ ok: boolean }> => req("/api/saved-views/reorder", { method: "POST", body: JSON.stringify({ ids }) }), - // --- direct messages (end-to-end encrypted) --- - messageMyKey: (): Promise => 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 => 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 => - req("/api/messages", { - method: "POST", - body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }), - }), - messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"), + ...messagingApi, // --- user tags --- createTag: (t: { name: string; color?: string; category?: string }) => diff --git a/frontend/src/lib/api/messaging.ts b/frontend/src/lib/api/messaging.ts new file mode 100644 index 0000000..95f9b0b --- /dev/null +++ b/frontend/src/lib/api/messaging.ts @@ -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 => 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 => 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 => + req("/api/messages", { + method: "POST", + body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }), + }), + messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"), +}; From 1223df6ccc00d3fd372079bb75796db896d67c79 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:45:02 +0200 Subject: [PATCH 13/15] refactor(api): code-review fixes (R7 S3b review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - barrel re-exports domains with `export type *` (not `export *`) so the internal downloadsApi/plexApi/messagingApi slices stay behind the façade; plex's runtime value exports (EMPTY_PLEX_FILTERS/plexFilterCount) re-exported explicitly - App.tsx: drop the now-redundant `prefs as Record` self-cast (Me.preferences is already that type) and its `prefsRec` alias - plex.ts: comment why plexSetState keeps its status union inline (reusing VideoStatus would make an api.ts↔plex.ts type cycle) Behaviour-identical. Gate green: typecheck, eslint 0, prettier, 74 vitest. --- frontend/src/App.tsx | 9 ++++----- frontend/src/lib/api.ts | 14 +++++++++----- frontend/src/lib/api/plex.ts | 2 ++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 454e26b..e792daa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -228,9 +228,8 @@ export default function App() { useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; - const prefsRec = prefs as Record; (["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 })); @@ -245,9 +244,9 @@ 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); } const lang = prefs.language; if (typeof lang === "string" && isSupported(lang)) setLanguage(lang); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 79619ba..baf0105 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,14 +18,18 @@ export { setUnauthorizedHandler, }; -// Domain modules split out of this file, re-exported so their types + the `api` façade stay -// importable from "./api". Their method slices are spread into `api` below. +// Domain modules split out of this file. Their TYPES are re-exported so consumers keep importing +// them from "./api" unchanged; their method slices stay internal (`export type *`, not `export *`) +// and reach consumers only through the `api` façade they're spread into below. Runtime value +// exports a domain genuinely owns (e.g. plex's EMPTY_PLEX_FILTERS/plexFilterCount) are re-exported +// explicitly. import { downloadsApi } from "./api/downloads"; -export * from "./api/downloads"; +export type * from "./api/downloads"; import { plexApi } from "./api/plex"; -export * from "./api/plex"; +export type * from "./api/plex"; +export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; import { messagingApi } from "./api/messaging"; -export * from "./api/messaging"; +export type * from "./api/messaging"; export interface Me { id: number; diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index e1c8059..0a9eab4 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -460,6 +460,8 @@ export const plexApi = { 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 => req(`/api/plex/item/${encodeURIComponent(id)}/state`, { method: "POST", From 175e49b3619a37a3215dc86154510824bff9a738 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 00:18:39 +0200 Subject: [PATCH 14/15] =?UTF-8?q?chore(release):=200.56.0=20=E2=80=94=20R7?= =?UTF-8?q?=20frontend=20API=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 2b045fa..c11ca46 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.55.0 \ No newline at end of file +0.56.0 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 09b0f2a..0202e1a 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -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", From 0d655e6f883e5682175c60fabea209e0e1d0642f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 00:24:38 +0200 Subject: [PATCH 15/15] fix(api): un-export types used only within their own module (knip gate) The S3b api.ts split introduced 5 exported-but-never-imported types (PlaylistKind/PlaylistSource, DownloadJobKind, YT internal option/data shapes). The publish gate's knip lane flagged them (my per-step checks ran tsc/eslint/ prettier/vitest but not knip). They're used inside their own modules by the interfaces, so make them local (consumers compare against literals, never name the type). --- frontend/src/lib/api.ts | 6 ++++-- frontend/src/lib/api/downloads.ts | 3 ++- frontend/src/lib/youtube.ts | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index baf0105..10115c2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -159,8 +159,10 @@ export interface FeedResponse { // or optional (dead surface). Re-add if a consumer ever needs it. } -export type PlaylistKind = "user" | "watch_later"; -export type PlaylistSource = "local" | "youtube"; +// Not exported: consumers compare `playlist.kind`/`.source` against string literals, they never +// name these types — keeping them local satisfies the no-unused-exports gate (knip). +type PlaylistKind = "user" | "watch_later"; +type PlaylistSource = "local" | "youtube"; export interface Playlist { id: number; diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index 3cb220b..c3340e1 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -41,7 +41,8 @@ interface DownloadAsset { export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; // "download" (default) or "edit" (a per-user trim/crop derivative cut from another job). -export type DownloadJobKind = "download" | "edit"; +// 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). diff --git a/frontend/src/lib/youtube.ts b/frontend/src/lib/youtube.ts index 82c087c..adc9ce7 100644 --- a/frontend/src/lib/youtube.ts +++ b/frontend/src/lib/youtube.ts @@ -3,7 +3,7 @@ // @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. -export interface YTVideoData { +interface YTVideoData { title: string; author: string; video_id?: string; @@ -34,7 +34,7 @@ export interface YTPlayerEvent { target: YTPlayer; } -export interface YTPlayerOptions { +interface YTPlayerOptions { width?: string | number; height?: string | number; videoId?: string;