refactor(api): pull feature-resident subsonic clients into lib/api

Move the 5 subsonic api modules that M3 had co-located into features
(subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the
feature-free infra layer, and drop their feature-barrel re-exports. Consumers
now import these protocol calls directly from @/lib/api/subsonic*; the feature
barrels export only domain UI/hooks/state.

This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol
clients are feature-free infra, not per-feature) and it kills the documented
api-induced feature<->feature cycles: offline->artist/playlist (getArtist/
getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*.
Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist
Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper,
tracked for M5, not api placement.

Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour
change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.

Tooling note: barrel-imported and dynamic-import api symbols were split out of
@/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the
deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
This commit is contained in:
Psychotoxical
2026-06-30 08:02:19 +02:00
parent 7e9cb60763
commit 8cc022581f
76 changed files with 89 additions and 93 deletions
@@ -1,11 +0,0 @@
import { api } from '@/lib/api/subsonicClient';
import type { AlbumInfo } from '@/lib/api/subsonicTypes';
export async function getAlbumInfo2(albumId: string): Promise<AlbumInfo | null> {
try {
const data = await api<{ albumInfo: AlbumInfo }>('getAlbumInfo2.view', { id: albumId });
return data.albumInfo ?? null;
} catch {
return null;
}
}
-1
View File
@@ -11,7 +11,6 @@
* sync, core), the album context-menu items (context-menu subsystem), the
* shared `TracklistColumnPicker` (also used by favorites), and `cover/*`.
*/
export * from './api/subsonicAlbumInfo';
export * from './hooks/useAlbumBrowseData';
export * from './hooks/useAlbumBrowseFilters';
export * from './hooks/useAlbumBrowseScrollReset';
+1 -1
View File
@@ -2,7 +2,7 @@ import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistInfo } from '@/features/artist';
import { getArtistInfo } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { shuffleArray } from '@/utils/playback/shuffleArray';
@@ -1,79 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonicClient', () => ({
api: vi.fn(),
apiForServer: vi.fn(),
libraryFilterParams: () => ({}),
libraryFilterParamsForServer: () => ({}),
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
similarSongsRequestCount: (count: number) => count,
}));
import { api } from '@/lib/api/subsonicClient';
import { fetchSimilarTracksRouted } from '@/features/artist/api/subsonicArtists';
import { useAuthStore } from '@/store/authStore';
const SID = 'srv-router';
const apiMock = vi.mocked(api);
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
useAuthStore.setState({
activeServerId: SID,
subsonicServerIdentityByServer: { [SID]: identity as never },
audiomusePluginProbeByServer: {},
instantMixProbeByServer: {},
audiomuseNavidromeByServer: {},
...probes,
} as never);
}
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
describe('fetchSimilarTracksRouted', () => {
beforeEach(() => {
apiMock.mockReset();
});
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['sonic-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('falls back to legacy when sonic returns empty', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'absent' },
});
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
});
});
-159
View File
@@ -1,159 +0,0 @@
import { useAuthStore } from '@/store/authStore';
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from '@/lib/api/subsonicClient';
import { filterSongsToServerLibrary } from '@/lib/api/subsonicLibrary';
import { filterSongsToActiveLibrary, similarSongsRequestCount } from '@/lib/api/subsonicLibrary';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
OP_SIMILAR_TRACKS,
} from '@/serverCapabilities/catalog';
import { resolveCallRoutesForServer } from '@/serverCapabilities/storeView';
import type {
SubsonicAlbum,
SubsonicArtist,
SubsonicArtistInfo,
SubsonicSong,
} from '@/lib/api/subsonicTypes';
export async function getArtists(): Promise<SubsonicArtist[]> {
type ArtistIndexEntry = { artist?: SubsonicArtist | SubsonicArtist[] };
const data = await api<{ artists?: { index?: ArtistIndexEntry | ArtistIndexEntry[] } }>('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 getArtistForServer(
serverId: string,
id: string,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
const data = await apiForServer<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(serverId, 'getArtist.view', { id });
const { album, ...artist } = data.artist;
return { artist, albums: album ?? [] };
}
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
return data.artistInfo2 ?? {};
}
export async function getArtistInfoForServer(
serverId: string,
id: string,
options?: { similarArtistCount?: number },
): Promise<SubsonicArtistInfo> {
const count = options?.similarArtistCount ?? 5;
const data = await apiForServer<{ artistInfo2: SubsonicArtistInfo }>(
serverId,
'getArtistInfo2.view',
{ id, count, ...libraryFilterParamsForServer(serverId) },
);
return data.artistInfo2 ?? {};
}
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return [];
return getTopSongsForServer(activeServerId, artist);
}
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
try {
const { musicLibraryFilterByServer } = useAuthStore.getState();
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
serverId,
'getTopSongs.view',
{ artist, count: topCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.topSongs?.song ?? [];
const filtered = await filterSongsToServerLibrary(raw, serverId);
return filtered.slice(0, 5);
} catch {
return [];
}
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
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<SubsonicSong[]> {
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 [];
}
}
/**
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
* extension (Navidrome ≥ 0.62 + AudioMuse plugin). Returns `[]` when the server
* has no provider (HTTP 404) so callers can fall back.
*/
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
'getSonicSimilarTracks.view',
{ id, count: requestCount, ...libraryFilterParams() },
);
const raw = data.sonicMatch;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
if (songs.length === 0) return [];
const filtered = await filterSongsToActiveLibrary(songs);
return filtered.slice(0, count);
} catch {
return [];
}
}
/**
* Capability-routed similar tracks for the active server. Prefers the sonic
* similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62),
* falling back to legacy `getSimilarSongs` on empty/unavailable.
*/
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return getSimilarSongs(songId, count);
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
if (routes.length === 0) return getSimilarSongs(songId, count);
for (const route of routes) {
const songs = route.transport === 'opensubsonic'
? await getSonicSimilarTracks(songId, count)
: await getSimilarSongs(songId, count);
if (songs.length > 0) return songs;
}
return [];
}
@@ -15,10 +15,10 @@ import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
vi.mock('@/features/artist/api/subsonicArtists');
vi.mock('@/lib/api/subsonicArtists');
vi.mock('@/lib/api/subsonicSearch');
import { getArtist, getArtistInfo, getTopSongs } from '@/features/artist/api/subsonicArtists';
import { getArtist, getArtistInfo, getTopSongs } from '@/lib/api/subsonicArtists';
import { search } from '@/lib/api/subsonicSearch';
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { search } from '@/lib/api/subsonicSearch';
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '@/features/artist/api/subsonicArtists';
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '@/lib/api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '@/lib/api/subsonicTypes';
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { getArtistInfoForServer } from '@/features/artist/api/subsonicArtists';
import { getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
import { makeCache } from '@/utils/cache/nowPlayingCache';
@@ -1,4 +1,4 @@
import { getArtists } from '@/features/artist/api/subsonicArtists';
import { getArtists } from '@/lib/api/subsonicArtists';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import { useCallback, useEffect, useRef, useState } from 'react';
import { dedupeById } from '@/utils/dedupeById';
-1
View File
@@ -11,7 +11,6 @@
* `*ContextItems`/`*ToPlaylistSubmenu` items (the cross-cutting context-menu
* subsystem, shared with album), and `PlaylistArtistCell` (playlist).
*/
export * from './api/subsonicArtists';
export * from './hooks/useArtistDetailData';
export * from './hooks/useArtistInfoBatch';
export * from './hooks/useArtistOfflineState';
@@ -1,6 +1,6 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { uploadArtistImage } from '@/features/playlist';
import { uploadArtistImage } from '@/lib/api/subsonicPlaylists';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { getSimilarSongs2, getTopSongs } from '@/features/artist/api/subsonicArtists';
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { getPlaylists } from '@/features/playlist';
import { getArtists, getArtist } from '@/features/artist';
import { getPlaylists } from '@/lib/api/subsonicPlaylists';
import { getArtists, getArtist } from '@/lib/api/subsonicArtists';
import { getAlbumList } from '@/lib/api/subsonicLibrary';
import { search as searchSubsonic } from '@/lib/api/subsonicSearch';
import type {
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { getInternetRadioStations } from '@/features/radio';
import { getInternetRadioStations } from '@/lib/api/subsonicRadio';
import { getStarred } from '@/lib/api/subsonicStarRating';
import type {
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
@@ -1,5 +1,5 @@
import { getSongForServer } from '@/lib/api/subsonicLibrary';
import { getArtistInfoForServer } from '@/features/artist';
import { getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicArtistInfo, SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,7 +13,7 @@ import { renderHook, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum, SubsonicArtist } from '@/lib/api/subsonicTypes';
vi.mock('@/features/artist');
vi.mock('@/lib/api/subsonicArtists');
vi.mock('@/lib/api/subsonicLibrary');
vi.mock('@/api/bandsintown');
vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
@@ -21,7 +21,7 @@ vi.mock('@/utils/network/subsonicNetworkGuard', () => ({
}));
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '@/features/artist';
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/lib/api/subsonicLibrary';
import { fetchBandsintownEvents } from '@/api/bandsintown';
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { getArtistInfoForServer } from '@/features/artist';
import { getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtistInfo, SubsonicSong } from '@/lib/api/subsonicTypes';
import { resolveNpAlbum, resolveNpDiscography, resolveNpSongMeta, resolveNpTopSongs } from '@/features/nowPlaying/utils/nowPlayingMetadataResolve';
import { fetchBandsintownEvents, type BandsintownEvent } from '@/api/bandsintown';
@@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import type { LibraryAdvancedSearchResponse } from '@/lib/api/library';
import * as subsonicArtists from '@/features/artist';
import * as subsonicArtists from '@/lib/api/subsonicArtists';
import * as subsonicLibrary from '@/lib/api/subsonicLibrary';
// Network reachability is decided by the guard; mock it so we can test both arms.
@@ -17,7 +17,7 @@
* is intentionally absent here.
*/
import { libraryGetTrack, libraryGetTracksByAlbum } from '@/lib/api/library';
import { getArtistForServer, getTopSongsForServer } from '@/features/artist';
import { getArtistForServer, getTopSongsForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
+1 -1
View File
@@ -1,7 +1,7 @@
import { libraryUpsertSongsFromApi } from '@/lib/api/library';
import { buildStreamUrl } from '@/lib/api/subsonicStreamUrl';
import { getAlbum } from '@/lib/api/subsonicLibrary';
import { getArtist } from '@/features/artist';
import { getArtist } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
@@ -28,7 +28,7 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({
getAlbumForServer: vi.fn(async () => ({ songs: [] })),
}));
vi.mock('@/features/artist', () => ({
vi.mock('@/lib/api/subsonicArtists', () => ({
getArtistForServer: vi.fn(async () => ({ albums: [] })),
}));
@@ -1,7 +1,7 @@
import { libraryUpsertSongsFromApi } from '@/lib/api/library';
import { librarySqlServerId } from '@/api/coverCache';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistForServer } from '@/features/artist';
import { getArtistForServer } from '@/lib/api/subsonicArtists';
import { getStarredForServer } from '@/lib/api/subsonicStarRating';
import { buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
@@ -53,11 +53,11 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({
getAlbumForServer: (serverId: string, albumId: string) => getAlbumForServerMock(serverId, albumId),
}));
vi.mock('@/features/artist', () => ({
vi.mock('@/lib/api/subsonicArtists', () => ({
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
}));
vi.mock('@/features/playlist', () => ({
vi.mock('@/lib/api/subsonicPlaylists', () => ({
getPlaylistForServer: (serverId: string, playlistId: string) =>
getPlaylistForServerMock(serverId, playlistId),
}));
@@ -1,6 +1,6 @@
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistForServer } from '@/features/artist';
import { getPlaylistForServer } from '@/features/playlist';
import { getArtistForServer } from '@/lib/api/subsonicArtists';
import { getPlaylistForServer } from '@/lib/api/subsonicPlaylists';
import type {
SubsonicAlbum,
SubsonicArtist,
@@ -29,7 +29,7 @@ vi.mock('@/utils/network/activeServerReachability', () => ({
onActiveServerBecameReachable: () => () => {},
}));
vi.mock('@/features/playlist/api/subsonicPlaylists', () => ({
vi.mock('@/lib/api/subsonicPlaylists', () => ({
getPlaylistForServer: (serverId: string, id: string) => getPlaylistMock(serverId, id),
}));
@@ -38,7 +38,7 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({
filterSongsToServerLibrary: (songs: SubsonicSong[]) => filterSongsMock(songs),
}));
vi.mock('@/features/artist', () => ({
vi.mock('@/lib/api/subsonicArtists', () => ({
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
}));
@@ -1,7 +1,7 @@
import { libraryGetTracksByAlbum, subscribeLibrarySyncIdle } from '@/lib/api/library';
import { getAlbumForServer, filterSongsToServerLibrary } from '@/lib/api/subsonicLibrary';
import { getPlaylistForServer } from '@/features/playlist';
import { getArtistForServer } from '@/features/artist';
import { getPlaylistForServer } from '@/lib/api/subsonicPlaylists';
import { getArtistForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
@@ -1,5 +1,5 @@
import { libraryGetTracksBatchChunked } from '@/lib/api/library';
import { getPlaylist } from '@/features/playlist';
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import type { OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
+1 -1
View File
@@ -19,7 +19,7 @@ const { authState, orbitState } = vi.hoisted(() => ({
orbitState: { sessionId: null as string | null },
}));
vi.mock('@/features/playlist', () => ({ getPlaylists, deletePlaylist }));
vi.mock('@/lib/api/subsonicPlaylists', () => ({ getPlaylists, deletePlaylist }));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({
+1 -1
View File
@@ -1,4 +1,4 @@
import { deletePlaylist, getPlaylists } from '@/features/playlist';
import { deletePlaylist, getPlaylists } from '@/lib/api/subsonicPlaylists';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { ORBIT_PLAYLIST_PREFIX, parseOrbitState } from '@/features/orbit/api/orbit';
+1 -1
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/features/playlist';
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import { getSong } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
+1 -1
View File
@@ -24,7 +24,7 @@ vi.mock('@/features/orbit/utils/remote', () => ({
vi.mock('@/features/orbit/store/orbitStore', () => ({ useOrbitStore: { getState: () => orbitStore } }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({}) } }));
vi.mock('@/store/playerStore', () => ({ usePlayerStore: { getState: () => ({ enqueue: vi.fn() }) } }));
vi.mock('@/features/playlist', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/lib/api/subsonicPlaylists', () => ({ createPlaylist: vi.fn(), deletePlaylist: vi.fn() }));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getSong: vi.fn() }));
vi.mock('@/utils/playback/songToTrack', () => ({ songToTrack: vi.fn() }));
+1 -1
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist } from '@/features/playlist';
import { createPlaylist, deletePlaylist } from '@/lib/api/subsonicPlaylists';
import { getSong } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useAuthStore } from '@/store/authStore';
+1 -1
View File
@@ -1,4 +1,4 @@
import { deletePlaylist, getPlaylists } from '@/features/playlist';
import { deletePlaylist, getPlaylists } from '@/lib/api/subsonicPlaylists';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { orbitOutboxPlaylistName, type OrbitState } from '@/features/orbit/api/orbit';
import { writeOrbitState } from '@/features/orbit/utils/remote';
+1 -1
View File
@@ -1,4 +1,4 @@
import { getPlaylist, getPlaylists, updatePlaylistMeta } from '@/features/playlist';
import { getPlaylist, getPlaylists, updatePlaylistMeta } from '@/lib/api/subsonicPlaylists';
import {
orbitSessionPlaylistName,
parseOrbitState,
+1 -1
View File
@@ -1,4 +1,4 @@
import { getPlaylist, getPlaylists, updatePlaylist } from '@/features/playlist';
import { getPlaylist, getPlaylists, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import { type OrbitOutboxMeta } from '@/features/orbit/api/orbit';
import { parseOutboxPlaylistName } from '@/features/orbit/utils/helpers';
import { type OutboxSnapshot } from '@/features/orbit/utils/stateMath';
@@ -1,110 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { api, apiForServer } from '@/lib/api/subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
}
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
const data = await api<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>('getPlaylist.view', { id });
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function getPlaylistForServer(
serverId: string,
id: string,
): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
if (!shouldAttemptSubsonicForServer(serverId)) {
throw new Error('Subsonic unavailable');
}
const data = await apiForServer<{ playlist: SubsonicPlaylist & { entry: SubsonicSong[] } }>(
serverId,
'getPlaylist.view',
{ id },
);
const { entry, ...playlist } = data.playlist;
return { playlist, songs: entry ?? [] };
}
export async function createPlaylist(name: string, songIds?: string[]): Promise<SubsonicPlaylist> {
const params: Record<string, unknown> = { name };
if (songIds && songIds.length > 0) {
params.songId = songIds;
}
const data = await api<{ playlist: SubsonicPlaylist }>('createPlaylist.view', params);
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
void import('@/features/offline')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { useOfflineBrowseContext } from '@/features/offline';
-1
View File
@@ -10,7 +10,6 @@
* `playlistDetailHelpers` (shared with offline + favorites; keeping it here
* would create a playlistoffline barrel cycle lib/shared in M4).
*/
export * from './api/subsonicPlaylists';
export * from './hooks/usePlaylistBulkPlayCallbacks';
export * from './hooks/usePlaylistCovers';
export * from './hooks/usePlaylistDerived';
@@ -1,4 +1,4 @@
import { updatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { updatePlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
+2 -2
View File
@@ -1,8 +1,8 @@
import { getPlaylists } from '@/features/playlist/api/subsonicPlaylists';
import { getPlaylists } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPlaylist as apiCreatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { createPlaylist as apiCreatePlaylist } from '@/lib/api/subsonicPlaylists';
import { useAuthStore } from '@/store/authStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { fetchOfflineBrowsablePlaylists } from '@/features/offline';
@@ -1,4 +1,4 @@
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import { songToTrack } from '@/utils/playback/songToTrack';
import { usePlayerStore } from '@/store/playerStore';
import { playPlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
@@ -1,5 +1,5 @@
import type React from 'react';
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { getPlaylist } from '@/lib/api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { getPlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '@/features/playlist/api/subsonicPlaylists';
import { getPlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { showToast } from '@/utils/ui/toast';
@@ -1,6 +1,6 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { deletePlaylist, getPlaylist, updatePlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { deletePlaylist, getPlaylist, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { showToast } from '@/utils/ui/toast';
-103
View File
@@ -1,103 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '@/store/authStore';
import { api } from '@/lib/api/subsonicClient';
import type { InternetRadioStation, RadioBrowserStation } from '@/lib/api/subsonicTypes';
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
} catch {
return [];
}
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await api('updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
@@ -5,7 +5,7 @@ import { Cast, Check, Loader2, Plus, X } from 'lucide-react';
import {
createInternetRadioStation, fetchUrlBytes, getInternetRadioStations,
getTopRadioStations, searchRadioBrowser, uploadRadioCoverArtBytes,
} from '@/features/radio/api/subsonicRadio';
} from '@/lib/api/subsonicRadio';
import {
type InternetRadioStation, type RadioBrowserStation, RADIO_PAGE_SIZE,
} from '@/lib/api/subsonicTypes';
-1
View File
@@ -8,7 +8,6 @@
* playback/audio core the player core drives them, so they are not part of
* this UI feature.
*/
export { getInternetRadioStations } from './api/subsonicRadio';
export { useRadioMetadata } from './hooks/useRadioMetadata';
export type { RadioMetadata } from './hooks/useRadioMetadata';
export { useRadioMprisSync } from './hooks/useRadioMprisSync';
+1 -1
View File
@@ -1,4 +1,4 @@
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt } from '@/features/radio/api/subsonicRadio';
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt } from '@/lib/api/subsonicRadio';
import { type InternetRadioStation } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { Plus, Search } from 'lucide-react';
@@ -1,145 +0,0 @@
import { useAuthStore } from '@/store/authStore';
import { genreTagsFor } from '@/utils/library/genreTags';
import { getArtists } from '@/features/artist';
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
import type {
StatisticsFormatSample,
StatisticsLibraryAggregates,
StatisticsOverviewData,
SubsonicAlbum,
SubsonicArtist,
SubsonicGenre,
SubsonicSong,
} from '@/lib/api/subsonicTypes';
/** Cache TTL for statistics page aggregates same 7-minute window as
* the rating prefetch cache in subsonicRatings.ts. */
const STATS_CACHE_TTL = 7 * 60 * 1000;
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
function statisticsPageCacheKey(prefix: string): string | null {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId] ?? 'all';
const folderPart = folder === 'all' ? 'all' : folder;
return `${prefix}:${activeServerId}:${folderPart}`;
}
const statisticsAggregatesCache = new Map<string, { value: StatisticsLibraryAggregates; expiresAt: number }>();
/**
* Walks up to 5000 newest albums (scoped by library filter). Cached per server + music folder for
* 7 minutes.
* Unknown/missing album genre is stored as `value: ''`; UI should map to i18n.
*/
export async function fetchStatisticsLibraryAggregates(): Promise<StatisticsLibraryAggregates> {
const key = statisticsPageCacheKey('statsAgg');
if (key) {
const hit = statisticsAggregatesCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
let playtimeSec = 0;
let albumsCounted = 0;
let songsCounted = 0;
const genreAgg = new Map<string, { songCount: number; albumCount: number }>();
const pageSize = 500;
const capped = false;
let offset = 0;
let nextPage = getAlbumList('alphabeticalByName', pageSize, 0);
for (;;) {
try {
const albums = await nextPage;
for (const a of albums) {
playtimeSec += a.duration ?? 0;
albumsCounted += 1;
const sc = a.songCount ?? 0;
songsCounted += sc;
const tags = genreTagsFor(a);
const labels = tags.length > 0 ? tags : [''];
for (const label of labels) {
let g = genreAgg.get(label);
if (!g) {
g = { songCount: 0, albumCount: 0 };
genreAgg.set(label, g);
}
g.songCount += sc;
g.albumCount += 1;
}
}
if (albums.length < pageSize) break;
offset += pageSize;
nextPage = getAlbumList('alphabeticalByName', pageSize, offset);
} catch {
break;
}
}
const genres: SubsonicGenre[] = [...genreAgg.entries()]
.map(([value, c]) => ({ value, songCount: c.songCount, albumCount: c.albumCount }))
.sort((a, b) => b.songCount - a.songCount);
const result: StatisticsLibraryAggregates = {
playtimeSec,
albumsCounted,
songsCounted,
capped,
genres,
};
if (key) {
statisticsAggregatesCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Recent / frequent / highest album strips + artist count for Statistics. */
const statisticsOverviewCache = new Map<string, { value: StatisticsOverviewData; expiresAt: number }>();
export async function fetchStatisticsOverview(): Promise<StatisticsOverviewData> {
const key = statisticsPageCacheKey('statsOverview');
if (key) {
const hit = statisticsOverviewCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const [recent, frequent, highest, artists] = await Promise.all([
getAlbumList('recent', 20).catch(() => [] as SubsonicAlbum[]),
getAlbumList('frequent', 12).catch(() => [] as SubsonicAlbum[]),
getAlbumList('highest', 12).catch(() => [] as SubsonicAlbum[]),
getArtists().catch(() => [] as SubsonicArtist[]),
]);
const result: StatisticsOverviewData = {
recent,
frequent,
highest,
artistCount: artists.length,
};
if (key) {
statisticsOverviewCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
/** Format (suffix) histogram from a random sample for Statistics. */
const statisticsFormatCache = new Map<string, { value: StatisticsFormatSample; expiresAt: number }>();
export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSample> {
const key = statisticsPageCacheKey('statsFormat');
if (key) {
const hit = statisticsFormatCache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
}
const songs = await getRandomSongs(500).catch(() => [] as SubsonicSong[]);
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const rows = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
const result: StatisticsFormatSample = { rows, sampleSize: songs.length };
if (key) {
statisticsFormatCache.set(key, { value: result, expiresAt: Date.now() + STATS_CACHE_TTL });
}
return result;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '@/features/stats/api/subsonicStatistics';
import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchStatisticsOverview } from '@/lib/api/subsonicStatistics';
import { getAlbumList } from '@/lib/api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicGenre } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState } from 'react';