mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -174,6 +174,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
||||
* **One-time migration:** if you had previously opened the per-tab Advanced group or customised any of the three sub-sections, Advanced Mode is auto-enabled on first launch — your existing tweaks stay visible.
|
||||
* i18n: `settings.advancedMode`, `settings.advancedModeTooltip`, `settings.advancedBadge`, and `settings.playlistLayout*` across all 9 locales.
|
||||
|
||||
### Offline Library — show cached albums from all servers
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#719](https://github.com/Psychotoxical/psysonic/pull/719)**
|
||||
|
||||
* **Offline Library** lists every cached album across all saved servers, not only the currently active one. Cover art loads from each album's source server; play and enqueue switch to that server when needed.
|
||||
* Sidebar entry, mobile **More** menu, disconnect auto-navigation, and the offline banner treat **any** cached content as available offline. With multiple servers, cards show which server the album came from.
|
||||
|
||||
## Changed
|
||||
|
||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useOrbitHost } from '../hooks/useOrbitHost';
|
||||
import { useOrbitGuest } from '../hooks/useOrbitGuest';
|
||||
import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs';
|
||||
import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState';
|
||||
import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle';
|
||||
import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n';
|
||||
@@ -86,10 +87,9 @@ export function AppShell() {
|
||||
const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar);
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar);
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { ALL_NAV_ITEMS } from '../config/navItems';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
|
||||
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
|
||||
|
||||
@@ -14,9 +15,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void })
|
||||
const { t } = useTranslation();
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const luckyMixBase = useLuckyMixAvailable();
|
||||
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea
|
||||
import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd';
|
||||
import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown';
|
||||
import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible';
|
||||
import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers';
|
||||
import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe';
|
||||
import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal';
|
||||
import SidebarNavBody from './sidebar/SidebarNavBody';
|
||||
@@ -60,7 +61,7 @@ export default function Sidebar({
|
||||
const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine);
|
||||
const loggingMode = useAuthStore(s => s.loggingMode);
|
||||
const setLoggingMode = useAuthStore(s => s.setLoggingMode);
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums);
|
||||
const sidebarItems = useSidebarStore(s => s.items);
|
||||
const setSidebarItems = useSidebarStore(s => s.setItems);
|
||||
const randomNavMode = useAuthStore(s => s.randomNavMode);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar',
|
||||
offlineLibraryTitle: 'Offline-Bibliothek',
|
||||
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
|
||||
offlineCachedOnServer: 'Auf {{server}}',
|
||||
offlineAlbumCount: '{{n}} Album',
|
||||
offlineAlbumCount_plural: '{{n}} Alben',
|
||||
offlineFilterAll: 'Alle',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'No server connection — cannot reach {{server}}',
|
||||
offlineLibraryTitle: 'Offline Library',
|
||||
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
|
||||
offlineCachedOnServer: 'On {{server}}',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'All',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Sin conexión al servidor — no se puede acceder a {{server}}',
|
||||
offlineLibraryTitle: 'Biblioteca Offline',
|
||||
offlineLibraryEmpty: 'No hay álbumes en caché aún. Conéctate, abre un álbum y click en "Disponible offline".',
|
||||
offlineCachedOnServer: 'En {{server}}',
|
||||
offlineAlbumCount: '{{n}} álbum',
|
||||
offlineAlbumCount_plural: '{{n}} álbumes',
|
||||
offlineFilterAll: 'Todos',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Pas de connexion au serveur — {{server}} inaccessible',
|
||||
offlineLibraryTitle: 'Bibliothèque hors ligne',
|
||||
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
|
||||
offlineCachedOnServer: 'Sur {{server}}',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Tout',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Ingen servertilkobling — kan ikke nå {{server}}',
|
||||
offlineLibraryTitle: 'Frakoblet bibliotek',
|
||||
offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".',
|
||||
offlineCachedOnServer: 'På {{server}}',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} album',
|
||||
offlineFilterAll: 'Alle',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Geen serververbinding — {{server}} niet bereikbaar',
|
||||
offlineLibraryTitle: 'Offline bibliotheek',
|
||||
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
|
||||
offlineCachedOnServer: 'Op {{server}}',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albums',
|
||||
offlineFilterAll: 'Alles',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: 'Nicio conexiune la server — nu s-a putut ajunge la {{server}}',
|
||||
offlineLibraryTitle: 'Librărie offline',
|
||||
offlineLibraryEmpty: 'Niciun album adăugat în cache. Conectează-te, deschide un album și apasă "Fă disponibil offline".',
|
||||
offlineCachedOnServer: 'Pe {{server}}',
|
||||
offlineAlbumCount: '{{n}} album',
|
||||
offlineAlbumCount_plural: '{{n}} albume',
|
||||
offlineFilterAll: 'Toate',
|
||||
|
||||
@@ -12,6 +12,7 @@ export const connection = {
|
||||
offlineLibraryTitle: 'Офлайн-библиотека',
|
||||
offlineLibraryEmpty:
|
||||
'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».',
|
||||
offlineCachedOnServer: 'С сервера {{server}}',
|
||||
offlineAlbumCount_one: '{{n}} альбом',
|
||||
offlineAlbumCount_few: '{{n}} альбома',
|
||||
offlineAlbumCount_many: '{{n}} альбомов',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const connection = {
|
||||
offlineNoCacheBanner: '无服务器连接 — 无法访问 {{server}}',
|
||||
offlineLibraryTitle: '离线音乐库',
|
||||
offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。',
|
||||
offlineCachedOnServer: '来自 {{server}}',
|
||||
offlineAlbumCount_one: '{{n}} 张专辑',
|
||||
offlineAlbumCount_plural: '{{n}} 张专辑',
|
||||
retry: '重试',
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Play, HardDriveDownload, Trash2, ListPlus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '../store/offlineStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import CachedImage from '../components/CachedImage';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import {
|
||||
buildOfflineTracksForAlbum,
|
||||
ensureServerForOfflineAlbum,
|
||||
offlineAlbumCoverArt,
|
||||
offlineTrackCount,
|
||||
} from '../utils/offline/offlineLibraryHelpers';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
|
||||
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
|
||||
|
||||
export default function OfflineLibrary() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const serverNames = useMemo(
|
||||
() => Object.fromEntries(servers.map(s => [s.id, s.name])),
|
||||
[servers],
|
||||
);
|
||||
const showServerLabels = servers.length > 1;
|
||||
const offlineAlbums = useOfflineStore(s => s.albums);
|
||||
const offlineTracks = useOfflineStore(s => s.tracks);
|
||||
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
|
||||
@@ -22,7 +33,10 @@ export default function OfflineLibrary() {
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
|
||||
const albums = useMemo(
|
||||
() => Object.values(offlineAlbums).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[offlineAlbums],
|
||||
);
|
||||
|
||||
const countByType = (type: FilterType) => {
|
||||
if (type === 'all') return albums.length;
|
||||
@@ -33,37 +47,35 @@ export default function OfflineLibrary() {
|
||||
? albums
|
||||
: albums.filter(a => (a.type ?? 'album') === filter);
|
||||
|
||||
const buildTracks = (albumId: string) => {
|
||||
const meta = offlineAlbums[`${serverId}:${albumId}`];
|
||||
if (!meta) return [];
|
||||
return meta.trackIds.flatMap(tid => {
|
||||
const t = offlineTracks[`${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,
|
||||
}];
|
||||
const runWithAlbumServer = useCallback(async (
|
||||
album: OfflineAlbumMeta,
|
||||
action: () => void,
|
||||
) => {
|
||||
const ok = await ensureServerForOfflineAlbum(album);
|
||||
if (!ok) {
|
||||
showToast(t('connection.switchFailed'), 4500, 'error');
|
||||
return;
|
||||
}
|
||||
action();
|
||||
}, [t]);
|
||||
|
||||
const handlePlay = (album: OfflineAlbumMeta) => {
|
||||
void runWithAlbumServer(album, () => {
|
||||
const tracks = buildOfflineTracksForAlbum(album, offlineTracks);
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlay = (albumId: string) => {
|
||||
const tracks = buildTracks(albumId);
|
||||
if (tracks[0]) playTrack(tracks[0], tracks);
|
||||
const handleEnqueue = (album: OfflineAlbumMeta) => {
|
||||
void runWithAlbumServer(album, () => {
|
||||
enqueue(buildOfflineTracksForAlbum(album, offlineTracks));
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnqueue = (albumId: string) => {
|
||||
enqueue(buildTracks(albumId));
|
||||
};
|
||||
|
||||
const renderCard = (album: typeof albums[0]) => {
|
||||
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
|
||||
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
|
||||
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
|
||||
const renderCard = (album: OfflineAlbumMeta) => {
|
||||
const { src: coverUrl, cacheKey } = offlineAlbumCoverArt(album, 300);
|
||||
const trackCount = offlineTrackCount(album, offlineTracks);
|
||||
const serverLabel = serverNames[album.serverId];
|
||||
return (
|
||||
<div className="album-card card offline-library-card">
|
||||
<div className="album-card-cover">
|
||||
@@ -77,7 +89,7 @@ export default function OfflineLibrary() {
|
||||
<div className="album-card-play-overlay">
|
||||
<button
|
||||
className="album-card-details-btn"
|
||||
onClick={() => handlePlay(album.id)}
|
||||
onClick={() => handlePlay(album)}
|
||||
aria-label={`${album.name} abspielen`}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
@@ -87,11 +99,16 @@ export default function OfflineLibrary() {
|
||||
<div className="album-card-info">
|
||||
<p className="album-card-title truncate">{album.name}</p>
|
||||
<p className="album-card-artist truncate">{album.artist}</p>
|
||||
{showServerLabels && serverLabel && (
|
||||
<p className="offline-library-server truncate" title={serverLabel}>
|
||||
{t('connection.offlineCachedOnServer', { server: serverLabel })}
|
||||
</p>
|
||||
)}
|
||||
{album.year && <p className="album-card-year">{album.year}</p>}
|
||||
<div className="offline-library-card-meta">
|
||||
<button
|
||||
className="offline-library-enqueue"
|
||||
onClick={() => handleEnqueue(album.id)}
|
||||
onClick={() => handleEnqueue(album)}
|
||||
data-tooltip={t('queue.appendToQueue')}
|
||||
data-tooltip-pos="top"
|
||||
aria-label={t('queue.appendToQueue')}
|
||||
@@ -103,7 +120,7 @@ export default function OfflineLibrary() {
|
||||
</span>
|
||||
<button
|
||||
className="offline-library-delete"
|
||||
onClick={() => deleteAlbum(album.id, serverId)}
|
||||
onClick={() => deleteAlbum(album.id, album.serverId)}
|
||||
data-tooltip={t('albumDetail.removeOffline')}
|
||||
data-tooltip-pos="top"
|
||||
>
|
||||
@@ -115,9 +132,8 @@ export default function OfflineLibrary() {
|
||||
);
|
||||
};
|
||||
|
||||
// For artist filter: group by artist name
|
||||
const renderArtistGroups = () => {
|
||||
const groups: Record<string, typeof albums> = {};
|
||||
const groups: Record<string, OfflineAlbumMeta[]> = {};
|
||||
for (const album of filtered) {
|
||||
const key = album.artist || '—';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.offline-library-server {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.offline-library-card .album-card-info {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
@@ -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