mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums Make Artists, All Albums, and artist/album detail pages work offline from the library index limited to on-disk library and favorite-auto tracks. Add a DEV header toggle to simulate offline browse for testing. * feat(offline): reactive DEV offline toggle with full disconnect simulation Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI refreshes on toggle. DEV force-offline now blocks server probes, reports disconnected status, and gates Subsonic like real offline for player parity. * feat(offline): bytes-first favorites when offline browse is active Load Favorites from local playback bytes, filter starred tracks client-side, and restrict album-level star queries to local album ids. Drop interim perf attempts (lean SQL, progressive load, connection singleton, prefetch UX). * feat(offline): tracks, help, player stats; suspend library picker offline - Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics - Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches - Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected) - Unified isOfflineSidebarNavAllowed for library + system entries * feat(offline): fork disconnect navigation by offline browse capability When the server drops: stay on the page if nothing is browsable offline; reload in place on offline-capable routes; otherwise redirect to All Albums instead of the old /offline or /favorites bounce. * feat(offline): browse cached playlists when the server is down List and open manually pinned regular playlists from local library-tier bytes offline, with sidebar/nav routing and read-only playlist UI. * feat(offline): read-only artist detail and local play-all paths Hide favorites and discography offline actions when browse is offline; load Play All, Shuffle, and top-track continuation from local album bytes. * feat(offline): read-only album detail and enqueue from local bytes Hide favorites, download, and cache-offline actions on album pages when offline browse is active. Favorites album cards enqueue via the same resolveAlbumForServer path as play, including local playback bytes. * chore: remove unused import in AlbumCard after enqueue refactor * feat(offline): unify browse integration contract across the app Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy; wire shell nav to a single capability source; migrate play/enqueue and context-menu paths off raw getAlbum; replace readOnly with action policy on detail surfaces. Tests updated for the media-resolve facade. * feat(offline): close browse contract gaps and fix offline Home feed Split offline browse modules, align favorites capability across servers, wire action policy on context menus, migrate hooks to useOfflineBrowseContext, and preserve stale Home feed cache when offline so the UI does not empty. * fix(offline): block playbar stars, close audit gaps, trim dead exports Hide star rating and favorite in PlayerBar when offline browse is active via offlineActionPolicy playerBar surface. Wire stay-reload token into browse hooks, migrate hooks to context.active, guard rating prefetch network calls, and route playlist load through resolvePlaylist. * docs: add CHANGELOG and credits for offline browse PR #1017 * fix(offline): stop DEV connection probe regression in tests React to devForceOffline transitions only in useConnectionStatus so mount does not double-fire check() or ignore disableBackgroundPolling. Add pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import * as offlineMediaResolve from '../offline/offlineMediaResolve';
|
||||
import { fetchArtistDetailTracks } from './runArtistDetailPlay';
|
||||
|
||||
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||
resolveAlbum: vi.fn(),
|
||||
resolveMediaServerId: vi.fn((id?: string | null) => id ?? 'srv-1'),
|
||||
}));
|
||||
|
||||
const resolveAlbumMock = vi.mocked(offlineMediaResolve.resolveAlbum);
|
||||
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: 'al-2', name: 'B', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2001 },
|
||||
{ id: 'al-1', name: 'A', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2000 },
|
||||
];
|
||||
|
||||
describe('fetchArtistDetailTracks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads albums via resolveAlbum when serverId is set', async () => {
|
||||
resolveAlbumMock
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 2 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[0],
|
||||
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
|
||||
});
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, 'srv-1');
|
||||
expect(tracks.map(t => t.id)).toEqual(['t1', 't2']);
|
||||
expect(resolveAlbumMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns empty when no server scope', async () => {
|
||||
vi.mocked(offlineMediaResolve.resolveMediaServerId).mockReturnValueOnce(null);
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, null);
|
||||
expect(tracks).toEqual([]);
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,53 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '../offline/offlineMediaResolve';
|
||||
|
||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
export async function fetchArtistDetailTracks(
|
||||
albums: SubsonicAlbum[],
|
||||
serverId?: string | null,
|
||||
): Promise<Track[]> {
|
||||
const sid = resolveMediaServerId(serverId ?? albums[0]?.serverId);
|
||||
if (!sid) return [];
|
||||
|
||||
const loaded = await Promise.all(albums.map(a => resolveAlbum(sid, a.id)));
|
||||
const sorted = loaded
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r =>
|
||||
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkPlayAll({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
await runBulkShuffle({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { isOfflineBrowseActive } from '../offline/offlineBrowseMode';
|
||||
import { loadOfflineAlbumCatalogChunk } from '../offline/offlineAlbumBrowseCatalog';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { fetchLocalAlbumCatalogChunk } from './albumBrowseLoad';
|
||||
|
||||
export type AlbumCatalogChunk = {
|
||||
albums: SubsonicAlbum[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export function mergeAlbumCatalogChunk(
|
||||
prev: SubsonicAlbum[],
|
||||
chunk: AlbumCatalogChunk,
|
||||
append: boolean,
|
||||
): { albums: SubsonicAlbum[]; offset: number } {
|
||||
if (!append) {
|
||||
return { albums: chunk.albums, offset: chunk.albums.length };
|
||||
}
|
||||
const merged = dedupeById([...prev, ...chunk.albums]);
|
||||
return { albums: merged, offset: merged.length };
|
||||
}
|
||||
|
||||
/** Local-index or offline-bytes catalog chunk for the albums grid. */
|
||||
export async function fetchAlbumBrowseCatalogChunk(
|
||||
serverId: string,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<AlbumCatalogChunk | null> {
|
||||
if (isOfflineBrowseActive()) {
|
||||
return loadOfflineAlbumCatalogChunk(
|
||||
serverId,
|
||||
query,
|
||||
offset,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
}
|
||||
return fetchLocalAlbumCatalogChunk(serverId, query, offset, chunkSize);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTopSongs } from '../../api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import { filterSongsToActiveLibrary, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
@@ -98,7 +99,7 @@ export async function pickSongsForAlbum(
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const full = await getAlbum(albumId).catch(() => null);
|
||||
const full = await resolveAlbumForActiveServer(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
const unique = uniqueBySongId(scopedSongs);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||
import {
|
||||
getActiveServerReachable,
|
||||
isActiveServerReachable,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
|
||||
describe('activeServerReachability', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
setActiveServerReachable(null);
|
||||
});
|
||||
|
||||
@@ -24,6 +26,13 @@ describe('activeServerReachability', () => {
|
||||
expect(getActiveServerReachable()).toBe(true);
|
||||
});
|
||||
|
||||
it('isActiveServerReachable is false when DEV force-offline is enabled', () => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
setActiveServerReachable(true);
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: true });
|
||||
expect(isActiveServerReachable()).toBe(false);
|
||||
});
|
||||
|
||||
it('onActiveServerBecameReachable fires only on false/null → true', () => {
|
||||
const listener = vi.fn();
|
||||
onActiveServerBecameReachable(listener);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||
|
||||
/**
|
||||
* Active-server reachability snapshot maintained by `useConnectionStatus`.
|
||||
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
|
||||
@@ -27,6 +29,7 @@ export function getActiveServerReachable(): boolean | null {
|
||||
|
||||
/** True only when the browser is online and the last active-server probe succeeded. */
|
||||
export function isActiveServerReachable(): boolean {
|
||||
if (isDevOfflineBrowseForced()) return false;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||
return activeServerReachable === true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl';
|
||||
import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore';
|
||||
import { isActiveServerReachable } from './activeServerReachability';
|
||||
|
||||
/**
|
||||
@@ -9,6 +10,7 @@ import { isActiveServerReachable } from './activeServerReachability';
|
||||
*/
|
||||
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
|
||||
if (!serverId) return false;
|
||||
if (isDevOfflineBrowseForced()) return false;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
|
||||
if (trackId) {
|
||||
const url = resolvePlaybackUrl(trackId, serverId);
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
favoritesOfflineBrowseEnabled,
|
||||
hasOfflineBrowsingContent,
|
||||
} from './favoritesOfflineBrowse';
|
||||
import {
|
||||
isOfflineSidebarLibraryNavAllowed,
|
||||
isOfflineSidebarNavAllowed,
|
||||
isOfflineSidebarSystemNavAllowed,
|
||||
} from './offlineNavPolicy';
|
||||
import {
|
||||
loadStarredFromLibraryIndex,
|
||||
mergeStarredFromServers,
|
||||
resolveAlbumForServer,
|
||||
} from './favoritesOfflineBrowse';
|
||||
} from './offlineStarredLoad';
|
||||
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||
|
||||
const isActiveServerReachableMock = vi.fn(() => true);
|
||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||
@@ -23,6 +30,7 @@ vi.mock('../network/subsonicNetworkGuard', () => ({
|
||||
const getAlbumForServerMock = vi.fn();
|
||||
const libraryAdvancedSearchMock = vi.fn();
|
||||
const libraryGetTracksByAlbumMock = vi.fn();
|
||||
const libraryGetTracksBatchChunkedMock = vi.fn();
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
|
||||
@@ -31,6 +39,7 @@ vi.mock('../../api/subsonicLibrary', () => ({
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
||||
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
||||
libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args),
|
||||
}));
|
||||
|
||||
describe('favoritesOfflineBrowse', () => {
|
||||
@@ -40,6 +49,8 @@ describe('favoritesOfflineBrowse', () => {
|
||||
getAlbumForServerMock.mockReset();
|
||||
libraryGetTracksByAlbumMock.mockReset();
|
||||
libraryAdvancedSearchMock.mockReset();
|
||||
libraryGetTracksBatchChunkedMock.mockReset();
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: false,
|
||||
activeServerId: 'srv-1',
|
||||
@@ -87,28 +98,121 @@ describe('favoritesOfflineBrowse', () => {
|
||||
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
|
||||
});
|
||||
|
||||
it('isOfflineSidebarLibraryNavAllowed keeps only favorites when offline', () => {
|
||||
it('isOfflineSidebarLibraryNavAllowed gates offline sidebar entries', () => {
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('albums', true)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('artists', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('tracks', false, false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, false)).toBe(false);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('offline', false, false)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex omits artist entity (no artist.starred_at in index)', async () => {
|
||||
it('isOfflineSidebarSystemNavAllowed keeps help and player stats offline', () => {
|
||||
expect(isOfflineSidebarSystemNavAllowed('help', false)).toBe(true);
|
||||
expect(isOfflineSidebarSystemNavAllowed('statistics', true)).toBe(true);
|
||||
expect(isOfflineSidebarSystemNavAllowed('statistics', false)).toBe(false);
|
||||
expect(isOfflineSidebarNavAllowed('help', false, false, false)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('statistics', false, false, true)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('tracks', false, true, false)).toBe(true);
|
||||
expect(isOfflineSidebarNavAllowed('playlists', false, false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex uses starred advanced search when not offline-bytes', async () => {
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }],
|
||||
artists: [{ id: 'art-99', name: 'Not A Favorite', serverId: 'srv-1' }],
|
||||
tracks: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', durationSec: 1, serverId: 'srv-1' }],
|
||||
artists: [],
|
||||
});
|
||||
|
||||
const starred = await loadStarredFromLibraryIndex('srv-1');
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
}));
|
||||
expect(libraryGetTracksBatchChunkedMock).not.toHaveBeenCalled();
|
||||
expect(starred.artists).toEqual([]);
|
||||
expect(starred.songs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loadStarredFromLibraryIndex prefers local bytes then starred filter when offline', async () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
'a.test:t2': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/a.test/a/al/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Starred',
|
||||
artist: 'X',
|
||||
album: 'A',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 1,
|
||||
starredAt: 1,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Not starred',
|
||||
artist: 'X',
|
||||
album: 'A',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 1,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
]);
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{
|
||||
id: 'alb-2',
|
||||
name: 'Album star only',
|
||||
artist: 'Y',
|
||||
artistId: 'art-2',
|
||||
starredAt: 1,
|
||||
serverId: 'srv-1',
|
||||
}],
|
||||
artists: [],
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
const starred = await loadStarredFromLibraryIndex('srv-1', true);
|
||||
|
||||
expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalled();
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalled();
|
||||
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
entityTypes: ['album'],
|
||||
starredOnly: true,
|
||||
restrictAlbumIds: ['alb-1'],
|
||||
}));
|
||||
expect(starred.songs).toHaveLength(1);
|
||||
expect(starred.songs[0]?.id).toBe('t1');
|
||||
expect(starred.albums.map(a => a.id).sort()).toEqual(['alb-1', 'alb-2']);
|
||||
});
|
||||
|
||||
it('resolveAlbumForServer uses library index when network fails', async () => {
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
import { getStarredForServer } from '../../api/subsonicStarRating';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type {
|
||||
StarredResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
||||
|
||||
/** Saved servers with a local library index (cross-server favorites scope). */
|
||||
@@ -34,14 +17,6 @@ export function favoritesOfflineBrowseEnabled(): boolean {
|
||||
return favoritesServerIds().length > 0;
|
||||
}
|
||||
|
||||
export function isOfflineSidebarLibraryNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
): boolean {
|
||||
if (navId === 'favorites') return favoritesOfflineBrowse;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */
|
||||
export function hasOfflineBrowsingContent(
|
||||
offlineAlbums: Record<string, OfflineAlbumMeta>,
|
||||
@@ -50,203 +25,3 @@ export function hasOfflineBrowsingContent(
|
||||
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults {
|
||||
const withServer = <T extends { id: string }>(items: T[]): (T & { serverId: string })[] =>
|
||||
items.map(item => ({ ...item, serverId }));
|
||||
|
||||
return {
|
||||
artists: withServer(starred.artists),
|
||||
albums: withServer(starred.albums),
|
||||
songs: withServer(starred.songs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */
|
||||
export function mergeStarredFromServers(
|
||||
entries: { serverId: string; starred: StarredResults }[],
|
||||
): StarredResults {
|
||||
const artists: SubsonicArtist[] = [];
|
||||
const albums: SubsonicAlbum[] = [];
|
||||
const songs: SubsonicSong[] = [];
|
||||
for (const { serverId, starred } of entries) {
|
||||
const tagged = tagStarredWithServer(starred, serverId);
|
||||
artists.push(...tagged.artists);
|
||||
albums.push(...tagged.albums);
|
||||
songs.push(...tagged.songs);
|
||||
}
|
||||
return {
|
||||
artists: dedupeById(artists),
|
||||
albums: dedupeById(albums),
|
||||
songs: dedupeById(songs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromLibraryIndex(serverId: string): Promise<StarredResults> {
|
||||
// Artist-level favorites are network-only today (`artist` has no `starred_at`;
|
||||
// `starredOnly` on artists would return the whole artist table). Songs/albums
|
||||
// use track/album stars in the index.
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
limit: 10_000,
|
||||
});
|
||||
return {
|
||||
artists: [],
|
||||
albums: response.albums.map(albumToAlbum),
|
||||
songs: response.tracks.map(trackToSong),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromAllLibraryIndexes(): Promise<StarredResults> {
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/** Online starred merge with per-server local index fallback. */
|
||||
export async function loadStarredFromAllServersOnline(): Promise<StarredResults> {
|
||||
if (!isActiveServerReachable()) {
|
||||
return loadStarredFromAllLibraryIndexes();
|
||||
}
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await getStarredForServer(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Album detail / play / offline pin: use the network album when reachable so the
|
||||
* track list is complete. The library index may only contain a subset (e.g.
|
||||
* starred tracks or a partial sync) — never prefer that over `getAlbum` online.
|
||||
* When the server is unreachable, fall back to the index when favorites-offline
|
||||
* browsing is enabled.
|
||||
*/
|
||||
export async function resolveAlbumForServer(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
const data = await getAlbumForServer(serverId, albumId);
|
||||
return { album: data.album, songs: data.songs };
|
||||
} catch {
|
||||
/* fall through to library index */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadAlbumFromLibraryIndex(serverId, albumId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAlbumFromLibraryIndex(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const songs = tracks.map(trackToSong);
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
});
|
||||
const albumDto = albumSearch.albums[0];
|
||||
if (albumDto) {
|
||||
const album = albumToAlbum(albumDto);
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
serverId,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
const first = tracks[0];
|
||||
return {
|
||||
album: {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
coverArt: first.coverArtId ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined,
|
||||
serverId,
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLibraryIndex(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'artist'],
|
||||
limit: 10_000,
|
||||
});
|
||||
const albums = response.albums
|
||||
.filter(a => a.artistId === artistId)
|
||||
.map(albumToAlbum)
|
||||
.map(a => ({ ...a, serverId }));
|
||||
const artistDto = response.artists.find(a => a.id === artistId);
|
||||
if (!artistDto && albums.length === 0) return null;
|
||||
|
||||
const artist = artistDto
|
||||
? { ...artistToArtist(artistDto), serverId }
|
||||
: {
|
||||
id: artistId,
|
||||
name: albums[0]?.artist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return {
|
||||
artist: {
|
||||
...artist,
|
||||
albumCount: albums.length,
|
||||
},
|
||||
albums,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import { getMediaDir } from '../media/mediaDir';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
|
||||
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import { favoritesServerIds, loadAlbumFromLibraryIndex } from './favoritesOfflineBrowse';
|
||||
import { favoritesServerIds } from './favoritesOfflineBrowse';
|
||||
import { loadAlbumFromLibraryIndex } from './offlineLibraryIndexLoad';
|
||||
import {
|
||||
entryBelongsToServer,
|
||||
hasLocalLibraryBytes,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { offlineActionPolicy } from './offlineActionPolicy';
|
||||
|
||||
describe('offlineActionPolicy', () => {
|
||||
it('allows all mutations when offline browse is inactive', () => {
|
||||
const p = offlineActionPolicy('albumDetail', false);
|
||||
expect(p.canFavorite).toBe(true);
|
||||
expect(p.canDownload).toBe(true);
|
||||
expect(p.canPinOffline).toBe(true);
|
||||
expect(p.canAddToPlaylist).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks server mutations when offline browse is active', () => {
|
||||
const p = offlineActionPolicy('albumDetail', true);
|
||||
expect(p.canFavorite).toBe(false);
|
||||
expect(p.canRate).toBe(false);
|
||||
expect(p.canDownload).toBe(false);
|
||||
expect(p.canPinOffline).toBe(false);
|
||||
expect(p.canAddToPlaylist).toBe(false);
|
||||
expect(p.canShowBio).toBe(false);
|
||||
});
|
||||
|
||||
it('applies same read-only policy to context menu surfaces', () => {
|
||||
expect(offlineActionPolicy('contextMenuAlbum', true).canFavorite).toBe(false);
|
||||
expect(offlineActionPolicy('contextMenuSong', true).canAddToPlaylist).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks rating and favorite in player bar when offline browse is active', () => {
|
||||
const p = offlineActionPolicy('playerBar', true);
|
||||
expect(p.canRate).toBe(false);
|
||||
expect(p.canFavorite).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
export type OfflineSurface =
|
||||
| 'albumDetail'
|
||||
| 'artistDetail'
|
||||
| 'albumCard'
|
||||
| 'trackRow'
|
||||
| 'playlistDetail'
|
||||
| 'playlistsHeader'
|
||||
| 'contextMenuAlbum'
|
||||
| 'contextMenuSong'
|
||||
| 'contextMenuArtist'
|
||||
| 'contextMenuPlaylist'
|
||||
| 'hero'
|
||||
| 'statistics'
|
||||
| 'playerBar';
|
||||
|
||||
export type OfflineActionPolicy = {
|
||||
canFavorite: boolean;
|
||||
canRate: boolean;
|
||||
canDownload: boolean;
|
||||
canPinOffline: boolean;
|
||||
canCacheDiscography: boolean;
|
||||
canAddToPlaylist: boolean;
|
||||
canEditPlaylist: boolean;
|
||||
canShowBio: boolean;
|
||||
canScrobble: boolean;
|
||||
};
|
||||
|
||||
const ALLOW_ALL: OfflineActionPolicy = {
|
||||
canFavorite: true,
|
||||
canRate: true,
|
||||
canDownload: true,
|
||||
canPinOffline: true,
|
||||
canCacheDiscography: true,
|
||||
canAddToPlaylist: true,
|
||||
canEditPlaylist: true,
|
||||
canShowBio: true,
|
||||
canScrobble: true,
|
||||
};
|
||||
|
||||
const READ_ONLY_MUTATIONS: OfflineActionPolicy = {
|
||||
canFavorite: false,
|
||||
canRate: false,
|
||||
canDownload: false,
|
||||
canPinOffline: false,
|
||||
canCacheDiscography: false,
|
||||
canAddToPlaylist: false,
|
||||
canEditPlaylist: false,
|
||||
canShowBio: false,
|
||||
canScrobble: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* What server-mutating actions are allowed on a UI surface while offline browse is active.
|
||||
* `surface` is reserved for per-surface divergence; today all surfaces share read-only policy.
|
||||
*/
|
||||
export function offlineActionPolicy(_surface: OfflineSurface, active: boolean): OfflineActionPolicy {
|
||||
if (!active) return ALLOW_ALL;
|
||||
return READ_ONLY_MUTATIONS;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import type { AlbumBrowseQuery } from '../library/albumBrowseTypes';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import {
|
||||
fetchOfflineLocalAlbumCatalogChunk,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
type OfflineAlbumCatalogChunk = {
|
||||
albums: SubsonicAlbum[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
/** Offline album grid catalog chunk; null when offline browse or local bytes are unavailable. */
|
||||
export async function loadOfflineAlbumCatalogChunk(
|
||||
serverId: string,
|
||||
browseQuery: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<OfflineAlbumCatalogChunk | null> {
|
||||
if (!isOfflineBrowseActive() || !offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const chunk = await fetchOfflineLocalAlbumCatalogChunk(
|
||||
serverId,
|
||||
browseQuery,
|
||||
offset,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
if (chunk == null) return null;
|
||||
return { albums: chunk.albums, hasMore: chunk.hasMore };
|
||||
}
|
||||
|
||||
/** Initial offline album browse load for the albums grid. */
|
||||
export async function loadOfflineAlbumBrowseInitial(
|
||||
serverId: string,
|
||||
browseQuery: AlbumBrowseQuery,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): Promise<OfflineAlbumCatalogChunk> {
|
||||
const first = await loadOfflineAlbumCatalogChunk(
|
||||
serverId,
|
||||
browseQuery,
|
||||
0,
|
||||
chunkSize,
|
||||
starredOverrides,
|
||||
);
|
||||
return first ?? { albums: [], hasMore: false };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
buildOfflineBrowseContext,
|
||||
computeOfflineBrowseCapabilities,
|
||||
offlineBrowseNavFlags,
|
||||
} from './offlineBrowseContext';
|
||||
|
||||
vi.mock('./offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: vi.fn(() => false),
|
||||
countLocalBrowsableTracks: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('./offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { offlineLocalBrowseEnabled } from './offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse';
|
||||
|
||||
describe('offlineBrowseContext', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: false,
|
||||
activeServerId: 'srv-1',
|
||||
} as Partial<ReturnType<typeof useAuthStore.getState>>);
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(false);
|
||||
vi.mocked(playlistsOfflineBrowseEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('computeOfflineBrowseCapabilities returns all false when nothing enabled', () => {
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: 'srv-1',
|
||||
favoritesOfflineEnabled: false,
|
||||
offlineAlbums: {},
|
||||
playerStats: false,
|
||||
});
|
||||
expect(caps).toEqual({
|
||||
localLibrary: false,
|
||||
favorites: false,
|
||||
playlists: false,
|
||||
manualPins: false,
|
||||
playerStats: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('favorites capability uses cross-server index when setting is on', () => {
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: true,
|
||||
servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }],
|
||||
activeServerId: null,
|
||||
});
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: null,
|
||||
favoritesOfflineEnabled: true,
|
||||
offlineAlbums: {},
|
||||
playerStats: false,
|
||||
});
|
||||
expect(caps.favorites).toBe(true);
|
||||
});
|
||||
|
||||
it('buildOfflineBrowseContext sets hasBrowseCapability from capabilities', () => {
|
||||
vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(true);
|
||||
const caps = computeOfflineBrowseCapabilities({
|
||||
activeServerId: 'srv-1',
|
||||
favoritesOfflineEnabled: false,
|
||||
offlineAlbums: {},
|
||||
playerStats: true,
|
||||
});
|
||||
const ctx = buildOfflineBrowseContext({
|
||||
active: true,
|
||||
serverId: 'srv-1',
|
||||
capabilities: caps,
|
||||
connStatus: 'disconnected',
|
||||
hasBrowsingContent: true,
|
||||
});
|
||||
expect(ctx.hasBrowseCapability).toBe(true);
|
||||
expect(ctx.capabilities.playerStats).toBe(true);
|
||||
});
|
||||
|
||||
it('offlineBrowseNavFlags maps capability fields for sidebar', () => {
|
||||
const flags = offlineBrowseNavFlags({
|
||||
localLibrary: true,
|
||||
favorites: false,
|
||||
playlists: true,
|
||||
manualPins: true,
|
||||
playerStats: false,
|
||||
});
|
||||
expect(flags).toEqual({
|
||||
favoritesOfflineBrowse: false,
|
||||
localLibraryBrowse: true,
|
||||
playlistsOfflineBrowse: true,
|
||||
playerStatsBrowse: false,
|
||||
hasManualOfflineContent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import type { OfflineAlbumMeta } from '../../store/offlineStore';
|
||||
import { favoritesOfflineBrowseEnabled } from './favoritesOfflineBrowse';
|
||||
import { hasOfflineBrowseCapability } from './offlineBrowseRouting';
|
||||
import { offlineLocalBrowseEnabled } from './offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse';
|
||||
import { hasAnyOfflineAlbums } from './offlineLibraryHelpers';
|
||||
|
||||
export type OfflineBrowseCapabilities = {
|
||||
localLibrary: boolean;
|
||||
favorites: boolean;
|
||||
playlists: boolean;
|
||||
manualPins: boolean;
|
||||
playerStats: boolean;
|
||||
};
|
||||
|
||||
export type OfflineBrowseContext = {
|
||||
active: boolean;
|
||||
serverId: string | null;
|
||||
capabilities: OfflineBrowseCapabilities;
|
||||
/** Disconnect fork / banner: local library, favorites, or manual pins. */
|
||||
hasBrowseCapability: boolean;
|
||||
/** Any offline bytes to show (includes favorite-auto without browse). */
|
||||
hasBrowsingContent: boolean;
|
||||
connStatus: ConnectionStatus;
|
||||
};
|
||||
|
||||
type ComputeOfflineBrowseCapabilitiesInput = {
|
||||
activeServerId: string | null;
|
||||
favoritesOfflineEnabled: boolean;
|
||||
offlineAlbums: Record<string, OfflineAlbumMeta>;
|
||||
playerStats: boolean;
|
||||
};
|
||||
|
||||
/** Pure capability snapshot for tests and non-React callers. */
|
||||
export function computeOfflineBrowseCapabilities(
|
||||
input: ComputeOfflineBrowseCapabilitiesInput,
|
||||
): OfflineBrowseCapabilities {
|
||||
const { activeServerId, favoritesOfflineEnabled, offlineAlbums, playerStats } = input;
|
||||
|
||||
return {
|
||||
localLibrary: offlineLocalBrowseEnabled(activeServerId),
|
||||
favorites: favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled),
|
||||
playlists: playlistsOfflineBrowseEnabled(activeServerId),
|
||||
manualPins: hasAnyOfflineAlbums(offlineAlbums),
|
||||
playerStats,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOfflineBrowseContext(input: {
|
||||
active: boolean;
|
||||
serverId: string | null;
|
||||
capabilities: OfflineBrowseCapabilities;
|
||||
connStatus: ConnectionStatus;
|
||||
hasBrowsingContent: boolean;
|
||||
}): OfflineBrowseContext {
|
||||
const { capabilities, hasBrowsingContent, ...rest } = input;
|
||||
return {
|
||||
...rest,
|
||||
capabilities,
|
||||
hasBrowseCapability: hasOfflineBrowseCapability(
|
||||
capabilities.localLibrary,
|
||||
capabilities.favorites,
|
||||
capabilities.manualPins,
|
||||
),
|
||||
hasBrowsingContent,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sidebar / disconnect helpers — maps capability snapshot to nav gate flags. */
|
||||
export function offlineBrowseNavFlags(capabilities: OfflineBrowseCapabilities): {
|
||||
favoritesOfflineBrowse: boolean;
|
||||
localLibraryBrowse: boolean;
|
||||
playlistsOfflineBrowse: boolean;
|
||||
playerStatsBrowse: boolean;
|
||||
hasManualOfflineContent: boolean;
|
||||
} {
|
||||
return {
|
||||
favoritesOfflineBrowse: capabilities.favorites,
|
||||
localLibraryBrowse: capabilities.localLibrary,
|
||||
playlistsOfflineBrowse: capabilities.playlists,
|
||||
playerStatsBrowse: capabilities.playerStats,
|
||||
hasManualOfflineContent: capabilities.manualPins,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cross-server favorites scope (setting + any indexed server). */
|
||||
function favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled: boolean): boolean {
|
||||
if (!favoritesOfflineEnabled) return false;
|
||||
return favoritesOfflineBrowseEnabled();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore';
|
||||
import { useOfflineBrowseActive } from './offlineBrowseMode';
|
||||
|
||||
describe('useOfflineBrowseActive', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
});
|
||||
|
||||
it('enables offline browse when DEV force-offline is set', () => {
|
||||
if (!import.meta.env.DEV) return;
|
||||
|
||||
act(() => {
|
||||
useDevOfflineBrowseStore.getState().setForceOffline(true);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useOfflineBrowseActive());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
isDevOfflineBrowseForced,
|
||||
useDevOfflineBrowseStore,
|
||||
} from '../../store/devOfflineBrowseStore';
|
||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
|
||||
/** True when browse/detail pages should use local-bytes-only data sources. */
|
||||
export function isOfflineBrowseActive(): boolean {
|
||||
if (isDevOfflineBrowseForced()) return true;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive offline-browse flag for React trees. Re-renders when the DEV toggle,
|
||||
* browser online state, or active-server connection status changes.
|
||||
*/
|
||||
export function useOfflineBrowseActive(): boolean {
|
||||
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
|
||||
if (import.meta.env.DEV && devForceOffline) return true;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
if (connStatus === 'disconnected') return true;
|
||||
if (connStatus === 'connected') return false;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
hasOfflineBrowseCapability,
|
||||
isPathOfflineBrowsable,
|
||||
resolveOfflineDisconnectNavAction,
|
||||
} from './offlineBrowseRouting';
|
||||
|
||||
describe('offlineBrowseRouting', () => {
|
||||
it('hasOfflineBrowseCapability is true when any offline surface exists', () => {
|
||||
expect(hasOfflineBrowseCapability(false, false, false)).toBe(false);
|
||||
expect(hasOfflineBrowseCapability(true, false, false)).toBe(true);
|
||||
expect(hasOfflineBrowseCapability(false, true, false)).toBe(true);
|
||||
expect(hasOfflineBrowseCapability(false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('isPathOfflineBrowsable covers library, detail, and local-only routes', () => {
|
||||
const ctx = [true, true, true, true] as const;
|
||||
expect(isPathOfflineBrowsable('/albums', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/album/abc', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/artist/abc', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/playlists', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/playlists/pl-1', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/now-playing', ...ctx)).toBe(true);
|
||||
expect(isPathOfflineBrowsable('/', ...ctx)).toBe(false);
|
||||
expect(isPathOfflineBrowsable('/playlists', true, true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction stays when nothing offline', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, false, false))
|
||||
.toEqual({ kind: 'stay' });
|
||||
expect(resolveOfflineDisconnectNavAction('/albums', false, false, false, false, false))
|
||||
.toEqual({ kind: 'stay' });
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction reloads allowed pages', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/artists', false, true, false, false, false))
|
||||
.toEqual({ kind: 'stay-reload' });
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, true, true))
|
||||
.toEqual({ kind: 'stay-reload' });
|
||||
});
|
||||
|
||||
it('resolveOfflineDisconnectNavAction redirects disallowed pages to all albums', () => {
|
||||
expect(resolveOfflineDisconnectNavAction('/playlists', false, true, false, false, false))
|
||||
.toEqual({ kind: 'redirect', to: '/albums' });
|
||||
expect(resolveOfflineDisconnectNavAction('/', false, true, false, false, false))
|
||||
.toEqual({ kind: 'redirect', to: '/albums' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isOfflineSidebarNavAllowed } from './offlineNavPolicy';
|
||||
|
||||
/** Any offline browse surface the disconnect fork may use. */
|
||||
export function hasOfflineBrowseCapability(
|
||||
localLibraryBrowse: boolean,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
hasManualOfflineContent: boolean,
|
||||
): boolean {
|
||||
return localLibraryBrowse || favoritesOfflineBrowse || hasManualOfflineContent;
|
||||
}
|
||||
|
||||
/** Map a route to a sidebar nav id for offline-allow checks (detail pages included). */
|
||||
function offlineNavIdForPathname(pathname: string): string | null {
|
||||
if (pathname === '/albums') return 'allAlbums';
|
||||
if (pathname === '/artists' || pathname.startsWith('/artist/')) return 'artists';
|
||||
if (pathname === '/playlists' || pathname.startsWith('/playlists/')) return 'playlists';
|
||||
if (pathname === '/tracks') return 'tracks';
|
||||
if (pathname === '/favorites') return 'favorites';
|
||||
if (pathname === '/offline') return 'offline';
|
||||
if (pathname === '/help') return 'help';
|
||||
if (pathname === '/statistics' || pathname === '/player-stats') return 'statistics';
|
||||
if (pathname.startsWith('/album/')) return 'allAlbums';
|
||||
return null;
|
||||
}
|
||||
|
||||
const OFFLINE_ALWAYS_STAY_PATHS = new Set([
|
||||
'/now-playing',
|
||||
'/settings',
|
||||
]);
|
||||
|
||||
export function isPathOfflineBrowsable(
|
||||
pathname: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse: boolean,
|
||||
): boolean {
|
||||
if (OFFLINE_ALWAYS_STAY_PATHS.has(pathname)) return true;
|
||||
const navId = offlineNavIdForPathname(pathname);
|
||||
if (!navId) return false;
|
||||
return isOfflineSidebarNavAllowed(
|
||||
navId,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playerStatsBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
);
|
||||
}
|
||||
|
||||
type OfflineDisconnectNavAction =
|
||||
| { kind: 'stay' }
|
||||
| { kind: 'stay-reload' }
|
||||
| { kind: 'redirect'; to: '/albums' };
|
||||
|
||||
/** Decide what to do when the active server just became unreachable. */
|
||||
export function resolveOfflineDisconnectNavAction(
|
||||
pathname: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse: boolean,
|
||||
hasManualOfflineContent: boolean,
|
||||
): OfflineDisconnectNavAction {
|
||||
if (!hasOfflineBrowseCapability(localLibraryBrowse, favoritesOfflineBrowse, hasManualOfflineContent)) {
|
||||
return { kind: 'stay' };
|
||||
}
|
||||
if (isPathOfflineBrowsable(
|
||||
pathname,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playerStatsBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
)) {
|
||||
return { kind: 'stay-reload' };
|
||||
}
|
||||
return { kind: 'redirect', to: '/albums' };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
resetOfflineLibraryFilterSuspendState,
|
||||
restoreMusicLibraryFiltersAfterOffline,
|
||||
suspendMusicLibraryFiltersForOffline,
|
||||
} from './offlineLibraryFilterSuspend';
|
||||
|
||||
describe('offlineLibraryFilterSuspend', () => {
|
||||
beforeEach(() => {
|
||||
resetOfflineLibraryFilterSuspendState();
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
musicLibraryFilterByServer: { 'srv-a': 'lib-1' },
|
||||
musicLibraryFilterVersion: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('suspend saves scoped filter and resets active server to all', () => {
|
||||
suspendMusicLibraryFiltersForOffline();
|
||||
expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('all');
|
||||
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('restore brings back the saved filter after reconnect', () => {
|
||||
suspendMusicLibraryFiltersForOffline();
|
||||
restoreMusicLibraryFiltersAfterOffline();
|
||||
expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('lib-1');
|
||||
expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
let savedFilterByServer: Record<string, 'all' | string> | null = null;
|
||||
|
||||
/** Remember sidebar library filters and browse all libraries while offline. */
|
||||
export function suspendMusicLibraryFiltersForOffline(): void {
|
||||
if (savedFilterByServer != null) return;
|
||||
const auth = useAuthStore.getState();
|
||||
savedFilterByServer = { ...auth.musicLibraryFilterByServer };
|
||||
const serverId = auth.activeServerId;
|
||||
if (!serverId) return;
|
||||
const current = auth.musicLibraryFilterByServer[serverId] ?? 'all';
|
||||
if (current !== 'all') {
|
||||
auth.setMusicLibraryFilter('all');
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the pre-offline library filter for the active server. */
|
||||
export function restoreMusicLibraryFiltersAfterOffline(): void {
|
||||
if (!savedFilterByServer) return;
|
||||
const snapshot = savedFilterByServer;
|
||||
savedFilterByServer = null;
|
||||
const auth = useAuthStore.getState();
|
||||
const serverId = auth.activeServerId;
|
||||
if (!serverId) return;
|
||||
const saved = snapshot[serverId] ?? 'all';
|
||||
const current = auth.musicLibraryFilterByServer[serverId] ?? 'all';
|
||||
if (saved !== current) {
|
||||
auth.setMusicLibraryFilter(saved);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper — drop suspended snapshot without restoring. */
|
||||
export function resetOfflineLibraryFilterSuspendState(): void {
|
||||
savedFilterByServer = null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
|
||||
export async function loadAlbumFromLibraryIndex(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const songs = tracks.map(trackToSong);
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
});
|
||||
const albumDto = albumSearch.albums[0];
|
||||
if (albumDto) {
|
||||
const album = albumToAlbum(albumDto);
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
serverId,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
const first = tracks[0];
|
||||
return {
|
||||
album: {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
coverArt: first.coverArtId ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined,
|
||||
serverId,
|
||||
},
|
||||
songs: songs.map(s => ({ ...s, serverId })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLibraryIndex(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'artist'],
|
||||
limit: 10_000,
|
||||
});
|
||||
const albums = response.albums
|
||||
.filter(a => a.artistId === artistId)
|
||||
.map(albumToAlbum)
|
||||
.map(a => ({ ...a, serverId }));
|
||||
const artistDto = response.artists.find(a => a.id === artistId);
|
||||
if (!artistDto && albums.length === 0) return null;
|
||||
|
||||
const artist = artistDto
|
||||
? { ...artistToArtist(artistDto), serverId }
|
||||
: {
|
||||
id: artistId,
|
||||
name: albums[0]?.artist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return {
|
||||
artist: {
|
||||
...artist,
|
||||
albumCount: albums.length,
|
||||
},
|
||||
albums,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { LibraryTrackDto } from '../../api/library';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
countLocalBrowsableTracks,
|
||||
fetchOfflineLocalBrowsableSongPage,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
const { libraryGetTracksBatchChunkedMock } = vi.hoisted(() => ({
|
||||
libraryGetTracksBatchChunkedMock: vi.fn(async (): Promise<LibraryTrackDto[]> => []),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryGetTracksBatchChunked: libraryGetTracksBatchChunkedMock,
|
||||
libraryGetTracksByAlbum: vi.fn(async () => []),
|
||||
libraryAdvancedSearch: vi.fn(async () => ({ albums: [], artists: [], tracks: [] })),
|
||||
}));
|
||||
|
||||
describe('offlineLocalBrowse', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
libraryGetTracksBatchChunkedMock.mockReset();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('offlineLocalBrowseEnabled requires index and local bytes', () => {
|
||||
expect(offlineLocalBrowseEnabled('srv-a')).toBe(false);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(countLocalBrowsableTracks('srv-a')).toBe(1);
|
||||
expect(offlineLocalBrowseEnabled('srv-a')).toBe(true);
|
||||
});
|
||||
|
||||
it('fetchOfflineLocalBrowsableSongPage pages local bytes alphabetically', async () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
'a.test:t2': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/a.test/a/al/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValue([
|
||||
{
|
||||
id: 't2', title: 'Beta', artist: 'A', album: 'Al', albumId: 'al-1',
|
||||
durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {},
|
||||
},
|
||||
{
|
||||
id: 't1', title: 'Alpha', artist: 'A', album: 'Al', albumId: 'al-1',
|
||||
durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const page = await fetchOfflineLocalBrowsableSongPage('srv-a', 0, 1);
|
||||
expect(page?.songs.map(s => s.id)).toEqual(['t1']);
|
||||
expect(page?.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { LibraryTrackDto } from '../../api/library';
|
||||
import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '../../api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { LocalPlaybackEntry } from '../../store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
resolveTrackCoverArtId,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import {
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByGenres,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from '../library/albumBrowseFilters';
|
||||
import type { AlbumBrowseQuery } from '../library/albumBrowseTypes';
|
||||
import { sortSubsonicAlbums } from '../library/albumBrowseSort';
|
||||
import { isLosslessSuffix } from '../library/losslessFormats';
|
||||
import { entryBelongsToServer } from './offlineLibraryHelpers';
|
||||
|
||||
function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] {
|
||||
return [...songs].sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
function listBrowsableEntries(serverId: string): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries).filter(
|
||||
e => (e.tier === 'library' || e.tier === 'favorite-auto')
|
||||
&& !!e.localPath
|
||||
&& entryBelongsToServer(e, serverId),
|
||||
);
|
||||
}
|
||||
|
||||
export function countLocalBrowsableTracks(serverId: string): number {
|
||||
return listBrowsableEntries(serverId).length;
|
||||
}
|
||||
|
||||
/** Local library index + at least one on-disk library/favorites track for this server. */
|
||||
export function offlineLocalBrowseEnabled(serverId: string | null | undefined): boolean {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
return countLocalBrowsableTracks(serverId) > 0;
|
||||
}
|
||||
|
||||
/** Track DTOs for every library/favorite-auto entry with on-disk bytes for this server. */
|
||||
export async function fetchBrowsableLocalTrackDtos(serverId: string): Promise<LibraryTrackDto[]> {
|
||||
const entries = listBrowsableEntries(serverId);
|
||||
if (entries.length === 0) return [];
|
||||
const refs = entries.map(e => ({ serverId, trackId: e.trackId }));
|
||||
return libraryGetTracksBatchChunked(refs);
|
||||
}
|
||||
|
||||
export function buildAlbumFromTracks(
|
||||
albumId: string,
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicAlbum {
|
||||
const songs = tracks.map(trackToSong).map(s => ({ ...s, serverId }));
|
||||
const first = tracks[0];
|
||||
const starred = tracks.some(t => t.starredAt != null);
|
||||
return {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? first.albumArtist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
coverArt: resolveTrackCoverArtId(first) ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
genre: first.genre ?? undefined,
|
||||
songCount: songs.length,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
starred: starred ? new Date().toISOString() : undefined,
|
||||
serverId,
|
||||
};
|
||||
}
|
||||
|
||||
function aggregateAlbumsFromTracks(
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicAlbum[] {
|
||||
const byAlbum = new Map<string, LibraryTrackDto[]>();
|
||||
for (const track of tracks) {
|
||||
const albumId = track.albumId;
|
||||
if (!albumId) continue;
|
||||
const list = byAlbum.get(albumId) ?? [];
|
||||
list.push(track);
|
||||
byAlbum.set(albumId, list);
|
||||
}
|
||||
return [...byAlbum.entries()].map(([albumId, albumTracks]) =>
|
||||
buildAlbumFromTracks(albumId, albumTracks, serverId),
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateArtistsFromTracks(
|
||||
tracks: LibraryTrackDto[],
|
||||
serverId: string,
|
||||
): SubsonicArtist[] {
|
||||
const albumIdsByArtist = new Map<string, Set<string>>();
|
||||
const names = new Map<string, string>();
|
||||
for (const track of tracks) {
|
||||
const artistId = track.artistId;
|
||||
if (!artistId) continue;
|
||||
names.set(artistId, track.artist ?? track.albumArtist ?? artistId);
|
||||
const set = albumIdsByArtist.get(artistId) ?? new Set<string>();
|
||||
if (track.albumId) set.add(track.albumId);
|
||||
albumIdsByArtist.set(artistId, set);
|
||||
}
|
||||
return [...names.entries()]
|
||||
.map(([id, name]) => ({
|
||||
id,
|
||||
name,
|
||||
albumCount: albumIdsByArtist.get(id)?.size ?? 0,
|
||||
serverId,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function applyAlbumBrowseQuery(
|
||||
albums: SubsonicAlbum[],
|
||||
query: AlbumBrowseQuery,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): SubsonicAlbum[] {
|
||||
let out = albums;
|
||||
if (query.genres.length > 0) {
|
||||
out = filterAlbumsByGenres(out, query.genres);
|
||||
}
|
||||
if (query.year) {
|
||||
out = filterAlbumsByYearBounds(out, query.year);
|
||||
}
|
||||
if (query.starredOnly) {
|
||||
out = filterAlbumsByStarred(out, starredOverrides);
|
||||
}
|
||||
if (query.compFilter !== 'all') {
|
||||
out = filterAlbumsByCompilation(out, query.compFilter);
|
||||
}
|
||||
return sortSubsonicAlbums(out, query.sort);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalBrowsableSongPage(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<{ songs: SubsonicSong[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const songs = sortBrowsableSongs(
|
||||
tracks.map(trackToSong).map(s => ({ ...s, serverId })),
|
||||
);
|
||||
const slice = songs.slice(offset, offset + chunkSize);
|
||||
return { songs: slice, hasMore: offset + chunkSize < songs.length };
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalBrowsableSongs(
|
||||
serverId: string,
|
||||
query: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const matched = tracks
|
||||
.filter(t =>
|
||||
(t.title?.toLowerCase().includes(q))
|
||||
|| (t.artist?.toLowerCase().includes(q))
|
||||
|| (t.album?.toLowerCase().includes(q)),
|
||||
)
|
||||
.map(trackToSong)
|
||||
.map(s => ({ ...s, serverId }));
|
||||
return sortBrowsableSongs(matched).slice(offset, offset + chunkSize);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalStarredArtists(serverId: string): Promise<SubsonicArtist[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => t.starredAt != null);
|
||||
return aggregateArtistsFromTracks(tracks, serverId);
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
const artists = aggregateArtistsFromTracks(tracks, serverId);
|
||||
const slice = artists.slice(offset, offset + chunkSize);
|
||||
return {
|
||||
artists: slice,
|
||||
hasMore: offset + chunkSize < artists.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalArtists(
|
||||
serverId: string,
|
||||
query: string,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
return aggregateArtistsFromTracks(tracks, serverId)
|
||||
.filter(a => a.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export async function fetchOfflineLocalAlbumCatalogChunk(
|
||||
serverId: string,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
starredOverrides: Record<string, boolean> = {},
|
||||
): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
let tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (query.losslessOnly) {
|
||||
tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined));
|
||||
}
|
||||
let albums = aggregateAlbumsFromTracks(tracks, serverId);
|
||||
albums = applyAlbumBrowseQuery(albums, query, starredOverrides);
|
||||
const slice = albums.slice(offset, offset + chunkSize);
|
||||
return {
|
||||
albums: slice,
|
||||
hasMore: offset + chunkSize < albums.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchOfflineLocalAlbums(
|
||||
serverId: string,
|
||||
query: string,
|
||||
losslessOnly = false,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
let tracks = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (losslessOnly) {
|
||||
tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined));
|
||||
}
|
||||
return aggregateAlbumsFromTracks(tracks, serverId)
|
||||
.filter(a => a.name.toLowerCase().includes(q) || a.artist.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export async function loadAlbumFromLocalPlayback(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId));
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
const localTracks = tracks.filter(t => localIds.has(t.id));
|
||||
if (localTracks.length === 0) return null;
|
||||
|
||||
const songs = localTracks.map(trackToSong).map(s => ({ ...s, serverId }));
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
restrictAlbumIds: [albumId],
|
||||
limit: 1,
|
||||
}).catch(() => null);
|
||||
const albumDto = albumSearch?.albums[0];
|
||||
const album = albumDto
|
||||
? { ...albumToAlbum(albumDto), serverId, songCount: songs.length }
|
||||
: buildAlbumFromTracks(albumId, localTracks, serverId);
|
||||
|
||||
return {
|
||||
album: {
|
||||
...album,
|
||||
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
|
||||
},
|
||||
songs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadArtistFromLocalPlayback(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return null;
|
||||
const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId));
|
||||
const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(
|
||||
t => t.artistId === artistId && localIds.has(t.id),
|
||||
);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const albums = aggregateAlbumsFromTracks(tracks, serverId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const artistDto = tracks[0];
|
||||
const artistSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['artist'],
|
||||
limit: 10_000,
|
||||
}).catch(() => null);
|
||||
const match = artistSearch?.artists.find(a => a.id === artistId);
|
||||
|
||||
const artist = match
|
||||
? { ...artistToArtist(match), serverId, albumCount: albums.length }
|
||||
: {
|
||||
id: artistId,
|
||||
name: artistDto.artist ?? artistDto.albumArtist ?? artistId,
|
||||
albumCount: albums.length,
|
||||
serverId,
|
||||
};
|
||||
|
||||
return { artist, albums };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveAlbumForActiveServer,
|
||||
resolveArtist,
|
||||
resolvePlaylist,
|
||||
} from './offlineMediaResolve';
|
||||
|
||||
const isOfflineBrowseActiveMock = vi.fn(() => false);
|
||||
const offlineLocalBrowseEnabledMock = vi.fn((_serverId: string) => false);
|
||||
const playlistsOfflineBrowseEnabledMock = vi.fn((_serverId: string) => false);
|
||||
const loadAlbumFromLocalPlaybackMock = vi.fn();
|
||||
const loadArtistFromLocalPlaybackMock = vi.fn();
|
||||
const loadAlbumFromLibraryIndexMock = vi.fn();
|
||||
const loadArtistFromLibraryIndexMock = vi.fn();
|
||||
const loadOfflineBrowsablePlaylistMock = vi.fn();
|
||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||
const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({}));
|
||||
const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({}));
|
||||
const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({}));
|
||||
|
||||
vi.mock('./offlineBrowseMode', () => ({
|
||||
isOfflineBrowseActive: () => isOfflineBrowseActiveMock(),
|
||||
}));
|
||||
|
||||
vi.mock('./offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: (id: string) => offlineLocalBrowseEnabledMock(id),
|
||||
loadAlbumFromLocalPlayback: (serverId: string, albumId: string) =>
|
||||
loadAlbumFromLocalPlaybackMock(serverId, albumId),
|
||||
loadArtistFromLocalPlayback: (serverId: string, artistId: string) =>
|
||||
loadArtistFromLocalPlaybackMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('./offlineLibraryIndexLoad', () => ({
|
||||
loadAlbumFromLibraryIndex: (...args: unknown[]) => loadAlbumFromLibraryIndexMock(...args),
|
||||
loadArtistFromLibraryIndex: (...args: unknown[]) => loadArtistFromLibraryIndexMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: (id: string) => playlistsOfflineBrowseEnabledMock(id),
|
||||
loadOfflineBrowsablePlaylist: (playlistId: string, serverId: string) =>
|
||||
loadOfflineBrowsablePlaylistMock(playlistId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('../network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) =>
|
||||
shouldAttemptSubsonicForServerMock(serverId, trackId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: (serverId: string, albumId: string) => getAlbumForServerMock(serverId, albumId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicArtists', () => ({
|
||||
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/subsonicPlaylists', () => ({
|
||||
getPlaylistForServer: (serverId: string, playlistId: string) =>
|
||||
getPlaylistForServerMock(serverId, playlistId),
|
||||
}));
|
||||
|
||||
describe('offlineMediaResolve', () => {
|
||||
beforeEach(() => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(false);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(false);
|
||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(false);
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
loadAlbumFromLocalPlaybackMock.mockReset();
|
||||
loadArtistFromLocalPlaybackMock.mockReset();
|
||||
loadAlbumFromLibraryIndexMock.mockReset();
|
||||
loadArtistFromLibraryIndexMock.mockReset();
|
||||
loadOfflineBrowsablePlaylistMock.mockReset();
|
||||
getAlbumForServerMock.mockReset();
|
||||
getArtistForServerMock.mockReset();
|
||||
getPlaylistForServerMock.mockReset();
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true, activeServerId: 'srv-1' } as Partial<
|
||||
ReturnType<typeof useAuthStore.getState>
|
||||
>);
|
||||
});
|
||||
|
||||
it('resolveAlbum prefers local bytes when offline browse and local library enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(true);
|
||||
loadAlbumFromLocalPlaybackMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Local' },
|
||||
songs: [{ id: 't1', title: 'One' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(loadAlbumFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getAlbumForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolveAlbum uses network when allowed', async () => {
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Net' },
|
||||
songs: [{ id: 't1' }, { id: 't2' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('resolveAlbum falls back to library index when network blocked', async () => {
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(false);
|
||||
loadAlbumFromLibraryIndexMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Idx' },
|
||||
songs: [{ id: 't1' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.album.name).toBe('Idx');
|
||||
});
|
||||
|
||||
it('resolveAlbumForActiveServer uses active server id', async () => {
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-2' },
|
||||
songs: [],
|
||||
});
|
||||
await resolveAlbumForActiveServer('alb-2');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2');
|
||||
});
|
||||
|
||||
it('resolveArtist prefers local bytes when offline browse and local library enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(true);
|
||||
loadArtistFromLocalPlaybackMock.mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Local Artist' },
|
||||
albums: [{ id: 'alb-1' }],
|
||||
});
|
||||
const result = await resolveArtist('srv-1', 'art-1');
|
||||
expect(loadArtistFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'art-1');
|
||||
expect(result?.albums).toHaveLength(1);
|
||||
expect(getArtistForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolvePlaylist uses offline browse cache when enabled', async () => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(true);
|
||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(true);
|
||||
loadOfflineBrowsablePlaylistMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-1', name: 'Offline' },
|
||||
songs: [{ id: 't1' }],
|
||||
});
|
||||
const result = await resolvePlaylist('srv-1', 'pl-1');
|
||||
expect(loadOfflineBrowsablePlaylistMock).toHaveBeenCalledWith('pl-1', 'srv-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getPlaylistForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolvePlaylist uses network when allowed', async () => {
|
||||
getPlaylistForServerMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-2', name: 'Net' },
|
||||
songs: [{ id: 't1' }, { id: 't2' }],
|
||||
});
|
||||
const result = await resolvePlaylist('srv-1', 'pl-2');
|
||||
expect(getPlaylistForServerMock).toHaveBeenCalledWith('srv-1', 'pl-2');
|
||||
expect(result?.songs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { getAlbumForServer } from '../../api/subsonicLibrary';
|
||||
import { getArtistForServer } from '../../api/subsonicArtists';
|
||||
import { getPlaylistForServer } from '../../api/subsonicPlaylists';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicPlaylist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import {
|
||||
loadAlbumFromLibraryIndex,
|
||||
loadArtistFromLibraryIndex,
|
||||
} from './offlineLibraryIndexLoad';
|
||||
import {
|
||||
loadAlbumFromLocalPlayback,
|
||||
loadArtistFromLocalPlayback,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
import {
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from './offlinePlaylistBrowse';
|
||||
|
||||
export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] };
|
||||
|
||||
/**
|
||||
* Album detail / play / enqueue: network album when reachable (complete track list);
|
||||
* local bytes or library index when offline browse is active.
|
||||
*/
|
||||
export async function resolveAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<ResolvedAlbum | null> {
|
||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadAlbumFromLocalPlayback(serverId, albumId);
|
||||
}
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
const data = await getAlbumForServer(serverId, albumId);
|
||||
return { album: data.album, songs: data.songs };
|
||||
} catch {
|
||||
/* fall through to library index */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadAlbumFromLibraryIndex(serverId, albumId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link resolveAlbum}. */
|
||||
export const resolveAlbumForServer = resolveAlbum;
|
||||
|
||||
export async function resolveArtist(
|
||||
serverId: string,
|
||||
artistId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
|
||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadArtistFromLocalPlayback(serverId, artistId);
|
||||
}
|
||||
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
|
||||
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
|
||||
|
||||
if (networkAllowed) {
|
||||
try {
|
||||
return await getArtistForServer(serverId, artistId);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
} else if (!favoritesOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadArtistFromLibraryIndex(serverId, artistId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePlaylist(
|
||||
serverId: string,
|
||||
playlistId: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> {
|
||||
if (isOfflineBrowseActive() && playlistsOfflineBrowseEnabled(serverId)) {
|
||||
const offline = await loadOfflineBrowsablePlaylist(playlistId, serverId);
|
||||
if (offline) return offline;
|
||||
}
|
||||
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return null;
|
||||
|
||||
try {
|
||||
return await getPlaylistForServer(serverId, playlistId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMediaServerId(explicit?: string | null): string | null {
|
||||
return explicit ?? useAuthStore.getState().activeServerId;
|
||||
}
|
||||
|
||||
/** Resolve album for active server when `serverId` omitted. */
|
||||
export async function resolveAlbumForActiveServer(
|
||||
albumId: string,
|
||||
serverId?: string,
|
||||
): Promise<ResolvedAlbum | null> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) return null;
|
||||
return resolveAlbum(sid, albumId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Sidebar / mobile-more navigation gates while offline browse is active. */
|
||||
|
||||
export function isOfflineSidebarLibraryNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse = false,
|
||||
playlistsOfflineBrowse = false,
|
||||
): boolean {
|
||||
if (navId === 'favorites') return favoritesOfflineBrowse;
|
||||
if (navId === 'artists' || navId === 'allAlbums' || navId === 'tracks') return localLibraryBrowse;
|
||||
if (navId === 'playlists') return playlistsOfflineBrowse;
|
||||
if (navId === 'offline') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** System nav entries that stay available without a Subsonic connection. */
|
||||
export function isOfflineSidebarSystemNavAllowed(
|
||||
navId: string,
|
||||
playerStatsBrowse: boolean,
|
||||
): boolean {
|
||||
if (navId === 'help') return true;
|
||||
if (navId === 'statistics') return playerStatsBrowse;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sidebar / mobile-more gate while offline browse is active. */
|
||||
export function isOfflineSidebarNavAllowed(
|
||||
navId: string,
|
||||
favoritesOfflineBrowse: boolean,
|
||||
localLibraryBrowse: boolean,
|
||||
playerStatsBrowse: boolean,
|
||||
playlistsOfflineBrowse = false,
|
||||
): boolean {
|
||||
if (isOfflineSidebarSystemNavAllowed(navId, playerStatsBrowse)) return true;
|
||||
return isOfflineSidebarLibraryNavAllowed(
|
||||
navId,
|
||||
favoritesOfflineBrowse,
|
||||
localLibraryBrowse,
|
||||
playlistsOfflineBrowse,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as libraryApi from '../../api/library';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
import {
|
||||
fetchOfflineBrowsablePlaylists,
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from './offlinePlaylistBrowse';
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryGetTracksBatchChunked: vi.fn(),
|
||||
}));
|
||||
|
||||
const libraryGetTracksBatchChunkedMock = vi.mocked(libraryApi.libraryGetTracksBatchChunked);
|
||||
|
||||
function seedPlaylistPin(serverId = 'srv-1', indexKey = 'srv-1') {
|
||||
useAuthStore.setState({
|
||||
activeServerId: serverId,
|
||||
servers: [{
|
||||
id: serverId,
|
||||
name: 'Test',
|
||||
url: 'https://music.test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}],
|
||||
});
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
[`${indexKey}:pl-1`]: {
|
||||
id: 'pl-1',
|
||||
serverId: indexKey,
|
||||
name: 'Road mix',
|
||||
artist: '',
|
||||
trackIds: ['t1', 't2'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
[`${indexKey}:t1`]: {
|
||||
serverIndexKey: indexKey,
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
|
||||
},
|
||||
[`${indexKey}:t2`]: {
|
||||
serverIndexKey: indexKey,
|
||||
trackId: 't2',
|
||||
localPath: '/media/library/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('offlinePlaylistBrowse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAuthStore.setState({ activeServerId: null, servers: [] });
|
||||
useLibraryIndexStore.setState({ masterEnabled: false });
|
||||
useOfflineStore.setState({ albums: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('playlistsOfflineBrowseEnabled is true when cached playlist bytes exist', () => {
|
||||
seedPlaylistPin();
|
||||
expect(playlistsOfflineBrowseEnabled('srv-1')).toBe(true);
|
||||
expect(playlistsOfflineBrowseEnabled(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchOfflineBrowsablePlaylists returns cached regular playlists only', async () => {
|
||||
seedPlaylistPin();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'Ar',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't2',
|
||||
title: 'B',
|
||||
artist: 'Br',
|
||||
album: 'Bl',
|
||||
albumId: 'al-2',
|
||||
durationSec: 200,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const playlists = await fetchOfflineBrowsablePlaylists('srv-1');
|
||||
expect(playlists).toHaveLength(1);
|
||||
expect(playlists[0]?.id).toBe('pl-1');
|
||||
expect(playlists[0]?.name).toBe('Road mix');
|
||||
expect(playlists[0]?.songCount).toBe(2);
|
||||
expect(playlists[0]?.duration).toBe(300);
|
||||
});
|
||||
|
||||
it('loadOfflineBrowsablePlaylist preserves playlist track order', async () => {
|
||||
seedPlaylistPin();
|
||||
libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't1',
|
||||
title: 'A',
|
||||
artist: 'Ar',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
id: 't2',
|
||||
title: 'B',
|
||||
artist: 'Br',
|
||||
album: 'Bl',
|
||||
albumId: 'al-2',
|
||||
durationSec: 200,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const loaded = await loadOfflineBrowsablePlaylist('pl-1', 'srv-1');
|
||||
expect(loaded?.playlist.name).toBe('Road mix');
|
||||
expect(loaded?.songs.map(s => s.id)).toEqual(['t1', 't2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { libraryGetTracksBatchChunked } from '../../api/library';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import type { PinnedGroup } from '../../store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
|
||||
import { trackToSong } from '../library/advancedSearchLocal';
|
||||
import { isManualOfflinePlaylist } from './pinnedOfflineSync';
|
||||
import {
|
||||
hasLocalLibraryBytes,
|
||||
indexKeyBelongsToServer,
|
||||
resolveOfflineAlbumMeta,
|
||||
} from './offlineLibraryHelpers';
|
||||
|
||||
function listPlaylistPinnedGroupsForServer(serverId: string): PinnedGroup[] {
|
||||
return useLocalPlaybackStore.getState()
|
||||
.listPinnedGroups()
|
||||
.filter(g => g.pinSource.kind === 'playlist' && indexKeyBelongsToServer(g.serverIndexKey, serverId));
|
||||
}
|
||||
|
||||
function orderedPlayableTrackIds(
|
||||
playlistId: string,
|
||||
serverId: string,
|
||||
group: PinnedGroup,
|
||||
): string[] {
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const ordered = meta?.trackIds?.length ? meta.trackIds : group.trackIds;
|
||||
return ordered.filter(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
|
||||
/** Cached regular playlists with on-disk bytes for the active server. */
|
||||
export function playlistsOfflineBrowseEnabled(serverId: string | null | undefined): boolean {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
return listPlaylistPinnedGroupsForServer(serverId).some(g => {
|
||||
if (!isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName)) {
|
||||
return false;
|
||||
}
|
||||
return orderedPlayableTrackIds(g.pinSource.sourceId, serverId, g).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchOfflineBrowsablePlaylists(serverId: string): Promise<SubsonicPlaylist[]> {
|
||||
const groups = listPlaylistPinnedGroupsForServer(serverId)
|
||||
.filter(g => isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName));
|
||||
|
||||
const playlists: SubsonicPlaylist[] = [];
|
||||
for (const group of groups) {
|
||||
const playlistId = group.pinSource.sourceId;
|
||||
const trackIds = orderedPlayableTrackIds(playlistId, serverId, group);
|
||||
if (trackIds.length === 0) continue;
|
||||
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const refs = trackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(dtos.map(d => [d.id, d]));
|
||||
let duration = 0;
|
||||
for (const trackId of trackIds) {
|
||||
duration += byId.get(trackId)?.durationSec ?? 0;
|
||||
}
|
||||
|
||||
playlists.push({
|
||||
id: playlistId,
|
||||
name: group.pinSource.displayName ?? meta?.name ?? playlistId,
|
||||
songCount: trackIds.length,
|
||||
duration,
|
||||
created: '',
|
||||
changed: '',
|
||||
coverArt: meta?.coverArt,
|
||||
});
|
||||
}
|
||||
|
||||
return playlists.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function loadOfflineBrowsablePlaylist(
|
||||
playlistId: string,
|
||||
serverId: string,
|
||||
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> {
|
||||
const group = listPlaylistPinnedGroupsForServer(serverId)
|
||||
.find(g => g.pinSource.sourceId === playlistId);
|
||||
if (!group) return null;
|
||||
if (!isManualOfflinePlaylist(playlistId, serverId, group.pinSource.displayName)) return null;
|
||||
|
||||
const trackIds = orderedPlayableTrackIds(playlistId, serverId, group);
|
||||
if (trackIds.length === 0) return null;
|
||||
|
||||
const meta = resolveOfflineAlbumMeta(playlistId, serverId);
|
||||
const refs = trackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(dtos.map(d => [d.id, d]));
|
||||
const songs = trackIds
|
||||
.map(id => byId.get(id))
|
||||
.filter((dto): dto is NonNullable<typeof dto> => !!dto)
|
||||
.map(dto => ({ ...trackToSong(dto), serverId }));
|
||||
|
||||
const duration = songs.reduce((sum, song) => sum + (song.duration ?? 0), 0);
|
||||
return {
|
||||
playlist: {
|
||||
id: playlistId,
|
||||
name: group.pinSource.displayName ?? meta?.name ?? playlistId,
|
||||
songCount: songs.length,
|
||||
duration,
|
||||
created: '',
|
||||
changed: '',
|
||||
coverArt: meta?.coverArt,
|
||||
},
|
||||
songs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { getStarredForServer } from '../../api/subsonicStarRating';
|
||||
import { libraryAdvancedSearch } from '../../api/library';
|
||||
import type {
|
||||
StarredResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '../../api/subsonicTypes';
|
||||
import { isActiveServerReachable } from '../network/activeServerReachability';
|
||||
import {
|
||||
albumToAlbum,
|
||||
trackToSong,
|
||||
} from '../library/advancedSearchLocal';
|
||||
import { dedupeById } from '../dedupeById';
|
||||
import { isOfflineBrowseActive } from './offlineBrowseMode';
|
||||
import { favoritesServerIds } from './favoritesOfflineBrowse';
|
||||
import {
|
||||
buildAlbumFromTracks,
|
||||
fetchBrowsableLocalTrackDtos,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from './offlineLocalBrowse';
|
||||
|
||||
function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults {
|
||||
const withServer = <T extends { id: string }>(items: T[]): (T & { serverId: string })[] =>
|
||||
items.map(item => ({ ...item, serverId }));
|
||||
|
||||
return {
|
||||
artists: withServer(starred.artists),
|
||||
albums: withServer(starred.albums),
|
||||
songs: withServer(starred.songs),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */
|
||||
export function mergeStarredFromServers(
|
||||
entries: { serverId: string; starred: StarredResults }[],
|
||||
): StarredResults {
|
||||
const artists: SubsonicArtist[] = [];
|
||||
const albums: SubsonicAlbum[] = [];
|
||||
const songs: SubsonicSong[] = [];
|
||||
for (const { serverId, starred } of entries) {
|
||||
const tagged = tagStarredWithServer(starred, serverId);
|
||||
artists.push(...tagged.artists);
|
||||
albums.push(...tagged.albums);
|
||||
songs.push(...tagged.songs);
|
||||
}
|
||||
return {
|
||||
artists: dedupeById(artists),
|
||||
albums: dedupeById(albums),
|
||||
songs: dedupeById(songs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline favorites: start from on-disk bytes, then keep starred tracks/albums only.
|
||||
* Avoids scanning the full starred catalog in SQL when only a local subset is playable.
|
||||
*/
|
||||
async function loadStarredFromBrowsableLocalBytes(serverId: string): Promise<StarredResults> {
|
||||
const allLocal = await fetchBrowsableLocalTrackDtos(serverId);
|
||||
if (allLocal.length === 0) {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
const starredTracks = allLocal.filter(t => t.starredAt != null);
|
||||
const songs = starredTracks
|
||||
.map(trackToSong)
|
||||
.map(s => ({ ...s, serverId }));
|
||||
|
||||
const albumsById = new Map<string, SubsonicAlbum>();
|
||||
const byStarredAlbum = new Map<string, typeof allLocal>();
|
||||
for (const track of starredTracks) {
|
||||
if (!track.albumId) continue;
|
||||
const list = byStarredAlbum.get(track.albumId) ?? [];
|
||||
list.push(track);
|
||||
byStarredAlbum.set(track.albumId, list);
|
||||
}
|
||||
for (const [albumId, albumTracks] of byStarredAlbum) {
|
||||
albumsById.set(albumId, buildAlbumFromTracks(albumId, albumTracks, serverId));
|
||||
}
|
||||
|
||||
const localAlbumIds = [...new Set(
|
||||
allLocal.map(t => t.albumId).filter((id): id is string => !!id),
|
||||
)];
|
||||
if (localAlbumIds.length > 0) {
|
||||
const albumSearch = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album'],
|
||||
starredOnly: true,
|
||||
restrictAlbumIds: localAlbumIds,
|
||||
limit: localAlbumIds.length,
|
||||
skipTotals: true,
|
||||
});
|
||||
for (const dto of albumSearch.albums) {
|
||||
albumsById.set(dto.id, { ...albumToAlbum(dto), serverId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
artists: [],
|
||||
albums: [...albumsById.values()],
|
||||
songs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromLibraryIndex(
|
||||
serverId: string,
|
||||
preferLocalBytes = false,
|
||||
): Promise<StarredResults> {
|
||||
if (preferLocalBytes && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadStarredFromBrowsableLocalBytes(serverId);
|
||||
}
|
||||
|
||||
// Artist-level favorites are network-only today (`artist` has no `starred_at`;
|
||||
// `starredOnly` on artists would return the whole artist table). Songs/albums
|
||||
// use track/album stars in the index.
|
||||
const response = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
entityTypes: ['album', 'track'],
|
||||
starredOnly: true,
|
||||
limit: 10_000,
|
||||
});
|
||||
return {
|
||||
artists: [],
|
||||
albums: response.albums.map(albumToAlbum),
|
||||
songs: response.tracks.map(trackToSong),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadStarredFromAllLibraryIndexes(
|
||||
preferLocalBytes = isOfflineBrowseActive(),
|
||||
): Promise<StarredResults> {
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId, preferLocalBytes);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
|
||||
/** Online starred merge with per-server local index fallback. */
|
||||
export async function loadStarredFromAllServersOnline(): Promise<StarredResults> {
|
||||
if (!isActiveServerReachable()) {
|
||||
return loadStarredFromAllLibraryIndexes();
|
||||
}
|
||||
const serverIds = favoritesServerIds();
|
||||
const entries = await Promise.all(
|
||||
serverIds.map(async serverId => {
|
||||
try {
|
||||
const starred = await getStarredForServer(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
try {
|
||||
const starred = await loadStarredFromLibraryIndex(serverId);
|
||||
return { serverId, starred };
|
||||
} catch {
|
||||
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return mergeStarredFromServers(entries);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ vi.mock('./offlineLibraryHelpers', () => ({
|
||||
isOfflinePinCompleteMock(albumId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('./favoritesOfflineBrowse', () => ({
|
||||
vi.mock('./offlineMediaResolve', () => ({
|
||||
resolveAlbumForServer: (serverId: string, albumId: string) =>
|
||||
resolveAlbumForServerMock(serverId, albumId),
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isActiveServerReachable, onActiveServerBecameReachable } from '../netwo
|
||||
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
|
||||
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
||||
import { isOfflinePinComplete } from './offlineLibraryHelpers';
|
||||
import { resolveAlbumForServer } from './favoritesOfflineBrowse';
|
||||
import { resolveAlbumForServer } from './offlineMediaResolve';
|
||||
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { getPlaylist } from '../../api/subsonicPlaylists';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { DeviceSyncSource } from '../../store/deviceSyncStore';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveArtist,
|
||||
resolveMediaServerId,
|
||||
resolvePlaylist,
|
||||
} from '../offline/offlineMediaResolve';
|
||||
|
||||
export async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
|
||||
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
|
||||
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
|
||||
const { albums } = await getArtist(source.id);
|
||||
// Parallel album fetches — Navidrome handles getAlbum requests in flight
|
||||
// without serialising. Sequential awaits here multiplied a 50-album artist
|
||||
// sync into 50 round-trips (~7 s blocking) before any device write started.
|
||||
const serverId = resolveMediaServerId();
|
||||
if (!serverId) return [];
|
||||
|
||||
if (source.type === 'playlist') {
|
||||
const result = await resolvePlaylist(serverId, source.id);
|
||||
return result?.songs ?? [];
|
||||
}
|
||||
if (source.type === 'album') {
|
||||
const result = await resolveAlbum(serverId, source.id);
|
||||
return result?.songs ?? [];
|
||||
}
|
||||
|
||||
const artistData = await resolveArtist(serverId, source.id);
|
||||
if (!artistData) return [];
|
||||
|
||||
const results = await Promise.all(
|
||||
albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [] as SubsonicSong[])),
|
||||
artistData.albums.map(a =>
|
||||
resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [] as SubsonicSong[]),
|
||||
),
|
||||
);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
@@ -4,20 +4,37 @@ import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
vi.mock('../../api/subsonicLibrary', () => ({
|
||||
getAlbum: vi.fn(),
|
||||
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||
resolveAlbumForActiveServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./fadeOut', () => ({
|
||||
fadeOut: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { playAlbum, playAlbumShuffled } from './playAlbum';
|
||||
import * as shuffleModule from './shuffleArray';
|
||||
|
||||
const albumPayload = {
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
};
|
||||
|
||||
function stubPlaybackActions() {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
@@ -40,22 +57,7 @@ describe('playAlbum', () => {
|
||||
resetPlayerStore();
|
||||
resetOrbitStore();
|
||||
stubPlaybackActions();
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
});
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -90,22 +92,7 @@ describe('playAlbumShuffled', () => {
|
||||
resetPlayerStore();
|
||||
resetOrbitStore();
|
||||
stubPlaybackActions();
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-1',
|
||||
name: 'Test Album',
|
||||
artist: 'Test Artist',
|
||||
artistId: 'artist-1',
|
||||
songCount: 3,
|
||||
duration: 540,
|
||||
genre: 'Rock',
|
||||
},
|
||||
songs: [
|
||||
makeSubsonicSong({ id: 't1', title: 'One' }),
|
||||
makeSubsonicSong({ id: 't2', title: 'Two' }),
|
||||
makeSubsonicSong({ id: 't3', title: 'Three' }),
|
||||
],
|
||||
});
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -143,7 +130,7 @@ describe('playAlbumShuffled', () => {
|
||||
});
|
||||
|
||||
it('does not start playback for an empty album', async () => {
|
||||
vi.mocked(getAlbum).mockResolvedValue({
|
||||
vi.mocked(resolveAlbumForActiveServer).mockResolvedValue({
|
||||
album: {
|
||||
id: 'al-empty',
|
||||
name: 'Empty',
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { resolveAlbumForServer } from '../offline/favoritesOfflineBrowse';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import { songToTrack } from './songToTrack';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { fadeOut } from './fadeOut';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { shuffleArray } from './shuffleArray';
|
||||
|
||||
async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
||||
const albumData = serverId
|
||||
? await resolveAlbumForServer(serverId, albumId)
|
||||
: await getAlbum(albumId).then(d => ({ album: d.album, songs: d.songs }));
|
||||
export async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
|
||||
const albumData = await resolveAlbumForActiveServer(albumId, serverId);
|
||||
if (!albumData) throw new Error(`Album ${albumId} not available`);
|
||||
const albumGenre = albumData.album.genre;
|
||||
const ownerServerId = serverId ?? albumData.album.serverId;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { getAlbum } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../offline/offlineMediaResolve';
|
||||
import { songToTrack } from './songToTrack';
|
||||
import { shuffleArray } from './shuffleArray';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
/**
|
||||
* All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”.
|
||||
*/
|
||||
export async function playArtistShuffled(artistId: string): Promise<void> {
|
||||
const { albums } = await getArtist(artistId);
|
||||
if (albums.length === 0) {
|
||||
export async function playArtistShuffled(artistId: string, serverId?: string): Promise<void> {
|
||||
const sid = resolveMediaServerId(serverId);
|
||||
if (!sid) throw new Error('play_artist_no_tracks');
|
||||
|
||||
const artistData = await resolveArtist(sid, artistId);
|
||||
if (!artistData || artistData.albums.length === 0) {
|
||||
throw new Error('play_artist_no_tracks');
|
||||
}
|
||||
|
||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
const results = await Promise.all(artistData.albums.map(a => resolveAlbum(sid, a.id)));
|
||||
const sorted = results
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
const tracks = sorted.flatMap(r =>
|
||||
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAlbum, getSong } from '../../api/subsonicLibrary';
|
||||
import { getSong } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
|
||||
import { songToTrack } from './songToTrack';
|
||||
import { playAlbum } from './playAlbum';
|
||||
import { playArtistShuffled } from './playArtistShuffled';
|
||||
@@ -17,14 +18,10 @@ export async function playByOpaqueId(id: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { songs } = await getAlbum(trimmed);
|
||||
if (songs.length > 0) {
|
||||
await playAlbum(trimmed);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* not an album */
|
||||
const albumData = await resolveAlbumForActiveServer(trimmed);
|
||||
if (albumData && albumData.songs.length > 0) {
|
||||
await playAlbum(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,10 @@ import type React from 'react';
|
||||
import { getPlaylist } from '../../api/subsonicPlaylists';
|
||||
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlaylistStore } from '../../store/playlistStore';
|
||||
import { isOfflineBrowseActive } from '../offline/offlineBrowseMode';
|
||||
import { resolvePlaylist } from '../offline/offlineMediaResolve';
|
||||
|
||||
export interface RunPlaylistLoadDeps {
|
||||
id: string;
|
||||
@@ -14,23 +17,41 @@ export interface RunPlaylistLoadDeps {
|
||||
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
}
|
||||
|
||||
function applyLoadedPlaylist(
|
||||
deps: RunPlaylistLoadDeps,
|
||||
playlist: SubsonicPlaylist,
|
||||
songs: SubsonicSong[],
|
||||
): void {
|
||||
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
|
||||
setPlaylist(playlist);
|
||||
setSongs(songs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
songs.forEach(s => {
|
||||
if (s.userRating) init[s.id] = s.userRating;
|
||||
if (s.starred) starred.add(s.id);
|
||||
});
|
||||
setRatings(init);
|
||||
setStarredSongs(starred);
|
||||
}
|
||||
|
||||
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
|
||||
const { id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
|
||||
setLoading(true);
|
||||
try {
|
||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||
if (isOfflineBrowseActive() && serverId) {
|
||||
const loaded = await resolvePlaylist(serverId, id);
|
||||
if (loaded) {
|
||||
applyLoadedPlaylist(deps, loaded.playlist, loaded.songs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { playlist, songs } = await getPlaylist(id);
|
||||
const filteredSongs = await filterSongsToActiveLibrary(songs);
|
||||
setPlaylist(playlist);
|
||||
setSongs(filteredSongs);
|
||||
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
|
||||
const init: Record<string, number> = {};
|
||||
const starred = new Set<string>();
|
||||
filteredSongs.forEach(s => {
|
||||
if (s.userRating) init[s.id] = s.userRating;
|
||||
if (s.starred) starred.add(s.id);
|
||||
});
|
||||
setRatings(init);
|
||||
setStarredSongs(starred);
|
||||
applyLoadedPlaylist(deps, playlist, filteredSongs);
|
||||
} catch {
|
||||
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
|
||||
if (stub) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { getAlbum, getSong } from '../../api/subsonicLibrary';
|
||||
import { getSong } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveArtist } from '../offline/offlineMediaResolve';
|
||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import type { Location, NavigateFunction } from 'react-router-dom';
|
||||
@@ -130,9 +130,8 @@ export async function applySharePastePayload(
|
||||
}
|
||||
|
||||
if (payload.k === 'album') {
|
||||
try {
|
||||
await getAlbum(payload.id);
|
||||
} catch {
|
||||
const albumResult = await resolveAlbum(serverId, payload.id);
|
||||
if (!albumResult) {
|
||||
showToast(t('sharePaste.albumUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
@@ -146,9 +145,8 @@ export async function applySharePastePayload(
|
||||
}
|
||||
|
||||
if (payload.k === 'artist') {
|
||||
try {
|
||||
await getArtist(payload.id);
|
||||
} catch {
|
||||
const artistResult = await resolveArtist(serverId, payload.id);
|
||||
if (!artistResult) {
|
||||
showToast(t('sharePaste.artistUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
@@ -159,11 +157,10 @@ export async function applySharePastePayload(
|
||||
|
||||
if (payload.k === 'composer') {
|
||||
// Same id space as artists (Subsonic / Navidrome use one id pool for
|
||||
// every participant role), so getArtist still validates the entity —
|
||||
// every participant role), so resolveArtist still validates the entity —
|
||||
// the difference is which view we navigate to.
|
||||
try {
|
||||
await getArtist(payload.id);
|
||||
} catch {
|
||||
const composerResult = await resolveArtist(serverId, payload.id);
|
||||
if (!composerResult) {
|
||||
showToast(t('sharePaste.composerUnavailable'), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
getArtistWithCredentials,
|
||||
getSongWithCredentials,
|
||||
} from '../../api/subsonicEntityWithCredentials';
|
||||
import { getAlbum, getSong } from '../../api/subsonicLibrary';
|
||||
import { getArtist } from '../../api/subsonicArtists';
|
||||
import { getSong } from '../../api/subsonicLibrary';
|
||||
import { resolveAlbum, resolveArtist } from '../offline/offlineMediaResolve';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
@@ -118,7 +118,9 @@ async function getAlbumAfterActivation(
|
||||
serverId: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
activateShareServer(serverId);
|
||||
return getAlbum(id);
|
||||
const result = await resolveAlbum(serverId, id);
|
||||
if (!result) throw new Error('album unavailable');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getArtistAfterActivation(
|
||||
@@ -126,7 +128,9 @@ async function getArtistAfterActivation(
|
||||
serverId: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
activateShareServer(serverId);
|
||||
return getArtist(id);
|
||||
const result = await resolveArtist(serverId, id);
|
||||
if (!result) throw new Error('artist unavailable');
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveShareSearchPayload(
|
||||
|
||||
Reference in New Issue
Block a user