mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of `api/subsonic.ts`: - `subsonicLibrary.ts` — browse + random + per-song fetch (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`, `getRandomAlbums`, `getAlbumList`, `getRandomSongs`, `getRandomSongsFiltered`, `getSong`, `getAlbum`, `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus the private `albumIdsInActiveLibraryScope` cache). - `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`, `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`, `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and `similarSongsRequestCount` for the per-library scoping fallback. - `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers with the shared 7-min cache. Calls back into Library/Artists for the per-id fetch. 51 external call sites migrated to direct imports. No re-export shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts` keep their `RATING_CACHE_TTL` constant locally (same 7-min window). subsonic.ts: 1078 → 762 LOC (−316).
This commit is contained in:
committed by
GitHub
parent
72030f17fd
commit
006635de4b
@@ -21,21 +21,9 @@ vi.mock('axios', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import { getAlbumInfo2, getStarred, pingWithCredentials, ping, search } from './subsonic';
|
||||||
getAlbum,
|
import { getAlbum, getMusicDirectory, getMusicFolders, getMusicIndexes, getRandomSongs, getSong } from './subsonicLibrary';
|
||||||
getAlbumInfo2,
|
import { getArtists, getTopSongs } from './subsonicArtists';
|
||||||
getArtists,
|
|
||||||
getMusicDirectory,
|
|
||||||
getMusicFolders,
|
|
||||||
getMusicIndexes,
|
|
||||||
getRandomSongs,
|
|
||||||
getSong,
|
|
||||||
getStarred,
|
|
||||||
getTopSongs,
|
|
||||||
pingWithCredentials,
|
|
||||||
ping,
|
|
||||||
search,
|
|
||||||
} from './subsonic';
|
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
* mocking and are not in this PR.
|
* mocking and are not in this PR.
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey, parseSubsonicEntityStarRating } from './subsonic';
|
import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey } from './subsonic';
|
||||||
|
import { parseSubsonicEntityStarRating } from './subsonicRatings';
|
||||||
import { getClient, libraryFilterParams } from './subsonicClient';
|
import { getClient, libraryFilterParams } from './subsonicClient';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||||
|
|||||||
+6
-322
@@ -15,6 +15,12 @@ import {
|
|||||||
libraryFilterParams,
|
libraryFilterParams,
|
||||||
secureRandomSalt,
|
secureRandomSalt,
|
||||||
} from './subsonicClient';
|
} from './subsonicClient';
|
||||||
|
import { getAlbumList, getRandomSongs } from './subsonicLibrary';
|
||||||
|
import { getArtists } from './subsonicArtists';
|
||||||
|
|
||||||
|
/** Cache TTL for statistics page aggregates — same 7-minute window as
|
||||||
|
* the rating prefetch cache in subsonicRatings.ts. */
|
||||||
|
const RATING_CACHE_TTL = 7 * 60 * 1000;
|
||||||
import type {
|
import type {
|
||||||
AlbumInfo,
|
AlbumInfo,
|
||||||
EntityRatingSupportLevel,
|
EntityRatingSupportLevel,
|
||||||
@@ -43,53 +49,8 @@ import type {
|
|||||||
|
|
||||||
|
|
||||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||||
// ─── API Methods ──────────────────────────────────────────────
|
|
||||||
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
|
|
||||||
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
|
||||||
'getMusicDirectory.view',
|
|
||||||
{ id },
|
|
||||||
);
|
|
||||||
const dir = data.directory;
|
|
||||||
const raw = dir.child;
|
|
||||||
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
|
||||||
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the top-level artist/directory entries for a music folder root.
|
|
||||||
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs —
|
|
||||||
* use getIndexes.view with musicFolderId instead. */
|
|
||||||
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
|
||||||
type IndexArtist = { id: string; name: string; coverArt?: string };
|
|
||||||
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
|
|
||||||
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
|
|
||||||
'getIndexes.view',
|
|
||||||
{ musicFolderId },
|
|
||||||
);
|
|
||||||
const raw = data.indexes?.index;
|
|
||||||
if (!raw) return [];
|
|
||||||
const indices = Array.isArray(raw) ? raw : [raw];
|
|
||||||
const entries: SubsonicDirectoryEntry[] = [];
|
|
||||||
for (const idx of indices) {
|
|
||||||
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
|
|
||||||
for (const a of artists) {
|
|
||||||
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
|
||||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
|
||||||
'getMusicFolders.view',
|
|
||||||
);
|
|
||||||
const raw = data.musicFolders?.musicFolder;
|
|
||||||
if (!raw) return [];
|
|
||||||
const arr = Array.isArray(raw) ? raw : [raw];
|
|
||||||
return arr.map(f => ({
|
|
||||||
id: String((f as { id: string | number }).id),
|
|
||||||
name: (f as { name?: string }).name ?? 'Library',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ping(): Promise<boolean> {
|
export async function ping(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -220,237 +181,22 @@ export function scheduleInstantMixProbeForServer(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
|
||||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
|
||||||
type: 'random',
|
|
||||||
size,
|
|
||||||
...libraryFilterParams(),
|
|
||||||
});
|
|
||||||
return data.albumList2?.album ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAlbumList(
|
|
||||||
type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest',
|
|
||||||
size = 30,
|
|
||||||
offset = 0,
|
|
||||||
extra: Record<string, unknown> = {}
|
|
||||||
): Promise<SubsonicAlbum[]> {
|
|
||||||
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
|
||||||
type,
|
|
||||||
size,
|
|
||||||
offset,
|
|
||||||
_t: Date.now(),
|
|
||||||
...libraryFilterParams(),
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
return data.albumList2?.album ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
|
||||||
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
|
||||||
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
|
||||||
*/
|
|
||||||
let scopedLibraryAlbumIdCache: {
|
|
||||||
serverId: string;
|
|
||||||
folderId: string;
|
|
||||||
filterVersion: number;
|
|
||||||
ids: Set<string>;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
|
||||||
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
|
||||||
if (!activeServerId) return null;
|
|
||||||
const folder = musicLibraryFilterByServer[activeServerId];
|
|
||||||
if (folder === undefined || folder === 'all') {
|
|
||||||
scopedLibraryAlbumIdCache = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const hit = scopedLibraryAlbumIdCache;
|
|
||||||
if (
|
|
||||||
hit &&
|
|
||||||
hit.serverId === activeServerId &&
|
|
||||||
hit.folderId === folder &&
|
|
||||||
hit.filterVersion === musicLibraryFilterVersion
|
|
||||||
) {
|
|
||||||
return hit.ids;
|
|
||||||
}
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const pageSize = 500;
|
|
||||||
let offset = 0;
|
|
||||||
for (;;) {
|
|
||||||
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
|
|
||||||
for (const a of albums) ids.add(a.id);
|
|
||||||
if (albums.length < pageSize) break;
|
|
||||||
offset += pageSize;
|
|
||||||
if (offset > 500_000) break;
|
|
||||||
}
|
|
||||||
scopedLibraryAlbumIdCache = {
|
|
||||||
serverId: activeServerId,
|
|
||||||
folderId: folder,
|
|
||||||
filterVersion: musicLibraryFilterVersion,
|
|
||||||
ids,
|
|
||||||
};
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
|
||||||
const allowed = await albumIdsInActiveLibraryScope();
|
|
||||||
if (!allowed || allowed.size === 0) return songs;
|
|
||||||
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
|
||||||
function similarSongsRequestCount(desired: number): number {
|
|
||||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
|
||||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
|
||||||
if (f === undefined || f === 'all') return desired;
|
|
||||||
return Math.min(300, Math.max(desired, desired * 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
|
||||||
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
|
||||||
if (genre) params.genre = genre;
|
|
||||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
|
||||||
return data.randomSongs?.song ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extended random song fetch with server-side year/genre filtering. */
|
|
||||||
export async function getRandomSongsFiltered(
|
|
||||||
filters: RandomSongsFilters,
|
|
||||||
timeout = 15000,
|
|
||||||
): Promise<SubsonicSong[]> {
|
|
||||||
const params: Record<string, string | number> = {
|
|
||||||
size: filters.size ?? 50,
|
|
||||||
_t: Date.now(),
|
|
||||||
...libraryFilterParams(),
|
|
||||||
};
|
|
||||||
if (filters.genre) params.genre = filters.genre;
|
|
||||||
if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear;
|
|
||||||
if (typeof filters.toYear === 'number') params.toYear = filters.toYear;
|
|
||||||
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
|
||||||
return data.randomSongs?.song ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
|
||||||
try {
|
|
||||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
|
||||||
return data.song ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
|
||||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
|
||||||
const { song, ...album } = data.album;
|
|
||||||
return { album, songs: song ?? [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
|
||||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
|
||||||
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
|
|
||||||
|
|
||||||
function getCachedRating(key: string): number | undefined | null {
|
|
||||||
const entry = ratingCache.get(key);
|
|
||||||
if (!entry) return null; // cache miss
|
|
||||||
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCachedRating(key: string, value: number | undefined): void {
|
|
||||||
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEntityUserRating(v: unknown): number | undefined {
|
|
||||||
if (v === null || v === undefined) return undefined;
|
|
||||||
const n = typeof v === 'number' ? v : Number(v);
|
|
||||||
if (!Number.isFinite(n)) return undefined;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
|
|
||||||
export function parseSubsonicEntityStarRating(entity: {
|
|
||||||
userRating?: unknown;
|
|
||||||
rating?: unknown;
|
|
||||||
}): number | undefined {
|
|
||||||
return parseEntityUserRating(entity.userRating ?? entity.rating);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bump when rating parse keys change so stale cache entries are not reused. */
|
|
||||||
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
|
|
||||||
|
|
||||||
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
|
||||||
export async function prefetchArtistUserRatings(
|
|
||||||
ids: string[],
|
|
||||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
const unique = [...new Set(ids.filter(Boolean))];
|
|
||||||
const out = new Map<string, number>();
|
|
||||||
if (!unique.length) return out;
|
|
||||||
const uncached: string[] = [];
|
|
||||||
for (const id of unique) {
|
|
||||||
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
|
||||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
|
||||||
else uncached.push(id);
|
|
||||||
}
|
|
||||||
if (!uncached.length) return out;
|
|
||||||
let next = 0;
|
|
||||||
async function worker() {
|
|
||||||
for (;;) {
|
|
||||||
const i = next++;
|
|
||||||
if (i >= uncached.length) return;
|
|
||||||
const id = uncached[i];
|
|
||||||
try {
|
|
||||||
const { artist } = await getArtist(id);
|
|
||||||
const r = parseSubsonicEntityStarRating(artist);
|
|
||||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
|
||||||
if (r !== undefined) out.set(id, r);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nWorkers = Math.min(concurrency, uncached.length);
|
|
||||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
|
||||||
export async function prefetchAlbumUserRatings(
|
|
||||||
ids: string[],
|
|
||||||
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
const unique = [...new Set(ids.filter(Boolean))];
|
|
||||||
const out = new Map<string, number>();
|
|
||||||
if (!unique.length) return out;
|
|
||||||
const uncached: string[] = [];
|
|
||||||
for (const id of unique) {
|
|
||||||
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
|
||||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
|
||||||
else uncached.push(id);
|
|
||||||
}
|
|
||||||
if (!uncached.length) return out;
|
|
||||||
let next = 0;
|
|
||||||
async function worker() {
|
|
||||||
for (;;) {
|
|
||||||
const i = next++;
|
|
||||||
if (i >= uncached.length) return;
|
|
||||||
const id = uncached[i];
|
|
||||||
try {
|
|
||||||
const { album } = await getAlbum(id);
|
|
||||||
const r = parseSubsonicEntityStarRating(album);
|
|
||||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
|
||||||
if (r !== undefined) out.set(id, r);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nWorkers = Math.min(concurrency, uncached.length);
|
|
||||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
|
/** Paginated album stats for Statistics (playtime, counts, genre breakdown). Same TTL as rating prefetch. */
|
||||||
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
|
/** Key `prefix:serverId:folder` — Statistics caches share scope with `libraryFilterParams()`. */
|
||||||
@@ -578,73 +324,11 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
|
||||||
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
|
||||||
...libraryFilterParams(),
|
|
||||||
});
|
|
||||||
const rawIdx = data.artists?.index;
|
|
||||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
|
||||||
const artists: SubsonicArtist[] = [];
|
|
||||||
for (const idx of indices) {
|
|
||||||
const rawArt = idx.artist;
|
|
||||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
|
||||||
artists.push(...arr);
|
|
||||||
}
|
|
||||||
return artists;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
|
||||||
const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id });
|
|
||||||
const { album, ...artist } = data.artist;
|
|
||||||
return { artist, albums: album ?? [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
|
||||||
const count = options?.similarArtistCount ?? 5;
|
|
||||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
|
||||||
return data.artistInfo2 ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
|
||||||
try {
|
|
||||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
|
||||||
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
|
||||||
const topCount = scoped ? 20 : 5;
|
|
||||||
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
|
||||||
const raw = data.topSongs?.song ?? [];
|
|
||||||
const filtered = await filterSongsToActiveLibrary(raw);
|
|
||||||
return filtered.slice(0, 5);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSimilarSongs2(id: string, count = 50): Promise<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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||||
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { api, libraryFilterParams } from './subsonicClient';
|
||||||
|
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
|
||||||
|
import type {
|
||||||
|
SubsonicAlbum,
|
||||||
|
SubsonicArtist,
|
||||||
|
SubsonicArtistInfo,
|
||||||
|
SubsonicSong,
|
||||||
|
} from './subsonicTypes';
|
||||||
|
|
||||||
|
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||||
|
const data = await api<{ artists: { index: any } }>('getArtists.view', {
|
||||||
|
...libraryFilterParams(),
|
||||||
|
});
|
||||||
|
const rawIdx = data.artists?.index;
|
||||||
|
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||||
|
const artists: SubsonicArtist[] = [];
|
||||||
|
for (const idx of indices) {
|
||||||
|
const rawArt = idx.artist;
|
||||||
|
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||||
|
artists.push(...arr);
|
||||||
|
}
|
||||||
|
return artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||||
|
const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id });
|
||||||
|
const { album, ...artist } = data.artist;
|
||||||
|
return { artist, albums: album ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||||
|
const count = options?.similarArtistCount ?? 5;
|
||||||
|
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||||
|
return data.artistInfo2 ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||||
|
try {
|
||||||
|
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||||
|
const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
|
||||||
|
const topCount = scoped ? 20 : 5;
|
||||||
|
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
|
||||||
|
const raw = data.topSongs?.song ?? [];
|
||||||
|
const filtered = await filterSongsToActiveLibrary(raw);
|
||||||
|
return filtered.slice(0, 5);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSimilarSongs2(id: string, count = 50): Promise<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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { api, libraryFilterParams } from './subsonicClient';
|
||||||
|
import type {
|
||||||
|
RandomSongsFilters,
|
||||||
|
SubsonicAlbum,
|
||||||
|
SubsonicDirectory,
|
||||||
|
SubsonicDirectoryEntry,
|
||||||
|
SubsonicMusicFolder,
|
||||||
|
SubsonicSong,
|
||||||
|
} from './subsonicTypes';
|
||||||
|
|
||||||
|
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
|
||||||
|
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
||||||
|
'getMusicDirectory.view',
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
const dir = data.directory;
|
||||||
|
const raw = dir.child;
|
||||||
|
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||||
|
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the top-level artist/directory entries for a music folder root.
|
||||||
|
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs —
|
||||||
|
* use getIndexes.view with musicFolderId instead. */
|
||||||
|
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
||||||
|
type IndexArtist = { id: string; name: string; coverArt?: string };
|
||||||
|
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
|
||||||
|
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
|
||||||
|
'getIndexes.view',
|
||||||
|
{ musicFolderId },
|
||||||
|
);
|
||||||
|
const raw = data.indexes?.index;
|
||||||
|
if (!raw) return [];
|
||||||
|
const indices = Array.isArray(raw) ? raw : [raw];
|
||||||
|
const entries: SubsonicDirectoryEntry[] = [];
|
||||||
|
for (const idx of indices) {
|
||||||
|
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
|
||||||
|
for (const a of artists) {
|
||||||
|
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||||
|
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||||
|
'getMusicFolders.view',
|
||||||
|
);
|
||||||
|
const raw = data.musicFolders?.musicFolder;
|
||||||
|
if (!raw) return [];
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
return arr.map(f => ({
|
||||||
|
id: String((f as { id: string | number }).id),
|
||||||
|
name: (f as { name?: string }).name ?? 'Library',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRandomAlbums(size = 6): Promise<SubsonicAlbum[]> {
|
||||||
|
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
|
type: 'random',
|
||||||
|
size,
|
||||||
|
...libraryFilterParams(),
|
||||||
|
});
|
||||||
|
return data.albumList2?.album ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumList(
|
||||||
|
type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest',
|
||||||
|
size = 30,
|
||||||
|
offset = 0,
|
||||||
|
extra: Record<string, unknown> = {}
|
||||||
|
): Promise<SubsonicAlbum[]> {
|
||||||
|
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||||
|
type,
|
||||||
|
size,
|
||||||
|
offset,
|
||||||
|
_t: Date.now(),
|
||||||
|
...libraryFilterParams(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
return data.albumList2?.album ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
|
||||||
|
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
|
||||||
|
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
|
||||||
|
*/
|
||||||
|
let scopedLibraryAlbumIdCache: {
|
||||||
|
serverId: string;
|
||||||
|
folderId: string;
|
||||||
|
filterVersion: number;
|
||||||
|
ids: Set<string>;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
|
||||||
|
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||||
|
if (!activeServerId) return null;
|
||||||
|
const folder = musicLibraryFilterByServer[activeServerId];
|
||||||
|
if (folder === undefined || folder === 'all') {
|
||||||
|
scopedLibraryAlbumIdCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hit = scopedLibraryAlbumIdCache;
|
||||||
|
if (
|
||||||
|
hit &&
|
||||||
|
hit.serverId === activeServerId &&
|
||||||
|
hit.folderId === folder &&
|
||||||
|
hit.filterVersion === musicLibraryFilterVersion
|
||||||
|
) {
|
||||||
|
return hit.ids;
|
||||||
|
}
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const pageSize = 500;
|
||||||
|
let offset = 0;
|
||||||
|
for (;;) {
|
||||||
|
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
|
||||||
|
for (const a of albums) ids.add(a.id);
|
||||||
|
if (albums.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
if (offset > 500_000) break;
|
||||||
|
}
|
||||||
|
scopedLibraryAlbumIdCache = {
|
||||||
|
serverId: activeServerId,
|
||||||
|
folderId: folder,
|
||||||
|
filterVersion: musicLibraryFilterVersion,
|
||||||
|
ids,
|
||||||
|
};
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||||
|
const allowed = await albumIdsInActiveLibraryScope();
|
||||||
|
if (!allowed || allowed.size === 0) return songs;
|
||||||
|
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||||
|
export function similarSongsRequestCount(desired: number): number {
|
||||||
|
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||||
|
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||||
|
if (f === undefined || f === 'all') return desired;
|
||||||
|
return Math.min(300, Math.max(desired, desired * 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
|
||||||
|
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
|
||||||
|
if (genre) params.genre = genre;
|
||||||
|
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||||
|
return data.randomSongs?.song ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extended random song fetch with server-side year/genre filtering. */
|
||||||
|
export async function getRandomSongsFiltered(
|
||||||
|
filters: RandomSongsFilters,
|
||||||
|
timeout = 15000,
|
||||||
|
): Promise<SubsonicSong[]> {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
size: filters.size ?? 50,
|
||||||
|
_t: Date.now(),
|
||||||
|
...libraryFilterParams(),
|
||||||
|
};
|
||||||
|
if (filters.genre) params.genre = filters.genre;
|
||||||
|
if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear;
|
||||||
|
if (typeof filters.toYear === 'number') params.toYear = filters.toYear;
|
||||||
|
const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout);
|
||||||
|
return data.randomSongs?.song ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||||
|
try {
|
||||||
|
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||||
|
return data.song ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||||
|
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||||
|
const { song, ...album } = data.album;
|
||||||
|
return { album, songs: song ?? [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { getArtist } from './subsonicArtists';
|
||||||
|
import { getAlbum } from './subsonicLibrary';
|
||||||
|
|
||||||
|
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||||
|
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||||
|
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
|
||||||
|
|
||||||
|
function getCachedRating(key: string): number | undefined | null {
|
||||||
|
const entry = ratingCache.get(key);
|
||||||
|
if (!entry) return null; // cache miss
|
||||||
|
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedRating(key: string, value: number | undefined): void {
|
||||||
|
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntityUserRating(v: unknown): number | undefined {
|
||||||
|
if (v === null || v === undefined) return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
|
||||||
|
export function parseSubsonicEntityStarRating(entity: {
|
||||||
|
userRating?: unknown;
|
||||||
|
rating?: unknown;
|
||||||
|
}): number | undefined {
|
||||||
|
return parseEntityUserRating(entity.userRating ?? entity.rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bump when rating parse keys change so stale cache entries are not reused. */
|
||||||
|
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
|
||||||
|
|
||||||
|
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
|
||||||
|
export async function prefetchArtistUserRatings(
|
||||||
|
ids: string[],
|
||||||
|
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const unique = [...new Set(ids.filter(Boolean))];
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
if (!unique.length) return out;
|
||||||
|
const uncached: string[] = [];
|
||||||
|
for (const id of unique) {
|
||||||
|
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||||
|
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||||
|
else uncached.push(id);
|
||||||
|
}
|
||||||
|
if (!uncached.length) return out;
|
||||||
|
let next = 0;
|
||||||
|
async function worker() {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= uncached.length) return;
|
||||||
|
const id = uncached[i];
|
||||||
|
try {
|
||||||
|
const { artist } = await getArtist(id);
|
||||||
|
const r = parseSubsonicEntityStarRating(artist);
|
||||||
|
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||||
|
if (r !== undefined) out.set(id, r);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nWorkers = Math.min(concurrency, uncached.length);
|
||||||
|
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
|
||||||
|
export async function prefetchAlbumUserRatings(
|
||||||
|
ids: string[],
|
||||||
|
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const unique = [...new Set(ids.filter(Boolean))];
|
||||||
|
const out = new Map<string, number>();
|
||||||
|
if (!unique.length) return out;
|
||||||
|
const uncached: string[] = [];
|
||||||
|
for (const id of unique) {
|
||||||
|
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||||
|
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||||
|
else uncached.push(id);
|
||||||
|
}
|
||||||
|
if (!uncached.length) return out;
|
||||||
|
let next = 0;
|
||||||
|
async function worker() {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= uncached.length) return;
|
||||||
|
const id = uncached[i];
|
||||||
|
try {
|
||||||
|
const { album } = await getAlbum(id);
|
||||||
|
const r = parseSubsonicEntityStarRating(album);
|
||||||
|
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||||
|
if (r !== undefined) out.set(id, r);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nWorkers = Math.min(concurrency, uncached.length);
|
||||||
|
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getMusicFolders } from '../api/subsonicLibrary';
|
||||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -36,7 +37,7 @@ import { useOrbitStore } from '../store/orbitStore';
|
|||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||||
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
import { useConnectionStatus } from '../hooks/useConnectionStatus';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { getMusicFolders, probeEntityRatingSupport } from '../api/subsonic';
|
import { probeEntityRatingSupport } from '../api/subsonic';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getSimilarSongs } from '../api/subsonicArtists';
|
||||||
|
import { getMusicFolders } from '../api/subsonicLibrary';
|
||||||
import { flushPlayQueuePosition } from '../store/queueSync';
|
import { flushPlayQueuePosition } from '../store/queueSync';
|
||||||
import { shuffleArray } from '../utils/shuffleArray';
|
import { shuffleArray } from '../utils/shuffleArray';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
@@ -11,11 +13,7 @@ import { showToast } from '../utils/toast';
|
|||||||
import { endOrbitSession, leaveOrbitSession } from '../utils/orbit';
|
import { endOrbitSession, leaveOrbitSession } from '../utils/orbit';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import {
|
import { search as subsonicSearch } from '../api/subsonic';
|
||||||
getMusicFolders,
|
|
||||||
getSimilarSongs,
|
|
||||||
search as subsonicSearch,
|
|
||||||
} from '../api/subsonic';
|
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { switchActiveServer } from '../utils/switchActiveServer';
|
import { switchActiveServer } from '../utils/switchActiveServer';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { memo, useMemo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, ListPlus, Music } from 'lucide-react';
|
import { Play, ListPlus, Music } from 'lucide-react';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getArtist, getArtistInfo } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
|
import { getSimilarSongs2, getSimilarSongs, getTopSongs, getArtist } from '../api/subsonicArtists';
|
||||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicPlaylist } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicPlaylist } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
@@ -15,7 +17,7 @@ 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 { star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, updatePlaylist, setRating } from '../api/subsonic';
|
import { star, unstar, buildDownloadUrl, getPlaylists, getPlaylist, updatePlaylist, setRating } from '../api/subsonic';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { 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';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getArtistInfo } from '../api/subsonicArtists';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||||
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
Moon, Sunrise,
|
Moon, Sunrise,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
import { getCachedBlob } from '../utils/imageCache';
|
import { getCachedBlob } from '../utils/imageCache';
|
||||||
import { extractCoverColors } from '../utils/dynamicColors';
|
import { extractCoverColors } from '../utils/dynamicColors';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
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 { Play, ListPlus } from 'lucide-react';
|
import { Play, ListPlus } from 'lucide-react';
|
||||||
import { getRandomAlbums, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import CachedImage, { useCachedUrl } from './CachedImage';
|
import CachedImage, { useCachedUrl } from './CachedImage';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Check, X, Inbox } from 'lucide-react';
|
import { Check, X, Inbox } from 'lucide-react';
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
declineOrbitSuggestion,
|
declineOrbitSuggestion,
|
||||||
suggestionKey,
|
suggestionKey,
|
||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import { getSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
|
import { getArtistInfo } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -5,7 +7,6 @@ import { Info } from 'lucide-react';
|
|||||||
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { getArtistInfo, getSong } from '../api/subsonic';
|
|
||||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import OverlayScrollArea from './OverlayScrollArea';
|
import OverlayScrollArea from './OverlayScrollArea';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Radio, Clock } from 'lucide-react';
|
import { Radio, Clock } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { getSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import CachedImage from './CachedImage';
|
import CachedImage from './CachedImage';
|
||||||
import OrbitQueueHead from './OrbitQueueHead';
|
import OrbitQueueHead from './OrbitQueueHead';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
|
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
|
||||||
@@ -5,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { useHelpModalStore } from '../store/helpModalStore';
|
import { useHelpModalStore } from '../store/helpModalStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { getSong } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
endOrbitSession,
|
endOrbitSession,
|
||||||
leaveOrbitSession,
|
leaveOrbitSession,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
import type { SubsonicPlaylist } from '../api/subsonicTypes';
|
||||||
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
import { registerQueueListScrollTopReader, consumePendingQueueListScrollTop } from '../store/queueUndo';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
@@ -10,7 +11,7 @@ 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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist } from '../api/subsonic';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { 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 { 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';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -17,7 +18,7 @@ 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 { getAlbumList, getPlaylists } from '../api/subsonic';
|
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,10 +1,10 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getSong } from '../api/subsonic';
|
|
||||||
import { ndGetSongPath } from '../api/navidromeAdmin';
|
import { ndGetSongPath } from '../api/navidromeAdmin';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAlbumList } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -5,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { save } from '@tauri-apps/plugin-dialog';
|
import { save } from '@tauri-apps/plugin-dialog';
|
||||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { getAlbumList } from '../api/subsonic';
|
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import {
|
import {
|
||||||
exportAlbumCardBlob,
|
exportAlbumCardBlob,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { getSong, setRating, star, unstar } from '../api/subsonic';
|
import { setRating, star, unstar } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { usePreviewStore } from '../store/previewStore';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { getSong } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
readOrbitState,
|
readOrbitState,
|
||||||
writeOrbitHeartbeat,
|
writeOrbitHeartbeat,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { getSong } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
writeOrbitState,
|
writeOrbitState,
|
||||||
writeOrbitHeartbeat,
|
writeOrbitHeartbeat,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SlidersVertical } from 'lucide-react';
|
import { SlidersVertical } from 'lucide-react';
|
||||||
import { search, searchSongsPaged, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs } from '../api/subsonic';
|
import { search, searchSongsPaged, getGenres, getAlbumsByGenre } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong, SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicSong, SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Search, X, ListPlus } from 'lucide-react';
|
import { Search, X, ListPlus } from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar } from '../api/subsonic';
|
import { setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
||||||
@@ -6,7 +7,7 @@ import GenreFilterBar from '../components/GenreFilterBar';
|
|||||||
import YearFilterButton from '../components/YearFilterButton';
|
import YearFilterButton from '../components/YearFilterButton';
|
||||||
import StarFilterButton from '../components/StarFilterButton';
|
import StarFilterButton from '../components/StarFilterButton';
|
||||||
import SortDropdown from '../components/SortDropdown';
|
import SortDropdown from '../components/SortDropdown';
|
||||||
import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
|
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2 } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
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 { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, search, setRating, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
import { search, setRating, buildCoverArtUrl, coverArtCacheKey, star, unstar, uploadArtistImage } from '../api/subsonic';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import 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,7 +1,8 @@
|
|||||||
|
import { getArtists } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getArtists, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
|
||||||
import StarFilterButton from '../components/StarFilterButton';
|
import StarFilterButton from '../components/StarFilterButton';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { getArtist, getArtistInfo } from '../api/subsonicArtists';
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { getArtist, getArtistInfo, star, unstar, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { star, unstar, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse';
|
import { ndListAlbumsByArtistRole } from '../api/navidromeBrowse';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getArtists, getArtist } from '../api/subsonicArtists';
|
||||||
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist } from '../api/subsonicTypes';
|
import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -12,7 +14,7 @@ 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, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist, buildDownloadUrl, search as searchSubsonic } from '../api/subsonic';
|
import { getPlaylists, getPlaylist, buildDownloadUrl, search as searchSubsonic } from '../api/subsonic';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { IS_WINDOWS } from '../utils/platform';
|
import { IS_WINDOWS } from '../utils/platform';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicDirectoryEntry, SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicDirectoryEntry, SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
import { getMusicFolders, getMusicDirectory, getMusicIndexes } from '../api/subsonic';
|
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { getArtists } from '../api/subsonicArtists';
|
||||||
|
import { getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Hero from '../components/Hero';
|
import Hero from '../components/Hero';
|
||||||
@@ -5,7 +7,6 @@ import AlbumRow from '../components/AlbumRow';
|
|||||||
import SongRail from '../components/SongRail';
|
import SongRail from '../components/SongRail';
|
||||||
import BecauseYouLikeRail from '../components/BecauseYouLikeRail';
|
import BecauseYouLikeRail from '../components/BecauseYouLikeRail';
|
||||||
import LosslessAlbumsRail from '../components/LosslessAlbumsRail';
|
import LosslessAlbumsRail from '../components/LosslessAlbumsRail';
|
||||||
import { getAlbumList, getArtists, getRandomSongs } from '../api/subsonic';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||||
import { getAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { buildDownloadUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react';
|
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react';
|
||||||
import { getAlbumList, getAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import { getSong, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicSong, SubsonicArtistInfo, SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -7,7 +9,7 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, getAlbum, getArtist, getArtistInfo, getTopSongs } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import {
|
import {
|
||||||
lastfmIsConfigured,
|
lastfmIsConfigured,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getRandomSongs, filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
@@ -6,7 +7,7 @@ import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { ChevronDown, ChevronLeft, ChevronRight, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles, Square, AudioLines } from 'lucide-react';
|
import { 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, search, setRating, star, unstar, getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary } from '../api/subsonic';
|
import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, buildDownloadUrl } from '../api/subsonic';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { 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,9 +1,10 @@
|
|||||||
|
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
|
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
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, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres, filterSongsToActiveLibrary } from '../api/subsonic';
|
import { deletePlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist, getGenres } 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,7 +1,8 @@
|
|||||||
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
||||||
import { getAlbumList, getAlbumsByGenre, getAlbum, buildDownloadUrl } from '../api/subsonic';
|
import { getAlbumsByGenre, buildDownloadUrl } from '../api/subsonic';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
import GenreFilterBar from '../components/GenreFilterBar';
|
import GenreFilterBar from '../components/GenreFilterBar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
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, getAlbumList } from '../api/subsonic';
|
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,10 +1,11 @@
|
|||||||
|
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
import { Play, ListPlus, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getRandomSongs, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
|
||||||
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
|
import { getArtist } from '../api/subsonicArtists';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
|
import { buildStreamUrl } from '../api/subsonic';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
|
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
import { getSong } from '../api/subsonic';
|
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* // In the test:
|
* // In the test:
|
||||||
* vi.mock('@/api/subsonic');
|
* vi.mock('@/api/subsonic');
|
||||||
* import { getAlbum, buildStreamUrl } from '@/api/subsonic';
|
* import { buildStreamUrl } from '@/api/subsonic';
|
||||||
* import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic';
|
* import { sampleAlbumWithSongs, mockStreamUrl } from '@/test/mocks/subsonic';
|
||||||
*
|
*
|
||||||
* beforeEach(() => {
|
* beforeEach(() => {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
* Realistic shape matters more than perfect coverage — these fixtures
|
* Realistic shape matters more than perfect coverage — these fixtures
|
||||||
* mirror what Navidrome actually returns for common queries.
|
* mirror what Navidrome actually returns for common queries.
|
||||||
*/
|
*/
|
||||||
|
import { getAlbum } from '@/api/subsonicLibrary';
|
||||||
import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist } from '@/api/subsonicTypes';
|
import type { SubsonicSong, SubsonicAlbum, SubsonicPlaylist } from '@/api/subsonicTypes';
|
||||||
import { makeSubsonicSong } from '@/test/helpers/factories';
|
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { getArtist } from '../api/subsonicArtists';
|
||||||
|
import { getAlbum, getSong } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { getAlbum, getArtist, getSong } from '../api/subsonic';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
|
import { findServerIdForShareUrl, type EntitySharePayloadV1 } from './shareLink';
|
||||||
|
|||||||
@@ -6,12 +6,17 @@
|
|||||||
* refactor (2026-05-12). This test pins the artist-first / random-fallback
|
* refactor (2026-05-12). This test pins the artist-first / random-fallback
|
||||||
* order, the dedup contract against existingIds, and the autoAdded flag.
|
* order, the dedup contract against existingIds, and the autoAdded flag.
|
||||||
*/
|
*/
|
||||||
|
import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../api/subsonic', () => ({
|
vi.mock('../api/subsonicArtists', () => ({
|
||||||
getSimilarSongs2: vi.fn(),
|
getSimilarSongs2: vi.fn(),
|
||||||
getTopSongs: vi.fn(),
|
getTopSongs: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api/subsonicLibrary', () => ({
|
||||||
getRandomSongs: vi.fn(),
|
getRandomSongs: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -22,7 +27,6 @@ vi.mock('./mixRatingFilter', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates';
|
import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates';
|
||||||
import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
enrichSongsForMixRatingFilter,
|
enrichSongsForMixRatingFilter,
|
||||||
getMixMinRatingsConfigFromAuth,
|
getMixMinRatingsConfigFromAuth,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
|
|
||||||
import {
|
import {
|
||||||
enrichSongsForMixRatingFilter,
|
enrichSongsForMixRatingFilter,
|
||||||
getMixMinRatingsConfigFromAuth,
|
getMixMinRatingsConfigFromAuth,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { getAlbumList } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { downloadDir, join } from '@tauri-apps/api/path';
|
import { downloadDir, join } from '@tauri-apps/api/path';
|
||||||
import { getAlbumList, buildCoverArtUrl } from '../api/subsonic';
|
import { buildCoverArtUrl } from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
// Catppuccin Macchiato palette
|
// Catppuccin Macchiato palette
|
||||||
const M = {
|
const M = {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { getSimilarSongs, getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs, getSimilarSongs, getTopSongs } from '../api/subsonic';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../api/subsonicRatings';
|
||||||
|
import { getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { getRandomSongs, parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../api/subsonic';
|
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||||
|
|||||||
+2
-9
@@ -1,13 +1,6 @@
|
|||||||
|
import { getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import {
|
import { createPlaylist, updatePlaylist, updatePlaylistMeta, deletePlaylist, getPlaylist, getPlaylists } from '../api/subsonic';
|
||||||
createPlaylist,
|
|
||||||
updatePlaylist,
|
|
||||||
updatePlaylistMeta,
|
|
||||||
deletePlaylist,
|
|
||||||
getPlaylist,
|
|
||||||
getPlaylists,
|
|
||||||
getSong,
|
|
||||||
} 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';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getAlbum } from '../api/subsonic';
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { songToTrack } from './songToTrack';
|
import { songToTrack } from './songToTrack';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { getArtist } from '../api/subsonicArtists';
|
||||||
|
import { getAlbum } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { shuffleArray } from '../utils/shuffleArray';
|
import { shuffleArray } from '../utils/shuffleArray';
|
||||||
import { getAlbum, getArtist } from '../api/subsonic';
|
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
/**
|
/**
|
||||||
* All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”.
|
* All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { getAlbum, getSong } from '../api/subsonicLibrary';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { getAlbum, getSong } from '../api/subsonic';
|
|
||||||
import { playAlbum } from './playAlbum';
|
import { playAlbum } from './playAlbum';
|
||||||
import { playArtistShuffled } from './playArtistShuffled';
|
import { playArtistShuffled } from './playArtistShuffled';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
|||||||
Reference in New Issue
Block a user