Files
siftlode/frontend/src/lib/queryKeys.ts
T
peter b5f9cf14db feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
2026-10-24 00:00:50 +02:00

185 lines
11 KiB
TypeScript

import type { FeedFilters, FileListParams } 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 motive: `["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-summary`), so collapsing them would change
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
export const qk = {
// --- shared roots (invalidated from more than one domain) ---
me: () => ["me"] as const,
myStatus: () => ["my-status"] as const,
tags: () => ["tags"] as const,
// Per-channel suggested user tags, learned from the manual-tag pattern. Separate root so
// accepting a suggestion can invalidate it without touching the whole tag catalog.
tagSuggestions: () => ["tag-suggestions"] as const,
// --- feed / video ---
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
// key" is the ONE rule across the factory. It also keeps a nullable segment intact: `q`/`id`
// accept null because some call sites key on nullable state (`selectedId`, the search string),
// and a NULL is a real key segment that must be preserved (`["playlist", null]`) rather than
// silently collapsed to the prefix.
feed: (filters?: FeedFilters) =>
filters !== undefined ? (["feed", filters] as const) : (["feed"] as const),
feedSummary: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
feedYears: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const),
ytSearch: (q?: string | null, count?: number) =>
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
videoDetail: (id: string) => ["video-detail", id] as const,
// --- channels ---
channels: () => ["channels"] as const,
channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const,
watchedChannels: () => ["watched-channels"] as const,
watchedChannelVideos: (id: string) => ["watched-channels", "videos", id] 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,
// Keyed by source ("feed" | "plex") so each module's views cache independently; `savedViews()` (no
// arg) stays a prefix for a cross-source invalidation.
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
// --- account / misc --- (me/myStatus/tags already declared as shared roots above)
version: () => ["version"] as const,
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
releaseNotes: (lang: string) => ["release-notes", lang] 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,
// --- sharing ---
shareRecipients: () => ["share-recipients"] as const,
// --- files (the library) ---
// Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
fileFolders: () => ["file-folders"] as const,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const,
apiTokens: () => ["api-tokens"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
downloadIndex: () => ["download-index"] as const,
downloadProfiles: () => ["download-profiles"] as const,
downloadCredentials: () => ["download-credentials"] as const,
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
folderLinks: (folderId: number) => ["folder-links", folderId] as const,
dlQuota: (userId: number) => ["dl-quota", userId] as const,
storyboard: (jobId: number) => ["storyboard", jobId] as const,
// --- admin ---
adminUsers: () => ["admin-users"] as const,
adminUserAccess: (id: number) => ["admin-user-access", id] as const,
adminInvites: () => ["admin-invites"] as const,
adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const,
adminAudit: (filters?: object) =>
filters !== undefined ? (["admin-audit", filters] as const) : (["admin-audit"] as const),
adminAuditFacets: (filters?: object) =>
filters !== undefined
? (["admin-audit-facets", filters] as const)
: (["admin-audit-facets"] as const),
adminRuns: (filters: object) => ["admin-runs", filters] as const,
adminRunFacets: (filters: object) => ["admin-run-facets", filters] as const,
// The console's filters are part of the key; `since` is not (it moves with the clock) — the
// window it came from is.
adminLogs: (filters: object) => ["admin-logs", filters] as const,
adminLogFacets: (filters: object) => ["admin-log-facets", filters] as const,
adminStorage: () => ["admin-storage"] as const,
// --- debug sessions ---
debugSessions: () => ["debug-sessions"] as const,
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
// The tracker's rendering of a body: the text is the key. Nothing debounces it — react-query has
// no such thing — so one fetch per distinct text. What holds the count down is that the composer
// unmounts the editor while the preview is up, plus the cache for an unchanged draft; a preview
// shown BESIDE the fields would need a real debounce first (see routes/debug_sessions.py).
debugSessionPreview: (id: string, text: string) =>
["debug-sessions", id, "preview", text] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (filters: object) => ["admin-quota", filters] 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.
// The Home surface. `discover()` with no args is the prefix every mutation invalidates —
// pinning something changes the shelf AND can change the hero, so the two are never invalidated
// separately. The reroll has its own key so pressing "Mást kérek" does not refetch the rails —
// but it sits UNDER the same root, because a sibling root ("discover-pick") is not matched by
// that prefix and the rerolled hero silently outlived every invalidation on the page: play it,
// finish it, and it was still the answer on screen with a zeroed progress bar.
discover: (...rest: unknown[]) => ["discover", ...rest],
discoverPick: (...rest: unknown[]) => ["discover", "pick", ...rest],
comfortPins: (lang?: string) => ["comfort-pins", ...(lang ? [lang] : [])] as const,
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
takeout: () => ["takeout"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
// Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches it and
// the Surprise draw below for a prefix invalidation after a watched-toggle.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre
// filter narrows against the language-specific genre column, like the grid). UNDER the discover root,
// so whatever refreshes the rails (a watched toggle, a player closing) refreshes this one too: its
// refetch reads the same draw again (`keep`), and only the ↻ deals a new one. As a sibling root the
// draw's cards kept the watch state they were drawn with.
plexSurprise: (...rest: unknown[]) => ["plex-discover", "surprise", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
// Optional trailing `lang`: a language switch remounts the detail with a new key so HU/EN
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.
plexShow: (showId?: string, lang?: string) =>
showId !== undefined
? (["plex-show", showId, ...(lang ? [lang] : [])] as const)
: (["plex-show"] as const),
// A show card's next episode. Its own root, not under `plex-show`: a watch write anywhere moves
// it, and the card reads it fresh (staleTime 0) instead of trusting every write site to invalidate.
plexShowNext: (showId: string, lang?: string) =>
["plex-show-next", showId, ...(lang ? [lang] : [])] as const,
plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const,
// Extraction state of one subtitle track. Not keyed by the playhead: the position is an argument
// that steers the background extraction, not part of the track's identity.
plexSubtitle: (id: string, ord: number) => ["plex-subtitle", id, ord] as const,
plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest],
// `lang` as on plexShow: omit it for a prefix that matches both languages.
plexPlaylist: (playlistId: number, lang?: string) =>
["plex-playlist", playlistId, ...(lang ? [lang] : [])] 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,
plexPerson: (name: string, lang?: string) =>
["plex-person", name, ...(lang ? [lang] : [])] as const,
};
/** The lists the SERVER puts in the reader's alphabet from the language stored in their preferences
* (backend `lang_for_user` → `app/collation.py`), so their keys carry no language. Once a language
* switch has been saved they are asked for again (App's `changeLanguage`): they re-sort, and a
* title-sorted feed never reads its next page under the other alphabet's cursor. */
export const LANGUAGE_ORDERED = [
qk.feed(),
qk.channels(),
qk.discoveredChannels(),
qk.blockedChannels(),
qk.shareRecipients(),
qk.plexCollections(),
] as const;