refactor(api): F.51 — extract playlists + play queue + radio + statistics (#616)

Four more domain-eng modules out of `api/subsonic.ts`:

- `subsonicPlaylists.ts` — `getPlaylists`/`getPlaylist`/`createPlaylist`
  /`updatePlaylist`/`updatePlaylistMeta`/`uploadPlaylistCoverArt`/
  `uploadArtistImage`/`deletePlaylist`. The two upload helpers use
  the Tauri-side CORS bypass (`upload_playlist_cover` /
  `upload_artist_image`).
- `subsonicPlayQueue.ts` — `getPlayQueue`/`savePlayQueue`.
- `subsonicRadio.ts` — Internet Radio CRUD (4 fns), Tauri-side cover
  art ops (3 fns), RadioBrowser search/top + `fetchUrlBytes`.
- `subsonicStatistics.ts` — `fetchStatisticsLibraryAggregates`/
  `fetchStatisticsOverview`/`fetchStatisticsFormatSample` with their
  three per-server-folder caches and the `statisticsPageCacheKey`
  helper. `STATS_CACHE_TTL` renamed from the misleadingly-shared
  `RATING_CACHE_TTL` constant.

Also: drop ~20 now-unused type/value imports from `subsonic.ts`, trim
three orphan jsdoc comments left behind by earlier slices, fix four
`vi.mock` targets (`queueSync`, `playerStore.persistence`) plus the
dynamic `await import('../api/subsonic')` calls in `ContextMenu.tsx`
to point at the new module paths.

Pure code-move. subsonic.ts: 561 → 144 LOC (−417). What's left:
ping/pingWithCredentials/probeInstantMix/scheduleInstantMixProbe +
internal `apiWithCredentials`/`restBaseFromUrl`.
This commit is contained in:
Frank Stellmacher
2026-05-13 01:03:13 +02:00
committed by GitHub
parent 9606a99efb
commit f7b2799d39
21 changed files with 401 additions and 449 deletions
+1 -418
View File
@@ -1,6 +1,5 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import {
isNavidromeAudiomuseSoftwareEligible,
@@ -11,46 +10,9 @@ import {
SUBSONIC_CLIENT,
api,
getAuthParams,
getClient,
libraryFilterParams,
secureRandomSalt,
} from './subsonicClient';
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
import { getArtists } from './subsonicArtists';
/** Cache TTL for statistics page aggregates — same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
const RATING_CACHE_TTL = 7 * 60 * 1000;
import type {
AlbumInfo,
EntityRatingSupportLevel,
InternetRadioStation,
PingWithCredentialsResult,
RadioBrowserStation,
RandomSongsFilters,
SearchResults,
StarredResults,
StatisticsFormatSample,
StatisticsLibraryAggregates,
StatisticsOverviewData,
SubsonicAlbum,
SubsonicArtist,
SubsonicArtistInfo,
SubsonicDirectory,
SubsonicDirectoryEntry,
SubsonicGenre,
SubsonicMusicFolder,
SubsonicNowPlaying,
SubsonicOpenArtistRef,
SubsonicPlaylist,
SubsonicSong,
SubsonicStructuredLyrics,
} from './subsonicTypes';
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
export async function ping(): Promise<boolean> {
try {
@@ -180,382 +142,3 @@ export function scheduleInstantMixProbeForServer(
useAuthStore.getState().setInstantMixProbe(serverId, result),
);
}
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes (same `RATING_CACHE_TTL` as album/artist rating prefetch).
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
offset += pageSize;
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + RATING_CACHE_TTL });
}
return result;
}
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
// ─── Playlists ────────────────────────────────────────────────
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
// ─── Play Queue Sync ──────────────────────────────────────────
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
try {
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
} catch {
return { songs: [] };
}
}
export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise<void> {
const params: Record<string, unknown> = {};
if (songIds.length > 0) params.id = songIds;
if (current !== undefined) params.current = current;
if (position !== undefined) params.position = position;
await api('savePlayQueue.view', params);
}
// ─── Now Playing ──────────────────────────────────────────────
// ─── Internet Radio ───────────────────────────────────────────
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
+20
View File
@@ -0,0 +1,20 @@
import { api } from './subsonicClient';
import type { SubsonicSong } from './subsonicTypes';
export async function getPlayQueue(): Promise<{ current?: string; position?: number; songs: SubsonicSong[] }> {
try {
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
const pq = data.playQueue;
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
} catch {
return { songs: [] };
}
}
export async function savePlayQueue(songIds: string[], current?: string, position?: number): Promise<void> {
const params: Record<string, unknown> = {};
if (songIds.length > 0) params.id = songIds;
if (current !== undefined) params.current = current;
if (position !== undefined) params.position = position;
await api('savePlayQueue.view', params);
}
+90
View File
@@ -0,0 +1,90 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from './subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
+103
View File
@@ -0,0 +1,103 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { api } from './subsonicClient';
import type { InternetRadioStation, RadioBrowserStation } from './subsonicTypes';
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
+141
View File
@@ -0,0 +1,141 @@
import { useAuthStore } from '../store/authStore';
import { getArtists } from './subsonicArtists';
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
import type {
StatisticsFormatSample,
StatisticsLibraryAggregates,
StatisticsOverviewData,
SubsonicAlbum,
SubsonicArtist,
SubsonicGenre,
SubsonicSong,
} from './subsonicTypes';
/** Cache TTL for statistics page aggregates — same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
const STATS_CACHE_TTL = 7 * 60 * 1000;
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes.
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const label = (a.genre?.trim()) ? a.genre.trim() : '';
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
if (albums.length < pageSize) break;
offset += pageSize;
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
+15 -15
View File
@@ -1,3 +1,4 @@
import { getPlaylists, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { star, unstar, setRating } from '../api/subsonicStarRating';
import { getAlbum } from '../api/subsonicLibrary';
@@ -19,7 +20,6 @@ import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { getPlaylists, getPlaylist, updatePlaylist } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -284,7 +284,7 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -347,7 +347,7 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
};
const handleCreateWithToast = async (songIds: string[]) => {
const { createPlaylist } = await import('../api/subsonic');
const { createPlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -411,7 +411,7 @@ function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId }: { albumIds
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const { createPlaylist } = await import('../api/subsonic');
const { createPlaylist } = await import('../api/subsonicPlaylists');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
usePlaylistStore.getState().touchPlaylist(pl.id);
@@ -525,7 +525,7 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
}, [artistIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -628,7 +628,7 @@ function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId }: { artist
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const { createPlaylist } = await import('../api/subsonic');
const { createPlaylist } = await import('../api/subsonicPlaylists');
const pl = await createPlaylist(name, songIds);
if (pl?.id) {
usePlaylistStore.getState().touchPlaylist(pl.id);
@@ -748,7 +748,7 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../api/subsonic');
const { createPlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
try {
const newPl = await createPlaylist(newName.trim(), []);
@@ -763,7 +763,7 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
};
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -782,7 +782,7 @@ function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }: { play
};
const handleAdd = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -894,7 +894,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('../api/subsonic');
const { createPlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
try {
const newPl = await createPlaylist(newName.trim(), []);
@@ -909,7 +909,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
};
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -939,7 +939,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
};
const handleMerge = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
@@ -1663,7 +1663,7 @@ export default function ContextMenu() {
</div>
{playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { getPlaylist, updatePlaylist } = await import('../api/subsonic');
const { getPlaylist, updatePlaylist } = await import('../api/subsonicPlaylists');
const { showToast } = await import('../utils/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
@@ -1906,7 +1906,7 @@ export default function ContextMenu() {
<div className="context-menu-divider" />
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { deletePlaylist } = await import('../api/subsonic');
const { deletePlaylist } = await import('../api/subsonicPlaylists');
const { removeId } = usePlaylistStore.getState();
try {
await deletePlaylist(playlist.id);
@@ -2120,7 +2120,7 @@ export default function ContextMenu() {
</div>
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { showToast } = await import('../utils/toast');
const { deletePlaylist } = await import('../api/subsonic');
const { deletePlaylist } = await import('../api/subsonicPlaylists');
const { removeId } = usePlaylistStore.getState();
const deletedIds: string[] = [];
for (const pl of selectedPlaylists) {
+1 -1
View File
@@ -1,3 +1,4 @@
import { getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonicPlaylists';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicPlaylist } from '../api/subsonicTypes';
@@ -12,7 +13,6 @@ import OrbitGuestQueue from './OrbitGuestQueue';
import OrbitQueueHead from './OrbitQueueHead';
import HostApprovalQueue from './HostApprovalQueue';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
import { getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
+1 -1
View File
@@ -1,3 +1,4 @@
import { getPlaylists } from '../api/subsonicPlaylists';
import { getAlbumList } from '../api/subsonicLibrary';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
@@ -18,7 +19,6 @@ import {
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import WhatsNewBanner from './WhatsNewBanner';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { ALL_NAV_ITEMS } from '../config/navItems';
import OverlayScrollArea from './OverlayScrollArea';
+1 -1
View File
@@ -1,3 +1,4 @@
import { uploadArtistImage } from '../api/subsonicPlaylists';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { search } from '../api/subsonicSearch';
@@ -7,7 +8,6 @@ import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo }
import { songToTrack } from '../utils/songToTrack';
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { uploadArtistImage } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
+1 -1
View File
@@ -1,3 +1,4 @@
import { getPlaylists, getPlaylist } from '../api/subsonicPlaylists';
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getArtists, getArtist } from '../api/subsonicArtists';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
@@ -15,7 +16,6 @@ import CustomSelect from '../components/CustomSelect';
import { useTranslation } from 'react-i18next';
import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { getPlaylists, getPlaylist } from '../api/subsonic';
import { search as searchSubsonic } from '../api/subsonicSearch';
import { showToast } from '../utils/toast';
import { IS_WINDOWS } from '../utils/platform';
+1 -1
View File
@@ -1,3 +1,4 @@
import { getInternetRadioStations } from '../api/subsonicRadio';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { getStarred, setRating, unstar } from '../api/subsonicStarRating';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation } from '../api/subsonicTypes';
@@ -7,7 +8,6 @@ import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CachedImage from '../components/CachedImage';
import { getInternetRadioStations } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePreviewStore } from '../store/previewStore';
import StarRating from '../components/StarRating';
+1 -1
View File
@@ -1,10 +1,10 @@
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt, uploadRadioCoverArtBytes, searchRadioBrowser, getTopRadioStations, fetchUrlBytes } from '../api/subsonicRadio';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { type InternetRadioStation, type RadioBrowserStation, RADIO_PAGE_SIZE } from '../api/subsonicTypes';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Cast, Plus, Trash2, X, Globe, Camera, Loader2, Search, Heart, Check } from 'lucide-react';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt, uploadRadioCoverArtBytes, searchRadioBrowser, getTopRadioStations, fetchUrlBytes } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import CachedImage from '../components/CachedImage';
import { invalidateCoverArt } from '../utils/imageCache';
+1 -1
View File
@@ -1,3 +1,4 @@
import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonicPlaylists';
import { buildDownloadUrl, coverArtCacheKey, buildCoverArtUrl } from '../api/subsonicStreamUrl';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { search } from '../api/subsonicSearch';
@@ -10,7 +11,6 @@ import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square, AudioLines } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
+1 -1
View File
@@ -1,3 +1,4 @@
import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonicPlaylists';
import { getGenres } from '../api/subsonicGenres';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
@@ -6,7 +7,6 @@ import { songToTrack } from '../utils/songToTrack';
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, CheckSquare2, Check, Clock3, Sparkles, Pencil } from 'lucide-react';
import { deletePlaylist, getPlaylist, updatePlaylist } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import { useAuthStore } from '../store/authStore';
+1 -1
View File
@@ -1,8 +1,8 @@
import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '../api/subsonicStatistics';
import { getAlbumList } from '../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { Share2 } from 'lucide-react';
import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '../api/subsonic';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
import StatsExportModal from '../components/StatsExportModal';
+1 -1
View File
@@ -1,6 +1,6 @@
import { getPlayQueue } from '../api/subsonicPlayQueue';
import { invoke } from '@tauri-apps/api/core';
import i18n from '../i18n';
import { getPlayQueue } from '../api/subsonic';
import { songToTrack } from '../utils/songToTrack';
import { showToast } from '../utils/toast';
import { useAuthStore } from './authStore';
+16 -2
View File
@@ -8,6 +8,7 @@
* Mocks `savePlayQueue` at the module boundary so we can assert the exact
* args passed to the Subsonic API call.
*/
import { savePlayQueue } from '@/api/subsonicPlayQueue';
import { initAudioListeners } from './initAudioListeners';
import { flushPlayQueuePosition } from './queueSync';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -16,22 +17,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// real `savePlayQueue` leak through to `playerStore.ts`'s relative import.
// Listing every export the store uses keeps the override stable.
vi.mock('@/api/subsonic', () => ({
pingWithCredentials: vi.fn(async () => ({ ok: true })),
}));
vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/api/subsonicStreamUrl', () => ({
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
}));
vi.mock('@/api/subsonicLibrary', () => ({
getSong: vi.fn(async () => null),
getRandomSongs: vi.fn(async () => []),
}));
vi.mock('@/api/subsonicArtists', () => ({
getSimilarSongs2: vi.fn(async () => []),
getTopSongs: vi.fn(async () => []),
}));
vi.mock('@/api/subsonicAlbumInfo', () => ({
getAlbumInfo2: vi.fn(async () => null),
}));
vi.mock('@/api/subsonicScrobble', () => ({
reportNowPlaying: vi.fn(async () => undefined),
scrobbleSong: vi.fn(async () => undefined),
}));
vi.mock('@/api/subsonicStarRating', () => ({
setRating: vi.fn(async () => undefined),
probeEntityRatingSupport: vi.fn(async () => 'track_only'),
pingWithCredentials: vi.fn(async () => ({ ok: true })),
}));
vi.mock('@/api/lastfm', () => ({
@@ -41,7 +56,6 @@ vi.mock('@/api/lastfm', () => ({
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
import { savePlayQueue } from '@/api/subsonic';
import { usePlayerStore } from './playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
+2 -1
View File
@@ -1,7 +1,8 @@
import { getPlaylists } from '../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../api/subsonicTypes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getPlaylists, createPlaylist as apiCreatePlaylist } from '../api/subsonic';
import { createPlaylist as apiCreatePlaylist } from '../api/subsonicPlaylists';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
+1 -1
View File
@@ -17,7 +17,7 @@ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
progressSnapshot: { currentTime: 0, progress: 0, buffered: 0 },
}));
vi.mock('../api/subsonic', () => ({ savePlayQueue: savePlayQueueMock }));
vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
vi.mock('./playerStore', () => ({
usePlayerStore: { getState: () => playerState },
}));
+1 -1
View File
@@ -1,5 +1,5 @@
import { savePlayQueue } from '../api/subsonicPlayQueue';
import type { Track } from './playerStoreTypes';
import { savePlayQueue } from '../api/subsonic';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlayerStore } from './playerStore';
/**
+1 -1
View File
@@ -1,6 +1,6 @@
import { createPlaylist, updatePlaylist, updatePlaylistMeta, deletePlaylist, getPlaylist, getPlaylists } from '../api/subsonicPlaylists';
import { getSong } from '../api/subsonicLibrary';
import { songToTrack } from '../utils/songToTrack';
import { createPlaylist, updatePlaylist, updatePlaylistMeta, deletePlaylist, getPlaylist, getPlaylists } from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';