Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
688 changes: 0 additions & 688 deletions webapp/src/api/queries.ts

This file was deleted.

71 changes: 71 additions & 0 deletions webapp/src/api/queries/administration.ts
Original file line number Diff line number Diff line change
@@ -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()]),
});
154 changes: 154 additions & 0 deletions webapp/src/api/queries/catalog.ts
Original file line number Diff line number Diff line change
@@ -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,
});
50 changes: 50 additions & 0 deletions webapp/src/api/queries/collections.ts
Original file line number Diff line number Diff line change
@@ -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),
});
77 changes: 77 additions & 0 deletions webapp/src/api/queries/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Every question this client asks the server, and the key each answer is held
* under.
*
* Gathered here rather than written at each screen, because a key is shared
* state: two screens that spell one differently keep two copies of the same
* answer and disagree after a mutation, and two that spell different questions
* the same way serve each other's data. Neither mistake shows up in a type.
*
* A key is the question, argument by argument — `["albums", sort, scope]` and
* not `["albums"]` — so changing a sort or a library asks again instead of
* returning what the other one answered.
*/

export {
administrationQuery,
apiTokensQuery,
libraryMembersQuery,
librarySettingsQuery,
ownApiTokensQuery,
ownSubsonicCredentialQuery,
} from "./administration";
export {
albumQuery,
albumsQuery,
artistAlbumsQuery,
artistQuery,
artistsQuery,
artistTopSongsQuery,
ENRICHMENT_ASKS,
ENRICHMENT_RETRY_MS,
enrichmentRetryDelay,
genreSongsQuery,
genresQuery,
randomSongsQuery,
searchQuery,
songsQuery,
} from "./catalog";
export {
bookmarksQuery,
playlistsQuery,
podcastChannelQuery,
podcastsQuery,
radioStationsQuery,
sharesQuery,
smartPlaylistQuery,
smartPlaylistsQuery,
} from "./collections";
export {
favoriteTracksQuery,
historyTracksQuery,
mixesQuery,
mixQuery,
nowPlayingQuery,
scrobbleRowsQuery,
statsQuery,
uncertainScrobblesQuery,
} from "./listening";
export {
attentionSummaryQuery,
attentionTracksQuery,
creditCandidatesQuery,
creditDecisionsQuery,
filedTreeQuery,
folderTracksQuery,
purgedTracksQuery,
qualitySummaryQuery,
qualityTracksQuery,
trackQualityQuery,
unavailableTracksQuery,
} from "./maintenance";
export {
canvasQuery,
lyricsQuery,
trackEditQuery,
trackPropertiesQuery,
} from "./tracks";
Loading
Loading