diff --git a/src/api/subsonic.async.test.ts b/src/api/subsonic.async.test.ts index e29a7c51..dd87df04 100644 --- a/src/api/subsonic.async.test.ts +++ b/src/api/subsonic.async.test.ts @@ -21,21 +21,9 @@ vi.mock('axios', () => ({ })); import axios from 'axios'; -import { - getAlbum, - getAlbumInfo2, - getArtists, - getMusicDirectory, - getMusicFolders, - getMusicIndexes, - getRandomSongs, - getSong, - getStarred, - getTopSongs, - pingWithCredentials, - ping, - search, -} from './subsonic'; +import { getAlbumInfo2, getStarred, pingWithCredentials, ping, search } from './subsonic'; +import { getAlbum, getMusicDirectory, getMusicFolders, getMusicIndexes, getRandomSongs, getSong } from './subsonicLibrary'; +import { getArtists, getTopSongs } from './subsonicArtists'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 9d289161..f8b9b4c1 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -11,7 +11,8 @@ * mocking and are not in this PR. */ import { beforeEach, describe, expect, it } from 'vitest'; -import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey, parseSubsonicEntityStarRating } from './subsonic'; +import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey } from './subsonic'; +import { parseSubsonicEntityStarRating } from './subsonicRatings'; import { getClient, libraryFilterParams } from './subsonicClient'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 94084255..be8bb97e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -15,6 +15,12 @@ import { 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, @@ -43,53 +49,8 @@ import type { /** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */ -// ─── API Methods ────────────────────────────────────────────── -export async function getMusicDirectory(id: string): Promise { - const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>( - 'getMusicDirectory.view', - { id }, - ); - const dir = data.directory; - const raw = dir.child; - const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; - return { id: dir.id, parent: dir.parent, name: dir.name, child }; -} -/** Returns the top-level artist/directory entries for a music folder root. - * Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs — - * use getIndexes.view with musicFolderId instead. */ -export async function getMusicIndexes(musicFolderId: string): Promise { - type IndexArtist = { id: string; name: string; coverArt?: string }; - type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] }; - const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>( - 'getIndexes.view', - { musicFolderId }, - ); - const raw = data.indexes?.index; - if (!raw) return []; - const indices = Array.isArray(raw) ? raw : [raw]; - const entries: SubsonicDirectoryEntry[] = []; - for (const idx of indices) { - const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : []; - for (const a of artists) { - entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt }); - } - } - return entries; -} -export async function getMusicFolders(): Promise { - const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>( - 'getMusicFolders.view', - ); - const raw = data.musicFolders?.musicFolder; - if (!raw) return []; - const arr = Array.isArray(raw) ? raw : [raw]; - return arr.map(f => ({ - id: String((f as { id: string | number }).id), - name: (f as { name?: string }).name ?? 'Library', - })); -} export async function ping(): Promise { try { @@ -220,237 +181,22 @@ export function scheduleInstantMixProbeForServer( ); } -export async function getRandomAlbums(size = 6): Promise { - const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { - type: 'random', - size, - ...libraryFilterParams(), - }); - return data.albumList2?.album ?? []; -} -export async function getAlbumList( - type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest', - size = 30, - offset = 0, - extra: Record = {} -): Promise { - const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { - type, - size, - offset, - _t: Date.now(), - ...libraryFilterParams(), - ...extra, - }); - return data.albumList2?.album ?? []; -} -/** - * Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs, - * so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of - * album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set. - */ -let scopedLibraryAlbumIdCache: { - serverId: string; - folderId: string; - filterVersion: number; - ids: Set; -} | null = null; -async function albumIdsInActiveLibraryScope(): Promise | null> { - const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState(); - if (!activeServerId) return null; - const folder = musicLibraryFilterByServer[activeServerId]; - if (folder === undefined || folder === 'all') { - scopedLibraryAlbumIdCache = null; - return null; - } - const hit = scopedLibraryAlbumIdCache; - if ( - hit && - hit.serverId === activeServerId && - hit.folderId === folder && - hit.filterVersion === musicLibraryFilterVersion - ) { - return hit.ids; - } - const ids = new Set(); - const pageSize = 500; - let offset = 0; - for (;;) { - const albums = await getAlbumList('alphabeticalByName', pageSize, offset); - for (const a of albums) ids.add(a.id); - if (albums.length < pageSize) break; - offset += pageSize; - if (offset > 500_000) break; - } - scopedLibraryAlbumIdCache = { - serverId: activeServerId, - folderId: folder, - filterVersion: musicLibraryFilterVersion, - ids, - }; - return ids; -} -export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise { - const allowed = await albumIdsInActiveLibraryScope(); - if (!allowed || allowed.size === 0) return songs; - return songs.filter(s => s.albumId && allowed.has(s.albumId)); -} -/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */ -function similarSongsRequestCount(desired: number): number { - const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); - const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined; - if (f === undefined || f === 'all') return desired; - return Math.min(300, Math.max(desired, desired * 4)); -} -export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise { - const params: Record = { size, _t: Date.now(), ...libraryFilterParams() }; - if (genre) params.genre = genre; - const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); - return data.randomSongs?.song ?? []; -} -/** Extended random song fetch with server-side year/genre filtering. */ -export async function getRandomSongsFiltered( - filters: RandomSongsFilters, - timeout = 15000, -): Promise { - const params: Record = { - size: filters.size ?? 50, - _t: Date.now(), - ...libraryFilterParams(), - }; - if (filters.genre) params.genre = filters.genre; - if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear; - if (typeof filters.toYear === 'number') params.toYear = filters.toYear; - const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); - return data.randomSongs?.song ?? []; -} -export async function getSong(id: string): Promise { - try { - const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); - return data.song ?? null; - } catch { - return null; - } -} -export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { - const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id }); - const { song, ...album } = data.album; - return { album, songs: song ?? [] }; -} -const MIX_RATING_PREFETCH_CONCURRENCY = 8; -const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes -const ratingCache = new Map(); -function getCachedRating(key: string): number | undefined | null { - const entry = ratingCache.get(key); - if (!entry) return null; // cache miss - if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; } - return entry.value; -} -function setCachedRating(key: string, value: number | undefined): void { - ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL }); -} -function parseEntityUserRating(v: unknown): number | undefined { - if (v === null || v === undefined) return undefined; - const n = typeof v === 'number' ? v : Number(v); - if (!Number.isFinite(n)) return undefined; - return n; -} -/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */ -export function parseSubsonicEntityStarRating(entity: { - userRating?: unknown; - rating?: unknown; -}): number | undefined { - return parseEntityUserRating(entity.userRating ?? entity.rating); -} -/** Bump when rating parse keys change so stale cache entries are not reused. */ -const ENTITY_RATING_CACHE_KEY_VER = 'v2'; -/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */ -export async function prefetchArtistUserRatings( - ids: string[], - concurrency = MIX_RATING_PREFETCH_CONCURRENCY, -): Promise> { - const unique = [...new Set(ids.filter(Boolean))]; - const out = new Map(); - if (!unique.length) return out; - const uncached: string[] = []; - for (const id of unique) { - const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`); - if (cached !== null) { if (cached !== undefined) out.set(id, cached); } - else uncached.push(id); - } - if (!uncached.length) return out; - let next = 0; - async function worker() { - for (;;) { - const i = next++; - if (i >= uncached.length) return; - const id = uncached[i]; - try { - const { artist } = await getArtist(id); - const r = parseSubsonicEntityStarRating(artist); - setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r); - if (r !== undefined) out.set(id, r); - } catch { - /* ignore */ - } - } - } - const nWorkers = Math.min(concurrency, uncached.length); - await Promise.all(Array.from({ length: nWorkers }, () => worker())); - return out; -} - -/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */ -export async function prefetchAlbumUserRatings( - ids: string[], - concurrency = MIX_RATING_PREFETCH_CONCURRENCY, -): Promise> { - const unique = [...new Set(ids.filter(Boolean))]; - const out = new Map(); - if (!unique.length) return out; - const uncached: string[] = []; - for (const id of unique) { - const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`); - if (cached !== null) { if (cached !== undefined) out.set(id, cached); } - else uncached.push(id); - } - if (!uncached.length) return out; - let next = 0; - async function worker() { - for (;;) { - const i = next++; - if (i >= uncached.length) return; - const id = uncached[i]; - try { - const { album } = await getAlbum(id); - const r = parseSubsonicEntityStarRating(album); - setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r); - if (r !== undefined) out.set(id, r); - } catch { - /* ignore */ - } - } - } - const nWorkers = Math.min(concurrency, uncached.length); - await Promise.all(Array.from({ length: nWorkers }, () => worker())); - return out; -} /** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */ /** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */ @@ -578,73 +324,11 @@ export async function fetchStatisticsFormatSample(): Promise { - const data = await api<{ artists: { index: any } }>('getArtists.view', { - ...libraryFilterParams(), - }); - const rawIdx = data.artists?.index; - const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []); - const artists: SubsonicArtist[] = []; - for (const idx of indices) { - const rawArt = idx.artist; - const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []); - artists.push(...arr); - } - return artists; -} -export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { - const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id }); - const { album, ...artist } = data.artist; - return { artist, albums: album ?? [] }; -} -export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise { - const count = options?.similarArtistCount ?? 5; - const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() }); - return data.artistInfo2 ?? {}; -} -export async function getTopSongs(artist: string): Promise { - try { - const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); - const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all'; - const topCount = scoped ? 20 : 5; - const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() }); - const raw = data.topSongs?.song ?? []; - const filtered = await filterSongsToActiveLibrary(raw); - return filtered.slice(0, 5); - } catch { - return []; - } -} -export async function getSimilarSongs2(id: string, count = 50): Promise { - try { - const requestCount = similarSongsRequestCount(count); - const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() }); - const raw = data.similarSongs2?.song ?? []; - const filtered = await filterSongsToActiveLibrary(raw); - return filtered.slice(0, count); - } catch { - return []; - } -} -/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */ -export async function getSimilarSongs(id: string, count = 50): Promise { - try { - const requestCount = similarSongsRequestCount(count); - const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() }); - const raw = data.similarSongs?.song; - if (!raw) return []; - const list = Array.isArray(raw) ? raw : [raw]; - const filtered = await filterSongsToActiveLibrary(list); - return filtered.slice(0, count); - } catch { - return []; - } -} export async function getGenres(): Promise { const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view'); diff --git a/src/api/subsonicArtists.ts b/src/api/subsonicArtists.ts new file mode 100644 index 00000000..78dbb992 --- /dev/null +++ b/src/api/subsonicArtists.ts @@ -0,0 +1,77 @@ +import { useAuthStore } from '../store/authStore'; +import { api, libraryFilterParams } from './subsonicClient'; +import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary'; +import type { + SubsonicAlbum, + SubsonicArtist, + SubsonicArtistInfo, + SubsonicSong, +} from './subsonicTypes'; + +export async function getArtists(): Promise { + const data = await api<{ artists: { index: any } }>('getArtists.view', { + ...libraryFilterParams(), + }); + const rawIdx = data.artists?.index; + const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []); + const artists: SubsonicArtist[] = []; + for (const idx of indices) { + const rawArt = idx.artist; + const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []); + artists.push(...arr); + } + return artists; +} + +export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { + const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id }); + const { album, ...artist } = data.artist; + return { artist, albums: album ?? [] }; +} + +export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise { + const count = options?.similarArtistCount ?? 5; + const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() }); + return data.artistInfo2 ?? {}; +} + +export async function getTopSongs(artist: string): Promise { + try { + const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); + const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all'; + const topCount = scoped ? 20 : 5; + const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() }); + const raw = data.topSongs?.song ?? []; + const filtered = await filterSongsToActiveLibrary(raw); + return filtered.slice(0, 5); + } catch { + return []; + } +} + +export async function getSimilarSongs2(id: string, count = 50): Promise { + try { + const requestCount = similarSongsRequestCount(count); + const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() }); + const raw = data.similarSongs2?.song ?? []; + const filtered = await filterSongsToActiveLibrary(raw); + return filtered.slice(0, count); + } catch { + return []; + } +} + +/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */ +export async function getSimilarSongs(id: string, count = 50): Promise { + try { + const requestCount = similarSongsRequestCount(count); + const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() }); + const raw = data.similarSongs?.song; + if (!raw) return []; + const list = Array.isArray(raw) ? raw : [raw]; + const filtered = await filterSongsToActiveLibrary(list); + return filtered.slice(0, count); + } catch { + return []; + } +} diff --git a/src/api/subsonicLibrary.ts b/src/api/subsonicLibrary.ts new file mode 100644 index 00000000..e1f59fef --- /dev/null +++ b/src/api/subsonicLibrary.ts @@ -0,0 +1,184 @@ +import { useAuthStore } from '../store/authStore'; +import { api, libraryFilterParams } from './subsonicClient'; +import type { + RandomSongsFilters, + SubsonicAlbum, + SubsonicDirectory, + SubsonicDirectoryEntry, + SubsonicMusicFolder, + SubsonicSong, +} from './subsonicTypes'; + +export async function getMusicDirectory(id: string): Promise { + const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>( + 'getMusicDirectory.view', + { id }, + ); + const dir = data.directory; + const raw = dir.child; + const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; + return { id: dir.id, parent: dir.parent, name: dir.name, child }; +} + +/** Returns the top-level artist/directory entries for a music folder root. + * Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs — + * use getIndexes.view with musicFolderId instead. */ +export async function getMusicIndexes(musicFolderId: string): Promise { + type IndexArtist = { id: string; name: string; coverArt?: string }; + type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] }; + const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>( + 'getIndexes.view', + { musicFolderId }, + ); + const raw = data.indexes?.index; + if (!raw) return []; + const indices = Array.isArray(raw) ? raw : [raw]; + const entries: SubsonicDirectoryEntry[] = []; + for (const idx of indices) { + const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : []; + for (const a of artists) { + entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt }); + } + } + return entries; +} + +export async function getMusicFolders(): Promise { + const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>( + 'getMusicFolders.view', + ); + const raw = data.musicFolders?.musicFolder; + if (!raw) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map(f => ({ + id: String((f as { id: string | number }).id), + name: (f as { name?: string }).name ?? 'Library', + })); +} + +export async function getRandomAlbums(size = 6): Promise { + const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { + type: 'random', + size, + ...libraryFilterParams(), + }); + return data.albumList2?.album ?? []; +} + +export async function getAlbumList( + type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest', + size = 30, + offset = 0, + extra: Record = {} +): Promise { + const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { + type, + size, + offset, + _t: Date.now(), + ...libraryFilterParams(), + ...extra, + }); + return data.albumList2?.album ?? []; +} + +/** + * Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs, + * so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of + * album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set. + */ +let scopedLibraryAlbumIdCache: { + serverId: string; + folderId: string; + filterVersion: number; + ids: Set; +} | null = null; + +async function albumIdsInActiveLibraryScope(): Promise | null> { + const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState(); + if (!activeServerId) return null; + const folder = musicLibraryFilterByServer[activeServerId]; + if (folder === undefined || folder === 'all') { + scopedLibraryAlbumIdCache = null; + return null; + } + const hit = scopedLibraryAlbumIdCache; + if ( + hit && + hit.serverId === activeServerId && + hit.folderId === folder && + hit.filterVersion === musicLibraryFilterVersion + ) { + return hit.ids; + } + const ids = new Set(); + const pageSize = 500; + let offset = 0; + for (;;) { + const albums = await getAlbumList('alphabeticalByName', pageSize, offset); + for (const a of albums) ids.add(a.id); + if (albums.length < pageSize) break; + offset += pageSize; + if (offset > 500_000) break; + } + scopedLibraryAlbumIdCache = { + serverId: activeServerId, + folderId: folder, + filterVersion: musicLibraryFilterVersion, + ids, + }; + return ids; +} + +export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise { + const allowed = await albumIdsInActiveLibraryScope(); + if (!allowed || allowed.size === 0) return songs; + return songs.filter(s => s.albumId && allowed.has(s.albumId)); +} + +/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */ +export function similarSongsRequestCount(desired: number): number { + const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); + const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined; + if (f === undefined || f === 'all') return desired; + return Math.min(300, Math.max(desired, desired * 4)); +} + +export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise { + const params: Record = { size, _t: Date.now(), ...libraryFilterParams() }; + if (genre) params.genre = genre; + const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); + return data.randomSongs?.song ?? []; +} + +/** Extended random song fetch with server-side year/genre filtering. */ +export async function getRandomSongsFiltered( + filters: RandomSongsFilters, + timeout = 15000, +): Promise { + const params: Record = { + size: filters.size ?? 50, + _t: Date.now(), + ...libraryFilterParams(), + }; + if (filters.genre) params.genre = filters.genre; + if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear; + if (typeof filters.toYear === 'number') params.toYear = filters.toYear; + const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); + return data.randomSongs?.song ?? []; +} + +export async function getSong(id: string): Promise { + try { + const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); + return data.song ?? null; + } catch { + return null; + } +} + +export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id }); + const { song, ...album } = data.album; + return { album, songs: song ?? [] }; +} diff --git a/src/api/subsonicRatings.ts b/src/api/subsonicRatings.ts new file mode 100644 index 00000000..cc1bc18d --- /dev/null +++ b/src/api/subsonicRatings.ts @@ -0,0 +1,107 @@ +import { getArtist } from './subsonicArtists'; +import { getAlbum } from './subsonicLibrary'; + +const MIX_RATING_PREFETCH_CONCURRENCY = 8; +const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes +const ratingCache = new Map(); + +function getCachedRating(key: string): number | undefined | null { + const entry = ratingCache.get(key); + if (!entry) return null; // cache miss + if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; } + return entry.value; +} + +function setCachedRating(key: string, value: number | undefined): void { + ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL }); +} + +function parseEntityUserRating(v: unknown): number | undefined { + if (v === null || v === undefined) return undefined; + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return undefined; + return n; +} + +/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */ +export function parseSubsonicEntityStarRating(entity: { + userRating?: unknown; + rating?: unknown; +}): number | undefined { + return parseEntityUserRating(entity.userRating ?? entity.rating); +} + +/** Bump when rating parse keys change so stale cache entries are not reused. */ +const ENTITY_RATING_CACHE_KEY_VER = 'v2'; + +/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */ +export async function prefetchArtistUserRatings( + ids: string[], + concurrency = MIX_RATING_PREFETCH_CONCURRENCY, +): Promise> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + if (!unique.length) return out; + const uncached: string[] = []; + for (const id of unique) { + const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`); + if (cached !== null) { if (cached !== undefined) out.set(id, cached); } + else uncached.push(id); + } + if (!uncached.length) return out; + let next = 0; + async function worker() { + for (;;) { + const i = next++; + if (i >= uncached.length) return; + const id = uncached[i]; + try { + const { artist } = await getArtist(id); + const r = parseSubsonicEntityStarRating(artist); + setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r); + if (r !== undefined) out.set(id, r); + } catch { + /* ignore */ + } + } + } + const nWorkers = Math.min(concurrency, uncached.length); + await Promise.all(Array.from({ length: nWorkers }, () => worker())); + return out; +} + +/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */ +export async function prefetchAlbumUserRatings( + ids: string[], + concurrency = MIX_RATING_PREFETCH_CONCURRENCY, +): Promise> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + if (!unique.length) return out; + const uncached: string[] = []; + for (const id of unique) { + const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`); + if (cached !== null) { if (cached !== undefined) out.set(id, cached); } + else uncached.push(id); + } + if (!uncached.length) return out; + let next = 0; + async function worker() { + for (;;) { + const i = next++; + if (i >= uncached.length) return; + const id = uncached[i]; + try { + const { album } = await getAlbum(id); + const r = parseSubsonicEntityStarRating(album); + setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r); + if (r !== undefined) out.set(id, r); + } catch { + /* ignore */ + } + } + } + const nWorkers = Math.min(concurrency, uncached.length); + await Promise.all(Array.from({ length: nWorkers }, () => worker())); + return out; +} diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index f8484b57..5e6dad03 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,3 +1,4 @@ +import { getMusicFolders } from '../api/subsonicLibrary'; import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { invoke } from '@tauri-apps/api/core'; @@ -36,7 +37,7 @@ import { useOrbitStore } from '../store/orbitStore'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useConnectionStatus } from '../hooks/useConnectionStatus'; import { useAuthStore } from '../store/authStore'; -import { getMusicFolders, probeEntityRatingSupport } from '../api/subsonic'; +import { probeEntityRatingSupport } from '../api/subsonic'; import { useOfflineStore } from '../store/offlineStore'; import { usePlayerStore } from '../store/playerStore'; import { useThemeStore } from '../store/themeStore'; diff --git a/src/app/TauriEventBridge.tsx b/src/app/TauriEventBridge.tsx index 56e57783..d89105f0 100644 --- a/src/app/TauriEventBridge.tsx +++ b/src/app/TauriEventBridge.tsx @@ -1,3 +1,5 @@ +import { getSimilarSongs } from '../api/subsonicArtists'; +import { getMusicFolders } from '../api/subsonicLibrary'; import { flushPlayQueuePosition } from '../store/queueSync'; import { shuffleArray } from '../utils/shuffleArray'; import { songToTrack } from '../utils/songToTrack'; @@ -11,11 +13,7 @@ import { showToast } from '../utils/toast'; import { endOrbitSession, leaveOrbitSession } from '../utils/orbit'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; -import { - getMusicFolders, - getSimilarSongs, - search as subsonicSearch, -} from '../api/subsonic'; +import { search as subsonicSearch } from '../api/subsonic'; import i18n from '../i18n'; import { switchActiveServer } from '../utils/switchActiveServer'; import { usePlayerStore } from '../store/playerStore'; diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 6fd35ab4..df5ce1f3 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -1,10 +1,11 @@ +import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { memo, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index 108fa603..e86e3e62 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -1,10 +1,12 @@ +import { getArtist, getArtistInfo } from '../api/subsonicArtists'; +import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Music } from 'lucide-react'; -import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getArtist, getArtistInfo } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index e7675540..0b03f2c1 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,3 +1,5 @@ +import { getAlbum } from '../api/subsonicLibrary'; +import { getSimilarSongs2, getSimilarSongs, getTopSongs, getArtist } from '../api/subsonicArtists'; import type { SubsonicAlbum, SubsonicArtist, SubsonicPlaylist } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import type { Track } from '../store/playerStoreTypes'; @@ -15,7 +17,7 @@ import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; -import { star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, updatePlaylist, setRating } from '../api/subsonic'; +import { star, unstar, buildDownloadUrl, getPlaylists, getPlaylist, updatePlaylist, setRating } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 18717b04..f22f67bc 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,3 +1,4 @@ +import { getArtistInfo } from '../api/subsonicArtists'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress'; import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react'; import { @@ -6,7 +7,7 @@ import { Moon, Sunrise, } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; -import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import { useCachedUrl } from './CachedImage'; import { getCachedBlob } from '../utils/imageCache'; import { extractCoverColors } from '../utils/dynamicColors'; diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index e97aee64..82fb6092 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,9 +1,10 @@ +import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, ListPlus } from 'lucide-react'; -import { getRandomAlbums, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; diff --git a/src/components/HostApprovalQueue.tsx b/src/components/HostApprovalQueue.tsx index 8d61b708..f83e5dfb 100644 --- a/src/components/HostApprovalQueue.tsx +++ b/src/components/HostApprovalQueue.tsx @@ -1,3 +1,4 @@ +import { getSong } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { useEffect, useMemo, useState } from 'react'; import { Check, X, Inbox } from 'lucide-react'; @@ -8,7 +9,7 @@ import { declineOrbitSuggestion, suggestionKey, } from '../utils/orbit'; -import { getSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage from './CachedImage'; import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit'; diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index 4e227aca..601e1ae4 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -1,3 +1,5 @@ +import { getSong } from '../api/subsonicLibrary'; +import { getArtistInfo } from '../api/subsonicArtists'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -5,7 +7,6 @@ import { Info } from 'lucide-react'; import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; -import { getArtistInfo, getSong } from '../api/subsonic'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import CachedImage from './CachedImage'; import OverlayScrollArea from './OverlayScrollArea'; diff --git a/src/components/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx index 93d43734..7d2c0fcc 100644 --- a/src/components/OrbitGuestQueue.tsx +++ b/src/components/OrbitGuestQueue.tsx @@ -1,9 +1,10 @@ +import { getSong } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { useEffect, useMemo, useState } from 'react'; import { Radio, Clock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; -import { getSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage from './CachedImage'; import OrbitQueueHead from './OrbitQueueHead'; diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index e6303cee..d4590bde 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -1,3 +1,4 @@ +import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; import { useEffect, useRef, useState } from 'react'; import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react'; @@ -5,7 +6,6 @@ import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { useHelpModalStore } from '../store/helpModalStore'; import { usePlayerStore } from '../store/playerStore'; -import { getSong } from '../api/subsonic'; import { endOrbitSession, leaveOrbitSession, diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 5b7a179f..c5498ab5 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,3 +1,4 @@ +import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicPlaylist } from '../api/subsonicTypes'; import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo'; import { songToTrack } from '../utils/songToTrack'; @@ -10,7 +11,7 @@ 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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, 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 fb3688de..f67e89c9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { getAlbumList } from '../api/subsonicLibrary'; import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { invoke } from '@tauri-apps/api/core'; @@ -17,7 +18,7 @@ import { import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; import WhatsNewBanner from './WhatsNewBanner'; -import { getAlbumList, getPlaylists } from '../api/subsonic'; +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/components/SongInfoModal.tsx b/src/components/SongInfoModal.tsx index 3d6b4839..be797f16 100644 --- a/src/components/SongInfoModal.tsx +++ b/src/components/SongInfoModal.tsx @@ -1,10 +1,10 @@ +import { getSong } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; -import { getSong } from '../api/subsonic'; import { ndGetSongPath } from '../api/navidromeAdmin'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; diff --git a/src/components/StatsExportModal.tsx b/src/components/StatsExportModal.tsx index 2cccb50e..8a1e02b6 100644 --- a/src/components/StatsExportModal.tsx +++ b/src/components/StatsExportModal.tsx @@ -1,3 +1,4 @@ +import { getAlbumList } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -5,7 +6,6 @@ import { useTranslation } from 'react-i18next'; import { X } from 'lucide-react'; import { save } from '@tauri-apps/plugin-dialog'; import { writeFile } from '@tauri-apps/plugin-fs'; -import { getAlbumList } from '../api/subsonic'; import { showToast } from '../utils/toast'; import { exportAlbumCardBlob, diff --git a/src/config/shortcutActions.ts b/src/config/shortcutActions.ts index c2fc6903..06ef92e1 100644 --- a/src/config/shortcutActions.ts +++ b/src/config/shortcutActions.ts @@ -1,8 +1,9 @@ +import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import i18n from '../i18n'; -import { getSong, setRating, star, unstar } from '../api/subsonic'; +import { setRating, star, unstar } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { usePreviewStore } from '../store/previewStore'; import { useLyricsStore } from '../store/lyricsStore'; diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 03e479fc..bf6e3cbb 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -1,9 +1,9 @@ +import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; import { useEffect, useRef } from 'react'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { getSong } from '../api/subsonic'; import { readOrbitState, writeOrbitHeartbeat, diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 4264f819..b8edfe3c 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -1,8 +1,8 @@ +import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; import { useEffect, useRef } from 'react'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore } from '../store/playerStore'; -import { getSong } from '../api/subsonic'; import { writeOrbitState, writeOrbitHeartbeat, diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index 635cb3c9..a8ff2370 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -1,8 +1,9 @@ +import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SlidersVertical } from 'lucide-react'; -import { search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs } from '../api/subsonic'; +import { search, searchSongsPaged, getGenres, getAlbumsByGenre } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 0cbc6272..14150fa3 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -1,10 +1,12 @@ +import { getArtist, getArtistInfo } from '../api/subsonicArtists'; +import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicSong, SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Search, X, ListPlus } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; -import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar } from '../api/subsonic'; +import { setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index b6d0e20a..b7505216 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -1,3 +1,4 @@ +import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react'; @@ -6,7 +7,7 @@ import GenreFilterBar from '../components/GenreFilterBar'; import YearFilterButton from '../components/YearFilterButton'; import StarFilterButton from '../components/StarFilterButton'; import SortDropdown from '../components/SortDropdown'; -import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic'; +import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 1154b97e..60226d3f 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,8 +1,10 @@ +import { getAlbum } from '../api/subsonicLibrary'; +import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2 } from '../api/subsonicArtists'; import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import { useEffect, useState, useRef, Fragment, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; +import { search, setRating, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import CoverLightbox from '../components/CoverLightbox'; diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index b5302757..3a6dbeea 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -1,7 +1,8 @@ +import { getArtists } from '../api/subsonicArtists'; import type { SubsonicArtist } from '../api/subsonicTypes'; import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getArtists, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react'; import StarFilterButton from '../components/StarFilterButton'; import { usePlayerStore } from '../store/playerStore'; diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx index 4b885f9d..f37207cd 100644 --- a/src/pages/ComposerDetail.tsx +++ b/src/pages/ComposerDetail.tsx @@ -1,7 +1,8 @@ +import { getArtist, getArtistInfo } from '../api/subsonicArtists'; import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '../api/subsonicTypes'; import { useEffect, useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getArtist, getArtistInfo, star, unstar, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { star, unstar, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; diff --git a/src/pages/DeviceSync.tsx b/src/pages/DeviceSync.tsx index 86371674..ecb9159f 100644 --- a/src/pages/DeviceSync.tsx +++ b/src/pages/DeviceSync.tsx @@ -1,3 +1,5 @@ +import { getArtists, getArtist } from '../api/subsonicArtists'; +import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist } from '../api/subsonicTypes'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { invoke } from '@tauri-apps/api/core'; @@ -12,7 +14,7 @@ import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; -import { getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist, buildDownloadUrl, search as searchSubsonic } from '../api/subsonic'; +import { getPlaylists, getPlaylist, buildDownloadUrl, search as searchSubsonic } from '../api/subsonic'; import { showToast } from '../utils/toast'; import { IS_WINDOWS } from '../utils/platform'; diff --git a/src/pages/FolderBrowser.tsx b/src/pages/FolderBrowser.tsx index 3c29dd44..f69b8ba1 100644 --- a/src/pages/FolderBrowser.tsx +++ b/src/pages/FolderBrowser.tsx @@ -1,7 +1,7 @@ +import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary'; import type { SubsonicDirectoryEntry, SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes'; import type { Track } from '../store/playerStoreTypes'; import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react'; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index dd3164d9..4c14c493 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,3 +1,5 @@ +import { getArtists } from '../api/subsonicArtists'; +import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes'; import React, { useEffect, useState } from 'react'; import Hero from '../components/Hero'; @@ -5,7 +7,6 @@ import AlbumRow from '../components/AlbumRow'; import SongRail from '../components/SongRail'; import BecauseYouLikeRail from '../components/BecauseYouLikeRail'; import LosslessAlbumsRail from '../components/LosslessAlbumsRail'; -import { getAlbumList, getArtists, getRandomSongs } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index 9e1949f5..f17b26d6 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -1,9 +1,10 @@ +import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import AlbumCard from '../components/AlbumCard'; import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse'; -import { getAlbum, buildDownloadUrl } from '../api/subsonic'; +import { buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index 05f3ce7e..675f14e4 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -1,9 +1,10 @@ +import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react'; -import { getAlbumList, getAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import CachedImage from '../components/CachedImage'; diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index dc8fb4f1..6aa84fd2 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -1,9 +1,10 @@ +import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import React, { useEffect, useState, useCallback, useRef } from 'react'; import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; -import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic'; +import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index e28daed3..68134629 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -1,3 +1,5 @@ +import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists'; +import { getSong, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes'; import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -7,7 +9,7 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useLyricsStore } from '../store/lyricsStore'; -import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, getAlbum, getArtist, getArtistInfo, getTopSongs } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import { songToTrack } from '../utils/songToTrack'; import { lastfmIsConfigured, diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 1952cd3c..bc16fad2 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,3 +1,4 @@ +import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; @@ -6,7 +7,7 @@ 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, search, setRating, star, unstar, getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary } from '../api/subsonic'; +import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, buildDownloadUrl } 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 c4869820..87a916af 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -1,9 +1,10 @@ +import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes'; 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, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, filterSongsToActiveLibrary } from '../api/subsonic'; +import { deletePlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import { useAuthStore } from '../store/authStore'; diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 5eb26f8f..1c77a948 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -1,7 +1,8 @@ +import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import React, { useEffect, useState, useCallback, useRef } from 'react'; import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; -import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic'; +import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 08bcf529..91be61d7 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -1,7 +1,8 @@ +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, getAlbumList } from '../api/subsonic'; +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/pages/Tracks.tsx b/src/pages/Tracks.tsx index 5f300d37..667c0c43 100644 --- a/src/pages/Tracks.tsx +++ b/src/pages/Tracks.tsx @@ -1,10 +1,11 @@ +import { getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { getRandomSongs, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import CachedImage from '../components/CachedImage'; diff --git a/src/store/nextAction.ts b/src/store/nextAction.ts index 03430d76..52d5dbf4 100644 --- a/src/store/nextAction.ts +++ b/src/store/nextAction.ts @@ -1,5 +1,5 @@ +import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists'; import { invoke } from '@tauri-apps/api/core'; -import { getSimilarSongs2, getTopSongs } from '../api/subsonic'; import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; import { songToTrack } from '../utils/songToTrack'; import { useAuthStore } from './authStore'; diff --git a/src/store/offlineStore.ts b/src/store/offlineStore.ts index 911d6837..8528812e 100644 --- a/src/store/offlineStore.ts +++ b/src/store/offlineStore.ts @@ -1,8 +1,10 @@ +import { getAlbum } from '../api/subsonicLibrary'; +import { getArtist } from '../api/subsonicArtists'; import type { SubsonicSong } from '../api/subsonicTypes'; import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; -import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic'; +import { buildStreamUrl } from '../api/subsonic'; import { useAuthStore } from './authStore'; import { showToast } from '../utils/toast'; import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore'; diff --git a/src/store/resumeAction.ts b/src/store/resumeAction.ts index dde031b1..cebe21e5 100644 --- a/src/store/resumeAction.ts +++ b/src/store/resumeAction.ts @@ -1,6 +1,6 @@ +import { getSong } from '../api/subsonicLibrary'; import { invoke } from '@tauri-apps/api/core'; import { estimateLivePosition } from '../api/orbit'; -import { getSong } from '../api/subsonic'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; diff --git a/src/test/mocks/subsonic.ts b/src/test/mocks/subsonic.ts index 46dae9b9..19250418 100644 --- a/src/test/mocks/subsonic.ts +++ b/src/test/mocks/subsonic.ts @@ -7,7 +7,7 @@ * * // In the test: * vi.mock('@/api/subsonic'); - * import { getAlbum, buildStreamUrl } from '@/api/subsonic'; + * import { buildStreamUrl } from '@/api/subsonic'; * import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic'; * * beforeEach(() => { @@ -18,6 +18,7 @@ * Realistic shape matters more than perfect coverage — these fixtures * mirror what Navidrome actually returns for common queries. */ +import { getAlbum } from '@/api/subsonicLibrary'; import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist } from '@/api/subsonicTypes'; import { makeSubsonicSong } from '@/test/helpers/factories'; diff --git a/src/utils/applySharePaste.ts b/src/utils/applySharePaste.ts index bfee930e..95bce45a 100644 --- a/src/utils/applySharePaste.ts +++ b/src/utils/applySharePaste.ts @@ -1,8 +1,9 @@ +import { getArtist } from '../api/subsonicArtists'; +import { getAlbum, getSong } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/songToTrack'; import type { NavigateFunction } from 'react-router-dom'; import type { TFunction } from 'i18next'; -import { getAlbum, getArtist, getSong } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink'; diff --git a/src/utils/buildInfiniteQueueCandidates.test.ts b/src/utils/buildInfiniteQueueCandidates.test.ts index cfb58435..2e819ce6 100644 --- a/src/utils/buildInfiniteQueueCandidates.test.ts +++ b/src/utils/buildInfiniteQueueCandidates.test.ts @@ -6,12 +6,17 @@ * refactor (2026-05-12). This test pins the artist-first / random-fallback * order, the dedup contract against existingIds, and the autoAdded flag. */ +import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists'; +import { getRandomSongs } from '../api/subsonicLibrary'; import type { Track } from '../store/playerStoreTypes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('../api/subsonic', () => ({ +vi.mock('../api/subsonicArtists', () => ({ getSimilarSongs2: vi.fn(), getTopSongs: vi.fn(), +})); + +vi.mock('../api/subsonicLibrary', () => ({ getRandomSongs: vi.fn(), })); @@ -22,7 +27,6 @@ vi.mock('./mixRatingFilter', () => ({ })); import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates'; -import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic'; import { enrichSongsForMixRatingFilter, getMixMinRatingsConfigFromAuth, diff --git a/src/utils/buildInfiniteQueueCandidates.ts b/src/utils/buildInfiniteQueueCandidates.ts index ba55fd13..a8048b25 100644 --- a/src/utils/buildInfiniteQueueCandidates.ts +++ b/src/utils/buildInfiniteQueueCandidates.ts @@ -1,5 +1,6 @@ +import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists'; +import { getRandomSongs } from '../api/subsonicLibrary'; import type { Track } from '../store/playerStoreTypes'; -import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic'; import { enrichSongsForMixRatingFilter, getMixMinRatingsConfigFromAuth, diff --git a/src/utils/exportNewAlbums.ts b/src/utils/exportNewAlbums.ts index 89b5f35c..8d4a234a 100644 --- a/src/utils/exportNewAlbums.ts +++ b/src/utils/exportNewAlbums.ts @@ -1,7 +1,8 @@ +import { getAlbumList } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { writeFile } from '@tauri-apps/plugin-fs'; import { downloadDir, join } from '@tauri-apps/api/path'; -import { getAlbumList, buildCoverArtUrl } from '../api/subsonic'; +import { buildCoverArtUrl } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; // Catppuccin Macchiato palette const M = { diff --git a/src/utils/luckyMix.ts b/src/utils/luckyMix.ts index ce6c9f76..dee4b325 100644 --- a/src/utils/luckyMix.ts +++ b/src/utils/luckyMix.ts @@ -1,7 +1,8 @@ +import { getSimilarSongs, getTopSongs } from '../api/subsonicArtists'; +import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; import type { Track } from '../store/playerStoreTypes'; import { songToTrack } from '../utils/songToTrack'; -import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs, getSimilarSongs, getTopSongs } from '../api/subsonic'; import { invoke } from '@tauri-apps/api/core'; import i18n from '../i18n'; import { useAuthStore } from '../store/authStore'; diff --git a/src/utils/mixRatingFilter.ts b/src/utils/mixRatingFilter.ts index 384f27ac..e87b815d 100644 --- a/src/utils/mixRatingFilter.ts +++ b/src/utils/mixRatingFilter.ts @@ -1,5 +1,6 @@ +import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../api/subsonicRatings'; +import { getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; -import { getRandomSongs, parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; /** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */ diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 2b91d539..76e0938c 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -1,13 +1,6 @@ +import { getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; -import { - createPlaylist, - updatePlaylist, - updatePlaylistMeta, - deletePlaylist, - getPlaylist, - getPlaylists, - getSong, -} from '../api/subsonic'; +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'; diff --git a/src/utils/playAlbum.ts b/src/utils/playAlbum.ts index 568344f5..699d91cc 100644 --- a/src/utils/playAlbum.ts +++ b/src/utils/playAlbum.ts @@ -1,4 +1,4 @@ -import { getAlbum } from '../api/subsonic'; +import { getAlbum } from '../api/subsonicLibrary'; import { usePlayerStore } from '../store/playerStore'; import { songToTrack } from './songToTrack'; import { useOrbitStore } from '../store/orbitStore'; diff --git a/src/utils/playArtistShuffled.ts b/src/utils/playArtistShuffled.ts index 20f0fa99..68c5f5a6 100644 --- a/src/utils/playArtistShuffled.ts +++ b/src/utils/playArtistShuffled.ts @@ -1,6 +1,7 @@ +import { getArtist } from '../api/subsonicArtists'; +import { getAlbum } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; import { shuffleArray } from '../utils/shuffleArray'; -import { getAlbum, getArtist } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; /** * All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”. diff --git a/src/utils/playByOpaqueId.ts b/src/utils/playByOpaqueId.ts index 24438bf0..688dd2f7 100644 --- a/src/utils/playByOpaqueId.ts +++ b/src/utils/playByOpaqueId.ts @@ -1,5 +1,5 @@ +import { getAlbum, getSong } from '../api/subsonicLibrary'; import { songToTrack } from '../utils/songToTrack'; -import { getAlbum, getSong } from '../api/subsonic'; import { playAlbum } from './playAlbum'; import { playArtistShuffled } from './playArtistShuffled'; import { usePlayerStore } from '../store/playerStore';