Files
Psychotoxical-psysonic/src/store/offlineStore.ts
T
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:

- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
  `buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
  for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
  `setRating`, `probeEntityRatingSupport`. `setRating` still triggers
  the lazy `navidromeBrowse` cache invalidation; the same-folder
  lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
  `getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.

63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.

Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
2026-05-13 00:46:13 +02:00

325 lines
12 KiB
TypeScript

import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import { getArtist } from '../api/subsonicArtists';
import type { SubsonicSong } from '../api/subsonicTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast';
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
import { emitAnalysisStorageChanged } from './analysisSync';
export interface OfflineTrackMeta {
id: string;
serverId: string;
localPath: string;
title: string;
artist: string;
album: string;
albumId: string;
artistId?: string;
suffix: string;
duration: number;
bitRate?: number;
coverArt?: string;
year?: number;
genre?: string;
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
cachedAt: string;
}
export interface OfflineAlbumMeta {
id: string;
serverId: string;
name: string;
artist: string;
coverArt?: string;
year?: number;
trackIds: string[];
type?: 'album' | 'playlist' | 'artist';
}
// Re-export for components that import DownloadJob from offlineStore.
export type { DownloadJob } from './offlineJobStore';
interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
isAlbumDownloading: (albumId: string) => boolean;
getLocalUrl: (trackId: string, serverId: string) => string | null;
downloadAlbum: (
albumId: string,
albumName: string,
albumArtist: string,
coverArt: string | undefined,
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
type?: 'album' | 'playlist' | 'artist',
) => Promise<void>;
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
clearAll: (serverId: string) => Promise<void>;
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
}
export const useOfflineStore = create<OfflineState>()(
persist(
(set, get) => ({
tracks: {},
albums: {},
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
isAlbumDownloaded: (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album || album.trackIds.length === 0) return false;
return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]);
},
isAlbumDownloading: (albumId) =>
useOfflineJobStore.getState().jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
),
getLocalUrl: (trackId, serverId) => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return null;
return `psysonic-local://${meta.localPath}`;
},
clearAll: async (serverId) => {
const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`));
for (const key of albumKeys) {
const albumId = key.slice(`${serverId}:`.length);
await get().deleteAlbum(albumId, serverId);
}
},
getAlbumProgress: (albumId) => {
const albumJobs = useOfflineJobStore.getState().jobs.filter(j => j.albumId === albumId);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
// Frontend fires up to 8 invoke calls at a time so Rust always has work queued.
// The backend Semaphore (MAX_DL_CONCURRENCY = 4) is the real throttle —
// at most 4 HTTP streams run simultaneously regardless of this value.
const CONCURRENCY = 8;
const trackIds = songs.map(s => s.id);
const jobStore = useOfflineJobStore;
// Pre-flight: verify the target directory is accessible before queuing anything.
const customDir = useAuthStore.getState().offlineDownloadDir || null;
if (customDir) {
const ok = await invoke<boolean>('check_dir_accessible', { path: customDir }).catch(() => false);
if (!ok) {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
return;
}
}
// Register album in persisted store — 1 localStorage write.
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
},
}));
// Queue jobs in the non-persisted job store — zero localStorage writes.
jobStore.setState(state => ({
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...songs.map((s, i) => ({
trackId: s.id,
albumId,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: songs.length,
status: 'queued' as const,
})),
],
}));
// Accumulate completed tracks locally — persisted in ONE write at the very end.
const completedTracks: Record<string, OfflineTrackMeta> = {};
for (let i = 0; i < songs.length; i += CONCURRENCY) {
// Abort if the user cancelled this download.
if (cancelledDownloads.has(albumId)) {
cancelledDownloads.delete(albumId);
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
return;
}
const batch = songs.slice(i, i + CONCURRENCY);
const batchIds = new Set(batch.map(s => s.id));
// Mark batch as downloading — job store only, no localStorage write.
jobStore.setState(state => ({
jobs: state.jobs.map(j =>
j.albumId === albumId && batchIds.has(j.trackId)
? { ...j, status: 'downloading' }
: j,
),
}));
// Run all downloads concurrently, collect results without touching any store.
const results = await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
serverId,
url: buildStreamUrl(song.id),
suffix,
customDir,
});
return { song, suffix, localPath, error: null as string | null };
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND' && !cancelledDownloads.has(albumId)) {
cancelledDownloads.add(albumId);
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
return { song, suffix, localPath: null as string | null, error: msg };
}
}),
);
// Accumulate completed tracks locally (no store write yet).
for (const { song, suffix, localPath } of results) {
if (localPath) {
completedTracks[`${serverId}:${song.id}`] = {
id: song.id,
serverId,
localPath,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
suffix,
duration: song.duration,
bitRate: song.bitRate,
coverArt: song.coverArt,
year: song.year,
genre: song.genre,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
cachedAt: new Date().toISOString(),
};
}
}
// Update job statuses — job store only, no localStorage write.
const resultMap = new Map(results.map(r => [r.song.id, r]));
jobStore.setState(state => ({
jobs: state.jobs.map(j => {
if (j.albumId !== albumId) return j;
const r = resultMap.get(j.trackId);
if (!r) return j;
return { ...j, status: r.localPath ? 'done' : 'error' };
}),
}));
}
// Persist all completed tracks in ONE localStorage write.
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
// Clear completed jobs after a short delay.
setTimeout(() => {
jobStore.setState(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
}));
}, 2500);
},
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
// Deduplicate songs (a track can appear multiple times in a playlist).
const seen = new Set<string>();
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
// Store the entire playlist as one virtual album entry so the Offline Library
// shows a single card for the playlist rather than one card per album.
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
},
downloadArtist: async (artistId, artistName, serverId) => {
const jobStore = useOfflineJobStore;
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
try {
const res = await getArtist(artistId);
albums = res.albums;
} catch { return; }
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
}));
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
try {
const { songs } = await getAlbum(album.id);
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
} catch { /* skip failed album */ }
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
}));
}
setTimeout(() => {
jobStore.setState(state => {
const { [artistId]: _removed, ...rest } = state.bulkProgress;
return { bulkProgress: rest };
});
}, 3000);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
await Promise.all(
album.trackIds.map(async trackId => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
localPath: meta.localPath,
baseDir: useAuthStore.getState().offlineDownloadDir || null,
}).catch(() => {});
}),
);
for (const trackId of album.trackIds) {
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
}
set(state => {
const tracks = { ...state.tracks };
album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]);
const albums = { ...state.albums };
delete albums[`${serverId}:${albumId}`];
return { tracks, albums };
});
},
}),
{
name: 'psysonic-offline',
storage: createJSONStorage(() => localStorage),
partialize: state => ({ tracks: state.tracks, albums: state.albums }),
},
),
);