From f7b2799d393315f6e257ec9aa969e9b900097c59 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 01:03:13 +0200 Subject: [PATCH] =?UTF-8?q?refactor(api):=20F.51=20=E2=80=94=20extract=20p?= =?UTF-8?q?laylists=20+=20play=20queue=20+=20radio=20+=20statistics=20(#61?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- src/api/subsonic.ts | 419 +--------------------- src/api/subsonicPlayQueue.ts | 20 ++ src/api/subsonicPlaylists.ts | 90 +++++ src/api/subsonicRadio.ts | 103 ++++++ src/api/subsonicStatistics.ts | 141 ++++++++ src/components/ContextMenu.tsx | 30 +- src/components/QueuePanel.tsx | 2 +- src/components/Sidebar.tsx | 2 +- src/pages/ArtistDetail.tsx | 2 +- src/pages/DeviceSync.tsx | 2 +- src/pages/Favorites.tsx | 2 +- src/pages/InternetRadio.tsx | 2 +- src/pages/PlaylistDetail.tsx | 2 +- src/pages/Playlists.tsx | 2 +- src/pages/Statistics.tsx | 2 +- src/store/miscActions.ts | 2 +- src/store/playerStore.persistence.test.ts | 18 +- src/store/playlistStore.ts | 3 +- src/store/queueSync.test.ts | 2 +- src/store/queueSync.ts | 2 +- src/utils/orbit.ts | 2 +- 21 files changed, 401 insertions(+), 449 deletions(-) create mode 100644 src/api/subsonicPlayQueue.ts create mode 100644 src/api/subsonicPlaylists.ts create mode 100644 src/api/subsonicRadio.ts create mode 100644 src/api/subsonicStatistics.ts diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 860a2079..f58a7c47 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { 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(); - -/** - * 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 { - 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(); - 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(); - -export async function fetchStatisticsOverview(): Promise { - 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(); - -export async function fetchStatisticsFormatSample(): Promise { - 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 = {}; - 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 { - 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 { - const params: Record = { 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 { - 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 { - await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic }); -} - -export async function uploadPlaylistCoverArt(id: string, file: File): Promise { - // 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 { - // 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 { - 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 { - const params: Record = {}; - 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 { - 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 { - const params: Record = { 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 { - const params: Record = { id, name, streamUrl }; - if (homepageUrl) params.homepageUrl = homepageUrl; - await api('updateInternetRadioStation.view', params); -} - -export async function deleteInternetRadioStation(id: string): Promise { - await api('deleteInternetRadioStation.view', { id }); -} - -export async function uploadRadioCoverArt(id: string, file: File): Promise { - // 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 { - // 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 { - 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>): 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 { - const raw = await invoke>>('search_radio_browser', { query, offset }); - return parseRadioBrowserStations(raw); -} - -export async function getTopRadioStations(offset = 0): Promise { - const raw = await invoke>>('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 }); -} - - diff --git a/src/api/subsonicPlayQueue.ts b/src/api/subsonicPlayQueue.ts new file mode 100644 index 00000000..e241aa2e --- /dev/null +++ b/src/api/subsonicPlayQueue.ts @@ -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 { + const params: Record = {}; + if (songIds.length > 0) params.id = songIds; + if (current !== undefined) params.current = current; + if (position !== undefined) params.position = position; + await api('savePlayQueue.view', params); +} diff --git a/src/api/subsonicPlaylists.ts b/src/api/subsonicPlaylists.ts new file mode 100644 index 00000000..8a24392f --- /dev/null +++ b/src/api/subsonicPlaylists.ts @@ -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 { + 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 { + const params: Record = { 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 { + 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 { + await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic }); +} + +export async function uploadPlaylistCoverArt(id: string, file: File): Promise { + // 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 { + // 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 { + await api('deletePlaylist.view', { id }); +} diff --git a/src/api/subsonicRadio.ts b/src/api/subsonicRadio.ts new file mode 100644 index 00000000..cbcbb214 --- /dev/null +++ b/src/api/subsonicRadio.ts @@ -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 { + 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 { + const params: Record = { 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 { + const params: Record = { id, name, streamUrl }; + if (homepageUrl) params.homepageUrl = homepageUrl; + await api('updateInternetRadioStation.view', params); +} + +export async function deleteInternetRadioStation(id: string): Promise { + await api('deleteInternetRadioStation.view', { id }); +} + +export async function uploadRadioCoverArt(id: string, file: File): Promise { + // 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 { + // 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 { + 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>): 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 { + const raw = await invoke>>('search_radio_browser', { query, offset }); + return parseRadioBrowserStations(raw); +} + +export async function getTopRadioStations(offset = 0): Promise { + const raw = await invoke>>('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 }); +} diff --git a/src/api/subsonicStatistics.ts b/src/api/subsonicStatistics.ts new file mode 100644 index 00000000..e2b2f62e --- /dev/null +++ b/src/api/subsonicStatistics.ts @@ -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(); + +/** + * 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 { + 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(); + 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(); + +export async function fetchStatisticsOverview(): Promise { + 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(); + +export async function fetchStatisticsFormatSample(): Promise { + 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 = {}; + 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; +} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 92123bda..c6d76bdf 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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() { {playlistId && playlistSongIndex !== undefined && (
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() {
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() {
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) { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index e6cb5645..695406f0 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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'; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index f67e89c9..043a203c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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'; diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 928a0450..de06f049 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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'; diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 595dbba6..a576311a 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -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'; diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 018bc95e..643b3dd2 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -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'; diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx index 05431100..bfaafd5f 100644 --- a/src/pages/InternetRadio.tsx +++ b/src/pages/InternetRadio.tsx @@ -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'; diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 88c5b78b..46b9ffa8 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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'; diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index d4d130d2..1136dba3 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -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'; diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 91be61d7..1404352e 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -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'; diff --git a/src/store/miscActions.ts b/src/store/miscActions.ts index e1d879db..a4085517 100644 --- a/src/store/miscActions.ts +++ b/src/store/miscActions.ts @@ -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'; diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 3dd3b8c2..9cb0b36b 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -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'; diff --git a/src/store/playlistStore.ts b/src/store/playlistStore.ts index 356cc2b6..2d82ef92 100644 --- a/src/store/playlistStore.ts +++ b/src/store/playlistStore.ts @@ -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[]; diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index c7a1cc3b..4ddd7564 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -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 }, })); diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index 92851396..f7710507 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -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'; /** diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 76e0938c..683e8551 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -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';