mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(offline): co-locate offline feature into features/offline
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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 '@/features/offline/utils/favoritesOfflineBrowse';
|
||||
import {
|
||||
isOfflineSidebarLibraryNavAllowed,
|
||||
isOfflineSidebarNavAllowed,
|
||||
isOfflineSidebarSystemNavAllowed,
|
||||
} from '@/features/offline/utils/offlineNavPolicy';
|
||||
import {
|
||||
loadStarredFromLibraryIndex,
|
||||
mergeStarredFromServers,
|
||||
} from '@/features/offline/utils/offlineStarredLoad';
|
||||
import { resolveAlbumForServer } from '@/features/offline/utils/offlineMediaResolve';
|
||||
|
||||
const isActiveServerReachableMock = vi.fn(() => true);
|
||||
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
|
||||
vi.mock('@/utils/network/activeServerReachability', () => ({
|
||||
isActiveServerReachable: () => isActiveServerReachableMock(),
|
||||
}));
|
||||
vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) =>
|
||||
shouldAttemptSubsonicForServerMock(serverId, trackId),
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/library', () => ({
|
||||
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
|
||||
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
|
||||
libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args),
|
||||
}));
|
||||
|
||||
describe('favoritesOfflineBrowse', () => {
|
||||
beforeEach(() => {
|
||||
isActiveServerReachableMock.mockReturnValue(true);
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
getAlbumForServerMock.mockReset();
|
||||
libraryGetTracksByAlbumMock.mockReset();
|
||||
libraryAdvancedSearchMock.mockReset();
|
||||
libraryGetTracksBatchChunkedMock.mockReset();
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: false,
|
||||
activeServerId: 'srv-1',
|
||||
servers: [{ id: 'srv-1', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('favoritesOfflineBrowseEnabled requires setting and at least one indexed server', () => {
|
||||
expect(favoritesOfflineBrowseEnabled()).toBe(false);
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
expect(favoritesOfflineBrowseEnabled()).toBe(true);
|
||||
useAuthStore.setState({ servers: [] });
|
||||
expect(favoritesOfflineBrowseEnabled()).toBe(false);
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: true,
|
||||
activeServerId: null,
|
||||
servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
expect(favoritesOfflineBrowseEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('mergeStarredFromServers tags serverId and dedupes per server', () => {
|
||||
const merged = mergeStarredFromServers([
|
||||
{
|
||||
serverId: 'srv-1',
|
||||
starred: {
|
||||
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', songCount: 1, duration: 1 }],
|
||||
artists: [],
|
||||
songs: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', duration: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
serverId: 'srv-2',
|
||||
starred: {
|
||||
albums: [{ id: 'alb-1', name: 'B', artist: 'Y', artistId: 'art-2', songCount: 1, duration: 1 }],
|
||||
artists: [],
|
||||
songs: [{ id: 't-1', title: 'S2', artist: 'Y', album: 'B', albumId: 'alb-1', duration: 1 }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(merged.albums).toHaveLength(2);
|
||||
expect(merged.albums.map(a => a.serverId)).toEqual(['srv-1', 'srv-2']);
|
||||
expect(merged.songs).toHaveLength(2);
|
||||
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
|
||||
});
|
||||
|
||||
it('isOfflineSidebarLibraryNavAllowed gates offline sidebar entries', () => {
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true);
|
||||
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).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('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' }],
|
||||
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);
|
||||
getAlbumForServerMock.mockRejectedValue(new Error('Network Error'));
|
||||
libraryGetTracksByAlbumMock.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
artistId: 'art-1',
|
||||
durationSec: 200,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
]);
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{
|
||||
id: 'alb-1',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
artistId: 'art-1',
|
||||
serverId: 'srv-1',
|
||||
}],
|
||||
artists: [],
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
||||
expect(result?.album.id).toBe('alb-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
});
|
||||
|
||||
it('resolveAlbumForServer prefers full network album over partial library index', async () => {
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
libraryGetTracksByAlbumMock.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Indexed only',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 100,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
]);
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'art-1', songCount: 3, duration: 600 },
|
||||
songs: [
|
||||
{ id: 't1', title: 'One', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
||||
{ id: 't2', title: 'Two', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
||||
{ id: 't3', title: 'Three', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(3);
|
||||
expect(result?.songs.map(s => s.id)).toEqual(['t1', 't2', 't3']);
|
||||
});
|
||||
|
||||
it('resolveAlbumForServer uses library index when server is unreachable', async () => {
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(false);
|
||||
libraryGetTracksByAlbumMock.mockResolvedValue([
|
||||
{
|
||||
id: 't1',
|
||||
title: 'Offline track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'alb-1',
|
||||
durationSec: 200,
|
||||
serverId: 'srv-1',
|
||||
},
|
||||
]);
|
||||
libraryAdvancedSearchMock.mockResolvedValue({
|
||||
albums: [{
|
||||
id: 'alb-1',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
artistId: 'art-1',
|
||||
serverId: 'srv-1',
|
||||
}],
|
||||
artists: [],
|
||||
tracks: [],
|
||||
});
|
||||
|
||||
const result = await resolveAlbumForServer('srv-1', 'alb-1');
|
||||
expect(result?.songs).toHaveLength(1);
|
||||
expect(getAlbumForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolveAlbumForServer falls back to network when index misses', async () => {
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
isActiveServerReachableMock.mockReturnValue(true);
|
||||
libraryGetTracksByAlbumMock.mockResolvedValue([]);
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-2', name: 'Net', artist: 'A', artistId: 'a1', songCount: 1, duration: 1 },
|
||||
songs: [{ id: 't2', title: 'T', artist: 'A', album: 'Net', albumId: 'alb-2', duration: 1 }],
|
||||
});
|
||||
|
||||
const result = await resolveAlbumForServer('srv-1', 'alb-2');
|
||||
expect(result?.album.id).toBe('alb-2');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2');
|
||||
});
|
||||
|
||||
it('hasOfflineBrowsingContent includes favorite-auto bytes when browse is enabled', () => {
|
||||
expect(hasOfflineBrowsingContent({})).toBe(false);
|
||||
useAuthStore.setState({ favoritesOfflineEnabled: true });
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/fav/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasOfflineBrowsingContent({})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import type { OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
|
||||
/** Saved servers with a local library index (cross-server favorites scope). */
|
||||
export function favoritesServerIds(): string[] {
|
||||
const { servers } = useAuthStore.getState();
|
||||
const idx = useLibraryIndexStore.getState();
|
||||
return idx.indexedServerIds(servers.map(s => s.id));
|
||||
}
|
||||
|
||||
/** Favorites page may be browsed offline when auto-save is enabled and any index exists. */
|
||||
export function favoritesOfflineBrowseEnabled(): boolean {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.favoritesOfflineEnabled) return false;
|
||||
return favoritesServerIds().length > 0;
|
||||
}
|
||||
|
||||
/** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */
|
||||
export function hasOfflineBrowsingContent(
|
||||
offlineAlbums: Record<string, OfflineAlbumMeta>,
|
||||
): boolean {
|
||||
if (hasAnyOfflineAlbums(offlineAlbums)) return true;
|
||||
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FAVORITES_OFFLINE_JOB_ID } from '@/features/offline/utils/favoritesOfflineConstants';
|
||||
|
||||
describe('favoritesOfflineConstants', () => {
|
||||
it('uses a stable virtual job id', () => {
|
||||
expect(FAVORITES_OFFLINE_JOB_ID).toBe('__favorites__');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Virtual batch id for favorites offline download jobs (sidebar progress). */
|
||||
export const FAVORITES_OFFLINE_JOB_ID = '__favorites__';
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineJobStore } from '@/features/offline/store/offlineJobStore';
|
||||
import { FAVORITES_OFFLINE_JOB_ID } from '@/features/offline/utils/favoritesOfflineConstants';
|
||||
import {
|
||||
mergeStarredSongsUnion,
|
||||
onFavoritesOfflineStarChange,
|
||||
} from '@/features/offline/utils/favoritesOfflineSync';
|
||||
|
||||
const getStarredForServerMock = vi.fn(async (_serverId: string) => ({
|
||||
artists: [],
|
||||
albums: [],
|
||||
songs: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 1 }],
|
||||
}));
|
||||
|
||||
const isActiveServerReachableMock = vi.fn(() => true);
|
||||
|
||||
vi.mock('@/utils/network/activeServerReachability', () => ({
|
||||
isActiveServerReachable: () => isActiveServerReachableMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicStarRating', () => ({
|
||||
getStarredForServer: (serverId: string) => getStarredForServerMock(serverId),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: vi.fn(async () => ({ songs: [] })),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicArtists', () => ({
|
||||
getArtistForServer: vi.fn(async () => ({ albums: [] })),
|
||||
}));
|
||||
|
||||
const invokeMock = vi.fn(async (_cmd: string, _args?: unknown) => ({}));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
|
||||
}));
|
||||
|
||||
function song(id: string): SubsonicSong {
|
||||
return {
|
||||
id,
|
||||
title: `Track ${id}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: 'al-1',
|
||||
duration: 180,
|
||||
};
|
||||
}
|
||||
|
||||
describe('onFavoritesOfflineStarChange', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
isActiveServerReachableMock.mockReturnValue(true);
|
||||
getStarredForServerMock.mockClear();
|
||||
invokeMock.mockClear();
|
||||
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
|
||||
useAuthStore.setState({
|
||||
favoritesOfflineEnabled: true,
|
||||
activeServerId: 'srv-a',
|
||||
servers: [
|
||||
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not schedule sync while the active server is unreachable', async () => {
|
||||
isActiveServerReachableMock.mockReturnValue(false);
|
||||
onFavoritesOfflineStarChange('t1', 'song', true, 'srv-b');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getStarredForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedules sync for the explicit server, not only the active one', async () => {
|
||||
onFavoritesOfflineStarChange('t1', 'song', true, 'srv-b');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getStarredForServerMock).toHaveBeenCalledWith('srv-b');
|
||||
expect(getStarredForServerMock).not.toHaveBeenCalledWith('srv-a');
|
||||
});
|
||||
|
||||
it('aborts in-flight favorites Rust downloads when a star change reschedules sync', async () => {
|
||||
useOfflineJobStore.setState({
|
||||
jobs: [{
|
||||
trackId: 't1',
|
||||
albumId: FAVORITES_OFFLINE_JOB_ID,
|
||||
albumName: 'Favorites',
|
||||
trackTitle: 'T',
|
||||
trackIndex: 0,
|
||||
totalTracks: 1,
|
||||
status: 'downloading',
|
||||
downloadId: 'favorites-111',
|
||||
}],
|
||||
pinQueue: [],
|
||||
bulkProgress: {},
|
||||
});
|
||||
onFavoritesOfflineStarChange('t2', 'song', false, 'srv-a');
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
'cancel_offline_downloads',
|
||||
{ downloadIds: ['favorites-111'] },
|
||||
);
|
||||
expect(useOfflineJobStore.getState().jobs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeStarredSongsUnion', () => {
|
||||
it('dedupes the same track from direct song, album, and artist stars', () => {
|
||||
const shared = song('t-shared');
|
||||
const union = mergeStarredSongsUnion(
|
||||
[shared, song('t-solo')],
|
||||
[[shared, song('t-album-only')]],
|
||||
[[shared, song('t-artist-only')]],
|
||||
);
|
||||
expect(union.map(s => s.id).sort()).toEqual([
|
||||
't-album-only',
|
||||
't-artist-only',
|
||||
't-shared',
|
||||
't-solo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty when nothing is starred', () => {
|
||||
expect(mergeStarredSongsUnion([], [], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import { libraryUpsertSongsFromApi } from '@/api/library';
|
||||
import { librarySqlServerId } from '@/api/coverCache';
|
||||
import { getAlbumForServer } from '@/api/subsonicLibrary';
|
||||
import { getArtistForServer } from '@/api/subsonicArtists';
|
||||
import { getStarredForServer } from '@/api/subsonicStarRating';
|
||||
import { buildStreamUrlForServer } from '@/api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '@/i18n';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { cancelledDownloads, useOfflineJobStore } from '@/features/offline/store/offlineJobStore';
|
||||
import { useFavoritesOfflineSyncStore } from '@/features/offline/store/favoritesOfflineSyncStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
import { FAVORITES_OFFLINE_JOB_ID } from '@/features/offline/utils/favoritesOfflineConstants';
|
||||
import { isActiveServerReachable } from '@/utils/network/activeServerReachability';
|
||||
import { favoritesServerIds } from '@/features/offline/utils/favoritesOfflineBrowse';
|
||||
import { loadAlbumFromLibraryIndex } from '@/features/offline/utils/offlineLibraryIndexLoad';
|
||||
import {
|
||||
entryBelongsToServer,
|
||||
hasLocalLibraryBytes,
|
||||
hasLocalFavoriteAutoBytes,
|
||||
} from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
|
||||
const CONCURRENCY = 2;
|
||||
const DEBOUNCE_MS = 600;
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Accumulates server ids across debounced calls; `'all'` means fan-out to every server. */
|
||||
let pendingSyncServerIds: Set<string> | 'all' = new Set();
|
||||
let runToken = 0;
|
||||
/** Rust cancellation key for the active favorites batch (`download_track_local`). */
|
||||
let activeFavoritesDownloadId: string | null = null;
|
||||
|
||||
function rustDownloadIdsForFavoritesJobs(): string[] {
|
||||
const fromJobs = useOfflineJobStore
|
||||
.getState()
|
||||
.jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID && j.downloadId)
|
||||
.map(j => j.downloadId);
|
||||
const ids = new Set(fromJobs);
|
||||
if (activeFavoritesDownloadId) ids.add(activeFavoritesDownloadId);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Abort in-flight favorites transfers and invalidate the current JS batch loop. */
|
||||
function cancelInFlightFavoritesDownloads(): void {
|
||||
runToken += 1;
|
||||
cancelledDownloads.add(FAVORITES_OFFLINE_JOB_ID);
|
||||
const downloadIds = rustDownloadIdsForFavoritesJobs();
|
||||
if (downloadIds.length > 0) {
|
||||
invoke('cancel_offline_downloads', { downloadIds }).catch(() => {});
|
||||
for (const id of downloadIds) {
|
||||
invoke('clear_offline_cancel', { downloadId: id }).catch(() => {});
|
||||
}
|
||||
}
|
||||
activeFavoritesDownloadId = null;
|
||||
useOfflineJobStore.setState(state => ({
|
||||
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
|
||||
}));
|
||||
useFavoritesOfflineSyncStore.getState().setRunning(false);
|
||||
}
|
||||
|
||||
function serverIndexKeyForSync(serverId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
if (server) return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
|
||||
return resolveIndexKey(serverId) || serverId;
|
||||
}
|
||||
|
||||
function librarySqlScope(serverId: string): string {
|
||||
return librarySqlServerId(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of all tracks implied by starred songs, albums, and artists (deduped by track id).
|
||||
* File/index lifecycle keys off this set — never per-entity pin — so overlapping stars
|
||||
* (artist + song on the same album) share one `favorite-auto` row per track.
|
||||
*/
|
||||
export function mergeStarredSongsUnion(
|
||||
directSongs: SubsonicSong[],
|
||||
albumTrackLists: SubsonicSong[][],
|
||||
artistAlbumTrackLists: SubsonicSong[][],
|
||||
): SubsonicSong[] {
|
||||
const byId = new Map<string, SubsonicSong>();
|
||||
for (const song of directSongs) byId.set(song.id, song);
|
||||
for (const songs of albumTrackLists) {
|
||||
for (const song of songs) byId.set(song.id, song);
|
||||
}
|
||||
for (const songs of artistAlbumTrackLists) {
|
||||
for (const song of songs) byId.set(song.id, song);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Collect every starred track (direct songs + album/artist expansion) for one server. */
|
||||
export async function collectStarredSongs(serverId: string): Promise<SubsonicSong[]> {
|
||||
const starred = await getStarredForServer(serverId);
|
||||
const albumTrackLists: SubsonicSong[][] = [];
|
||||
for (const album of starred.albums) {
|
||||
try {
|
||||
const detail = await getAlbumForServer(serverId, album.id);
|
||||
albumTrackLists.push(detail.songs);
|
||||
} catch {
|
||||
try {
|
||||
const local = await loadAlbumFromLibraryIndex(serverId, album.id);
|
||||
if (local) albumTrackLists.push(local.songs);
|
||||
} catch {
|
||||
// skip unavailable album
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const artistAlbumTrackLists: SubsonicSong[][] = [];
|
||||
for (const artist of starred.artists) {
|
||||
try {
|
||||
const detail = await getArtistForServer(serverId, artist.id);
|
||||
for (const alb of detail.albums ?? []) {
|
||||
try {
|
||||
const albumDetail = await getAlbumForServer(serverId, alb.id);
|
||||
artistAlbumTrackLists.push(albumDetail.songs);
|
||||
} catch {
|
||||
try {
|
||||
const local = await loadAlbumFromLibraryIndex(serverId, alb.id);
|
||||
if (local) artistAlbumTrackLists.push(local.songs);
|
||||
} catch {
|
||||
// skip album
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip unavailable artist
|
||||
}
|
||||
}
|
||||
|
||||
return mergeStarredSongsUnion(starred.songs, albumTrackLists, artistAlbumTrackLists);
|
||||
}
|
||||
|
||||
function pendingFavoriteAutoSongs(songs: SubsonicSong[], serverId: string): SubsonicSong[] {
|
||||
return songs.filter(s => !hasLocalLibraryBytes(s.id, serverId) && !hasLocalFavoriteAutoBytes(s.id, serverId));
|
||||
}
|
||||
|
||||
async function pruneOrphanFavoriteAuto(
|
||||
serverId: string,
|
||||
targetIds: Set<string>,
|
||||
mediaDir: string | null,
|
||||
): Promise<void> {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const entry of Object.values(lp.entries)) {
|
||||
if (entry.tier !== 'favorite-auto') continue;
|
||||
if (!entryBelongsToServer(entry, serverId)) continue;
|
||||
if (targetIds.has(entry.trackId)) continue;
|
||||
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
|
||||
lp.removeEntry(entry.trackId, entry.serverIndexKey, 'favorite-unstar-prune');
|
||||
}
|
||||
await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function disableFavoritesOfflineSync(): Promise<void> {
|
||||
useAuthStore.getState().setFavoritesOfflineEnabled(false);
|
||||
cancelInFlightFavoritesDownloads();
|
||||
const mediaDir = getMediaDir();
|
||||
await useLocalPlaybackStore.getState().purgeFavoriteAutoDisk(mediaDir);
|
||||
useFavoritesOfflineSyncStore.getState().setTargetTrackIds([]);
|
||||
useFavoritesOfflineSyncStore.getState().setLastError(null);
|
||||
}
|
||||
|
||||
export function scheduleFavoritesOfflineSync(serverId?: string): void {
|
||||
if (!useAuthStore.getState().favoritesOfflineEnabled) return;
|
||||
if (!isActiveServerReachable()) return;
|
||||
cancelInFlightFavoritesDownloads();
|
||||
if (serverId) {
|
||||
if (pendingSyncServerIds !== 'all') {
|
||||
pendingSyncServerIds.add(serverId);
|
||||
}
|
||||
} else {
|
||||
pendingSyncServerIds = 'all';
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
const serverIds = pendingSyncServerIds === 'all'
|
||||
? favoritesServerIds()
|
||||
: [...pendingSyncServerIds];
|
||||
pendingSyncServerIds = new Set();
|
||||
void runFavoritesOfflineSyncBatch(serverIds);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after any successful star/unstar (song, album, or artist).
|
||||
* Deletions run only inside {@link runFavoritesOfflineSync} via {@link pruneOrphanFavoriteAuto}
|
||||
* against the merged track union — never eager per-entity removes (avoids deleting a file
|
||||
* that is still required because the same track is starred via artist/album).
|
||||
*/
|
||||
export function onFavoritesOfflineStarChange(
|
||||
_id: string,
|
||||
_type: 'song' | 'album' | 'artist',
|
||||
_starred: boolean,
|
||||
serverId?: string,
|
||||
): void {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.favoritesOfflineEnabled) return;
|
||||
const target = serverId ?? auth.activeServerId;
|
||||
if (!target) return;
|
||||
scheduleFavoritesOfflineSync(target);
|
||||
}
|
||||
|
||||
async function runFavoritesOfflineSyncBatch(serverIds: string[]): Promise<void> {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.favoritesOfflineEnabled || serverIds.length === 0) return;
|
||||
|
||||
const token = ++runToken;
|
||||
const syncStore = useFavoritesOfflineSyncStore.getState();
|
||||
syncStore.setRunning(true);
|
||||
syncStore.setLastError(null);
|
||||
|
||||
try {
|
||||
for (const serverId of serverIds) {
|
||||
if (token !== runToken) return;
|
||||
await runFavoritesOfflineSyncOneServer(serverId, token);
|
||||
}
|
||||
} finally {
|
||||
if (token === runToken) {
|
||||
syncStore.setRunning(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFavoritesOfflineSyncOneServer(serverId: string, token: number): Promise<void> {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.favoritesOfflineEnabled) return;
|
||||
const syncStore = useFavoritesOfflineSyncStore.getState();
|
||||
const jobStore = useOfflineJobStore;
|
||||
const serverIndexKey = serverIndexKeyForSync(serverId);
|
||||
const libraryServerId = librarySqlScope(serverId);
|
||||
const mediaDir = getMediaDir();
|
||||
const albumName = i18n.t('favorites.offlineJobName');
|
||||
|
||||
try {
|
||||
const allSongs = await collectStarredSongs(serverId);
|
||||
if (token !== runToken) return;
|
||||
|
||||
const targetIds = new Set(allSongs.map(s => s.id));
|
||||
syncStore.setTargetTrackIds([...targetIds]);
|
||||
|
||||
await pruneOrphanFavoriteAuto(serverId, targetIds, mediaDir);
|
||||
if (token !== runToken) return;
|
||||
|
||||
await libraryUpsertSongsFromApi(libraryServerId, allSongs).catch(() => {});
|
||||
|
||||
const pending = pendingFavoriteAutoSongs(allSongs, serverId);
|
||||
if (pending.length === 0) {
|
||||
jobStore.setState(state => ({
|
||||
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (token !== runToken) return;
|
||||
|
||||
cancelledDownloads.delete(FAVORITES_OFFLINE_JOB_ID);
|
||||
const downloadId = `favorites-${Date.now()}`;
|
||||
activeFavoritesDownloadId = downloadId;
|
||||
|
||||
jobStore.setState(state => ({
|
||||
jobs: [
|
||||
...state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
|
||||
...pending.map((s, i) => ({
|
||||
trackId: s.id,
|
||||
albumId: FAVORITES_OFFLINE_JOB_ID,
|
||||
albumName,
|
||||
trackTitle: s.title,
|
||||
trackIndex: i,
|
||||
totalTracks: pending.length,
|
||||
status: 'queued' as const,
|
||||
downloadId,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
|
||||
for (let i = 0; i < pending.length; i += CONCURRENCY) {
|
||||
if (token !== runToken || cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)) {
|
||||
cancelledDownloads.delete(FAVORITES_OFFLINE_JOB_ID);
|
||||
jobStore.setState(state => ({
|
||||
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
|
||||
}));
|
||||
invoke('cancel_offline_downloads', { downloadIds: [downloadId] }).catch(() => {});
|
||||
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
|
||||
activeFavoritesDownloadId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = pending.slice(i, i + CONCURRENCY);
|
||||
const batchIds = new Set(batch.map(s => s.id));
|
||||
|
||||
jobStore.setState(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.albumId === FAVORITES_OFFLINE_JOB_ID && batchIds.has(j.trackId)
|
||||
? { ...j, status: 'downloading' }
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async song => {
|
||||
const suffix = song.suffix || 'mp3';
|
||||
if (cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)) {
|
||||
return { song, error: 'CANCELLED' };
|
||||
}
|
||||
if (hasLocalLibraryBytes(song.id, serverId) || hasLocalFavoriteAutoBytes(song.id, serverId)) {
|
||||
return { song, error: null };
|
||||
}
|
||||
try {
|
||||
const res = await invoke<{ path: string; size: number; layoutFingerprint: string }>(
|
||||
'download_track_local',
|
||||
{
|
||||
tier: 'favorite-auto',
|
||||
trackId: song.id,
|
||||
serverIndexKey,
|
||||
libraryServerId,
|
||||
url: buildStreamUrlForServer(serverId, song.id),
|
||||
suffix,
|
||||
mediaDir,
|
||||
downloadId,
|
||||
},
|
||||
);
|
||||
if (
|
||||
token !== runToken
|
||||
|| cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)
|
||||
|| !targetIds.has(song.id)
|
||||
) {
|
||||
await invoke('delete_media_file', { localPath: res.path, mediaDir }).catch(() => {});
|
||||
return { song, error: 'CANCELLED' };
|
||||
}
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
serverIndexKey,
|
||||
trackId: song.id,
|
||||
localPath: res.path,
|
||||
sizeBytes: res.size,
|
||||
layoutFingerprint: res.layoutFingerprint,
|
||||
tier: 'favorite-auto',
|
||||
suffix,
|
||||
});
|
||||
return { song, error: null };
|
||||
} catch (err) {
|
||||
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : 'error');
|
||||
if (msg === 'CANCELLED') return { song, error: 'CANCELLED' };
|
||||
return { song, error: msg };
|
||||
}
|
||||
}),
|
||||
).then(results => {
|
||||
jobStore.setState(state => ({
|
||||
jobs: state.jobs.map(j => {
|
||||
if (j.albumId !== FAVORITES_OFFLINE_JOB_ID) return j;
|
||||
const hit = results.find(r => r.song.id === j.trackId);
|
||||
if (!hit) return j;
|
||||
if (hit.error === 'CANCELLED') return j;
|
||||
return {
|
||||
...j,
|
||||
status: hit.error ? ('error' as const) : ('done' as const),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
if (token === runToken) {
|
||||
jobStore.setState(state => ({
|
||||
jobs: state.jobs.filter(
|
||||
j => j.albumId !== FAVORITES_OFFLINE_JOB_ID || (j.status !== 'done' && j.status !== 'error'),
|
||||
),
|
||||
}));
|
||||
if (activeFavoritesDownloadId === downloadId) {
|
||||
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
|
||||
activeFavoritesDownloadId = null;
|
||||
}
|
||||
await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (token === runToken) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
syncStore.setLastError(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Run an initial sync when the setting is enabled (app start / server change). */
|
||||
export function initFavoritesOfflineSync(): () => void {
|
||||
const runIfEnabled = () => {
|
||||
if (useAuthStore.getState().favoritesOfflineEnabled) {
|
||||
scheduleFavoritesOfflineSync();
|
||||
}
|
||||
};
|
||||
runIfEnabled();
|
||||
return useAuthStore.subscribe((state, prev) => {
|
||||
if (state.favoritesOfflineEnabled && !prev.favoritesOfflineEnabled) {
|
||||
runIfEnabled();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import {
|
||||
entryNeedsFileRelocation,
|
||||
restoreOfflineLibraryPinSources,
|
||||
} from '@/features/offline/utils/legacyOfflineFileMigration';
|
||||
import type { LocalPlaybackEntry } from '@/store/localPlaybackStore';
|
||||
|
||||
function entry(overrides: Partial<LocalPlaybackEntry>): LocalPlaybackEntry {
|
||||
return {
|
||||
serverIndexKey: 'srv',
|
||||
trackId: 't1',
|
||||
localPath: '/old/path',
|
||||
layoutFingerprint: '',
|
||||
sizeBytes: 0,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('entryNeedsFileRelocation', () => {
|
||||
it('detects psysonic-offline flat paths', () => {
|
||||
expect(entryNeedsFileRelocation(entry({
|
||||
localPath: '/home/u/.local/share/psysonic-offline/host/t1.mp3',
|
||||
}))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips paths already under media/library', () => {
|
||||
expect(entryNeedsFileRelocation(entry({
|
||||
localPath: '/home/u/.local/share/media/library/host/Artist/Album/01 - Song.mp3',
|
||||
}))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips ephemeral tier', () => {
|
||||
expect(entryNeedsFileRelocation(entry({
|
||||
tier: 'ephemeral',
|
||||
localPath: '/home/u/psysonic-offline/host/t1.mp3',
|
||||
}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreOfflineLibraryPinSources', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
servers: [{ id: 'srv-uuid', name: 'Home', url: 'http://music.test', username: 'u', password: 'p' }],
|
||||
activeServerId: 'srv-uuid',
|
||||
});
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'music.test:al-1': {
|
||||
id: 'al-1',
|
||||
serverId: 'music.test',
|
||||
name: 'My Album',
|
||||
artist: 'Artist',
|
||||
trackIds: ['t1', 't2'],
|
||||
type: 'album',
|
||||
},
|
||||
},
|
||||
});
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'music.test:t1': {
|
||||
serverIndexKey: 'music.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/music.test/Artist/My Album/01.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 100,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches pinSource from offline albums for library entries', () => {
|
||||
expect(restoreOfflineLibraryPinSources()).toBe(1);
|
||||
const e = useLocalPlaybackStore.getState().entries['music.test:t1'];
|
||||
expect(e.pinSource).toEqual({ kind: 'album', sourceId: 'al-1', displayName: 'My Album' });
|
||||
expect(useLocalPlaybackStore.getState().listPinnedGroups()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { libraryGetTracksBatch } from '@/api/library';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { useLocalPlaybackStore, type LocalPlaybackEntry, type PinSource } from '@/store/localPlaybackStore';
|
||||
import { localPlaybackEntryKey } from '@/store/localPlaybackKeys';
|
||||
import { importLegacyLocalPlayback } from '@/store/localPlaybackMigration';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
|
||||
interface LegacyOfflineMigrationResult {
|
||||
trackId: string;
|
||||
serverIndexKey: string;
|
||||
path: string;
|
||||
size: number;
|
||||
layoutFingerprint: string;
|
||||
relocated: boolean;
|
||||
skippedReason?: string | null;
|
||||
}
|
||||
|
||||
type PersistCapableStore = {
|
||||
persist: {
|
||||
hasHydrated: () => boolean;
|
||||
onFinishHydration: (fn: () => void) => () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type LegacyOfflineBlob = {
|
||||
state?: {
|
||||
albums?: Record<string, OfflineAlbumMeta>;
|
||||
};
|
||||
};
|
||||
|
||||
function waitForStoreHydration(store: PersistCapableStore): Promise<void> {
|
||||
if (store.persist.hasHydrated()) return Promise.resolve();
|
||||
return new Promise(resolve => {
|
||||
const unsub = store.persist.onFinishHydration(() => {
|
||||
unsub();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function migrationDebug(payload: Record<string, unknown>): void {
|
||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'legacy-offline-migration',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function resolveIndexKeyForServerId(serverId: string): string {
|
||||
const trimmed = serverId.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const byId = servers.find(s => s.id === trimmed);
|
||||
if (byId) return resolveIndexKey(byId.id) || trimmed;
|
||||
return resolveIndexKey(trimmed) || trimmed;
|
||||
}
|
||||
|
||||
function collectLegacyOfflineAlbums(): OfflineAlbumMeta[] {
|
||||
const merged: Record<string, OfflineAlbumMeta> = {
|
||||
...useOfflineStore.getState().albums,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic-offline');
|
||||
if (raw) {
|
||||
const blob = JSON.parse(raw) as LegacyOfflineBlob;
|
||||
Object.assign(merged, blob.state?.albums ?? {});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return Object.values(merged);
|
||||
}
|
||||
|
||||
function pinSourceForTrack(
|
||||
serverIndexKey: string,
|
||||
trackId: string,
|
||||
albums: OfflineAlbumMeta[],
|
||||
): PinSource | undefined {
|
||||
for (const album of albums) {
|
||||
if (!album.trackIds.includes(trackId)) continue;
|
||||
const albumKey = resolveIndexKeyForServerId(album.serverId);
|
||||
if (albumKey !== serverIndexKey && album.serverId !== serverIndexKey) continue;
|
||||
return {
|
||||
kind: album.type ?? 'album',
|
||||
sourceId: album.id,
|
||||
displayName: album.name,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the file still uses the flat legacy offline layout (not under `media/…/library/`). */
|
||||
export function entryNeedsFileRelocation(entry: LocalPlaybackEntry): boolean {
|
||||
if (entry.tier !== 'library' || !entry.localPath.trim()) return false;
|
||||
const normalized = entry.localPath.replace(/\\/g, '/');
|
||||
if (normalized.includes('/media/library/')) return false;
|
||||
if (normalized.includes('/psysonic-offline/')) return true;
|
||||
return !normalized.includes('/library/');
|
||||
}
|
||||
|
||||
/** Offline Library cards need `pinSource` — restore from legacy albums + persist import. */
|
||||
export function restoreOfflineLibraryPinSources(): number {
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const albums = collectLegacyOfflineAlbums();
|
||||
const fromPersist = importLegacyLocalPlayback(servers);
|
||||
const store = useLocalPlaybackStore.getState();
|
||||
const merged = { ...store.entries };
|
||||
let updated = 0;
|
||||
|
||||
for (const entry of Object.values(merged)) {
|
||||
if (entry.tier !== 'library' || entry.pinSource) continue;
|
||||
const key = localPlaybackEntryKey(entry.serverIndexKey, entry.trackId);
|
||||
const legacy = fromPersist[key];
|
||||
const pin = legacy?.pinSource
|
||||
?? pinSourceForTrack(entry.serverIndexKey, entry.trackId, albums);
|
||||
if (!pin) continue;
|
||||
merged[key] = { ...entry, pinSource: pin };
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
useLocalPlaybackStore.setState({ entries: merged });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Group orphan library pins by album metadata when legacy cards are gone. */
|
||||
export async function inferPinSourcesFromLibraryIndex(): Promise<number> {
|
||||
const needs = Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'library' && !e.pinSource);
|
||||
if (needs.length === 0) return 0;
|
||||
|
||||
const refs = needs.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const tracks = await libraryGetTracksBatch(refs);
|
||||
const byRef = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
|
||||
let updated = 0;
|
||||
|
||||
for (const entry of needs) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = byRef.get(`${serverId}:${entry.trackId}`);
|
||||
if (!dto?.albumId) continue;
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
...entry,
|
||||
pinSource: {
|
||||
kind: 'album',
|
||||
sourceId: dto.albumId,
|
||||
displayName: dto.album,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function applyMigrationResults(results: LegacyOfflineMigrationResult[]): number {
|
||||
let relocated = 0;
|
||||
for (const r of results) {
|
||||
if (!r.path || r.skippedReason === 'library_track_not_found') continue;
|
||||
const key = localPlaybackEntryKey(r.serverIndexKey, r.trackId);
|
||||
const prev = useLocalPlaybackStore.getState().entries[key];
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
serverIndexKey: r.serverIndexKey,
|
||||
trackId: r.trackId,
|
||||
localPath: r.path,
|
||||
layoutFingerprint: r.layoutFingerprint || prev?.layoutFingerprint || '',
|
||||
sizeBytes: r.size || prev?.sizeBytes || 0,
|
||||
tier: 'library',
|
||||
cachedAt: prev?.cachedAt ?? Date.now(),
|
||||
pinSource: prev?.pinSource,
|
||||
suffix: prev?.suffix ?? 'mp3',
|
||||
});
|
||||
if (r.relocated) relocated += 1;
|
||||
}
|
||||
return relocated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan flat `psysonic-offline/{segment}/{trackId}.ext`, keep tracks that still
|
||||
* exist in the library index, move them under `media/library/…`, then restore
|
||||
* Offline Library grouping (`pinSource`).
|
||||
*/
|
||||
export async function runLegacyOfflineFileMigration(serverIndexKey?: string): Promise<number> {
|
||||
await waitForStoreHydration(useAuthStore as unknown as PersistCapableStore);
|
||||
await waitForStoreHydration(useLocalPlaybackStore as unknown as PersistCapableStore);
|
||||
await waitForStoreHydration(useOfflineStore as unknown as PersistCapableStore);
|
||||
|
||||
const customOfflineDir = useAuthStore.getState().offlineDownloadDir?.trim() || null;
|
||||
let relocated = 0;
|
||||
try {
|
||||
const results = await invoke<LegacyOfflineMigrationResult[]>('migrate_legacy_offline_disk', {
|
||||
mediaDir: getMediaDir(),
|
||||
customOfflineDir,
|
||||
serverIndexKeyFilter: serverIndexKey ?? null,
|
||||
});
|
||||
relocated = applyMigrationResults(results);
|
||||
migrationDebug({
|
||||
event: 'disk-migrate',
|
||||
serverIndexKey: serverIndexKey ?? null,
|
||||
scanned: results.length,
|
||||
relocated,
|
||||
results: results.map(r => ({
|
||||
trackId: r.trackId,
|
||||
relocated: r.relocated,
|
||||
skippedReason: r.skippedReason ?? null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
migrationDebug({
|
||||
event: 'disk-migrate-error',
|
||||
serverIndexKey: serverIndexKey ?? null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const fromAlbums = restoreOfflineLibraryPinSources();
|
||||
const fromIndex = await inferPinSourcesFromLibraryIndex();
|
||||
migrationDebug({
|
||||
event: 'pin-source-restore',
|
||||
fromAlbums,
|
||||
fromIndex,
|
||||
groups: useLocalPlaybackStore.getState().listPinnedGroups().length,
|
||||
});
|
||||
|
||||
return relocated;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { libraryUpsertSongsFromApi } from '@/api/library';
|
||||
import { librarySqlServerId } from '@/api/coverCache';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { LocalPlaybackEntry, PinSource } from '@/store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
import {
|
||||
entryBelongsToServer,
|
||||
findLocalPlaybackEntry,
|
||||
indexKeyBelongsToServer,
|
||||
} from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
|
||||
interface LibraryTrackProbeResult {
|
||||
path: string;
|
||||
size: number;
|
||||
layoutFingerprint: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface LibraryTierDiskHit {
|
||||
trackId: string;
|
||||
path: string;
|
||||
size: number;
|
||||
layoutFingerprint: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
export interface LibraryTierReconcileResult {
|
||||
syncedFromDisk: number;
|
||||
removedStaleIndex: number;
|
||||
orphansRemoved: number;
|
||||
}
|
||||
|
||||
function serverIndexKeyForServerId(serverId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
if (server) {
|
||||
return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
|
||||
}
|
||||
return resolveIndexKey(serverId) || serverId;
|
||||
}
|
||||
|
||||
function collectCandidateTrackIds(serverId: string, extraTrackIds: string[] = []): string[] {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const ids = new Set(extraTrackIds);
|
||||
for (const entry of libraryEntriesForServer(serverId)) {
|
||||
ids.add(entry.trackId);
|
||||
}
|
||||
for (const group of lp.listPinnedGroups()) {
|
||||
if (!indexKeyBelongsToServer(group.serverIndexKey, serverId)) continue;
|
||||
for (const trackId of group.trackIds) ids.add(trackId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function libraryEntriesForServer(serverId: string): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries).filter(
|
||||
e => e.tier === 'library' && entryBelongsToServer(e, serverId),
|
||||
);
|
||||
}
|
||||
|
||||
function upsertFromProbe(
|
||||
probe: LibraryTrackProbeResult,
|
||||
serverIndexKey: string,
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
suffix: string,
|
||||
pinSource?: PinSource,
|
||||
): void {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const existing = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (existing && existing.serverIndexKey !== serverIndexKey) {
|
||||
lp.removeEntry(trackId, existing.serverIndexKey, 'reconcile-key-normalize');
|
||||
}
|
||||
lp.upsertEntry({
|
||||
serverIndexKey,
|
||||
trackId,
|
||||
localPath: probe.path,
|
||||
sizeBytes: probe.size,
|
||||
layoutFingerprint: probe.layoutFingerprint,
|
||||
tier: 'library',
|
||||
pinSource: pinSource ?? existing?.pinSource,
|
||||
suffix,
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverLibraryTierHits(
|
||||
serverId: string,
|
||||
candidateTrackIds: string[],
|
||||
): Promise<LibraryTierDiskHit[]> {
|
||||
const serverIndexKey = serverIndexKeyForServerId(serverId);
|
||||
const libraryServerId = librarySqlServerId(serverId);
|
||||
try {
|
||||
return await invoke<LibraryTierDiskHit[]>('discover_library_tier_on_disk', {
|
||||
serverIndexKey,
|
||||
libraryServerId,
|
||||
candidateTrackIds,
|
||||
mediaDir: getMediaDir(),
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function importLibraryTierFromDisk(
|
||||
serverId: string,
|
||||
candidateTrackIds: string[],
|
||||
): Promise<{
|
||||
hits: LibraryTierDiskHit[];
|
||||
imported: number;
|
||||
hitByTrackId: Map<string, LibraryTierDiskHit>;
|
||||
}> {
|
||||
const serverIndexKey = serverIndexKeyForServerId(serverId);
|
||||
const hits = await discoverLibraryTierHits(serverId, candidateTrackIds);
|
||||
const hitByTrackId = new Map(hits.map(hit => [hit.trackId, hit]));
|
||||
let imported = 0;
|
||||
for (const hit of hits) {
|
||||
const existing = findLocalPlaybackEntry(hit.trackId, serverId);
|
||||
if (
|
||||
existing
|
||||
&& existing.localPath === hit.path
|
||||
&& existing.layoutFingerprint === hit.layoutFingerprint
|
||||
&& existing.sizeBytes === hit.size
|
||||
&& existing.serverIndexKey === serverIndexKey
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
upsertFromProbe(
|
||||
{
|
||||
path: hit.path,
|
||||
size: hit.size,
|
||||
layoutFingerprint: hit.layoutFingerprint,
|
||||
exists: true,
|
||||
},
|
||||
serverIndexKey,
|
||||
serverId,
|
||||
hit.trackId,
|
||||
hit.suffix || 'mp3',
|
||||
existing?.pinSource,
|
||||
);
|
||||
imported += 1;
|
||||
}
|
||||
return { hits, imported, hitByTrackId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional library-tier reconcile for one server scope:
|
||||
* - index row without bytes at canonical path → drop index row
|
||||
* - bytes at canonical path without index → upsert index row
|
||||
* - on-disk files not in the kept set → delete (orphan cleanup)
|
||||
*/
|
||||
/** Directory-first sweep for every configured server profile. */
|
||||
export async function reconcileAllLibraryTiersFromDisk(): Promise<void> {
|
||||
for (const server of useAuthStore.getState().servers) {
|
||||
await reconcileLibraryTierForServer(server.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileLibraryTierForServer(
|
||||
serverId: string,
|
||||
): Promise<LibraryTierReconcileResult> {
|
||||
const serverIndexKey = serverIndexKeyForServerId(serverId);
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const keepPaths = new Set<string>();
|
||||
let syncedFromDisk = 0;
|
||||
let removedStaleIndex = 0;
|
||||
|
||||
const candidates = collectCandidateTrackIds(serverId);
|
||||
const diskImport = await importLibraryTierFromDisk(serverId, candidates);
|
||||
syncedFromDisk += diskImport.imported;
|
||||
for (const hit of diskImport.hits) {
|
||||
keepPaths.add(hit.path);
|
||||
}
|
||||
|
||||
for (const entry of libraryEntriesForServer(serverId)) {
|
||||
const hit = diskImport.hitByTrackId.get(entry.trackId);
|
||||
if (hit) {
|
||||
keepPaths.add(hit.path);
|
||||
continue;
|
||||
}
|
||||
lp.removeEntry(entry.trackId, entry.serverIndexKey, 'reconcile-missing-bytes');
|
||||
removedStaleIndex += 1;
|
||||
}
|
||||
|
||||
let orphansRemoved: number;
|
||||
try {
|
||||
const removed = await invoke<string[]>('prune_orphan_library_tier_files', {
|
||||
serverIndexKey,
|
||||
keepPaths: [...keepPaths],
|
||||
mediaDir: getMediaDir(),
|
||||
});
|
||||
orphansRemoved = removed.length;
|
||||
} catch {
|
||||
orphansRemoved = 0;
|
||||
}
|
||||
|
||||
return { syncedFromDisk, removedStaleIndex, orphansRemoved };
|
||||
}
|
||||
|
||||
/** Album-scoped reconcile: sync index ↔ disk for the current track list, then prune orphans. */
|
||||
export async function reconcileLibraryTierForAlbum(
|
||||
serverId: string,
|
||||
songs: SubsonicSong[],
|
||||
pinSource?: PinSource,
|
||||
): Promise<LibraryTierReconcileResult> {
|
||||
const serverIndexKey = serverIndexKeyForServerId(serverId);
|
||||
const libraryServerId = librarySqlServerId(serverId);
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const keepPaths = new Set<string>();
|
||||
let syncedFromDisk = 0;
|
||||
let removedStaleIndex = 0;
|
||||
|
||||
await libraryUpsertSongsFromApi(libraryServerId, songs).catch(() => {});
|
||||
|
||||
const candidates = collectCandidateTrackIds(serverId, songs.map(song => song.id));
|
||||
const diskImport = await importLibraryTierFromDisk(serverId, candidates);
|
||||
|
||||
for (const song of songs) {
|
||||
const hit = diskImport.hitByTrackId.get(song.id);
|
||||
const existing = findLocalPlaybackEntry(song.id, serverId);
|
||||
if (hit) {
|
||||
keepPaths.add(hit.path);
|
||||
const effectivePin = pinSource ?? existing?.pinSource;
|
||||
if (
|
||||
!existing
|
||||
|| existing.localPath !== hit.path
|
||||
|| existing.layoutFingerprint !== hit.layoutFingerprint
|
||||
|| existing.serverIndexKey !== serverIndexKey
|
||||
) {
|
||||
upsertFromProbe(
|
||||
{
|
||||
path: hit.path,
|
||||
size: hit.size,
|
||||
layoutFingerprint: hit.layoutFingerprint,
|
||||
exists: true,
|
||||
},
|
||||
serverIndexKey,
|
||||
serverId,
|
||||
song.id,
|
||||
hit.suffix || song.suffix || 'mp3',
|
||||
effectivePin,
|
||||
);
|
||||
syncedFromDisk += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
lp.removeEntry(song.id, existing.serverIndexKey, 'reconcile-album-missing-bytes');
|
||||
removedStaleIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const hit of diskImport.hits) {
|
||||
keepPaths.add(hit.path);
|
||||
}
|
||||
|
||||
let orphansRemoved: number;
|
||||
try {
|
||||
const removed = await invoke<string[]>('prune_orphan_library_tier_files', {
|
||||
serverIndexKey,
|
||||
keepPaths: [...keepPaths],
|
||||
mediaDir: getMediaDir(),
|
||||
});
|
||||
orphansRemoved = removed.length;
|
||||
} catch {
|
||||
orphansRemoved = 0;
|
||||
}
|
||||
|
||||
return { syncedFromDisk, removedStaleIndex, orphansRemoved };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { offlineActionPolicy } from '@/features/offline/utils/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 '@/utils/library/albumBrowseTypes';
|
||||
import { isOfflineBrowseActive } from '@/features/offline/utils/offlineBrowseMode';
|
||||
import {
|
||||
fetchOfflineLocalAlbumCatalogChunk,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '@/features/offline/utils/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 '@/features/offline/utils/offlineBrowseContext';
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: vi.fn(() => false),
|
||||
countLocalBrowsableTracks: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { offlineLocalBrowseEnabled } from '@/features/offline/utils/offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from '@/features/offline/utils/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 '@/features/offline/store/offlineStore';
|
||||
import { favoritesOfflineBrowseEnabled } from '@/features/offline/utils/favoritesOfflineBrowse';
|
||||
import { hasOfflineBrowseCapability } from '@/features/offline/utils/offlineBrowseRouting';
|
||||
import { offlineLocalBrowseEnabled } from '@/features/offline/utils/offlineLocalBrowse';
|
||||
import { playlistsOfflineBrowseEnabled } from '@/features/offline/utils/offlinePlaylistBrowse';
|
||||
import { hasAnyOfflineAlbums } from '@/features/offline/utils/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,23 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useDevOfflineBrowseStore } from '@/features/offline/store/devOfflineBrowseStore';
|
||||
import { resetActiveServerConnectionSnapshot } from '@/utils/network/activeServerReachability';
|
||||
import { useOfflineBrowseActive } from '@/features/offline/utils/offlineBrowseMode';
|
||||
|
||||
describe('useOfflineBrowseActive', () => {
|
||||
beforeEach(() => {
|
||||
useDevOfflineBrowseStore.setState({ forceOffline: false });
|
||||
resetActiveServerConnectionSnapshot();
|
||||
});
|
||||
|
||||
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,27 @@
|
||||
import {
|
||||
isDevOfflineBrowseForced,
|
||||
useDevOfflineBrowseStore,
|
||||
} from '@/features/offline/store/devOfflineBrowseStore';
|
||||
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
|
||||
import { isActiveServerReachable } from '@/utils/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);
|
||||
// Shared status — all hook instances stay in sync after manual retry.
|
||||
useConnectionStatus();
|
||||
|
||||
if (import.meta.env.DEV && devForceOffline) return true;
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return true;
|
||||
return !isActiveServerReachable();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
hasOfflineBrowseCapability,
|
||||
isPathOfflineBrowsable,
|
||||
resolveOfflineDisconnectNavAction,
|
||||
} from '@/features/offline/utils/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 '@/features/offline/utils/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 '@/features/offline/utils/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,408 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
import {
|
||||
buildOfflineCacheQueueTracks,
|
||||
countFavoriteAutoTracks,
|
||||
buildTracksForOfflineCard,
|
||||
ensureServerForOfflineCard,
|
||||
hasAnyOfflineAlbums,
|
||||
hydrateOfflineLibraryCards,
|
||||
isOfflinePinComplete,
|
||||
pendingOfflinePinSongs,
|
||||
offlineAlbumCoverScope,
|
||||
offlineTrackCount,
|
||||
type OfflineLibraryCard,
|
||||
} from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
import * as libraryApi from '@/api/library';
|
||||
import { coverStorageKey } from '@/cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '@/cover/tiers';
|
||||
|
||||
vi.mock('@/utils/server/switchActiveServer', () => ({
|
||||
switchActiveServer: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/library', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/api/library')>();
|
||||
const libraryGetTracksBatch = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
libraryGetTracksBatch,
|
||||
libraryGetTracksBatchChunked: async (refs: Parameters<typeof actual.libraryGetTracksBatch>[0]) => {
|
||||
if (refs.length === 0) return [];
|
||||
const out: Awaited<ReturnType<typeof actual.libraryGetTracksBatch>> = [];
|
||||
for (let i = 0; i < refs.length; i += actual.LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + actual.LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
libraryGetTrack: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('offlineLibraryHelpers', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
servers: [{ id: 'a', name: 'Home', url: 'http://a.test', username: 'u', password: 'p' }],
|
||||
activeServerId: 'a',
|
||||
});
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('countFavoriteAutoTracks counts favorite-auto tier rows only', () => {
|
||||
expect(countFavoriteAutoTracks()).toBe(0);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/fav/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'favorite-auto',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
'a.test:t2': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't2',
|
||||
localPath: '/lib/t2.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(countFavoriteAutoTracks()).toBe(1);
|
||||
});
|
||||
|
||||
it('pendingOfflinePinSongs skips already pinned tracks', () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(pendingOfflinePinSongs([{ id: 't1' }, { id: 't2' }], 'a')).toEqual([{ id: 't2' }]);
|
||||
});
|
||||
|
||||
it('isOfflinePinComplete with songIds finds entries stored under server UUID', () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a:t1': {
|
||||
serverIndexKey: 'a',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(isOfflinePinComplete('al1', 'a', ['t1'])).toBe(true);
|
||||
});
|
||||
|
||||
it('isOfflinePinComplete checks localPlaybackStore pins by index key', () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:al1': {
|
||||
id: 'al1',
|
||||
serverId: 'a.test',
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
trackIds: ['t1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(isOfflinePinComplete('al1', 'a')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasAnyOfflineAlbums is true when pinned groups exist', () => {
|
||||
expect(hasAnyOfflineAlbums({})).toBe(false);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: '',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(hasAnyOfflineAlbums({})).toBe(true);
|
||||
});
|
||||
|
||||
it('offlineTrackCount counts pinned tracks on the card', () => {
|
||||
const card: OfflineLibraryCard = {
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
trackIds: ['t1', 't2'],
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
};
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: '',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(offlineTrackCount(card)).toBe(1);
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverScope is null when server profile is missing', () => {
|
||||
const card: OfflineLibraryCard = {
|
||||
serverIndexKey: 'gone',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
trackIds: [],
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
coverArt: 'ca1',
|
||||
};
|
||||
expect(offlineAlbumCoverScope(card)).toBeNull();
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverScope uses host index key compatible with disk cache', () => {
|
||||
const card: OfflineLibraryCard = {
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
trackIds: [],
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
coverArt: 'ca1',
|
||||
};
|
||||
const scope = offlineAlbumCoverScope(card);
|
||||
expect(scope).toMatchObject({ kind: 'server', serverId: 'a' });
|
||||
const tier = resolveCoverDisplayTier(300, { surface: 'dense' });
|
||||
expect(coverStorageKey(scope!, { cacheKind: 'album', cacheEntityId: 'ca1' }, tier)).toBe(
|
||||
'a.test:cover:album:ca1:512',
|
||||
);
|
||||
});
|
||||
|
||||
it('ensureServerForOfflineCard skips switch when already active', async () => {
|
||||
vi.mocked(switchActiveServer).mockClear();
|
||||
const card: OfflineLibraryCard = {
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
trackIds: [],
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
};
|
||||
await expect(ensureServerForOfflineCard(card)).resolves.toBe(true);
|
||||
expect(switchActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrateOfflineLibraryCards falls back to albumId when coverArtId is missing', async () => {
|
||||
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([{
|
||||
serverId: 'a',
|
||||
id: 't1',
|
||||
title: 'Song',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
}]);
|
||||
const cards = await hydrateOfflineLibraryCards([{
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al-1', displayName: 'Al' },
|
||||
trackIds: ['t1'],
|
||||
}]);
|
||||
expect(cards[0]?.coverArt).toBe('al-1');
|
||||
});
|
||||
|
||||
it('hydrateOfflineLibraryCards uses playlist name, quad cover, and no artist line', async () => {
|
||||
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([
|
||||
{
|
||||
serverId: 'a',
|
||||
id: 't1',
|
||||
title: 'Song A',
|
||||
artist: 'Artist A',
|
||||
album: 'Album A',
|
||||
albumId: 'al-a',
|
||||
coverArtId: 'cov-a',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
{
|
||||
serverId: 'a',
|
||||
id: 't2',
|
||||
title: 'Song B',
|
||||
artist: 'Artist B',
|
||||
album: 'Album B',
|
||||
albumId: 'al-b',
|
||||
coverArtId: 'cov-b',
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
},
|
||||
]);
|
||||
const cards = await hydrateOfflineLibraryCards([{
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'My Mix' },
|
||||
trackIds: ['t1', 't2'],
|
||||
}]);
|
||||
expect(cards[0]?.name).toBe('My Mix');
|
||||
expect(cards[0]?.artist).toBe('');
|
||||
expect(cards[0]?.coverArt).toBeUndefined();
|
||||
expect(cards[0]?.coverQuadIds).toEqual(['cov-a', 'cov-b', 'cov-a', 'cov-b']);
|
||||
});
|
||||
|
||||
it('hydrateOfflineLibraryCards uses legacy offline album coverArt', async () => {
|
||||
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([]);
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:al-1': {
|
||||
id: 'al-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
coverArt: 'legacy-cover',
|
||||
trackIds: ['t1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
const cards = await hydrateOfflineLibraryCards([{
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al-1' },
|
||||
trackIds: ['t1'],
|
||||
}]);
|
||||
expect(cards[0]?.coverArt).toBe('legacy-cover');
|
||||
});
|
||||
|
||||
it('buildTracksForOfflineCard falls back to local index when library batch misses', async () => {
|
||||
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([]);
|
||||
vi.mocked(libraryApi.libraryGetTrack).mockResolvedValue(null as never);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/Artist/Album/track.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const tracks = await buildTracksForOfflineCard({
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al1', displayName: 'Album' },
|
||||
trackIds: ['t1'],
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
});
|
||||
expect(tracks).toHaveLength(1);
|
||||
expect(tracks[0]?.id).toBe('t1');
|
||||
expect(tracks[0]?.suffix).toBe('mp3');
|
||||
});
|
||||
|
||||
it('buildOfflineCacheQueueTracks includes only ephemeral cache tracks', async () => {
|
||||
const hotDto = {
|
||||
id: 'hot1',
|
||||
serverId: 'a',
|
||||
title: 'Hot',
|
||||
artist: 'Ar',
|
||||
album: 'Al',
|
||||
albumId: 'al2',
|
||||
duration: 120,
|
||||
suffix: 'flac',
|
||||
};
|
||||
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([hotDto] as never);
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/Artist/Album/one.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
},
|
||||
'a.test:hot1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 'hot1',
|
||||
localPath: '/media/cache/a.test/hot1.flac',
|
||||
layoutFingerprint: 'fp2',
|
||||
sizeBytes: 2000,
|
||||
tier: 'ephemeral',
|
||||
cachedAt: 2,
|
||||
suffix: 'flac',
|
||||
},
|
||||
},
|
||||
});
|
||||
const { tracks, queueServerIndexKey } = await buildOfflineCacheQueueTracks();
|
||||
expect(queueServerIndexKey).toBe('a.test');
|
||||
expect(tracks.map(t => t.id)).toEqual(['hot1']);
|
||||
});
|
||||
|
||||
it('ensureServerForOfflineCard switches when card is on another server', async () => {
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'a', name: 'Home', url: 'http://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'b', name: 'Work', url: 'http://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
activeServerId: 'b',
|
||||
});
|
||||
const card: OfflineLibraryCard = {
|
||||
serverIndexKey: 'a.test',
|
||||
pinSource: { kind: 'album', sourceId: 'al1' },
|
||||
trackIds: [],
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
};
|
||||
await expect(ensureServerForOfflineCard(card)).resolves.toBe(true);
|
||||
expect(useAuthStore.getState().activeServerId).toBe('a');
|
||||
expect(switchActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
import type { LibraryTrackDto } from '@/api/library';
|
||||
import { libraryGetTrack, libraryGetTracksBatchChunked } from '@/api/library';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import type { CoverServerScope } from '@/cover/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { LocalPlaybackEntry, PinnedGroup, PinSource } from '@/store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { resolveTrackCoverArtId, trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
|
||||
export interface OfflineLibraryCard {
|
||||
serverIndexKey: string;
|
||||
pinSource: PinSource;
|
||||
trackIds: string[];
|
||||
name: string;
|
||||
artist: string;
|
||||
coverArt?: string;
|
||||
/** 2×2 collage when the playlist has no single custom cover. */
|
||||
coverQuadIds?: (string | null)[];
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export function resolveOfflineAlbumMeta(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
): OfflineAlbumMeta | undefined {
|
||||
const albums = useOfflineStore.getState().albums;
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
const indexKey = server ? serverIndexKeyForProfile(server) : serverId;
|
||||
return albums[`${indexKey}:${albumId}`] ?? albums[`${serverId}:${albumId}`];
|
||||
}
|
||||
|
||||
function serverIndexKeysForServerId(serverId: string): string[] {
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
const keys = new Set<string>();
|
||||
if (server) {
|
||||
const profileKey = serverIndexKeyForProfile(server);
|
||||
if (profileKey) keys.add(profileKey);
|
||||
keys.add(server.id);
|
||||
}
|
||||
keys.add(resolveIndexKey(serverId));
|
||||
keys.add(serverId);
|
||||
return [...keys].filter(Boolean);
|
||||
}
|
||||
|
||||
export function entryBelongsToServer(entry: LocalPlaybackEntry, serverId: string): boolean {
|
||||
return serverIndexKeysForServerId(serverId).includes(entry.serverIndexKey);
|
||||
}
|
||||
|
||||
export function indexKeyBelongsToServer(serverIndexKey: string, serverId: string): boolean {
|
||||
return serverIndexKeysForServerId(serverId).includes(serverIndexKey);
|
||||
}
|
||||
|
||||
/** Resolve a library-tier row across legacy UUID / URL index-key variants. */
|
||||
export function findLocalPlaybackEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
): LocalPlaybackEntry | null {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const hit = lp.getEntry(trackId, key);
|
||||
if (hit?.tier === 'library') return hit;
|
||||
}
|
||||
for (const entry of Object.values(lp.entries)) {
|
||||
if (entry.trackId !== trackId || entry.tier !== 'library') continue;
|
||||
if (entryBelongsToServer(entry, serverId)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Index cache; run {@link reconcileLibraryTierForAlbum} / server reconcile so rows match disk. */
|
||||
export function hasLocalLibraryBytes(trackId: string, serverId: string): boolean {
|
||||
return !!findLocalPlaybackEntry(trackId, serverId)?.localPath;
|
||||
}
|
||||
|
||||
/** Resolve a `favorite-auto` tier row across index-key variants. */
|
||||
export function findFavoriteAutoEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
): LocalPlaybackEntry | null {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const hit = lp.getEntry(trackId, key);
|
||||
if (hit?.tier === 'favorite-auto') return hit;
|
||||
}
|
||||
for (const entry of Object.values(lp.entries)) {
|
||||
if (entry.trackId !== trackId || entry.tier !== 'favorite-auto') continue;
|
||||
if (entryBelongsToServer(entry, serverId)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasLocalFavoriteAutoBytes(trackId: string, serverId: string): boolean {
|
||||
return !!findFavoriteAutoEntry(trackId, serverId)?.localPath;
|
||||
}
|
||||
|
||||
/** Manual offline library or favorites auto-sync — skip redundant hot-cache prefetch/promote. */
|
||||
export function hasLocalPersistentPlaybackBytes(trackId: string, serverId: string): boolean {
|
||||
return hasLocalLibraryBytes(trackId, serverId) || hasLocalFavoriteAutoBytes(trackId, serverId);
|
||||
}
|
||||
|
||||
/** Resolve `psysonic-local://` across legacy UUID / host index-key variants. */
|
||||
export function findLocalPlaybackUrl(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
tier: 'library' | 'ephemeral' | 'favorite-auto',
|
||||
): string | null {
|
||||
if (tier === 'library') {
|
||||
const entry = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
|
||||
return null;
|
||||
}
|
||||
if (tier === 'favorite-auto') {
|
||||
const entry = findFavoriteAutoEntry(trackId, serverId);
|
||||
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
|
||||
return null;
|
||||
}
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const url = lp.getLocalUrl(trackId, key, 'ephemeral');
|
||||
if (url) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Songs that still need a library-tier pin (used to skip redundant downloads). */
|
||||
export function pendingOfflinePinSongs<T extends { id: string }>(
|
||||
songs: T[],
|
||||
serverId: string,
|
||||
): T[] {
|
||||
return songs.filter(s => !hasLocalLibraryBytes(s.id, serverId));
|
||||
}
|
||||
|
||||
/** True when every track in the offline pin group has local library-tier bytes. */
|
||||
export function isOfflinePinComplete(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
songIds?: string[],
|
||||
): boolean {
|
||||
if (songIds?.length) {
|
||||
return songIds.every(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
const indexKey = server ? (serverIndexKeyForProfile(server) || serverId) : serverId;
|
||||
const meta = resolveOfflineAlbumMeta(albumId, serverId);
|
||||
const groupTrackIds = useLocalPlaybackStore.getState()
|
||||
.listPinnedGroups(indexKey)
|
||||
.find(g => g.pinSource.sourceId === albumId)?.trackIds;
|
||||
const trackIds = meta?.trackIds.length
|
||||
? meta.trackIds
|
||||
: (groupTrackIds ?? []);
|
||||
if (trackIds.length === 0) return false;
|
||||
return trackIds.every(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link reconcileLibraryTierForAlbum} from `./libraryTierReconcile`. */
|
||||
export async function syncAlbumLibraryTierFromDisk(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
songs: SubsonicSong[],
|
||||
pinSource?: PinSource,
|
||||
): Promise<number> {
|
||||
const { reconcileLibraryTierForAlbum } = await import('@/features/offline/utils/libraryTierReconcile');
|
||||
const result = await reconcileLibraryTierForAlbum(serverId, songs, pinSource ?? {
|
||||
kind: 'album',
|
||||
sourceId: albumId,
|
||||
});
|
||||
return result.syncedFromDisk;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link listOfflineLibraryCards}. */
|
||||
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
|
||||
if (Object.keys(albums).length > 0) return true;
|
||||
return useLocalPlaybackStore.getState().listPinnedGroups().length > 0;
|
||||
}
|
||||
|
||||
export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
|
||||
const song = trackToSong(dto);
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist ?? '',
|
||||
album: song.album,
|
||||
albumId: song.albumId ?? '',
|
||||
artistId: song.artistId,
|
||||
duration: song.duration ?? 0,
|
||||
coverArt: song.coverArt,
|
||||
discNumber: song.discNumber,
|
||||
track: song.track,
|
||||
year: song.year,
|
||||
bitRate: song.bitRate,
|
||||
suffix: song.suffix,
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
|
||||
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
|
||||
size: song.size,
|
||||
serverId: dto.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCoverForPinnedGroup(group: PinnedGroup): string | undefined {
|
||||
const albums: OfflineAlbumMeta[] = [...Object.values(useOfflineStore.getState().albums)];
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic-offline');
|
||||
if (raw) {
|
||||
const blob = JSON.parse(raw) as { state?: { albums?: Record<string, OfflineAlbumMeta> } };
|
||||
albums.push(...Object.values(blob.state?.albums ?? {}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const pinKind = group.pinSource.kind ?? 'album';
|
||||
for (const album of albums) {
|
||||
if (album.id !== group.pinSource.sourceId) continue;
|
||||
if (album.type && album.type !== pinKind) continue;
|
||||
const albumKey = resolveIndexKey(album.serverId);
|
||||
if (albumKey !== group.serverIndexKey && album.serverId !== group.serverIndexKey) continue;
|
||||
const cover = album.coverArt?.trim();
|
||||
if (cover) return cover;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPlaylistCoverQuad(
|
||||
trackIds: string[],
|
||||
byId: Map<string, LibraryTrackDto>,
|
||||
libraryServerId: string,
|
||||
): (string | null)[] {
|
||||
const seen = new Set<string>();
|
||||
const covers: string[] = [];
|
||||
for (const trackId of trackIds) {
|
||||
const dto = byId.get(`${libraryServerId}:${trackId}`);
|
||||
if (!dto) continue;
|
||||
const cover = resolveTrackCoverArtId(dto);
|
||||
if (!cover || seen.has(cover)) continue;
|
||||
seen.add(cover);
|
||||
covers.push(cover);
|
||||
if (covers.length >= 4) break;
|
||||
}
|
||||
if (covers.length === 0) return [];
|
||||
return Array.from({ length: 4 }, (_, i) => covers[i % covers.length] ?? null);
|
||||
}
|
||||
|
||||
export async function hydrateOfflineLibraryCards(
|
||||
groups: PinnedGroup[],
|
||||
): Promise<OfflineLibraryCard[]> {
|
||||
if (groups.length === 0) return [];
|
||||
const refs = groups.flatMap(g =>
|
||||
g.trackIds.map(trackId => ({
|
||||
serverId: resolveServerIdForIndexKey(g.serverIndexKey) || g.serverIndexKey,
|
||||
trackId,
|
||||
})),
|
||||
);
|
||||
const tracks = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
|
||||
|
||||
return groups.map(group => {
|
||||
const libraryServerId = resolveServerIdForIndexKey(group.serverIndexKey) || group.serverIndexKey;
|
||||
const first = group.trackIds
|
||||
.map(tid => byId.get(`${libraryServerId}:${tid}`))
|
||||
.find(Boolean);
|
||||
const pinKind = group.pinSource.kind ?? 'album';
|
||||
const pinnedMeta = resolveOfflineAlbumMeta(group.pinSource.sourceId, libraryServerId);
|
||||
const legacyCover = legacyCoverForPinnedGroup(group);
|
||||
const displayName = pinKind === 'album'
|
||||
? (group.pinSource.displayName
|
||||
?? first?.album
|
||||
?? first?.title
|
||||
?? group.pinSource.sourceId)
|
||||
: (group.pinSource.displayName ?? group.pinSource.sourceId);
|
||||
const artist = pinKind === 'artist'
|
||||
? (group.pinSource.displayName ?? first?.artist ?? '')
|
||||
: pinKind === 'playlist'
|
||||
? ''
|
||||
: (pinnedMeta?.artist?.trim()
|
||||
|| first?.albumArtist?.trim()
|
||||
|| first?.artist?.trim()
|
||||
|| '');
|
||||
|
||||
let coverArt: string | undefined;
|
||||
let coverQuadIds: (string | null)[] | undefined;
|
||||
if (pinKind === 'playlist') {
|
||||
coverArt = pinnedMeta?.coverArt?.trim() || legacyCover || undefined;
|
||||
if (!coverArt) {
|
||||
const quad = buildPlaylistCoverQuad(group.trackIds, byId, libraryServerId);
|
||||
if (quad.some(Boolean)) coverQuadIds = quad;
|
||||
}
|
||||
} else {
|
||||
coverArt = pinnedMeta?.coverArt?.trim()
|
||||
|| legacyCover
|
||||
|| (first ? resolveTrackCoverArtId(first) : undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
serverIndexKey: group.serverIndexKey,
|
||||
pinSource: group.pinSource,
|
||||
trackIds: group.trackIds,
|
||||
name: displayName,
|
||||
artist,
|
||||
coverArt,
|
||||
coverQuadIds,
|
||||
year: pinKind === 'album' ? (first?.year ?? undefined) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackTrackFromLocalEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
card: OfflineLibraryCard,
|
||||
): Track | null {
|
||||
const entry = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (!entry?.localPath) return null;
|
||||
return {
|
||||
id: trackId,
|
||||
title: card.pinSource.displayName ?? card.name,
|
||||
artist: card.artist,
|
||||
album: card.name,
|
||||
albumId: card.pinSource.kind === 'album' ? card.pinSource.sourceId : '',
|
||||
duration: 0,
|
||||
coverArt: card.coverArt,
|
||||
suffix: entry.suffix ?? 'mp3',
|
||||
size: entry.sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureServerForOfflineIndexKey(serverIndexKey: string): Promise<boolean> {
|
||||
const { activeServerId, servers } = useAuthStore.getState();
|
||||
const resolved = resolveServerIdForIndexKey(serverIndexKey) || serverIndexKey;
|
||||
if (resolved === activeServerId) return true;
|
||||
const server = servers.find(s => s.id === resolved)
|
||||
?? findServerByIdOrIndexKey(serverIndexKey);
|
||||
if (!server) return false;
|
||||
const auth = useAuthStore.getState();
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function listEphemeralCacheEntries(): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'ephemeral' && e.localPath)
|
||||
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
|
||||
}
|
||||
|
||||
/** Indexed ephemeral rows with on-disk bytes under `{media}/cache/`. */
|
||||
export function countEphemeralCacheTracks(): number {
|
||||
return listEphemeralCacheEntries().length;
|
||||
}
|
||||
|
||||
export function ephemeralCacheCoverScope(): CoverServerScope | null {
|
||||
const entry = listEphemeralCacheEntries()[0];
|
||||
if (!entry) return null;
|
||||
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to four cover IDs for a playlist-style collage from hot-cache tracks. */
|
||||
export async function collectEphemeralCacheCoverQuad(): Promise<(string | null)[]> {
|
||||
const ephemeral = listEphemeralCacheEntries();
|
||||
if (ephemeral.length === 0) return [null, null, null, null];
|
||||
const refs = ephemeral.slice(0, 16).map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const covers: string[] = [];
|
||||
for (const dto of dtos) {
|
||||
const cover = resolveTrackCoverArtId(dto);
|
||||
if (cover && !covers.includes(cover)) covers.push(cover);
|
||||
if (covers.length >= 4) break;
|
||||
}
|
||||
return Array.from({ length: 4 }, (_, i) => covers[i] ?? null);
|
||||
}
|
||||
|
||||
function listFavoriteAutoEntries(): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'favorite-auto' && e.localPath)
|
||||
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
|
||||
}
|
||||
|
||||
/** Indexed favorite-auto rows with on-disk bytes under `{media}/favorites/`. */
|
||||
export function countFavoriteAutoTracks(): number {
|
||||
return listFavoriteAutoEntries().length;
|
||||
}
|
||||
|
||||
export function favoriteAutoCoverScope(): CoverServerScope | null {
|
||||
const entry = listFavoriteAutoEntries()[0];
|
||||
if (!entry) return null;
|
||||
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
export type OfflineCoverQuadCell = {
|
||||
coverArtId: string;
|
||||
serverId: string;
|
||||
} | null;
|
||||
|
||||
/** Up to four cover cells for a collage from favorites-tier tracks (per-server scope). */
|
||||
export async function collectFavoriteAutoCoverQuad(): Promise<OfflineCoverQuadCell[]> {
|
||||
const favorites = listFavoriteAutoEntries();
|
||||
if (favorites.length === 0) return [null, null, null, null];
|
||||
const refs = favorites.slice(0, 16).map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const cells: { coverArtId: string; serverId: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const dto of dtos) {
|
||||
const coverArtId = resolveTrackCoverArtId(dto);
|
||||
if (!coverArtId) continue;
|
||||
const dedupeKey = `${dto.serverId}:${coverArtId}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
cells.push({ coverArtId, serverId: dto.serverId });
|
||||
if (cells.length >= 4) break;
|
||||
}
|
||||
return Array.from({ length: 4 }, (_, i) => cells[i] ?? null);
|
||||
}
|
||||
|
||||
/** Playable tracks under `{media}/favorites/` only (favorite-auto tier). */
|
||||
export async function buildOfflineFavoritesQueueTracks(): Promise<{
|
||||
tracks: Track[];
|
||||
queueServerIndexKey: string | null;
|
||||
}> {
|
||||
const favorites = listFavoriteAutoEntries();
|
||||
if (favorites.length === 0) {
|
||||
return { tracks: [], queueServerIndexKey: null };
|
||||
}
|
||||
|
||||
const refs = favorites.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
|
||||
|
||||
const tracks: Track[] = [];
|
||||
let queueServerIndexKey: string | null = null;
|
||||
for (const entry of favorites) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
} else {
|
||||
tracks.push({
|
||||
id: entry.trackId,
|
||||
title: entry.trackId,
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
suffix: entry.suffix,
|
||||
size: entry.sizeBytes,
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
queueServerIndexKey ??= entry.serverIndexKey;
|
||||
}
|
||||
|
||||
return { tracks, queueServerIndexKey };
|
||||
}
|
||||
|
||||
/** Playable tracks under `{media}/cache/` only (ephemeral / hot-cache tier). */
|
||||
export async function buildOfflineCacheQueueTracks(): Promise<{
|
||||
tracks: Track[];
|
||||
queueServerIndexKey: string | null;
|
||||
}> {
|
||||
const ephemeral = listEphemeralCacheEntries();
|
||||
if (ephemeral.length === 0) {
|
||||
return { tracks: [], queueServerIndexKey: null };
|
||||
}
|
||||
|
||||
const refs = ephemeral.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
|
||||
|
||||
const tracks: Track[] = [];
|
||||
let queueServerIndexKey: string | null = null;
|
||||
for (const entry of ephemeral) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
} else {
|
||||
tracks.push({
|
||||
id: entry.trackId,
|
||||
title: entry.trackId,
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
suffix: entry.suffix,
|
||||
size: entry.sizeBytes,
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
queueServerIndexKey ??= entry.serverIndexKey;
|
||||
}
|
||||
|
||||
return { tracks, queueServerIndexKey };
|
||||
}
|
||||
|
||||
export async function buildTracksForOfflineCard(card: OfflineLibraryCard): Promise<Track[]> {
|
||||
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
|
||||
const localTrackIds = card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
if (localTrackIds.length === 0) return [];
|
||||
|
||||
const refs = localTrackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [d.id, d]));
|
||||
const order = new Map(localTrackIds.map((id, i) => [id, i]));
|
||||
const tracks: Track[] = [];
|
||||
|
||||
for (const trackId of localTrackIds) {
|
||||
const dto = dtoById.get(trackId);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
continue;
|
||||
}
|
||||
const single = await libraryGetTrack(serverId, trackId).catch(() => null);
|
||||
if (single) {
|
||||
tracks.push(libraryDtoToTrack(single));
|
||||
continue;
|
||||
}
|
||||
const fallback = fallbackTrackFromLocalEntry(trackId, serverId, card);
|
||||
if (fallback) tracks.push(fallback);
|
||||
}
|
||||
|
||||
return tracks.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function buildOfflineTracksForAlbum(
|
||||
album: OfflineAlbumMeta,
|
||||
tracks: Record<string, never>,
|
||||
): Track[] {
|
||||
void tracks;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function offlineAlbumCoverScope(card: Pick<OfflineLibraryCard, 'serverIndexKey' | 'coverArt'>): CoverServerScope | null {
|
||||
if (!card.coverArt) return null;
|
||||
const server = findServerByIdOrIndexKey(card.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
/** Offline play only needs the library index + on-disk bytes — no live server ping. */
|
||||
export async function ensureServerForOfflineCard(card: OfflineLibraryCard): Promise<boolean> {
|
||||
return ensureServerForOfflineIndexKey(card.serverIndexKey);
|
||||
}
|
||||
|
||||
export function offlineQueueServerKeyForCard(card: OfflineLibraryCard): string {
|
||||
return canonicalQueueServerKey(card.serverIndexKey);
|
||||
}
|
||||
|
||||
export function offlineTrackCount(card: OfflineLibraryCard): number {
|
||||
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
|
||||
return card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId)).length;
|
||||
}
|
||||
|
||||
export function offlineLibraryCardKey(card: OfflineLibraryCard): string {
|
||||
return `${card.serverIndexKey}:${card.pinSource.kind}:${card.pinSource.sourceId}`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '@/api/library';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from '@/api/subsonicTypes';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from '@/utils/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 '@/features/offline/utils/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,310 @@
|
||||
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 '@/utils/library/advancedSearchLocal';
|
||||
import { albumIsCompilationFromTrackDtos } from '@/utils/library/albumCompilation';
|
||||
import {
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByGenres,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from '@/utils/library/albumBrowseFilters';
|
||||
import type { AlbumBrowseQuery } from '@/utils/library/albumBrowseTypes';
|
||||
import { sortSubsonicAlbums } from '@/utils/library/albumBrowseSort';
|
||||
import { isLosslessSuffix } from '@/utils/library/losslessFormats';
|
||||
import { entryBelongsToServer } from '@/features/offline/utils/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);
|
||||
const isCompilation = albumIsCompilationFromTrackDtos(tracks);
|
||||
return {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.albumArtist ?? first.artist ?? '',
|
||||
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,
|
||||
isCompilation: isCompilation || 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,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
resolveAlbum,
|
||||
resolveAlbumForActiveServer,
|
||||
resolveArtist,
|
||||
resolvePlaylist,
|
||||
} from '@/features/offline/utils/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) => ({}));
|
||||
const libraryIsReadyMock = vi.fn(async (_serverId: string) => false);
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineBrowseMode', () => ({
|
||||
isOfflineBrowseActive: () => isOfflineBrowseActiveMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineLocalBrowse', () => ({
|
||||
offlineLocalBrowseEnabled: (id: string) => offlineLocalBrowseEnabledMock(id),
|
||||
loadAlbumFromLocalPlayback: (serverId: string, albumId: string) =>
|
||||
loadAlbumFromLocalPlaybackMock(serverId, albumId),
|
||||
loadArtistFromLocalPlayback: (serverId: string, artistId: string) =>
|
||||
loadArtistFromLocalPlaybackMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineLibraryIndexLoad', () => ({
|
||||
loadAlbumFromLibraryIndex: (...args: unknown[]) => loadAlbumFromLibraryIndexMock(...args),
|
||||
loadArtistFromLibraryIndex: (...args: unknown[]) => loadArtistFromLibraryIndexMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlinePlaylistBrowse', () => ({
|
||||
playlistsOfflineBrowseEnabled: (id: string) => playlistsOfflineBrowseEnabledMock(id),
|
||||
loadOfflineBrowsablePlaylist: (playlistId: string, serverId: string) =>
|
||||
loadOfflineBrowsablePlaylistMock(playlistId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/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),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/library/libraryReady', () => ({
|
||||
libraryIsReady: (serverId: string) => libraryIsReadyMock(serverId),
|
||||
}));
|
||||
|
||||
describe('offlineMediaResolve', () => {
|
||||
beforeEach(() => {
|
||||
isOfflineBrowseActiveMock.mockReturnValue(false);
|
||||
offlineLocalBrowseEnabledMock.mockReturnValue(false);
|
||||
playlistsOfflineBrowseEnabledMock.mockReturnValue(false);
|
||||
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
|
||||
libraryIsReadyMock.mockReset();
|
||||
libraryIsReadyMock.mockResolvedValue(false);
|
||||
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('resolveAlbum prefers the library index when ready, without hitting the network', async () => {
|
||||
libraryIsReadyMock.mockResolvedValue(true);
|
||||
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');
|
||||
expect(getAlbumForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolveAlbum falls back to network when the index is ready but has no hit', async () => {
|
||||
libraryIsReadyMock.mockResolvedValue(true);
|
||||
loadAlbumFromLibraryIndexMock.mockResolvedValue(null);
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Net' },
|
||||
songs: [{ id: 't1' }, { id: 't2' }],
|
||||
});
|
||||
const result = await resolveAlbum('srv-1', 'alb-1');
|
||||
expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
|
||||
expect(result?.album.name).toBe('Net');
|
||||
});
|
||||
|
||||
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,130 @@
|
||||
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 '@/utils/network/subsonicNetworkGuard';
|
||||
import { isOfflineBrowseActive } from '@/features/offline/utils/offlineBrowseMode';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
import {
|
||||
loadAlbumFromLibraryIndex,
|
||||
loadArtistFromLibraryIndex,
|
||||
} from '@/features/offline/utils/offlineLibraryIndexLoad';
|
||||
import {
|
||||
loadAlbumFromLocalPlayback,
|
||||
loadArtistFromLocalPlayback,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '@/features/offline/utils/offlineLocalBrowse';
|
||||
import {
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from '@/features/offline/utils/offlinePlaylistBrowse';
|
||||
|
||||
export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] };
|
||||
|
||||
/**
|
||||
* Album detail / play / enqueue: the local SQLite index first when it is ready
|
||||
* (same data genre browse reads, no network round-trip, works offline), then the
|
||||
* network album when reachable (complete track list), then the index as fallback.
|
||||
* Local bytes when offline browse is active.
|
||||
*/
|
||||
export async function resolveAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<ResolvedAlbum | null> {
|
||||
if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) {
|
||||
return loadAlbumFromLocalPlayback(serverId, albumId);
|
||||
}
|
||||
if (await libraryIsReady(serverId)) {
|
||||
try {
|
||||
const hit = await loadAlbumFromLibraryIndex(serverId, albumId);
|
||||
if (hit) return hit;
|
||||
} catch { /* index error → network fallback */ }
|
||||
}
|
||||
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,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cancelledDownloads, useOfflineJobStore } from '@/features/offline/store/offlineJobStore';
|
||||
import {
|
||||
clearOfflinePinTasks,
|
||||
dequeueOfflinePin,
|
||||
enqueueOfflinePin,
|
||||
isAlbumPinQueued,
|
||||
registerOfflinePinExecutor,
|
||||
} from '@/features/offline/utils/offlinePinQueue';
|
||||
|
||||
describe('offlinePinQueue', () => {
|
||||
beforeEach(() => {
|
||||
cancelledDownloads.clear();
|
||||
clearOfflinePinTasks();
|
||||
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
|
||||
registerOfflinePinExecutor(async () => {});
|
||||
});
|
||||
|
||||
it('dequeues a queued album without affecting an active download', async () => {
|
||||
const gate = { unblock: undefined as (() => void) | undefined };
|
||||
registerOfflinePinExecutor(async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
gate.unblock = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
enqueueOfflinePin({
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album',
|
||||
});
|
||||
enqueueOfflinePin({
|
||||
albumId: 'alb-2',
|
||||
albumName: 'Two',
|
||||
albumArtist: 'B',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(isAlbumPinQueued('alb-2')).toBe(true));
|
||||
expect(dequeueOfflinePin('alb-2')).toBe(true);
|
||||
expect(isAlbumPinQueued('alb-2')).toBe(false);
|
||||
expect(useOfflineJobStore.getState().pinQueue).toHaveLength(1);
|
||||
|
||||
gate.unblock?.();
|
||||
await vi.waitFor(() => expect(useOfflineJobStore.getState().pinQueue).toHaveLength(0));
|
||||
});
|
||||
|
||||
it('allows re-enqueue after cancelDownload (e.g. remove offline cache)', async () => {
|
||||
const ran: string[] = [];
|
||||
registerOfflinePinExecutor(async task => {
|
||||
ran.push(task.albumId);
|
||||
});
|
||||
|
||||
const task = {
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album' as const,
|
||||
};
|
||||
|
||||
enqueueOfflinePin(task);
|
||||
await vi.waitFor(() => expect(ran).toEqual(['alb-1']));
|
||||
|
||||
useOfflineJobStore.getState().cancelDownload('alb-1');
|
||||
expect(cancelledDownloads.has('alb-1')).toBe(true);
|
||||
|
||||
enqueueOfflinePin(task);
|
||||
await vi.waitFor(() => expect(ran).toEqual(['alb-1', 'alb-1']));
|
||||
});
|
||||
|
||||
it('clears stale cancel flag when enqueueOfflinePin runs', async () => {
|
||||
cancelledDownloads.add('alb-1');
|
||||
const ran: string[] = [];
|
||||
registerOfflinePinExecutor(async task => {
|
||||
ran.push(task.albumId);
|
||||
});
|
||||
|
||||
enqueueOfflinePin({
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(ran).toEqual(['alb-1']));
|
||||
expect(cancelledDownloads.has('alb-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('dedupes duplicate album ids in the queue', () => {
|
||||
const task = {
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album' as const,
|
||||
};
|
||||
expect(enqueueOfflinePin(task)).toBe(true);
|
||||
expect(enqueueOfflinePin(task)).toBe(false);
|
||||
expect(useOfflineJobStore.getState().pinQueue).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not replace the in-flight task when a download is active', async () => {
|
||||
let capturedTrackIds: string[] = [];
|
||||
const gate = { unblock: undefined as (() => void) | undefined };
|
||||
registerOfflinePinExecutor(async task => {
|
||||
capturedTrackIds = task.songs.map(s => s.id);
|
||||
await new Promise<void>(resolve => {
|
||||
gate.unblock = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const base = {
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
serverId: 'srv',
|
||||
type: 'album' as const,
|
||||
};
|
||||
|
||||
enqueueOfflinePin({ ...base, songs: [{ id: 't1', title: 't1', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 }] });
|
||||
await vi.waitFor(() => {
|
||||
expect(useOfflineJobStore.getState().pinQueue[0]?.status).toBe('downloading');
|
||||
});
|
||||
|
||||
expect(enqueueOfflinePin({
|
||||
...base,
|
||||
songs: [
|
||||
{ id: 't1', title: 't1', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 },
|
||||
{ id: 't2', title: 't2', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 },
|
||||
],
|
||||
})).toBe(false);
|
||||
|
||||
gate.unblock?.();
|
||||
await vi.waitFor(() => expect(capturedTrackIds).toEqual(['t1']));
|
||||
});
|
||||
|
||||
it('processes albums one after another', async () => {
|
||||
const order: string[] = [];
|
||||
const gate = { unblock: undefined as (() => void) | undefined };
|
||||
registerOfflinePinExecutor(async task => {
|
||||
order.push(task.albumId);
|
||||
await new Promise<void>(resolve => {
|
||||
gate.unblock = () => resolve();
|
||||
});
|
||||
});
|
||||
|
||||
enqueueOfflinePin({
|
||||
albumId: 'alb-1',
|
||||
albumName: 'One',
|
||||
albumArtist: 'A',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album',
|
||||
});
|
||||
enqueueOfflinePin({
|
||||
albumId: 'alb-2',
|
||||
albumName: 'Two',
|
||||
albumArtist: 'B',
|
||||
coverArt: undefined,
|
||||
year: undefined,
|
||||
songs: [],
|
||||
serverId: 'srv',
|
||||
type: 'album',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(order).toEqual(['alb-1']));
|
||||
expect(useOfflineJobStore.getState().pinQueue.some(p => p.albumId === 'alb-2' && p.status === 'queued')).toBe(true);
|
||||
|
||||
gate.unblock?.();
|
||||
await vi.waitFor(() => expect(order).toEqual(['alb-1', 'alb-2']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import type { PinSource } from '@/store/localPlaybackStore';
|
||||
import {
|
||||
cancelledDownloads,
|
||||
useOfflineJobStore,
|
||||
type OfflinePinQueueEntry,
|
||||
} from '@/features/offline/store/offlineJobStore';
|
||||
|
||||
export type OfflinePinKind = PinSource['kind'];
|
||||
|
||||
export interface OfflinePinTask {
|
||||
albumId: string;
|
||||
albumName: string;
|
||||
albumArtist: string;
|
||||
coverArt: string | undefined;
|
||||
year: number | undefined;
|
||||
songs: SubsonicSong[];
|
||||
serverId: string;
|
||||
type: OfflinePinKind;
|
||||
/** When set, bump `bulkProgress[groupId].done` after each album finishes. */
|
||||
artistProgressGroupId?: string;
|
||||
}
|
||||
|
||||
type OfflinePinExecutor = (task: OfflinePinTask) => Promise<void>;
|
||||
|
||||
const pinTasks = new Map<string, OfflinePinTask>();
|
||||
let executor: OfflinePinExecutor | null = null;
|
||||
let queueDraining = false;
|
||||
|
||||
export function registerOfflinePinExecutor(fn: OfflinePinExecutor): void {
|
||||
executor = fn;
|
||||
}
|
||||
|
||||
export function clearOfflinePinTasks(): void {
|
||||
pinTasks.clear();
|
||||
}
|
||||
|
||||
export function removeOfflinePinTask(albumId: string): void {
|
||||
pinTasks.delete(albumId);
|
||||
}
|
||||
|
||||
/** True when the album is waiting in the pin queue (not actively downloading). */
|
||||
export function isAlbumPinQueued(albumId: string): boolean {
|
||||
return useOfflineJobStore.getState().pinQueue.some(
|
||||
p => p.albumId === albumId && p.status === 'queued',
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a queued pin before download starts. No-op if already downloading. */
|
||||
export function dequeueOfflinePin(albumId: string): boolean {
|
||||
const store = useOfflineJobStore.getState();
|
||||
const entry = store.pinQueue.find(p => p.albumId === albumId);
|
||||
if (!entry || entry.status !== 'queued') return false;
|
||||
cancelledDownloads.add(albumId);
|
||||
removeOfflinePinTask(albumId);
|
||||
store.removePinFromQueue(albumId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPinAlreadyScheduled(albumId: string): boolean {
|
||||
const { pinQueue } = useOfflineJobStore.getState();
|
||||
return pinQueue.some(p => p.albumId === albumId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a library-tier pin. Duplicate album/playlist/artist ids coalesce to one
|
||||
* entry; the queue drains one album at a time so parallel pins do not evict each other.
|
||||
*/
|
||||
export function enqueueOfflinePin(task: OfflinePinTask): boolean {
|
||||
cancelledDownloads.delete(task.albumId);
|
||||
|
||||
const store = useOfflineJobStore.getState();
|
||||
const existing = store.pinQueue.find(p => p.albumId === task.albumId);
|
||||
if (existing?.status === 'downloading') {
|
||||
return false;
|
||||
}
|
||||
|
||||
pinTasks.set(task.albumId, task);
|
||||
|
||||
if (existing?.status === 'queued') {
|
||||
scheduleOfflinePinQueue();
|
||||
return true;
|
||||
}
|
||||
if (isPinAlreadyScheduled(task.albumId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry: OfflinePinQueueEntry = {
|
||||
albumId: task.albumId,
|
||||
albumName: task.albumName,
|
||||
pinKind: task.type,
|
||||
status: 'queued',
|
||||
queuedAt: Date.now(),
|
||||
};
|
||||
useOfflineJobStore.setState(state => ({
|
||||
pinQueue: [...state.pinQueue, entry],
|
||||
}));
|
||||
scheduleOfflinePinQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scheduleOfflinePinQueue(): void {
|
||||
void drainOfflinePinQueue();
|
||||
}
|
||||
|
||||
async function drainOfflinePinQueue(): Promise<void> {
|
||||
if (queueDraining || !executor) return;
|
||||
queueDraining = true;
|
||||
try {
|
||||
while (true) {
|
||||
const store = useOfflineJobStore.getState();
|
||||
const next = store.pinQueue.find(p => p.status === 'queued');
|
||||
if (!next) break;
|
||||
|
||||
if (cancelledDownloads.has(next.albumId)) {
|
||||
store.removePinFromQueue(next.albumId);
|
||||
pinTasks.delete(next.albumId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = pinTasks.get(next.albumId);
|
||||
if (!task) {
|
||||
store.removePinFromQueue(next.albumId);
|
||||
continue;
|
||||
}
|
||||
|
||||
store.setPinQueueStatus(next.albumId, 'downloading');
|
||||
try {
|
||||
await executor(task);
|
||||
} catch {
|
||||
/* per-track errors are recorded on jobs; continue queue */
|
||||
} finally {
|
||||
if (task.artistProgressGroupId) {
|
||||
store.bumpBulkProgressDone(task.artistProgressGroupId);
|
||||
}
|
||||
store.removePinFromQueue(next.albumId);
|
||||
pinTasks.delete(next.albumId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
queueDraining = false;
|
||||
const stillQueued = useOfflineJobStore.getState().pinQueue.some(p => p.status === 'queued');
|
||||
if (stillQueued) {
|
||||
void drainOfflinePinQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 '@/features/offline/store/offlineStore';
|
||||
import {
|
||||
fetchOfflineBrowsablePlaylists,
|
||||
loadOfflineBrowsablePlaylist,
|
||||
playlistsOfflineBrowseEnabled,
|
||||
} from '@/features/offline/utils/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 '@/utils/library/advancedSearchLocal';
|
||||
import { isManualOfflinePlaylist } from '@/features/offline/utils/pinnedOfflineSync';
|
||||
import {
|
||||
hasLocalLibraryBytes,
|
||||
indexKeyBelongsToServer,
|
||||
resolveOfflineAlbumMeta,
|
||||
} from '@/features/offline/utils/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 '@/utils/network/activeServerReachability';
|
||||
import {
|
||||
albumToAlbum,
|
||||
trackToSong,
|
||||
} from '@/utils/library/advancedSearchLocal';
|
||||
import { dedupeById } from '@/utils/dedupeById';
|
||||
import { isOfflineBrowseActive } from '@/features/offline/utils/offlineBrowseMode';
|
||||
import { favoritesServerIds } from '@/features/offline/utils/favoritesOfflineBrowse';
|
||||
import {
|
||||
buildAlbumFromTracks,
|
||||
fetchBrowsableLocalTrackDtos,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '@/features/offline/utils/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);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineJobStore } from '@/features/offline/store/offlineJobStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import {
|
||||
isManualOfflinePlaylist,
|
||||
isPlaylistPinnedOffline,
|
||||
isSourcePinnedOffline,
|
||||
schedulePinnedAlbumSync,
|
||||
schedulePinnedPlaylistSync,
|
||||
scheduleSyncPinnedAlbumsAndArtists,
|
||||
syncAllPinnedPlaylists,
|
||||
syncPinnedArtistIfNeeded,
|
||||
} from '@/features/offline/utils/pinnedOfflineSync';
|
||||
import { SMART_PREFIX } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
|
||||
const getPlaylistMock = vi.fn();
|
||||
const getAlbumForServerMock = vi.fn();
|
||||
const getArtistForServerMock = vi.fn();
|
||||
const filterSongsMock = vi.fn(async (songs: SubsonicSong[]) => songs);
|
||||
const isReachableMock = vi.fn(() => true);
|
||||
const enqueueMock = vi.fn((_task: unknown) => true);
|
||||
const invokeMock = vi.fn(async (_cmd: string, _args?: unknown) => ({}));
|
||||
|
||||
vi.mock('@/utils/network/activeServerReachability', () => ({
|
||||
isActiveServerReachable: () => isReachableMock(),
|
||||
onActiveServerBecameReachable: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicPlaylists', () => ({
|
||||
getPlaylistForServer: (serverId: string, id: string) => getPlaylistMock(serverId, id),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicLibrary', () => ({
|
||||
getAlbumForServer: (serverId: string, id: string) => getAlbumForServerMock(serverId, id),
|
||||
filterSongsToServerLibrary: (songs: SubsonicSong[]) => filterSongsMock(songs),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/subsonicArtists', () => ({
|
||||
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/library', () => ({
|
||||
libraryGetTracksByAlbum: vi.fn(async () => []),
|
||||
subscribeLibrarySyncIdle: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlinePinQueue', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/features/offline/utils/offlinePinQueue')>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueOfflinePin: (task: unknown) => enqueueMock(task),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
|
||||
}));
|
||||
|
||||
function song(id: string): SubsonicSong {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
artist: 'A',
|
||||
album: 'Al',
|
||||
albumId: 'al-1',
|
||||
duration: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function seedAuth(): void {
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
}
|
||||
|
||||
describe('isPlaylistPinnedOffline', () => {
|
||||
beforeEach(() => {
|
||||
useOfflineStore.setState({ albums: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
seedAuth();
|
||||
});
|
||||
|
||||
it('returns true when offline meta marks a playlist pin', () => {
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:pl-1': {
|
||||
id: 'pl-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Mix',
|
||||
artist: '',
|
||||
trackIds: ['t1'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(isPlaylistPinnedOffline('pl-1', 'srv-a')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for uncached playlists', () => {
|
||||
expect(isPlaylistPinnedOffline('pl-9', 'srv-a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManualOfflinePlaylist', () => {
|
||||
beforeEach(() => seedAuth());
|
||||
|
||||
it('rejects smart playlist names', () => {
|
||||
expect(isManualOfflinePlaylist('pl-1', 'srv-a', `${SMART_PREFIX}Jazz`)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows regular playlist names', () => {
|
||||
expect(isManualOfflinePlaylist('pl-1', 'srv-a', 'Road mix')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedulePinnedPlaylistSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getPlaylistMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
invokeMock.mockClear();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:pl-1': {
|
||||
id: 'pl-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Road mix',
|
||||
artist: '',
|
||||
trackIds: ['t1'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
|
||||
},
|
||||
},
|
||||
});
|
||||
seedAuth();
|
||||
getPlaylistMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
|
||||
songs: [song('t2')],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does nothing when the playlist is not cached offline', async () => {
|
||||
schedulePinnedPlaylistSync('pl-9');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getPlaylistMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not sync smart playlists even when previously cached', async () => {
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:pl-smart': {
|
||||
id: 'pl-smart',
|
||||
serverId: 'a.test',
|
||||
name: `${SMART_PREFIX}Daily`,
|
||||
artist: '',
|
||||
trackIds: ['t1'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
schedulePinnedPlaylistSync('pl-smart');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getPlaylistMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prunes removed tracks and enqueues downloads for the new list', async () => {
|
||||
schedulePinnedPlaylistSync('pl-1');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
|
||||
expect(getPlaylistMock).toHaveBeenCalledWith('srv-a', 'pl-1');
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
'delete_media_file',
|
||||
expect.objectContaining({ localPath: '/media/library/a.test/a/al/t1.mp3' }),
|
||||
);
|
||||
expect(useLocalPlaybackStore.getState().entries['a.test:t1']).toBeUndefined();
|
||||
expect(enqueueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
albumId: 'pl-1',
|
||||
type: 'playlist',
|
||||
songs: [expect.objectContaining({ id: 't2' })],
|
||||
}),
|
||||
);
|
||||
expect(useOfflineStore.getState().albums['a.test:pl-1']?.trackIds).toEqual(['t2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleSyncPinnedAlbumsAndArtists', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getAlbumForServerMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:al-1': {
|
||||
id: 'al-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
trackIds: ['t1'],
|
||||
type: 'album',
|
||||
},
|
||||
},
|
||||
});
|
||||
seedAuth();
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
|
||||
songs: [song('t2')],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reconciles cached albums after a debounced library-scope trigger', async () => {
|
||||
scheduleSyncPinnedAlbumsAndArtists('srv-a');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
|
||||
expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedulePinnedAlbumSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getAlbumForServerMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
invokeMock.mockClear();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:al-1': {
|
||||
id: 'al-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
trackIds: ['t1'],
|
||||
type: 'album',
|
||||
},
|
||||
},
|
||||
});
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'a.test:t1': {
|
||||
serverIndexKey: 'a.test',
|
||||
trackId: 't1',
|
||||
localPath: '/media/library/a.test/a/al/t1.mp3',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'album', sourceId: 'al-1', displayName: 'Album' },
|
||||
},
|
||||
},
|
||||
});
|
||||
seedAuth();
|
||||
getAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
|
||||
songs: [song('t2')],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reconciles a cached album against the live track list', async () => {
|
||||
expect(isSourcePinnedOffline('al-1', 'srv-a', 'album')).toBe(true);
|
||||
schedulePinnedAlbumSync('al-1');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
|
||||
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
|
||||
expect(enqueueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
albumId: 'al-1',
|
||||
type: 'album',
|
||||
songs: [expect.objectContaining({ id: 't2' })],
|
||||
}),
|
||||
);
|
||||
expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
|
||||
});
|
||||
});
|
||||
|
||||
function seedArtistAlbumPin(albumId: string, trackId: string, serverIndexKey = 'a.test'): void {
|
||||
useOfflineStore.setState(state => ({
|
||||
albums: {
|
||||
...state.albums,
|
||||
[`${serverIndexKey}:${albumId}`]: {
|
||||
id: albumId,
|
||||
serverId: serverIndexKey,
|
||||
name: `Album ${albumId}`,
|
||||
artist: 'Artist',
|
||||
trackIds: [trackId],
|
||||
type: 'artist',
|
||||
},
|
||||
},
|
||||
}));
|
||||
useLocalPlaybackStore.setState(state => ({
|
||||
entries: {
|
||||
...state.entries,
|
||||
[`${serverIndexKey}:${trackId}`]: {
|
||||
serverIndexKey,
|
||||
trackId,
|
||||
localPath: `/media/library/${serverIndexKey}/a/${albumId}/${trackId}.mp3`,
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1000,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'artist', sourceId: albumId },
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('syncPinnedArtistIfNeeded', () => {
|
||||
beforeEach(() => {
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getArtistForServerMock.mockReset();
|
||||
getAlbumForServerMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
invokeMock.mockClear();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({ albums: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
seedAuth();
|
||||
});
|
||||
|
||||
it('prunes albums removed from the live artist catalog', async () => {
|
||||
seedArtistAlbumPin('al-1', 't1');
|
||||
seedArtistAlbumPin('al-2', 't2');
|
||||
getArtistForServerMock.mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist' },
|
||||
albums: [{ id: 'al-1', name: 'One', artist: 'Artist' }],
|
||||
});
|
||||
getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
|
||||
album: { id, name: id, artist: 'Artist' },
|
||||
songs: [song(id === 'al-1' ? 't1' : 't9')],
|
||||
}));
|
||||
|
||||
await syncPinnedArtistIfNeeded('art-1', 'srv-a');
|
||||
|
||||
expect(useOfflineStore.getState().albums['a.test:al-2']).toBeUndefined();
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
'delete_media_file',
|
||||
expect.objectContaining({ localPath: '/media/library/a.test/a/al-2/t2.mp3' }),
|
||||
);
|
||||
expect(useLocalPlaybackStore.getState().entries['a.test:t2']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('auto-downloads a new album when the full discography scope was pinned', async () => {
|
||||
seedArtistAlbumPin('al-1', 't1');
|
||||
seedArtistAlbumPin('al-2', 't2');
|
||||
getArtistForServerMock.mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist' },
|
||||
albums: [
|
||||
{ id: 'al-1', name: 'One', artist: 'Artist' },
|
||||
{ id: 'al-2', name: 'Two', artist: 'Artist' },
|
||||
{ id: 'al-3', name: 'Three', artist: 'Artist' },
|
||||
],
|
||||
});
|
||||
getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
|
||||
album: { id, name: id, artist: 'Artist' },
|
||||
songs: [song(`track-${id}`)],
|
||||
}));
|
||||
|
||||
await syncPinnedArtistIfNeeded('art-1', 'srv-a', ['al-1', 'al-2']);
|
||||
|
||||
expect(enqueueMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
albumId: 'al-3',
|
||||
type: 'artist',
|
||||
artistProgressGroupId: 'art-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncAllPinnedPlaylists', () => {
|
||||
beforeEach(() => {
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getPlaylistMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'b.test:pl-b': {
|
||||
id: 'pl-b',
|
||||
serverId: 'b.test',
|
||||
name: 'Remote mix',
|
||||
artist: '',
|
||||
trackIds: ['tb1'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-a',
|
||||
servers: [
|
||||
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
});
|
||||
getPlaylistMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-b', name: 'Remote mix', songCount: 1 },
|
||||
songs: [song('tb2')],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches each cached playlist from its owning server, not the active server', async () => {
|
||||
await syncAllPinnedPlaylists();
|
||||
expect(getPlaylistMock).toHaveBeenCalledWith('srv-b', 'pl-b');
|
||||
expect(useOfflineStore.getState().albums['b.test:pl-b']?.trackIds).toEqual(['tb2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedulePinnedPlaylistSync dedupe', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
isReachableMock.mockReturnValue(true);
|
||||
getPlaylistMock.mockReset();
|
||||
enqueueMock.mockReset();
|
||||
useOfflineJobStoreReset();
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'a.test:pl-1': {
|
||||
id: 'pl-1',
|
||||
serverId: 'a.test',
|
||||
name: 'Road mix',
|
||||
artist: '',
|
||||
trackIds: ['t1'],
|
||||
type: 'playlist',
|
||||
},
|
||||
},
|
||||
});
|
||||
seedAuth();
|
||||
getPlaylistMock.mockResolvedValue({
|
||||
playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
|
||||
songs: [song('t1')],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('coalesces duplicate schedule calls in one debounce window', async () => {
|
||||
schedulePinnedPlaylistSync('pl-1');
|
||||
schedulePinnedPlaylistSync('pl-1');
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
expect(getPlaylistMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function useOfflineJobStoreReset(): void {
|
||||
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { libraryGetTracksByAlbum, subscribeLibrarySyncIdle } from '@/api/library';
|
||||
import { getAlbumForServer, filterSongsToServerLibrary } from '@/api/subsonicLibrary';
|
||||
import { getPlaylistForServer } from '@/api/subsonicPlaylists';
|
||||
import { getArtistForServer } from '@/api/subsonicArtists';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { PinSource } from '@/store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { usePlaylistStore } from '@/store/playlistStore';
|
||||
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import {
|
||||
isActiveServerReachable,
|
||||
onActiveServerBecameReachable,
|
||||
} from '@/utils/network/activeServerReachability';
|
||||
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { findLocalPlaybackEntry } from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
import { enqueueOfflinePin } from '@/features/offline/utils/offlinePinQueue';
|
||||
|
||||
export type OfflinePinKind = PinSource['kind'];
|
||||
|
||||
const DEBOUNCE_MS = 600;
|
||||
const RETRY_WHILE_DOWNLOADING_MS = 2500;
|
||||
/** Cached regular playlists reconcile on this interval (and on in-app edits). */
|
||||
const PLAYLIST_SYNC_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
let playlistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let albumArtistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingPlaylistJobs: { sourceId: string; serverId: string }[] = [];
|
||||
const pendingAlbumJobs: { sourceId: string; serverId: string }[] = [];
|
||||
const pendingArtistJobs: { artistId: string; serverId: string; albumIds?: string[] }[] = [];
|
||||
/** Empty set entry means all servers; otherwise profile ids from library idle. */
|
||||
const pendingAlbumArtistServers = new Set<string | null>();
|
||||
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let playlistSyncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let stopLibraryIdle: (() => void) | null = null;
|
||||
|
||||
function serverIndexKeyForOffline(serverId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
if (server) return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
|
||||
return resolveIndexKey(serverId) || serverId;
|
||||
}
|
||||
|
||||
function belongsToProfile(metaServerKey: string, profileServerId: string): boolean {
|
||||
const indexKey = serverIndexKeyForOffline(profileServerId);
|
||||
return metaServerKey === profileServerId
|
||||
|| metaServerKey === indexKey
|
||||
|| resolveServerIdForIndexKey(metaServerKey) === profileServerId;
|
||||
}
|
||||
|
||||
function offlineMeta(sourceId: string, serverId: string) {
|
||||
const indexKey = serverIndexKeyForOffline(serverId);
|
||||
const albums = useOfflineStore.getState().albums;
|
||||
return albums[`${indexKey}:${sourceId}`] ?? albums[`${serverId}:${sourceId}`];
|
||||
}
|
||||
|
||||
function resolvePlaylistName(playlistId: string, serverId: string): string | undefined {
|
||||
return offlineMeta(playlistId, serverId)?.name
|
||||
?? usePlaylistStore.getState().playlists.find(p => p.id === playlistId)?.name;
|
||||
}
|
||||
|
||||
/** Smart playlists refresh from server rules — not eligible for manual offline cache/sync. */
|
||||
export function isManualOfflinePlaylist(playlistId: string, serverId: string, name?: string): boolean {
|
||||
const resolved = name ?? resolvePlaylistName(playlistId, serverId);
|
||||
return !resolved || !isSmartPlaylistName(resolved);
|
||||
}
|
||||
|
||||
/** True when a source was manually cached offline with the given pin kind. */
|
||||
export function isSourcePinnedOffline(
|
||||
sourceId: string,
|
||||
serverId: string,
|
||||
kind: OfflinePinKind,
|
||||
): boolean {
|
||||
const meta = offlineMeta(sourceId, serverId);
|
||||
if (meta?.type === kind) return true;
|
||||
|
||||
const indexKey = serverIndexKeyForOffline(serverId);
|
||||
const group = useLocalPlaybackStore.getState()
|
||||
.listPinnedGroups(indexKey)
|
||||
.find(g => g.pinSource.kind === kind && g.pinSource.sourceId === sourceId);
|
||||
return (group?.trackIds.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link isSourcePinnedOffline} with kind `playlist`. */
|
||||
export function isPlaylistPinnedOffline(playlistId: string, serverId: string): boolean {
|
||||
return isSourcePinnedOffline(playlistId, serverId, 'playlist');
|
||||
}
|
||||
|
||||
function trackStillNeededByOtherPin(
|
||||
trackId: string,
|
||||
serverIndexKey: string,
|
||||
exceptKind: OfflinePinKind,
|
||||
exceptSourceId: string,
|
||||
): boolean {
|
||||
for (const group of useLocalPlaybackStore.getState().listPinnedGroups(serverIndexKey)) {
|
||||
if (group.pinSource.kind === exceptKind && group.pinSource.sourceId === exceptSourceId) continue;
|
||||
if (group.trackIds.includes(trackId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pruneRemovedPinTracks(
|
||||
sourceId: string,
|
||||
serverId: string,
|
||||
kind: OfflinePinKind,
|
||||
keepIds: Set<string>,
|
||||
): Promise<void> {
|
||||
const indexKey = serverIndexKeyForOffline(serverId);
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
const mediaDir = getMediaDir();
|
||||
const group = lp.listPinnedGroups(indexKey)
|
||||
.find(g => g.pinSource.kind === kind && g.pinSource.sourceId === sourceId);
|
||||
const previousIds = group?.trackIds ?? offlineMeta(sourceId, serverId)?.trackIds ?? [];
|
||||
|
||||
for (const trackId of previousIds) {
|
||||
if (keepIds.has(trackId)) continue;
|
||||
if (trackStillNeededByOtherPin(trackId, indexKey, kind, sourceId)) continue;
|
||||
|
||||
const entry = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (!entry?.localPath || entry.tier !== 'library') continue;
|
||||
if (entry.pinSource?.kind !== kind || entry.pinSource.sourceId !== sourceId) continue;
|
||||
|
||||
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
|
||||
lp.removeEntry(trackId, entry.serverIndexKey, `${kind}-sync-prune`);
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeSongs(songs: SubsonicSong[]): SubsonicSong[] {
|
||||
const seen = new Set<string>();
|
||||
return songs.filter(s => {
|
||||
if (seen.has(s.id)) return false;
|
||||
seen.add(s.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function updateOfflineMeta(
|
||||
sourceId: string,
|
||||
serverId: string,
|
||||
kind: OfflinePinKind,
|
||||
patch: {
|
||||
name: string;
|
||||
albumArtist: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
trackIds: string[];
|
||||
},
|
||||
): void {
|
||||
const indexKey = serverIndexKeyForOffline(serverId);
|
||||
useOfflineStore.setState(state => {
|
||||
const key = `${indexKey}:${sourceId}`;
|
||||
const legacyKey = `${serverId}:${sourceId}`;
|
||||
const existing = state.albums[key] ?? state.albums[legacyKey];
|
||||
const nextAlbums = { ...state.albums };
|
||||
delete nextAlbums[legacyKey];
|
||||
nextAlbums[key] = {
|
||||
...(existing ?? {
|
||||
id: sourceId,
|
||||
serverId: indexKey,
|
||||
artist: patch.albumArtist,
|
||||
}),
|
||||
id: sourceId,
|
||||
serverId: indexKey,
|
||||
name: patch.name,
|
||||
artist: patch.albumArtist,
|
||||
coverArt: patch.coverArt ?? existing?.coverArt,
|
||||
year: patch.year ?? existing?.year,
|
||||
trackIds: patch.trackIds,
|
||||
type: kind,
|
||||
};
|
||||
return { albums: nextAlbums };
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRetryWhileDownloading(
|
||||
sourceId: string,
|
||||
serverId: string,
|
||||
kind: OfflinePinKind,
|
||||
): void {
|
||||
const key = `${serverId}:${kind}:${sourceId}`;
|
||||
const prev = retryTimers.get(key);
|
||||
if (prev) clearTimeout(prev);
|
||||
retryTimers.set(key, setTimeout(() => {
|
||||
retryTimers.delete(key);
|
||||
void syncPinnedSourceIfNeeded(sourceId, serverId, kind);
|
||||
}, RETRY_WHILE_DOWNLOADING_MS));
|
||||
}
|
||||
|
||||
interface SyncPinOptions {
|
||||
prefetchedSongs?: SubsonicSong[];
|
||||
name?: string;
|
||||
albumArtist?: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
artistProgressGroupId?: string;
|
||||
/** Download even when the source is not pinned yet (new album in a fully cached discography). */
|
||||
allowUnpinned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a manually cached pin: download new tracks, drop removed ones,
|
||||
* update persisted offline metadata.
|
||||
*/
|
||||
export async function syncPinnedSourceIfNeeded(
|
||||
sourceId: string,
|
||||
serverId: string,
|
||||
kind: OfflinePinKind,
|
||||
options: SyncPinOptions = {},
|
||||
): Promise<void> {
|
||||
if (!isActiveServerReachable()) return;
|
||||
const alreadyPinned = isSourcePinnedOffline(sourceId, serverId, kind);
|
||||
if (!alreadyPinned && !options.allowUnpinned) return;
|
||||
if (kind === 'playlist' && !isManualOfflinePlaylist(sourceId, serverId, options.name)) return;
|
||||
|
||||
let songs = options.prefetchedSongs;
|
||||
let displayName = options.name ?? offlineMeta(sourceId, serverId)?.name ?? sourceId;
|
||||
let albumArtist = options.albumArtist ?? offlineMeta(sourceId, serverId)?.artist ?? '';
|
||||
let coverArt = options.coverArt ?? offlineMeta(sourceId, serverId)?.coverArt;
|
||||
let year = options.year ?? offlineMeta(sourceId, serverId)?.year;
|
||||
|
||||
if (!songs) {
|
||||
try {
|
||||
if (kind === 'playlist') {
|
||||
const data = await getPlaylistForServer(serverId, sourceId);
|
||||
displayName = data.playlist.name;
|
||||
coverArt = data.playlist.coverArt ?? coverArt;
|
||||
songs = await filterSongsToServerLibrary(data.songs, serverId);
|
||||
} else {
|
||||
const data = await getAlbumForServer(serverId, sourceId);
|
||||
displayName = data.album.name;
|
||||
albumArtist = data.album.artist ?? albumArtist;
|
||||
coverArt = data.album.coverArt ?? coverArt;
|
||||
year = data.album.year ?? year;
|
||||
songs = await filterSongsToServerLibrary(data.songs, serverId);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
songs = await filterSongsToServerLibrary(songs, serverId);
|
||||
}
|
||||
|
||||
const unique = dedupeSongs(songs);
|
||||
const keepIds = new Set(unique.map(s => s.id));
|
||||
|
||||
await pruneRemovedPinTracks(sourceId, serverId, kind, keepIds);
|
||||
updateOfflineMeta(sourceId, serverId, kind, {
|
||||
name: displayName,
|
||||
albumArtist,
|
||||
coverArt,
|
||||
year,
|
||||
trackIds: unique.map(s => s.id),
|
||||
});
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
if (offline.isAlbumDownloading(sourceId)) {
|
||||
scheduleRetryWhileDownloading(sourceId, serverId, kind);
|
||||
return;
|
||||
}
|
||||
|
||||
const enqueued = enqueueOfflinePin({
|
||||
albumId: sourceId,
|
||||
albumName: displayName,
|
||||
albumArtist,
|
||||
coverArt,
|
||||
year,
|
||||
songs: unique,
|
||||
serverId,
|
||||
type: kind,
|
||||
artistProgressGroupId: options.artistProgressGroupId,
|
||||
});
|
||||
if (!enqueued && offline.isAlbumDownloading(sourceId)) {
|
||||
scheduleRetryWhileDownloading(sourceId, serverId, kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link syncPinnedSourceIfNeeded} with kind `playlist`. */
|
||||
export async function syncPinnedPlaylistIfNeeded(
|
||||
playlistId: string,
|
||||
serverId?: string,
|
||||
prefetchedSongs?: SubsonicSong[],
|
||||
): Promise<void> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) return;
|
||||
await syncPinnedSourceIfNeeded(playlistId, sid, 'playlist', { prefetchedSongs });
|
||||
}
|
||||
|
||||
export async function syncPinnedAlbumIfNeeded(
|
||||
albumId: string,
|
||||
serverId?: string,
|
||||
prefetchedSongs?: SubsonicSong[],
|
||||
): Promise<void> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) return;
|
||||
await syncPinnedSourceIfNeeded(albumId, sid, 'album', { prefetchedSongs });
|
||||
}
|
||||
|
||||
/** Any album in the artist discography was cached with type `artist`. */
|
||||
export function isArtistDiscographyPinnedOffline(
|
||||
serverId: string,
|
||||
albumIds: string[],
|
||||
): boolean {
|
||||
return albumIds.some(id => isSourcePinnedOffline(id, serverId, 'artist'));
|
||||
}
|
||||
|
||||
function listPinnedArtistAlbumIds(serverId: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const meta of Object.values(useOfflineStore.getState().albums)) {
|
||||
if (meta.type !== 'artist') continue;
|
||||
if (!belongsToProfile(meta.serverId, serverId)) continue;
|
||||
ids.add(meta.id);
|
||||
}
|
||||
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
|
||||
if (group.pinSource.kind !== 'artist') continue;
|
||||
if (!belongsToProfile(group.serverIndexKey, serverId)) continue;
|
||||
ids.add(group.pinSource.sourceId);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a cached artist discography: refresh pinned albums, drop albums
|
||||
* removed from the catalog, and fetch new albums when the scope was fully cached.
|
||||
* When every album in the known scope is already pinned, newly released albums
|
||||
* download automatically (intended “keep discography complete” UX).
|
||||
*/
|
||||
export async function syncPinnedArtistIfNeeded(
|
||||
artistId: string,
|
||||
serverId?: string,
|
||||
knownAlbumIds?: string[],
|
||||
): Promise<void> {
|
||||
if (!isActiveServerReachable()) return;
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid || !artistId) return;
|
||||
|
||||
const pinnedBefore = listPinnedArtistAlbumIds(sid);
|
||||
const scopeIds = knownAlbumIds ?? pinnedBefore;
|
||||
if (!isArtistDiscographyPinnedOffline(sid, scopeIds) && pinnedBefore.length === 0) return;
|
||||
|
||||
let liveAlbumIds: string[];
|
||||
try {
|
||||
const { albums } = await getArtistForServer(sid, artistId);
|
||||
liveAlbumIds = albums.map(a => a.id);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeFullyPinned = scopeIds.length > 0
|
||||
&& scopeIds.every(id => isSourcePinnedOffline(id, sid, 'artist'));
|
||||
const liveSet = new Set(liveAlbumIds);
|
||||
|
||||
for (const oldAlbumId of pinnedBefore) {
|
||||
if (liveSet.has(oldAlbumId)) continue;
|
||||
await pruneRemovedPinTracks(oldAlbumId, sid, 'artist', new Set());
|
||||
const indexKey = serverIndexKeyForOffline(sid);
|
||||
useOfflineStore.setState(state => {
|
||||
const albums = { ...state.albums };
|
||||
delete albums[`${indexKey}:${oldAlbumId}`];
|
||||
delete albums[`${sid}:${oldAlbumId}`];
|
||||
return { albums };
|
||||
});
|
||||
}
|
||||
|
||||
for (const albumId of liveAlbumIds) {
|
||||
const shouldSync = isSourcePinnedOffline(albumId, sid, 'artist')
|
||||
|| (scopeFullyPinned && pinnedBefore.length > 0);
|
||||
if (!shouldSync) continue;
|
||||
await syncPinnedSourceIfNeeded(albumId, sid, 'artist', {
|
||||
artistProgressGroupId: artistId,
|
||||
allowUnpinned: !isSourcePinnedOffline(albumId, sid, 'artist'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pushUniquePlaylistJob(sourceId: string, serverId: string): void {
|
||||
if (pendingPlaylistJobs.some(j => j.sourceId === sourceId && j.serverId === serverId)) return;
|
||||
pendingPlaylistJobs.push({ sourceId, serverId });
|
||||
}
|
||||
|
||||
function pushUniqueAlbumJob(sourceId: string, serverId: string): void {
|
||||
if (pendingAlbumJobs.some(j => j.sourceId === sourceId && j.serverId === serverId)) return;
|
||||
pendingAlbumJobs.push({ sourceId, serverId });
|
||||
}
|
||||
|
||||
function pushUniqueArtistJob(artistId: string, serverId: string, albumIds?: string[]): void {
|
||||
if (pendingArtistJobs.some(j => j.artistId === artistId && j.serverId === serverId)) return;
|
||||
pendingArtistJobs.push({ artistId, serverId, albumIds });
|
||||
}
|
||||
|
||||
function flushPendingPlaylistJobs(): void {
|
||||
playlistDebounceTimer = null;
|
||||
const jobs = [...pendingPlaylistJobs];
|
||||
pendingPlaylistJobs.length = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
void syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'playlist');
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingAlbumArtistJobs(): void {
|
||||
albumArtistDebounceTimer = null;
|
||||
const albums = [...pendingAlbumJobs];
|
||||
const artists = [...pendingArtistJobs];
|
||||
const servers = [...pendingAlbumArtistServers];
|
||||
pendingAlbumJobs.length = 0;
|
||||
pendingArtistJobs.length = 0;
|
||||
pendingAlbumArtistServers.clear();
|
||||
|
||||
for (const job of albums) {
|
||||
void syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'album');
|
||||
}
|
||||
for (const job of artists) {
|
||||
void syncPinnedArtistIfNeeded(job.artistId, job.serverId, job.albumIds);
|
||||
}
|
||||
if (servers.length > 0) {
|
||||
for (const serverId of servers) {
|
||||
void syncAllPinnedAlbumsAndArtists(serverId ?? undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDebouncedPlaylistSync(): void {
|
||||
if (playlistDebounceTimer) clearTimeout(playlistDebounceTimer);
|
||||
playlistDebounceTimer = setTimeout(flushPendingPlaylistJobs, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function scheduleDebouncedAlbumArtistSync(): void {
|
||||
if (albumArtistDebounceTimer) clearTimeout(albumArtistDebounceTimer);
|
||||
albumArtistDebounceTimer = setTimeout(flushPendingAlbumArtistJobs, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function metaMatchesServer(metaServerKey: string, serverId?: string): boolean {
|
||||
if (!serverId) return true;
|
||||
return belongsToProfile(metaServerKey, serverId);
|
||||
}
|
||||
|
||||
async function groupPinnedArtistAlbumsByArtistId(
|
||||
serverId: string,
|
||||
albumIds: Iterable<string>,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const byArtist = new Map<string, string[]>();
|
||||
for (const albumId of albumIds) {
|
||||
try {
|
||||
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
|
||||
const artistId = tracks[0]?.artistId;
|
||||
if (!artistId) continue;
|
||||
const list = byArtist.get(artistId) ?? [];
|
||||
list.push(albumId);
|
||||
byArtist.set(artistId, list);
|
||||
} catch {
|
||||
// index row missing — fall back to per-album reconcile below
|
||||
}
|
||||
}
|
||||
return byArtist;
|
||||
}
|
||||
|
||||
export function schedulePinnedPlaylistSync(playlistId: string, serverId?: string): void {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!playlistId || !sid) return;
|
||||
if (!isSourcePinnedOffline(playlistId, sid, 'playlist')) return;
|
||||
if (!isManualOfflinePlaylist(playlistId, sid)) return;
|
||||
if (!isActiveServerReachable()) return;
|
||||
pushUniquePlaylistJob(playlistId, sid);
|
||||
scheduleDebouncedPlaylistSync();
|
||||
}
|
||||
|
||||
export function schedulePinnedAlbumSync(albumId: string, serverId?: string): void {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!albumId || !sid) return;
|
||||
if (!isSourcePinnedOffline(albumId, sid, 'album')) return;
|
||||
if (!isActiveServerReachable()) return;
|
||||
pushUniqueAlbumJob(albumId, sid);
|
||||
scheduleDebouncedAlbumArtistSync();
|
||||
}
|
||||
|
||||
export function schedulePinnedArtistSync(
|
||||
artistId: string,
|
||||
serverId?: string,
|
||||
albumIds?: string[],
|
||||
): void {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid || !artistId) return;
|
||||
if (!isArtistDiscographyPinnedOffline(sid, albumIds ?? listPinnedArtistAlbumIds(sid))) return;
|
||||
if (!isActiveServerReachable()) return;
|
||||
pushUniqueArtistJob(artistId, sid, albumIds);
|
||||
scheduleDebouncedAlbumArtistSync();
|
||||
}
|
||||
|
||||
/** Reconcile every cached album pin and artist discography (optionally one server). */
|
||||
export async function syncAllPinnedAlbumsAndArtists(serverId?: string): Promise<void> {
|
||||
if (!isActiveServerReachable()) return;
|
||||
|
||||
const seenAlbums = new Set<string>();
|
||||
const artistAlbumIdsByServer = new Map<string, Set<string>>();
|
||||
|
||||
const albumJobs: { sourceId: string; serverId: string }[] = [];
|
||||
|
||||
const consider = (kind: OfflinePinKind, sourceId: string, metaServerKey: string) => {
|
||||
if (kind === 'playlist') return;
|
||||
const sid = resolveServerIdForIndexKey(metaServerKey) || metaServerKey;
|
||||
if (!metaMatchesServer(metaServerKey, serverId) && !metaMatchesServer(sid, serverId)) return;
|
||||
|
||||
if (kind === 'album') {
|
||||
const dedupe = `${sid}:${sourceId}`;
|
||||
if (seenAlbums.has(dedupe)) return;
|
||||
seenAlbums.add(dedupe);
|
||||
albumJobs.push({ sourceId, serverId: sid });
|
||||
return;
|
||||
}
|
||||
if (kind === 'artist') {
|
||||
const set = artistAlbumIdsByServer.get(sid) ?? new Set<string>();
|
||||
set.add(sourceId);
|
||||
artistAlbumIdsByServer.set(sid, set);
|
||||
}
|
||||
};
|
||||
|
||||
for (const meta of Object.values(useOfflineStore.getState().albums)) {
|
||||
consider(meta.type ?? 'album', meta.id, meta.serverId);
|
||||
}
|
||||
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
|
||||
consider(group.pinSource.kind, group.pinSource.sourceId, group.serverIndexKey);
|
||||
}
|
||||
|
||||
for (const job of albumJobs) {
|
||||
await syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'album');
|
||||
}
|
||||
|
||||
for (const [sid, albumIds] of artistAlbumIdsByServer) {
|
||||
const byArtist = await groupPinnedArtistAlbumsByArtistId(sid, albumIds);
|
||||
const assignedAlbums = new Set<string>();
|
||||
for (const [artistId, ids] of byArtist) {
|
||||
ids.forEach(id => assignedAlbums.add(id));
|
||||
await syncPinnedArtistIfNeeded(artistId, sid, ids);
|
||||
}
|
||||
for (const albumId of albumIds) {
|
||||
if (assignedAlbums.has(albumId)) continue;
|
||||
await syncPinnedSourceIfNeeded(albumId, sid, 'artist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every manually cached regular playlist (optionally one server). */
|
||||
export async function syncAllPinnedPlaylists(serverId?: string): Promise<void> {
|
||||
if (!isActiveServerReachable()) return;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const jobs: { sourceId: string; serverId: string }[] = [];
|
||||
|
||||
for (const meta of Object.values(useOfflineStore.getState().albums)) {
|
||||
if (meta.type !== 'playlist') continue;
|
||||
if (isSmartPlaylistName(meta.name)) continue;
|
||||
const sid = resolveServerIdForIndexKey(meta.serverId) || meta.serverId;
|
||||
if (!metaMatchesServer(meta.serverId, serverId) && !metaMatchesServer(sid, serverId)) continue;
|
||||
const dedupe = `${sid}:${meta.id}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
jobs.push({ sourceId: meta.id, serverId: sid });
|
||||
}
|
||||
|
||||
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
|
||||
if (group.pinSource.kind !== 'playlist') continue;
|
||||
if (isSmartPlaylistName(group.pinSource.displayName ?? '')) continue;
|
||||
const sid = resolveServerIdForIndexKey(group.serverIndexKey) || group.serverIndexKey;
|
||||
if (!metaMatchesServer(group.serverIndexKey, serverId) && !metaMatchesServer(sid, serverId)) continue;
|
||||
const dedupe = `${sid}:${group.pinSource.sourceId}`;
|
||||
if (seen.has(dedupe)) continue;
|
||||
seen.add(dedupe);
|
||||
jobs.push({ sourceId: group.pinSource.sourceId, serverId: sid });
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!isManualOfflinePlaylist(job.sourceId, job.serverId)) continue;
|
||||
await syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'playlist');
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link syncAllPinnedAlbumsAndArtists} + {@link syncAllPinnedPlaylists}. */
|
||||
export async function syncAllPinnedOffline(): Promise<void> {
|
||||
await syncAllPinnedAlbumsAndArtists();
|
||||
await syncAllPinnedPlaylists();
|
||||
}
|
||||
|
||||
export function scheduleSyncPinnedAlbumsAndArtists(serverId?: string): void {
|
||||
if (!isActiveServerReachable()) return;
|
||||
pendingAlbumArtistServers.add(serverId ?? null);
|
||||
scheduleDebouncedAlbumArtistSync();
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link scheduleSyncPinnedAlbumsAndArtists}. */
|
||||
export function scheduleSyncAllPinnedOffline(): void {
|
||||
scheduleSyncPinnedAlbumsAndArtists();
|
||||
void syncAllPinnedPlaylists();
|
||||
}
|
||||
|
||||
/** @deprecated Use hourly {@link syncAllPinnedPlaylists}. */
|
||||
export function scheduleSyncAllPinnedPlaylists(): void {
|
||||
if (!isActiveServerReachable()) return;
|
||||
void syncAllPinnedPlaylists();
|
||||
}
|
||||
|
||||
function onLibraryBecameIdle(serverIndexKey: string, kind: string, ok: boolean): void {
|
||||
if (!ok) return;
|
||||
if (kind !== 'initial_sync' && kind !== 'delta_sync') return;
|
||||
if (!isActiveServerReachable()) return;
|
||||
const serverId = resolveServerIdForIndexKey(serverIndexKey);
|
||||
scheduleSyncPinnedAlbumsAndArtists(serverId);
|
||||
}
|
||||
|
||||
export function initPinnedOfflineSync(): () => void {
|
||||
void subscribeLibrarySyncIdle(payload => {
|
||||
onLibraryBecameIdle(payload.serverId, payload.kind, payload.ok);
|
||||
}).then(unlisten => {
|
||||
stopLibraryIdle = unlisten;
|
||||
});
|
||||
|
||||
playlistSyncInterval = setInterval(() => {
|
||||
if (isActiveServerReachable()) void syncAllPinnedPlaylists();
|
||||
}, PLAYLIST_SYNC_INTERVAL_MS);
|
||||
|
||||
const stopReachable = onActiveServerBecameReachable(() => {
|
||||
scheduleSyncPinnedAlbumsAndArtists();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (playlistDebounceTimer) clearTimeout(playlistDebounceTimer);
|
||||
if (albumArtistDebounceTimer) clearTimeout(albumArtistDebounceTimer);
|
||||
if (playlistSyncInterval) clearInterval(playlistSyncInterval);
|
||||
stopLibraryIdle?.();
|
||||
stopLibraryIdle = null;
|
||||
for (const t of retryTimers.values()) clearTimeout(t);
|
||||
retryTimers.clear();
|
||||
stopReachable();
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link initPinnedOfflineSync}. */
|
||||
export function initPinnedPlaylistOfflineSync(): () => void {
|
||||
return initPinnedOfflineSync();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { resumeIncompleteOfflinePins } from '@/features/offline/utils/resumeIncompleteOfflinePins';
|
||||
|
||||
const isActiveServerReachableMock = vi.fn(() => true);
|
||||
const isOfflinePinCompleteMock = vi.fn((_albumId: string, _serverId: string) => false);
|
||||
const resolveAlbumForServerMock = vi.fn();
|
||||
const downloadAlbumMock = vi.fn();
|
||||
|
||||
vi.mock('@/utils/network/activeServerReachability', () => ({
|
||||
isActiveServerReachable: () => isActiveServerReachableMock(),
|
||||
onActiveServerBecameReachable: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineLibraryHelpers', () => ({
|
||||
isOfflinePinComplete: (albumId: string, serverId: string) =>
|
||||
isOfflinePinCompleteMock(albumId, serverId),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/offline/utils/offlineMediaResolve', () => ({
|
||||
resolveAlbumForServer: (serverId: string, albumId: string) =>
|
||||
resolveAlbumForServerMock(serverId, albumId),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/library', () => ({
|
||||
libraryGetTracksBatchChunked: vi.fn(async () => []),
|
||||
LIBRARY_TRACKS_BATCH_LIMIT: 100,
|
||||
}));
|
||||
|
||||
describe('resumeIncompleteOfflinePins', () => {
|
||||
beforeEach(() => {
|
||||
isActiveServerReachableMock.mockReturnValue(true);
|
||||
isOfflinePinCompleteMock.mockReturnValue(false);
|
||||
resolveAlbumForServerMock.mockReset();
|
||||
downloadAlbumMock.mockReset();
|
||||
useAuthStore.setState({
|
||||
activeServerId: 'srv-1',
|
||||
servers: [{ id: 'srv-1', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
|
||||
});
|
||||
useOfflineStore.setState({
|
||||
albums: {
|
||||
'srv-1:alb-1': {
|
||||
id: 'alb-1',
|
||||
serverId: 'srv-1',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
trackIds: ['t1', 't2'],
|
||||
type: 'album',
|
||||
},
|
||||
},
|
||||
});
|
||||
useOfflineStore.setState({
|
||||
downloadAlbum: downloadAlbumMock as never,
|
||||
isAlbumDownloading: () => false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips when the active server is unreachable', async () => {
|
||||
isActiveServerReachableMock.mockReturnValue(false);
|
||||
await resumeIncompleteOfflinePins();
|
||||
expect(downloadAlbumMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips pins that are already complete on disk', async () => {
|
||||
isOfflinePinCompleteMock.mockReturnValue(true);
|
||||
await resumeIncompleteOfflinePins();
|
||||
expect(downloadAlbumMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-queues downloadAlbum for incomplete persisted pins', async () => {
|
||||
resolveAlbumForServerMock.mockResolvedValue({
|
||||
album: { id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 2, duration: 2 },
|
||||
songs: [
|
||||
{ id: 't1', title: 'One', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 1 },
|
||||
{ id: 't2', title: 'Two', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 1 },
|
||||
],
|
||||
});
|
||||
await resumeIncompleteOfflinePins();
|
||||
expect(downloadAlbumMock).toHaveBeenCalledWith(
|
||||
'alb-1',
|
||||
'Album',
|
||||
'Artist',
|
||||
undefined,
|
||||
undefined,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 't1' }),
|
||||
expect.objectContaining({ id: 't2' }),
|
||||
]),
|
||||
'srv-1',
|
||||
'album',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { libraryGetTracksBatchChunked } from '@/api/library';
|
||||
import { getPlaylist } from '@/api/subsonicPlaylists';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||
import { trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { isActiveServerReachable, onActiveServerBecameReachable } from '@/utils/network/activeServerReachability';
|
||||
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { isOfflinePinComplete } from '@/features/offline/utils/offlineLibraryHelpers';
|
||||
import { resolveAlbumForServer } from '@/features/offline/utils/offlineMediaResolve';
|
||||
|
||||
const DEBOUNCE_MS = 800;
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function songsForOfflineMeta(
|
||||
meta: OfflineAlbumMeta,
|
||||
profileServerId: string,
|
||||
): Promise<SubsonicSong[]> {
|
||||
if (meta.type === 'playlist' && shouldAttemptSubsonicForServer(profileServerId)) {
|
||||
const activeId = useAuthStore.getState().activeServerId;
|
||||
if (activeId === profileServerId) {
|
||||
try {
|
||||
const { songs } = await getPlaylist(meta.id);
|
||||
if (songs.length > 0) return songs;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
} else if (meta.type !== 'playlist') {
|
||||
try {
|
||||
const resolved = await resolveAlbumForServer(profileServerId, meta.id);
|
||||
if (resolved?.songs.length) return resolved.songs;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const refs = meta.trackIds.map(trackId => ({ serverId: profileServerId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs).catch(() => []);
|
||||
return dtos.map(trackToSong);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-queue library-tier pins that still lack on-disk bytes (e.g. user left the
|
||||
* album page mid-download, or the app restarted while jobs were in memory only).
|
||||
*/
|
||||
export async function resumeIncompleteOfflinePins(): Promise<void> {
|
||||
if (!isActiveServerReachable()) return;
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
const metas = Object.values(offline.albums);
|
||||
|
||||
for (const meta of metas) {
|
||||
if (!meta.trackIds?.length) continue;
|
||||
const profileServerId = resolveServerIdForIndexKey(meta.serverId) || meta.serverId;
|
||||
if (offline.isAlbumDownloading(meta.id)) continue;
|
||||
if (isOfflinePinComplete(meta.id, profileServerId, meta.trackIds)) continue;
|
||||
|
||||
const songs = await songsForOfflineMeta(meta, profileServerId);
|
||||
if (songs.length === 0) continue;
|
||||
|
||||
void offline.downloadAlbum(
|
||||
meta.id,
|
||||
meta.name,
|
||||
meta.artist,
|
||||
meta.coverArt,
|
||||
meta.year,
|
||||
songs,
|
||||
profileServerId,
|
||||
meta.type ?? 'album',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleResumeIncompleteOfflinePins(): void {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
void resumeIncompleteOfflinePins();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** App start, server switch, and reconnect → finish interrupted offline pins. */
|
||||
export function initResumeIncompleteOfflinePins(): () => void {
|
||||
scheduleResumeIncompleteOfflinePins();
|
||||
const stopReachable = onActiveServerBecameReachable(() => scheduleResumeIncompleteOfflinePins());
|
||||
const stopAuth = useAuthStore.subscribe((state, prev) => {
|
||||
if (state.activeServerId !== prev.activeServerId) {
|
||||
scheduleResumeIncompleteOfflinePins();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
stopReachable();
|
||||
stopAuth();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user