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