mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(offline): Offline Library shows cached albums from all servers (#719)
* feat(offline): show cached albums from all servers in Offline Library List every offline album regardless of active server; load cover art per source server and switch before play/enqueue. Sidebar, mobile nav, and disconnect auto-nav use any cached content; multi-server cards show a label. * docs(changelog): link offline library PR #719 * chore(pr-719): address review — CHANGELOG in Added, helper tests Move release note to ## Added per team changelog policy; cover offlineAlbumCoverArt and ensureServerForOfflineAlbum in unit tests.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { switchActiveServer } from '../server/switchActiveServer';
|
||||
import {
|
||||
buildOfflineTracksForAlbum,
|
||||
ensureServerForOfflineAlbum,
|
||||
hasAnyOfflineAlbums,
|
||||
offlineAlbumCoverArt,
|
||||
offlineTrackCount,
|
||||
} from './offlineLibraryHelpers';
|
||||
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
|
||||
|
||||
vi.mock('../server/switchActiveServer', () => ({
|
||||
switchActiveServer: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
describe('offlineLibraryHelpers', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({
|
||||
servers: [{ id: 'a', name: 'Home', url: 'http://a.test', username: 'u', password: 'p' }],
|
||||
activeServerId: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
it('hasAnyOfflineAlbums is true when any album exists', () => {
|
||||
expect(hasAnyOfflineAlbums({})).toBe(false);
|
||||
expect(hasAnyOfflineAlbums({
|
||||
'a:al1': { id: 'al1', serverId: 'a', name: 'X', artist: 'Y', trackIds: [] },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('buildOfflineTracksForAlbum uses album serverId in track keys', () => {
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: ['t1', 't2'],
|
||||
};
|
||||
const tracks: Record<string, OfflineTrackMeta> = {
|
||||
'a:t1': {
|
||||
id: 't1', serverId: 'a', localPath: '/x.flac', title: 'One', artist: 'Ar',
|
||||
album: 'Al', albumId: 'al1', suffix: 'flac', duration: 100, cachedAt: '2026-01-01',
|
||||
},
|
||||
'b:t2': {
|
||||
id: 't2', serverId: 'b', localPath: '/y.flac', title: 'Wrong', artist: 'Ar',
|
||||
album: 'Al', albumId: 'al1', suffix: 'flac', duration: 100, cachedAt: '2026-01-01',
|
||||
},
|
||||
};
|
||||
const built = buildOfflineTracksForAlbum(album, tracks);
|
||||
expect(built).toHaveLength(1);
|
||||
expect(built[0]?.title).toBe('One');
|
||||
expect(offlineTrackCount(album, tracks)).toBe(1);
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverArt returns empty when server profile is missing', () => {
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'gone', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
|
||||
};
|
||||
expect(offlineAlbumCoverArt(album, 300)).toEqual({ src: '', cacheKey: '' });
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverArt builds url when server exists', () => {
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
|
||||
};
|
||||
const { src, cacheKey } = offlineAlbumCoverArt(album, 300);
|
||||
expect(src).toContain('ca1');
|
||||
expect(cacheKey).toBe('a:cover:ca1:300');
|
||||
});
|
||||
|
||||
it('ensureServerForOfflineAlbum skips switch when already active', async () => {
|
||||
vi.mocked(switchActiveServer).mockClear();
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: [],
|
||||
};
|
||||
await expect(ensureServerForOfflineAlbum(album)).resolves.toBe(true);
|
||||
expect(switchActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureServerForOfflineAlbum switches when album 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 album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: [],
|
||||
};
|
||||
await expect(ensureServerForOfflineAlbum(album)).resolves.toBe(true);
|
||||
expect(switchActiveServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
|
||||
import { switchActiveServer } from '../server/switchActiveServer';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
|
||||
return Object.keys(albums).length > 0;
|
||||
}
|
||||
|
||||
export function buildOfflineTracksForAlbum(
|
||||
album: OfflineAlbumMeta,
|
||||
tracks: Record<string, OfflineTrackMeta>,
|
||||
): Track[] {
|
||||
const { serverId } = album;
|
||||
return album.trackIds.flatMap(tid => {
|
||||
const t = tracks[`${serverId}:${tid}`];
|
||||
if (!t) return [];
|
||||
return [{
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
duration: t.duration,
|
||||
coverArt: t.coverArt,
|
||||
track: undefined,
|
||||
year: t.year,
|
||||
bitRate: t.bitRate,
|
||||
suffix: t.suffix,
|
||||
genre: t.genre,
|
||||
replayGainTrackDb: t.replayGainTrackDb,
|
||||
replayGainAlbumDb: t.replayGainAlbumDb,
|
||||
replayGainPeak: t.replayGainPeak,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function offlineAlbumCoverArt(
|
||||
album: OfflineAlbumMeta,
|
||||
size: number,
|
||||
): { src: string; cacheKey: string } {
|
||||
if (!album.coverArt) return { src: '', cacheKey: '' };
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === album.serverId);
|
||||
if (!server) return { src: '', cacheKey: '' };
|
||||
return {
|
||||
src: buildCoverArtUrlForServer(server.url, server.username, server.password, album.coverArt, size),
|
||||
cacheKey: coverArtCacheKeyForServer(server.id, album.coverArt, size),
|
||||
};
|
||||
}
|
||||
|
||||
/** Switch active server when it differs from the album's source server (for offline play). */
|
||||
export async function ensureServerForOfflineAlbum(album: OfflineAlbumMeta): Promise<boolean> {
|
||||
const { activeServerId, servers } = useAuthStore.getState();
|
||||
if (album.serverId === activeServerId) return true;
|
||||
const server = servers.find(s => s.id === album.serverId);
|
||||
if (!server) return false;
|
||||
return switchActiveServer(server);
|
||||
}
|
||||
|
||||
export function offlineTrackCount(
|
||||
album: OfflineAlbumMeta,
|
||||
tracks: Record<string, OfflineTrackMeta>,
|
||||
): number {
|
||||
return album.trackIds.filter(tid => !!tracks[`${album.serverId}:${tid}`]).length;
|
||||
}
|
||||
Reference in New Issue
Block a user