From 8d11f843e77e8da02e6334fa982a4a3443105a73 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:24:35 +0100 Subject: [PATCH 1/2] refactor(webapp): move the admin, playing and queries modules into directories Signed-off-by: InstaZDLL --- webapp/src/api/{queries.ts => queries/index.ts} | 0 webapp/src/pages/{admin-page.tsx => admin-page/index.tsx} | 0 webapp/src/pages/{playing-page.tsx => playing-page/index.tsx} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename webapp/src/api/{queries.ts => queries/index.ts} (100%) rename webapp/src/pages/{admin-page.tsx => admin-page/index.tsx} (100%) rename webapp/src/pages/{playing-page.tsx => playing-page/index.tsx} (100%) diff --git a/webapp/src/api/queries.ts b/webapp/src/api/queries/index.ts similarity index 100% rename from webapp/src/api/queries.ts rename to webapp/src/api/queries/index.ts diff --git a/webapp/src/pages/admin-page.tsx b/webapp/src/pages/admin-page/index.tsx similarity index 100% rename from webapp/src/pages/admin-page.tsx rename to webapp/src/pages/admin-page/index.tsx diff --git a/webapp/src/pages/playing-page.tsx b/webapp/src/pages/playing-page/index.tsx similarity index 100% rename from webapp/src/pages/playing-page.tsx rename to webapp/src/pages/playing-page/index.tsx From 3f4590e740f8badfb361c84952925e81c3659ddf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:28:01 +0100 Subject: [PATCH 2/2] refactor(webapp): split the admin and playing pages and the query catalogue by section Signed-off-by: InstaZDLL --- webapp/src/api/queries/administration.ts | 71 ++ webapp/src/api/queries/catalog.ts | 154 ++++ webapp/src/api/queries/collections.ts | 50 ++ webapp/src/api/queries/index.ts | 737 ++---------------- webapp/src/api/queries/listening.ts | 173 ++++ webapp/src/api/queries/maintenance.ts | 175 +++++ webapp/src/api/queries/tracks.ts | 82 ++ webapp/src/pages/admin-page/activity.tsx | 131 ++++ webapp/src/pages/admin-page/credentials.tsx | 163 ++++ webapp/src/pages/admin-page/index.tsx | 649 +-------------- webapp/src/pages/admin-page/libraries.tsx | 361 +++++++++ webapp/src/pages/playing-page/detail-view.tsx | 82 ++ webapp/src/pages/playing-page/immersive.tsx | 137 ++++ webapp/src/pages/playing-page/index.tsx | 535 +------------ webapp/src/pages/playing-page/lyrics.tsx | 318 ++++++++ 15 files changed, 1986 insertions(+), 1832 deletions(-) create mode 100644 webapp/src/api/queries/administration.ts create mode 100644 webapp/src/api/queries/catalog.ts create mode 100644 webapp/src/api/queries/collections.ts create mode 100644 webapp/src/api/queries/listening.ts create mode 100644 webapp/src/api/queries/maintenance.ts create mode 100644 webapp/src/api/queries/tracks.ts create mode 100644 webapp/src/pages/admin-page/activity.tsx create mode 100644 webapp/src/pages/admin-page/credentials.tsx create mode 100644 webapp/src/pages/admin-page/libraries.tsx create mode 100644 webapp/src/pages/playing-page/detail-view.tsx create mode 100644 webapp/src/pages/playing-page/immersive.tsx create mode 100644 webapp/src/pages/playing-page/lyrics.tsx diff --git a/webapp/src/api/queries/administration.ts b/webapp/src/api/queries/administration.ts new file mode 100644 index 00000000..8a0d05df --- /dev/null +++ b/webapp/src/api/queries/administration.ts @@ -0,0 +1,71 @@ +// Accounts and libraries as an administrator sees them; one's own keys. + +import { queryOptions } from "@tanstack/react-query"; +import { + getLibrarySettings, + listApiTokens, + listLibraries, + listLibraryMembers, + listOwnApiTokens, + listUsers, + readOwnSubsonicCredential, +} from "../index"; + +export const apiTokensQuery = (username: string, open: boolean) => + queryOptions({ + queryKey: ["api-tokens", username], + queryFn: () => listApiTokens(username), + // Closed until asked for. This panel is rendered once per account, so + // loading on mount meant one request per account every time the admin + // screen opened — for a list almost nobody opens. + enabled: open, + }); + +/** + * This account's own registered clients. + * + * Unconditional, unlike `apiTokensQuery`, which is gated on a disclosure + * because the administration screen renders one per account. There is exactly + * one of these and the page it sits on is about it. + */ +export const ownApiTokensQuery = () => + queryOptions({ + queryKey: ["own-api-tokens"], + queryFn: listOwnApiTokens, + }); + +/** + * Whether this account holds a Subsonic credential. + * + * One of these, like the clients listing beside it, because the page it sits + * on is about this account. + */ +export const ownSubsonicCredentialQuery = () => + queryOptions({ + queryKey: ["own-subsonic-credential"], + queryFn: readOwnSubsonicCredential, + }); + +export const libraryMembersQuery = (libraryId: string, open: boolean) => + queryOptions({ + queryKey: ["library-members", libraryId], + queryFn: () => listLibraryMembers(libraryId), + enabled: open, + }); + +/** + * A library as an administrator manages it (RFC-021), asked for only once its + * panel is open: the admin screen draws one per library. + */ +export const librarySettingsQuery = (libraryId: string, open: boolean) => + queryOptions({ + queryKey: ["library-settings", libraryId], + queryFn: () => getLibrarySettings(libraryId), + enabled: open, + }); + +export const administrationQuery = () => + queryOptions({ + queryKey: ["administration"], + queryFn: () => Promise.all([listLibraries(), listUsers()]), + }); diff --git a/webapp/src/api/queries/catalog.ts b/webapp/src/api/queries/catalog.ts new file mode 100644 index 00000000..cfbf167f --- /dev/null +++ b/webapp/src/api/queries/catalog.ts @@ -0,0 +1,154 @@ +// The catalogue: albums, songs, artists, genres, a random draw, search. + +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { + type AlbumSort, + type ArtistDetail, + artistTopSongs, + getAlbum, + getArtist, + listAlbums, + listArtistAlbums, + listArtists, + listGenreSongs, + listGenres, + listRandomSongs, + listSongs, + SONG_PAGE, + type SongSort, + search, +} from "../index"; + +export const albumsQuery = (sort: AlbumSort, scope: string | undefined) => + queryOptions({ + queryKey: ["albums", sort, scope ?? null], + queryFn: () => listAlbums(sort, scope), + }); + +/** + * The catalogue's songs, a page at a time. + * + * The only infinite query here, and the only one that needs to be: every + * other listing is small enough to hold whole. A short page is the end — + * the route has no total to send, and inventing one would mean a second + * `COUNT(*)` over the same filter on every request. + */ +export const songsQuery = (sort: SongSort, scope: string | undefined) => + infiniteQueryOptions({ + queryKey: ["songs", sort, scope ?? null], + // The signal is handed on so a page still in flight when the sort or the + // library changes is abandoned instead of finishing into a key nobody + // will read. A page is two hundred rows, which is the largest answer this + // client asks for. + queryFn: ({ pageParam, signal }) => + listSongs(sort, scope, pageParam, SONG_PAGE, signal), + initialPageParam: 0, + getNextPageParam: (last, _pages, lastParam) => + last.length < SONG_PAGE ? undefined : lastParam + SONG_PAGE, + }); + +export const albumQuery = (albumId: string) => + queryOptions({ + queryKey: ["album", albumId], + queryFn: () => getAlbum(albumId), + }); + +export const artistsQuery = (scope: string | undefined) => + queryOptions({ + queryKey: ["artists", scope ?? null], + queryFn: () => listArtists(scope), + }); + +/** How many times an artist page is asked in all while enrichment is pending. */ +export const ENRICHMENT_ASKS = 6; +/** The pause between two of those asks. */ +export const ENRICHMENT_RETRY_MS = 3_000; + +/** + * When to ask for an artist again, or `false` for never. + * + * The web client does not read the library event stream, so it learns that a + * provider answered only by asking. It asks while the state is `pending`, up + * to {@link ENRICHMENT_ASKS} answers in all, and then stops: a provider that + * is unreachable stays `pending` on the server, and a page left open must + * not ask forever. The count is the query's, so a later visit refetches once + * on mount, as every page does, and does not start the series again. + */ +export function enrichmentRetryDelay( + artist: ArtistDetail | undefined, + answers: number, +): number | false { + if (artist?.enrichment?.state !== "pending") return false; + return answers < ENRICHMENT_ASKS ? ENRICHMENT_RETRY_MS : false; +} + +export const artistQuery = (artistId: string) => + queryOptions({ + queryKey: ["artist", artistId], + queryFn: () => getArtist(artistId), + // Failed asks count too: an error leaves the last answer — still + // `pending` — in place and does not move `dataUpdateCount`, so a server + // that keeps failing would otherwise be asked every few seconds for ever. + refetchInterval: (query) => + enrichmentRetryDelay( + query.state.data, + query.state.dataUpdateCount + query.state.errorUpdateCount, + ), + }); + +/** An artist's albums alone, for the album page's "more by". */ +export const artistAlbumsQuery = (artistId: string) => + queryOptions({ + queryKey: ["artist-albums", artistId], + queryFn: () => listArtistAlbums(artistId), + }); + +export const artistTopSongsQuery = (artistId: string) => + queryOptions({ + queryKey: ["artist-top-songs", artistId], + queryFn: () => artistTopSongs(artistId), + }); + +export const genresQuery = (scope: string | undefined) => + queryOptions({ + queryKey: ["genres", scope ?? null], + queryFn: () => listGenres(scope), + }); + +export const genreSongsQuery = (genre: string, scope: string | undefined) => + queryOptions({ + queryKey: ["genre-songs", genre, scope ?? null], + queryFn: () => listGenreSongs(genre, scope), + }); + +/** + * A fresh draw each time it is asked for. + * + * `staleTime: 0` on purpose, against the default: every other question here is + * worth holding, and this one is worth exactly the opposite. Coming back to a + * shuffle and seeing the previous shuffle would be a cache doing its job and + * the page failing at its own. + */ +export const randomSongsQuery = (scope: string | undefined) => + queryOptions({ + queryKey: ["random-songs", scope ?? null], + queryFn: () => listRandomSongs(100, undefined, scope), + staleTime: 0, + gcTime: 0, + }); + +export const searchQuery = (needle: string, scope: string | undefined) => + queryOptions({ + // The scope belongs in the key: changing library while a result is on + // screen has to re-ask, or the page keeps answering for the library it + // left. + queryKey: ["search", needle, scope ?? null], + // The signal is the query's own: a needle superseded while its request is + // still out is abandoned rather than left to finish into a cache entry + // nobody will read. It costs the screen nothing — every needle is its own + // key, so an answer that arrives late lands under the key it was asked + // for and not over the one on screen — and it stops a search box from + // spending one round trip per keystroke on the server. + queryFn: ({ signal }) => search(needle, scope, signal), + enabled: needle.length > 0, + }); diff --git a/webapp/src/api/queries/collections.ts b/webapp/src/api/queries/collections.ts new file mode 100644 index 00000000..f3bb54b9 --- /dev/null +++ b/webapp/src/api/queries/collections.ts @@ -0,0 +1,50 @@ +// What an account keeps: playlists, shares, bookmarks, radio, podcasts. + +import { queryOptions } from "@tanstack/react-query"; +import { + getPodcastChannel, + getPodcastOverview, + getSmartPlaylist, + listBookmarks, + listRadioStations, + listShares, + listSmartPlaylists, + listVisiblePlaylists, +} from "../index"; + +export const playlistsQuery = () => + queryOptions({ queryKey: ["playlists"], queryFn: listVisiblePlaylists }); + +export const sharesQuery = () => + queryOptions({ queryKey: ["shares"], queryFn: listShares }); + +export const bookmarksQuery = () => + queryOptions({ queryKey: ["bookmarks"], queryFn: listBookmarks }); + +export const radioStationsQuery = () => + queryOptions({ queryKey: ["radio-stations"], queryFn: listRadioStations }); + +/** + * Smart playlists (RFC-025), counted but not loaded: a listing carries what + * each one answers now, and the tracks are asked for when one is opened. + */ +export const smartPlaylistsQuery = () => + queryOptions({ queryKey: ["smart-playlists"], queryFn: listSmartPlaylists }); + +/** One smart playlist, evaluated by the server at the moment it is asked. */ +export const smartPlaylistQuery = (id: string) => + queryOptions({ + queryKey: ["smart-playlists", id], + queryFn: () => getSmartPlaylist(id), + }); + +/** The podcasts page: the switch, this account's role, the channels. */ +export const podcastsQuery = () => + queryOptions({ queryKey: ["podcasts"], queryFn: getPodcastOverview }); + +/** One channel and its episodes, with this account's position in each. */ +export const podcastChannelQuery = (id: string) => + queryOptions({ + queryKey: ["podcasts", id], + queryFn: () => getPodcastChannel(id), + }); diff --git a/webapp/src/api/queries/index.ts b/webapp/src/api/queries/index.ts index 28aa675f..495b3dd0 100644 --- a/webapp/src/api/queries/index.ts +++ b/webapp/src/api/queries/index.ts @@ -1,71 +1,3 @@ -import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; -import { playsByInstant, scrobbleRows, tracksToName } from "../lib/scrobbling"; -import { - type AlbumSort, - type ArtistDetail, - ATTENTION_TRACKS_PAGE, - artistTopSongs, - canvasUrl, - FOLDER_TRACKS_PAGE, - getAlbum, - getArtist, - getAttentionSummary, - getLibrarySettings, - getListeningStats, - getLyrics, - getMix, - getPodcastChannel, - getPodcastOverview, - getQualitySummary, - getSmartPlaylist, - getTrack, - getTrackCredits, - getTrackOverrides, - getTrackProperties, - getTrackQuality, - listAlbums, - listApiTokens, - listArtistAlbums, - listArtists, - listAttentionTracks, - listBookmarks, - listCreditCandidates, - listCreditDecisions, - listFavorites, - listFolders, - listFolderTracks, - listGenreSongs, - listGenres, - listHistory, - listLibraries, - listLibraryMembers, - listMixes, - listNowPlaying, - listOwnApiTokens, - listPurgedTracks, - listQualityTracks, - listRadioStations, - listRandomSongs, - listScrobbleDestinations, - listScrobbleLinks, - listShares, - listSmartPlaylists, - listSongs, - listUnavailableTracks, - listUncertainScrobbles, - listUsers, - listVisiblePlaylists, - QUALITY_TRACKS_PAGE, - type QualityVerdict, - readOwnSubsonicCredential, - SONG_PAGE, - type SongSort, - type StatsPeriod, - search, - type TrackCategory, - UNAVAILABLE_PAGE, -} from "./index"; - /** * Every question this client asks the server, and the key each answer is held * under. @@ -80,609 +12,66 @@ import { * returning what the other one answered. */ -const HISTORY_PLAYS = 200; - -export const albumsQuery = (sort: AlbumSort, scope: string | undefined) => - queryOptions({ - queryKey: ["albums", sort, scope ?? null], - queryFn: () => listAlbums(sort, scope), - }); - -/** - * The catalogue's songs, a page at a time. - * - * The only infinite query here, and the only one that needs to be: every - * other listing is small enough to hold whole. A short page is the end — - * the route has no total to send, and inventing one would mean a second - * `COUNT(*)` over the same filter on every request. - */ -export const songsQuery = (sort: SongSort, scope: string | undefined) => - infiniteQueryOptions({ - queryKey: ["songs", sort, scope ?? null], - // The signal is handed on so a page still in flight when the sort or the - // library changes is abandoned instead of finishing into a key nobody - // will read. A page is two hundred rows, which is the largest answer this - // client asks for. - queryFn: ({ pageParam, signal }) => - listSongs(sort, scope, pageParam, SONG_PAGE, signal), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.length < SONG_PAGE ? undefined : lastParam + SONG_PAGE, - }); - -export const albumQuery = (albumId: string) => - queryOptions({ - queryKey: ["album", albumId], - queryFn: () => getAlbum(albumId), - }); - -export const artistsQuery = (scope: string | undefined) => - queryOptions({ - queryKey: ["artists", scope ?? null], - queryFn: () => listArtists(scope), - }); - -/** How many times an artist page is asked in all while enrichment is pending. */ -export const ENRICHMENT_ASKS = 6; -/** The pause between two of those asks. */ -export const ENRICHMENT_RETRY_MS = 3_000; - -/** - * When to ask for an artist again, or `false` for never. - * - * The web client does not read the library event stream, so it learns that a - * provider answered only by asking. It asks while the state is `pending`, up - * to {@link ENRICHMENT_ASKS} answers in all, and then stops: a provider that - * is unreachable stays `pending` on the server, and a page left open must - * not ask forever. The count is the query's, so a later visit refetches once - * on mount, as every page does, and does not start the series again. - */ -export function enrichmentRetryDelay( - artist: ArtistDetail | undefined, - answers: number, -): number | false { - if (artist?.enrichment?.state !== "pending") return false; - return answers < ENRICHMENT_ASKS ? ENRICHMENT_RETRY_MS : false; -} - -export const artistQuery = (artistId: string) => - queryOptions({ - queryKey: ["artist", artistId], - queryFn: () => getArtist(artistId), - // Failed asks count too: an error leaves the last answer — still - // `pending` — in place and does not move `dataUpdateCount`, so a server - // that keeps failing would otherwise be asked every few seconds for ever. - refetchInterval: (query) => - enrichmentRetryDelay( - query.state.data, - query.state.dataUpdateCount + query.state.errorUpdateCount, - ), - }); - -/** An artist's albums alone, for the album page's "more by". */ -export const artistAlbumsQuery = (artistId: string) => - queryOptions({ - queryKey: ["artist-albums", artistId], - queryFn: () => listArtistAlbums(artistId), - }); - -export const artistTopSongsQuery = (artistId: string) => - queryOptions({ - queryKey: ["artist-top-songs", artistId], - queryFn: () => artistTopSongs(artistId), - }); - -export const genresQuery = (scope: string | undefined) => - queryOptions({ - queryKey: ["genres", scope ?? null], - queryFn: () => listGenres(scope), - }); - -export const genreSongsQuery = (genre: string, scope: string | undefined) => - queryOptions({ - queryKey: ["genre-songs", genre, scope ?? null], - queryFn: () => listGenreSongs(genre, scope), - }); - -/** - * A fresh draw each time it is asked for. - * - * `staleTime: 0` on purpose, against the default: every other question here is - * worth holding, and this one is worth exactly the opposite. Coming back to a - * shuffle and seeing the previous shuffle would be a cache doing its job and - * the page failing at its own. - */ -export const randomSongsQuery = (scope: string | undefined) => - queryOptions({ - queryKey: ["random-songs", scope ?? null], - queryFn: () => listRandomSongs(100, undefined, scope), - staleTime: 0, - gcTime: 0, - }); - -export const playlistsQuery = () => - queryOptions({ queryKey: ["playlists"], queryFn: listVisiblePlaylists }); - -export const sharesQuery = () => - queryOptions({ queryKey: ["shares"], queryFn: listShares }); - -export const bookmarksQuery = () => - queryOptions({ queryKey: ["bookmarks"], queryFn: listBookmarks }); - -export const radioStationsQuery = () => - queryOptions({ queryKey: ["radio-stations"], queryFn: listRadioStations }); - -/** - * One track as its properties panel shows it, with the corrections behind - * its tags so each corrected value can say that it is one. - * - * Not `trackEditQuery`, though it reads the same two routes: that one is the - * editor's and is invalidated by its saves; sharing a key would tie the two - * screens' lifetimes together for no saving worth having. - */ -export const trackPropertiesQuery = (trackId: string) => - queryOptions({ - queryKey: ["track-properties", trackId], - queryFn: async () => { - // The corrections only mark which tags were corrected. A failure to - // read them costs those marks, not the panel. - const [track, tracked] = await Promise.all([ - getTrackProperties(trackId), - getTrackOverrides(trackId).catch(() => null), - ]); - return { track, tracked }; - }, - }); - -/** - * A library's whole filed tree: bounded by the server's folder cap, so it - * is held whole rather than paged. - */ -/** - * "Needs attention" (RFC-015): every query of the page is keyed under - * `attention` and the library, so one invalidation after a decision, a - * correction or a scan refreshes the whole page. - */ -export const attentionSummaryQuery = (libraryId: string) => - queryOptions({ - queryKey: ["attention", libraryId, "summary"], - queryFn: () => getAttentionSummary(libraryId), - }); - -export const creditCandidatesQuery = (libraryId: string) => - queryOptions({ - queryKey: ["attention", libraryId, "credits"], - queryFn: () => listCreditCandidates(libraryId), - }); - -export const creditDecisionsQuery = (libraryId: string) => - queryOptions({ - queryKey: ["attention", libraryId, "decisions"], - queryFn: () => listCreditDecisions(libraryId), - }); - -/** The tracks of one category, a page at a time. A short page is the end. */ -export const attentionTracksQuery = ( - libraryId: string, - category: TrackCategory, -) => - infiniteQueryOptions({ - queryKey: ["attention", libraryId, "tracks", category], - queryFn: ({ pageParam, signal }) => - listAttentionTracks( - libraryId, - category, - pageParam, - ATTENTION_TRACKS_PAGE, - signal, - ), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.length < ATTENTION_TRACKS_PAGE - ? undefined - : lastParam + ATTENTION_TRACKS_PAGE, - }); - -/** - * The quality page (RFC-016): keyed under `quality` and the library, so one - * invalidation after a request for a new analysis refreshes the whole page. - * While the analysis runs, the summary is asked again every ten seconds by the - * page itself; the list follows when the counts move. - */ -export const qualitySummaryQuery = (libraryId: string) => - queryOptions({ - queryKey: ["quality", libraryId, "summary"], - queryFn: () => getQualitySummary(libraryId), - }); - -/** One filter of the list, a page at a time. A short page is the end. */ -export const qualityTracksQuery = ( - libraryId: string, - filter: { verdict?: QualityVerdict; codec?: string }, -) => - infiniteQueryOptions({ - queryKey: [ - "quality", - libraryId, - "tracks", - filter.verdict ?? "", - filter.codec ?? "", - ], - queryFn: ({ pageParam, signal }) => - listQualityTracks( - libraryId, - filter, - pageParam, - QUALITY_TRACKS_PAGE, - signal, - ), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.length < QUALITY_TRACKS_PAGE - ? undefined - : lastParam + QUALITY_TRACKS_PAGE, - }); - -/** One track's verdict and spectrum, for its properties. */ -export const trackQualityQuery = (trackId: string) => - queryOptions({ - queryKey: ["track-quality", trackId], - queryFn: () => getTrackQuality(trackId), - }); - -export const filedTreeQuery = (libraryId: string) => - queryOptions({ - queryKey: ["filed-tree", libraryId], - queryFn: () => listFolders(libraryId), - }); - -/** - * The tracks filed in one folder, or the unfiled ones, a page at a time. A - * short page is the end. - */ -export const folderTracksQuery = (libraryId: string, folder: string) => - infiniteQueryOptions({ - queryKey: ["folder-tracks", libraryId, folder], - queryFn: ({ pageParam, signal }) => - listFolderTracks( - libraryId, - folder, - pageParam, - FOLDER_TRACKS_PAGE, - signal, - ), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.length < FOLDER_TRACKS_PAGE - ? undefined - : lastParam + FOLDER_TRACKS_PAGE, - }); - -/** - * One account's listening over a period (RFC-024 decision 6). The period is - * the key, so switching it asks again rather than relabelling the last - * answer, and so is the listener's day (`useLocalDay`): the buckets are - * local days, and an answer from before midnight, or from another timezone, - * cuts them in the wrong place. - */ -export const statsQuery = (period: StatsPeriod, day: string) => - queryOptions({ - queryKey: ["stats", period, day], - queryFn: () => getListeningStats(period), - }); - -/** - * The mixes on the home page (RFC-024 decision 7). The server draws them once - * a day, so the answer is held for a while rather than re-asked on every - * visit to the albums — and the listener's day is in the key, because a key - * that never changes is never asked again: past midnight the shelf would keep - * yesterday's draw for as long as the tab stayed open. - */ -export const mixesQuery = (scope: string | undefined, day: string) => - queryOptions({ - queryKey: ["mixes", scope ?? null, day], - queryFn: () => listMixes(scope), - staleTime: 5 * 60_000, - }); - -export const mixQuery = ( - mixId: string, - scope: string | undefined, - day: string, -) => - queryOptions({ - queryKey: ["mix", mixId, scope ?? null, day], - queryFn: () => getMix(mixId, scope), - staleTime: 5 * 60_000, - }); - -export const lyricsQuery = (trackId: string) => - queryOptions({ - queryKey: ["lyrics", trackId], - queryFn: () => getLyrics(trackId), - }); - -/** - * Who is listening, re-asked on a timer. - * - * The panel used to hold the previous reading in a `useState` of its own, - * because the hook beneath it blanked its value at the start of every poll and - * flashed "nobody is listening" thirty seconds apart. A query keeps what it has - * while it refetches, so that workaround is gone rather than moved. - */ -export const nowPlayingQuery = () => - queryOptions({ - queryKey: ["now-playing"], - queryFn: listNowPlaying, - refetchInterval: 30_000, - staleTime: 0, - }); - -/** - * The favourites, resolved to the tracks themselves. - * - * `Promise.allSettled`, so one track that has since become unreadable costs its - * own row and not the page. - */ -export const favoriteTracksQuery = () => - queryOptions({ - queryKey: ["favorite-tracks"], - queryFn: async () => { - const favorites = await listFavorites(); - const tracks = favorites.filter((item) => item.entity_type === "track"); - const resolved = await Promise.allSettled( - tracks.map((item) => getTrack(item.entity_id)), - ); - return resolved.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], - ); - }, - }); - -/** - * Recently played, one entry per track. - * - * The route answers plays, not songs, and the same track appears many times. - * Keeping the first sighting of each id gives "recently played" in the order it - * was last played, and asks for each track once. - */ -export const historyTracksQuery = (limit: number) => - queryOptions({ - queryKey: ["history-tracks", limit], - queryFn: async () => { - const plays = await listHistory(HISTORY_PLAYS); - const seen = new Set(); - const ordered = plays.filter((play) => { - if (seen.has(play.track_id)) return false; - seen.add(play.track_id); - return true; - }); - const resolved = await Promise.allSettled( - ordered.slice(0, limit).map((play) => getTrack(play.track_id)), - ); - return resolved.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], - ); - }, - }); - -export const searchQuery = (needle: string, scope: string | undefined) => - queryOptions({ - // The scope belongs in the key: changing library while a result is on - // screen has to re-ask, or the page keeps answering for the library it - // left. - queryKey: ["search", needle, scope ?? null], - // The signal is the query's own: a needle superseded while its request is - // still out is abandoned rather than left to finish into a cache entry - // nobody will read. It costs the screen nothing — every needle is its own - // key, so an answer that arrives late lands under the key it was asked - // for and not over the one on screen — and it stops a search box from - // spending one round trip per keystroke on the server. - queryFn: ({ signal }) => search(needle, scope, signal), - enabled: needle.length > 0, - }); - -export const apiTokensQuery = (username: string, open: boolean) => - queryOptions({ - queryKey: ["api-tokens", username], - queryFn: () => listApiTokens(username), - // Closed until asked for. This panel is rendered once per account, so - // loading on mount meant one request per account every time the admin - // screen opened — for a list almost nobody opens. - enabled: open, - }); - -/** - * This account's own registered clients. - * - * Unconditional, unlike `apiTokensQuery`, which is gated on a disclosure - * because the administration screen renders one per account. There is exactly - * one of these and the page it sits on is about it. - */ -export const ownApiTokensQuery = () => - queryOptions({ - queryKey: ["own-api-tokens"], - queryFn: listOwnApiTokens, - }); - -/** - * Whether this account holds a Subsonic credential. - * - * One of these, like the clients listing beside it, because the page it sits - * on is about this account. - */ -export const ownSubsonicCredentialQuery = () => - queryOptions({ - queryKey: ["own-subsonic-credential"], - queryFn: readOwnSubsonicCredential, - }); - -export const libraryMembersQuery = (libraryId: string, open: boolean) => - queryOptions({ - queryKey: ["library-members", libraryId], - queryFn: () => listLibraryMembers(libraryId), - enabled: open, - }); - -/** - * A library as an administrator manages it (RFC-021), asked for only once its - * panel is open: the admin screen draws one per library. - */ -export const librarySettingsQuery = (libraryId: string, open: boolean) => - queryOptions({ - queryKey: ["library-settings", libraryId], - queryFn: () => getLibrarySettings(libraryId), - enabled: open, - }); - -/** - * The files a library's scans can no longer find (RFC-021), a page at a time, - * oldest first. Keyed under `missing` and the library, so one invalidation - * after a purge refreshes the page. - */ -export const unavailableTracksQuery = (libraryId: string) => - infiniteQueryOptions({ - queryKey: ["missing", libraryId], - queryFn: ({ pageParam, signal }) => - listUnavailableTracks(libraryId, pageParam, UNAVAILABLE_PAGE, signal), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.tracks.length < UNAVAILABLE_PAGE - ? undefined - : lastParam + UNAVAILABLE_PAGE, - }); - -export const purgedTracksQuery = (libraryId: string) => - infiniteQueryOptions({ - queryKey: ["missing", libraryId, "purged"], - queryFn: ({ pageParam, signal }) => - listPurgedTracks(libraryId, pageParam, UNAVAILABLE_PAGE, signal), - initialPageParam: 0, - getNextPageParam: (last, _pages, lastParam) => - last.tracks.length < UNAVAILABLE_PAGE - ? undefined - : lastParam + UNAVAILABLE_PAGE, - }); - -export const administrationQuery = () => - queryOptions({ - queryKey: ["administration"], - queryFn: () => Promise.all([listLibraries(), listUsers()]), - }); - -/** - * A canvas ticket, held for exactly as long as it is being looked at. - * - * `staleTime` and `gcTime` at zero, against the defaults, because what this - * answers is not data but a **credential with a deadline** — an AEAD-sealed - * ticket the `