Files
minstrel/web/src/lib/api/queries.ts
T
bvandeusen 8d1f2674fd
test-web / test (push) Failing after 32s
feat(web): admin page for files the library has lost — #2527
Renders GET /api/admin/library/missing under Admin -> Missing files.
Folder-grouped, because that is the unit an operator decides about: the
case behind #2523 was three reorganised albums, and forty individual
rows hides that it is really three decisions.

Each row leads with the fact that settles whether a missing file is
worth chasing -- "last played 2d ago" against "never played". The group
header carries how many tracks and how long they have been gone.

Read-only. No remove button anywhere: the row, its play history and its
likes survive a file going missing, and the scanner clears the mark by
itself when the file returns (or adopts the row if it returns renamed,
#2528). The page says so in its own copy rather than leaving the
operator to infer it.

Empty state explains the feature instead of the emptiness -- what puts a
row here (moved outside Minstrel, deleted, a drive that didn't mount)
and that rows leave on their own. Someone who has never seen this page
should not have to guess.

Paging follows the house pattern -- plain offset into the factory,
wrapped in $derived so a page change re-creates the query with a new
key. Passing a getter instead would capture the key once and paging
would silently not refetch. The pager only renders when it can do
something.
2026-08-16 12:03:59 -04:00

171 lines
6.7 KiB
TypeScript

import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { pageGetNextPageParam } from './paging';
import type {
ArtistRef,
ArtistDetail,
AlbumRef,
AlbumDetail,
Page,
SearchResponse,
TrackRef
} from './types';
export type ArtistSort = 'alpha' | 'newest';
export const ARTIST_PAGE_SIZE = 50;
export const SEARCH_SUMMARY_LIMIT = 10;
export const SEARCH_FACET_PAGE_SIZE = 50;
export const qk = {
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
artist: (id: string) => ['artist', id] as const,
album: (id: string) => ['album', id] as const,
search: (q: string) => ['search', { q }] as const,
searchArtists: (q: string) => ['searchArtists', { q }] as const,
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
searchTracks: (q: string) => ['searchTracks', { q }] as const,
likedIds: () => ['likedIds'] as const,
likedTracks: () => ['likedTracks'] as const,
likedAlbums: () => ['likedAlbums'] as const,
likedArtists: () => ['likedArtists'] as const,
history: () => ['history'] as const,
// Lidarr / requests / admin.
lidarrSearch: (q: string, kind: string) =>
['lidarrSearch', { q, kind }] as const,
myRequests: () => ['myRequests'] as const,
lidarrConfig: () => ['lidarrConfig'] as const,
lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const,
lidarrMetadataProfiles: () => ['lidarrMetadataProfiles'] as const,
lidarrRootFolders: () => ['lidarrRootFolders'] as const,
adminRequests: (status?: string) =>
['adminRequests', { status: status ?? 'all' }] as const,
myQuarantine: () => ['myQuarantine'] as const,
adminQuarantine: () => ['adminQuarantine'] as const,
adminQuarantineActions: (limit?: number) =>
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
adminPlaybackErrors: (resolved?: boolean) =>
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
tagProviders: () => ['tagProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
['adminDiagnostics', f] as const,
adminMissingFiles: (offset?: number) =>
['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
home: () => ['home'] as const,
albumsAlpha: () => ['albumsAlpha'] as const,
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
similarArtists: (artistId: string) => ['similarArtists', artistId] as const,
artistTopTracks: (artistId: string) => ['artistTopTracks', artistId] as const,
playlists: (kind?: 'user' | 'system' | 'all') =>
['playlists', { kind: kind ?? 'user' }] as const,
playlist: (id: string) => ['playlist', id] as const,
systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const,
// Browse indexes (#367). Keys carry no arguments — both are whole-library
// indexes, and the per-genre / per-year album lists are fetched outside
// svelte-query because their selection comes from the URL.
genres: () => ['genres'] as const,
albumYears: () => ['albumYears'] as const,
};
export function createArtistsQuery(sort: ArtistSort) {
return createInfiniteQuery({
queryKey: qk.artists(sort),
queryFn: ({ pageParam = 0 }) =>
api.get<Page<ArtistRef>>(
`/api/artists?sort=${sort}&limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`
),
initialPageParam: 0,
getNextPageParam: pageGetNextPageParam<ArtistRef>(),
});
}
export function createArtistQuery(id: string) {
return createQuery({
queryKey: qk.artist(id),
queryFn: () => api.get<ArtistDetail>(`/api/artists/${id}`),
});
}
export function createAlbumQuery(id: string) {
return createQuery({
queryKey: qk.album(id),
queryFn: () => api.get<AlbumDetail>(`/api/albums/${id}`),
});
}
export function createSimilarArtistsQuery(id: string) {
return createQuery({
queryKey: qk.similarArtists(id),
queryFn: () => api.get<ArtistRef[]>(`/api/artists/${id}/similar`),
enabled: id.length > 0,
});
}
export function createArtistTopTracksQuery(id: string) {
return createQuery({
queryKey: qk.artistTopTracks(id),
queryFn: () => api.get<TrackRef[]>(`/api/artists/${id}/top-tracks`),
enabled: id.length > 0,
});
}
export function createSearchQuery(q: string) {
return createQuery({
queryKey: qk.search(q),
queryFn: () =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_SUMMARY_LIMIT}`
),
enabled: q.length > 0
});
}
export function createSearchArtistsInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchArtists(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.artists),
initialPageParam: 0,
getNextPageParam: pageGetNextPageParam<ArtistRef>(),
enabled: q.length > 0
});
}
export function createSearchAlbumsInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchAlbums(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.albums),
initialPageParam: 0,
getNextPageParam: pageGetNextPageParam<AlbumRef>(),
enabled: q.length > 0
});
}
export function createSearchTracksInfiniteQuery(q: string) {
return createInfiniteQuery({
queryKey: qk.searchTracks(q),
queryFn: ({ pageParam = 0 }) =>
api.get<SearchResponse>(
`/api/search?q=${encodeURIComponent(q)}&limit=${SEARCH_FACET_PAGE_SIZE}&offset=${pageParam}`
).then((r) => r.tracks),
initialPageParam: 0,
getNextPageParam: pageGetNextPageParam<TrackRef>(),
enabled: q.length > 0
});
}