feat(library): render from locally-cached data when offline
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

The Library showed a blocking error with the backend unreachable. Now it
composes a read-only library from everything already in the RTK Query
cache (Tier-2 rehydrated last-seen data + anything fetched this session),
so it keeps rendering offline instead of erroring.

- selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe
  across getTracks/getAlbums/getArtists, the per-album/artist list
  endpoints, and single-entity fetches; skips pending/rejected entries
- LibraryPage: when offline, fall back to the composed lists (live data
  still wins online), filter client-side for search, show an offline
  banner, and never show the retry-only ErrorState
- i18n: `library.offline.*` (en + ru)
- test: selector composition / dedup / status filtering (3 cases)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-14 01:44:45 +03:00
parent 4aa071eeeb
commit 8a0e6782ad
5 changed files with 325 additions and 60 deletions
+103 -36
View File
@@ -8,6 +8,7 @@ import {
ScrollArea, ScrollArea,
Card, Card,
TextField, TextField,
Callout,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
import { import {
useGetTracksQuery, useGetTracksQuery,
@@ -18,13 +19,33 @@ import { TrackRow } from '../../components/track/TrackRow';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState'; import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState'; import { ErrorState } from '../../components/common/ErrorState';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useIsOffline } from '../../hooks/useConnectionStatus';
import {
selectLocalTracks,
selectLocalAlbums,
selectLocalArtists,
} from '../../store/selectors/localLibrary';
import { setQueue } from '../../store/slices/queue'; import { setQueue } from '../../store/slices/queue';
import type { Track, Album, Artist } from '../../api/types'; import type { Track, Album, Artist } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
/** Case-insensitive substring match used for client-side search while offline
* (the server can't run the query, so we filter the locally-cached library). */
function matchTrack(tr: Track, q: string): boolean {
return (
tr.title.toLowerCase().includes(q) ||
tr.artistName.toLowerCase().includes(q) ||
tr.albumTitle.toLowerCase().includes(q)
);
}
const matchAlbum = (a: Album, q: string): boolean =>
a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q);
const matchArtist = (a: Artist, q: string): boolean =>
a.name.toLowerCase().includes(q);
export function LibraryPage() { export function LibraryPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,6 +64,26 @@ export function LibraryPage() {
debouncedSearch ? { search } : undefined, debouncedSearch ? { search } : undefined,
); );
// Offline fallback: when the backend is unreachable, compose the library from
// whatever the RTKQ cache holds locally (rehydrated last-seen + this session),
// filtering client-side since the server can't run the search.
const offline = useIsOffline();
const localTracks = useAppSelector(selectLocalTracks);
const localAlbums = useAppSelector(selectLocalAlbums);
const localArtists = useAppSelector(selectLocalArtists);
const q = debouncedSearch.trim().toLowerCase();
// Live server data wins; offline we fall back to the locally-composed list.
const tracksToShow =
tracksQuery.data?.items ??
(offline ? (q ? localTracks.filter((tr) => matchTrack(tr, q)) : localTracks) : undefined);
const albumsToShow =
albumsQuery.data?.items ??
(offline ? (q ? localAlbums.filter((a) => matchAlbum(a, q)) : localAlbums) : undefined);
const artistsToShow =
artistsQuery.data?.items ??
(offline ? (q ? localArtists.filter((a) => matchArtist(a, q)) : localArtists) : undefined);
const handlePlayAll = (tracks: Track[]) => { const handlePlayAll = (tracks: Track[]) => {
dispatch( dispatch(
setQueue({ setQueue({
@@ -84,6 +125,12 @@ export function LibraryPage() {
</div> </div>
</div> </div>
{offline && (
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
<Callout variant="info">{t('library.offline.banner')}</Callout>
</div>
)}
<Tabs <Tabs
value={tab} value={tab}
onValueChange={setTab} onValueChange={setTab}
@@ -112,22 +159,28 @@ export function LibraryPage() {
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />} {!tracksToShow && tracksQuery.isLoading && (
{tracksQuery.isError && ( <LoadingSkeleton rows={12} />
)}
{!tracksToShow && !offline && tracksQuery.isError && (
<ErrorState onRetry={() => tracksQuery.refetch()} /> <ErrorState onRetry={() => tracksQuery.refetch()} />
)} )}
{tracksQuery.data && tracksQuery.data.items.length === 0 && ( {tracksToShow && tracksToShow.length === 0 && (
<EmptyState <EmptyState
icon="♫" icon="♫"
title={t('library.empty.tracks.title')} title={t(
description={t('library.empty.tracks.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.tracks.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.tracks.description',
)}
/> />
)} )}
{tracksQuery.data && {tracksToShow && tracksToShow.length > 0 && (
tracksQuery.data.items.length > 0 &&
(() => {
const data = tracksQuery.data!;
return (
<div> <div>
<div <div
style={{ style={{
@@ -139,7 +192,7 @@ export function LibraryPage() {
}} }}
> >
<button <button
onClick={() => handlePlayAll(data.items)} onClick={() => handlePlayAll(tracksToShow)}
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
@@ -149,37 +202,41 @@ export function LibraryPage() {
fontWeight: 500, fontWeight: 500,
}} }}
> >
{t('library.playAll', { count: data.total })} {t('library.playAll', { count: tracksToShow.length })}
</button> </button>
</div> </div>
{data.items.map((track, i) => ( {tracksToShow.map((track, i) => (
<TrackRow <TrackRow key={track.id} track={track} index={i} showAlbum />
key={track.id}
track={track}
index={i}
showAlbum
/>
))} ))}
</div> </div>
); )}
})()}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />} {!albumsToShow && albumsQuery.isLoading && (
{albumsQuery.isError && ( <LoadingSkeleton rows={8} height={72} />
)}
{!albumsToShow && !offline && albumsQuery.isError && (
<ErrorState onRetry={() => albumsQuery.refetch()} /> <ErrorState onRetry={() => albumsQuery.refetch()} />
)} )}
{albumsQuery.data && albumsQuery.data.items.length === 0 && ( {albumsToShow && albumsToShow.length === 0 && (
<EmptyState <EmptyState
icon="💿" icon="💿"
title={t('library.empty.albums.title')} title={t(
description={t('library.empty.albums.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.albums.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.albums.description',
)}
/> />
)} )}
{albumsQuery.data && ( {albumsToShow && albumsToShow.length > 0 && (
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -188,7 +245,7 @@ export function LibraryPage() {
padding: '1.25rem 1.5rem', padding: '1.25rem 1.5rem',
}} }}
> >
{albumsQuery.data.items.map((album) => ( {albumsToShow.map((album) => (
<AlbumCard <AlbumCard
key={album.id} key={album.id}
album={album} album={album}
@@ -202,20 +259,30 @@ export function LibraryPage() {
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}> <TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
<ScrollArea style={{ height: '100%' }}> <ScrollArea style={{ height: '100%' }}>
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />} {!artistsToShow && artistsQuery.isLoading && (
{artistsQuery.isError && ( <LoadingSkeleton rows={8} />
)}
{!artistsToShow && !offline && artistsQuery.isError && (
<ErrorState onRetry={() => artistsQuery.refetch()} /> <ErrorState onRetry={() => artistsQuery.refetch()} />
)} )}
{artistsQuery.data && artistsQuery.data.items.length === 0 && ( {artistsToShow && artistsToShow.length === 0 && (
<EmptyState <EmptyState
icon="🎤" icon="🎤"
title={t('library.empty.artists.title')} title={t(
description={t('library.empty.artists.description')} offline
? 'library.offline.emptyTitle'
: 'library.empty.artists.title',
)}
description={t(
offline
? 'library.offline.emptyDesc'
: 'library.empty.artists.description',
)}
/> />
)} )}
{artistsQuery.data && ( {artistsToShow && artistsToShow.length > 0 && (
<div style={{ padding: '0.5rem 0' }}> <div style={{ padding: '0.5rem 0' }}>
{artistsQuery.data.items.map((artist) => ( {artistsToShow.map((artist) => (
<ArtistRow <ArtistRow
key={artist.id} key={artist.id}
artist={artist} artist={artist}
+7
View File
@@ -97,6 +97,13 @@ const en = {
artistRow: { artistRow: {
meta: '{{albumCount}} albums · {{trackCount}} tracks', meta: '{{albumCount}} albums · {{trackCount}} tracks',
}, },
offline: {
banner:
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
emptyTitle: 'Nothing available offline',
emptyDesc:
'No library data is cached on this device yet. Connect to the server once to browse offline.',
},
}, },
album: { album: {
type: 'Album', type: 'Album',
+7
View File
@@ -99,6 +99,13 @@ const ru: Translations = {
artistRow: { artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков', meta: '{{albumCount}} альб. · {{trackCount}} треков',
}, },
offline: {
banner:
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
emptyTitle: 'Офлайн ничего нет',
emptyDesc:
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
},
}, },
album: { album: {
type: 'Альбом', type: 'Альбом',
+97
View File
@@ -0,0 +1,97 @@
/*
* Offline library composition. When the active backend is unreachable, a single
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
* can't rely on it to render the library. Instead we compose the "locally
* available" library from *every* fulfilled entry in the RTK Query cache —
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
* this session. This is read-only derived data, not a server-data slice copy:
* it reads straight from the RTKQ cache the architecture already owns.
*/
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../index';
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
interface CacheEntry {
status: string;
endpointName?: string;
data?: unknown;
}
const selectQueries = (state: RootState): Record<string, unknown> =>
state.api.queries;
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
const out: CacheEntry[] = [];
for (const entry of Object.values(queries)) {
const e = entry as CacheEntry | undefined;
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
}
return out;
}
/** Every track known locally, deduped by id (last write wins). */
export const selectLocalTracks = createSelector(
selectQueries,
(queries): Track[] => {
const byId = new Map<string, Track>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getTracks':
for (const t of (e.data as PaginatedResponse<Track>).items)
byId.set(t.id, t);
break;
case 'getAlbumTracks':
case 'getArtistTracks':
for (const t of e.data as Track[]) byId.set(t.id, t);
break;
case 'getTrack':
byId.set((e.data as Track).id, e.data as Track);
break;
}
}
return [...byId.values()];
},
);
/** Every album known locally, deduped by id. */
export const selectLocalAlbums = createSelector(
selectQueries,
(queries): Album[] => {
const byId = new Map<string, Album>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getAlbums':
for (const a of (e.data as PaginatedResponse<Album>).items)
byId.set(a.id, a);
break;
case 'getArtistAlbums':
for (const a of e.data as Album[]) byId.set(a.id, a);
break;
case 'getAlbum':
byId.set((e.data as Album).id, e.data as Album);
break;
}
}
return [...byId.values()];
},
);
/** Every artist known locally, deduped by id. */
export const selectLocalArtists = createSelector(
selectQueries,
(queries): Artist[] => {
const byId = new Map<string, Artist>();
for (const e of fulfilled(queries)) {
switch (e.endpointName) {
case 'getArtists':
for (const a of (e.data as PaginatedResponse<Artist>).items)
byId.set(a.id, a);
break;
case 'getArtist':
byId.set((e.data as Artist).id, e.data as Artist);
break;
}
}
return [...byId.values()];
},
);
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from '@rstest/core';
import {
selectLocalTracks,
selectLocalAlbums,
selectLocalArtists,
} from '../src/store/selectors/localLibrary';
import type { RootState } from '../src/store/index';
function stateWith(queries: Record<string, unknown>): RootState {
return { api: { queries } } as unknown as RootState;
}
const track = (id: string, over: Record<string, unknown> = {}) => ({
id,
title: `Track ${id}`,
artistName: 'A',
albumTitle: 'Alb',
...over,
});
test('selectLocalTracks unions getTracks pages, list endpoints and single tracks', () => {
const state = stateWith({
'getTracks(undefined)': {
status: 'fulfilled',
endpointName: 'getTracks',
data: { items: [track('1'), track('2')], total: 2 },
},
'getArtistTracks("x")': {
status: 'fulfilled',
endpointName: 'getArtistTracks',
data: [track('2'), track('3')], // 2 is a dupe
},
'getTrack("4")': {
status: 'fulfilled',
endpointName: 'getTrack',
data: track('4'),
},
});
const ids = selectLocalTracks(state)
.map((t) => t.id)
.sort();
expect(ids).toEqual(['1', '2', '3', '4']);
});
test('selectLocalTracks ignores pending/rejected and null-data entries', () => {
const state = stateWith({
'getTracks(a)': {
status: 'rejected',
endpointName: 'getTracks',
data: undefined,
},
'getTracks(b)': { status: 'pending', endpointName: 'getTracks' },
'getTracks(c)': {
status: 'fulfilled',
endpointName: 'getTracks',
data: { items: [track('9')], total: 1 },
},
});
expect(selectLocalTracks(state).map((t) => t.id)).toEqual(['9']);
});
test('selectLocalAlbums and selectLocalArtists compose and dedupe', () => {
const state = stateWith({
'getAlbums(undefined)': {
status: 'fulfilled',
endpointName: 'getAlbums',
data: { items: [{ id: 'al1' }, { id: 'al2' }], total: 2 },
},
'getArtistAlbums("x")': {
status: 'fulfilled',
endpointName: 'getArtistAlbums',
data: [{ id: 'al2' }], // dupe
},
'getArtists(undefined)': {
status: 'fulfilled',
endpointName: 'getArtists',
data: { items: [{ id: 'ar1' }], total: 1 },
},
});
expect(
selectLocalAlbums(state)
.map((a) => a.id)
.sort(),
).toEqual(['al1', 'al2']);
expect(selectLocalArtists(state).map((a) => a.id)).toEqual(['ar1']);
});