refactor(offline): co-locate offline feature into features/offline

This commit is contained in:
Psychotoxical
2026-06-30 00:56:08 +02:00
parent 70c145db06
commit d6dbb615fd
149 changed files with 521 additions and 466 deletions
@@ -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();
};
}