mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(api): co-locate server-protocol clients into lib/api
Move the Subsonic + Navidrome protocol clients and the library IPC facade (subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/, the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/). Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150 consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis infra stay in src/api/ pending their own M4 placement. tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
This commit is contained in:
@@ -5,7 +5,7 @@ import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKe
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||
import { restBaseFromUrl } from './subsonicClient';
|
||||
import { restBaseFromUrl } from '@/lib/api/subsonicClient';
|
||||
import type { CoverArtRef, CoverArtTier } from '../cover/types';
|
||||
|
||||
/** Library SQLite `track.server_id` uses host index keys, not auth profile UUIDs. */
|
||||
|
||||
@@ -1,945 +0,0 @@
|
||||
/**
|
||||
* Typed wrappers around the `library_*` Tauri commands (spec §7.1) plus
|
||||
* subscribers for `library:sync-progress` / `library:sync-idle` events
|
||||
* (§7.2). One thin file per cucadmuh's PR-5 kickoff Q1 — Settings UI
|
||||
* (LibraryTab) imports from here; nothing else in the app talks to the
|
||||
* backend library surface directly.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { serverIndexKeyFromUrl } from '../utils/server/serverIndexKey';
|
||||
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
|
||||
// ── DTO mirrors (camelCase, matching the Rust `#[serde(rename_all = "camelCase")]`) ─
|
||||
|
||||
export interface TrackRefDto {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
contentHash?: string | null;
|
||||
}
|
||||
|
||||
/** E3 readiness summary — present only on single-track `libraryGetTrack` reads. */
|
||||
export interface TrackEnrichmentDto {
|
||||
waveformReady: boolean;
|
||||
loudnessReady: boolean;
|
||||
lyricsCached: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryTrackDto {
|
||||
serverId: string;
|
||||
id: string;
|
||||
contentHash?: string | null;
|
||||
title: string;
|
||||
titleSort?: string | null;
|
||||
artist?: string | null;
|
||||
artistId?: string | null;
|
||||
album: string;
|
||||
albumId?: string | null;
|
||||
albumArtist?: string | null;
|
||||
durationSec: number;
|
||||
trackNumber?: number | null;
|
||||
discNumber?: number | null;
|
||||
year?: number | null;
|
||||
genre?: string | null;
|
||||
suffix?: string | null;
|
||||
bitRate?: number | null;
|
||||
sizeBytes?: number | null;
|
||||
coverArtId?: string | null;
|
||||
starredAt?: number | null;
|
||||
userRating?: number | null;
|
||||
playCount?: number | null;
|
||||
playedAt?: number | null;
|
||||
serverPath?: string | null;
|
||||
libraryId?: string | null;
|
||||
isrc?: string | null;
|
||||
mbidRecording?: string | null;
|
||||
bpm?: number | null;
|
||||
/** `'analysis'` | `'tag'` — Advanced Search BPM dual-storage projection only. */
|
||||
bpmSource?: string | null;
|
||||
replayGainTrackDb?: number | null;
|
||||
replayGainAlbumDb?: number | null;
|
||||
serverUpdatedAt?: number | null;
|
||||
serverCreatedAt?: number | null;
|
||||
syncedAt: number;
|
||||
/** E3: populated only by `libraryGetTrack` (omitted on list/batch reads). */
|
||||
enrichment?: TrackEnrichmentDto | null;
|
||||
rawJson: unknown;
|
||||
}
|
||||
|
||||
export interface SyncStateDto {
|
||||
serverId: string;
|
||||
libraryScope: string;
|
||||
syncPhase: string;
|
||||
capabilityFlags: number;
|
||||
libraryTier: string;
|
||||
lastFullSyncAt?: number | null;
|
||||
lastDeltaSyncAt?: number | null;
|
||||
nextPollAt?: number | null;
|
||||
serverLastScanIso?: string | null;
|
||||
indexesLastModifiedMs?: number | null;
|
||||
artistsLastModifiedMs?: number | null;
|
||||
ignoredArticles?: string | null;
|
||||
localTrackCount?: number | null;
|
||||
serverTrackCount?: number | null;
|
||||
lastError?: string | null;
|
||||
localTracksMaxUpdatedMs?: number | null;
|
||||
/** True when at least one non-deleted track exists locally (cheap EXISTS). */
|
||||
hasLocalTracks?: boolean;
|
||||
ingestStrategy?: string | null;
|
||||
ingestPhase?: string | null;
|
||||
/** Tracks ingested per persisted initial-sync cursor (IS-3 progress). */
|
||||
cursorIngestedCount?: number | null;
|
||||
n1BulkUnreliable?: boolean | null;
|
||||
}
|
||||
|
||||
export interface LibraryTracksEnvelope {
|
||||
tracks: LibraryTrackDto[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TrackArtifactDto {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
artifactKind: string;
|
||||
format: string;
|
||||
sourceKind: string;
|
||||
sourceId: string;
|
||||
language?: string | null;
|
||||
contentText?: string | null;
|
||||
contentBytes: number;
|
||||
notFound: boolean;
|
||||
contentHash?: string | null;
|
||||
fetchedAt: number;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
export interface ArtifactInputDto {
|
||||
artifactKind: string;
|
||||
format: string;
|
||||
sourceKind: string;
|
||||
sourceId: string;
|
||||
language?: string | null;
|
||||
contentText?: string | null;
|
||||
contentBlob?: number[] | null;
|
||||
contentBytes?: number;
|
||||
notFound?: boolean;
|
||||
contentHash?: string | null;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
export interface TrackFactDto {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
factKind: string;
|
||||
valueReal?: number | null;
|
||||
valueInt?: number | null;
|
||||
valueText?: string | null;
|
||||
unit?: string | null;
|
||||
sourceKind: string;
|
||||
sourceId: string;
|
||||
confidence: number;
|
||||
contentHash?: string | null;
|
||||
fetchedAt: number;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
export interface FactInputDto {
|
||||
factKind: string;
|
||||
valueReal?: number | null;
|
||||
valueInt?: number | null;
|
||||
valueText?: string | null;
|
||||
unit?: string | null;
|
||||
sourceKind: string;
|
||||
sourceId: string;
|
||||
confidence?: number;
|
||||
contentHash?: string | null;
|
||||
expiresAt?: number | null;
|
||||
}
|
||||
|
||||
export interface OfflinePathDto {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
localPath?: string | null;
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export interface PurgeReportDto {
|
||||
tracksDeleted: number;
|
||||
albumsDeleted: number;
|
||||
artistsDeleted: number;
|
||||
offlineRowsDeleted: number;
|
||||
bytesFreed: number;
|
||||
}
|
||||
|
||||
export interface SyncJobDto {
|
||||
jobId: string;
|
||||
serverId: string;
|
||||
kind: string; // 'initial_sync' | 'delta_sync'
|
||||
}
|
||||
|
||||
// ── Advanced Search (PR-5d, §5.13 / §5.5B) ────────────────────────────
|
||||
|
||||
export type LibraryEntityType = 'artist' | 'album' | 'track';
|
||||
|
||||
/** v1 operator set the Rust `FilterFieldRegistry` accepts (§5.13.2). */
|
||||
export type FilterOperator = 'eq' | 'gte' | 'lte' | 'between' | 'fts' | 'is_true' | 'in';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export interface LibraryFilterClause {
|
||||
field: string; // registry id, e.g. 'genre' | 'year' | 'bpm'
|
||||
op: FilterOperator;
|
||||
value?: string | number | boolean | null;
|
||||
valueTo?: number | null; // between: inclusive upper bound
|
||||
}
|
||||
|
||||
export interface LibrarySortClause {
|
||||
field: string;
|
||||
dir: SortDir;
|
||||
}
|
||||
|
||||
export interface LibraryAdvancedSearchRequest {
|
||||
serverId: string;
|
||||
libraryScope?: string | null;
|
||||
query?: string | null; // shorthand → fts clause on text fields
|
||||
entityTypes: LibraryEntityType[];
|
||||
filters?: LibraryFilterClause[];
|
||||
starredOnly?: boolean | null;
|
||||
/** Server favorites ids ∩ local filters (lossless, genre, year). */
|
||||
restrictAlbumIds?: string[] | null;
|
||||
sort?: LibrarySortClause[];
|
||||
limit: number;
|
||||
offset?: number;
|
||||
/** Skip expensive COUNT queries (Live Search). */
|
||||
skipTotals?: boolean;
|
||||
/** Album text query matches title/name only (All Albums scoped browse). */
|
||||
queryAlbumTitleOnly?: boolean | null;
|
||||
}
|
||||
|
||||
export interface LibraryAlbumDto {
|
||||
serverId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
artist?: string | null;
|
||||
artistId?: string | null;
|
||||
songCount?: number | null;
|
||||
durationSec?: number | null;
|
||||
year?: number | null;
|
||||
genre?: string | null;
|
||||
coverArtId?: string | null;
|
||||
starredAt?: number | null;
|
||||
syncedAt: number;
|
||||
rawJson: unknown;
|
||||
}
|
||||
|
||||
export interface LibraryArtistDto {
|
||||
serverId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
nameSort?: string | null;
|
||||
albumCount?: number | null;
|
||||
syncedAt: number;
|
||||
rawJson: unknown;
|
||||
}
|
||||
|
||||
export interface LibrarySearchTotals {
|
||||
artists: number;
|
||||
albums: number;
|
||||
tracks: number;
|
||||
}
|
||||
|
||||
export interface LibraryAdvancedSearchResponse {
|
||||
artists: LibraryArtistDto[];
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
totals: LibrarySearchTotals;
|
||||
/** Registry field ids actually applied — UI chips / debug. */
|
||||
appliedFilters: string[];
|
||||
source: 'local' | 'network' | 'mixed';
|
||||
}
|
||||
|
||||
export interface LibraryCrossServerSearchResponse {
|
||||
hits: LibraryTrackDto[];
|
||||
/** Fuzzy `title LIKE` matches the exact FTS pass missed (§5.9 / H3). */
|
||||
fuzzy: LibraryTrackDto[];
|
||||
serversSearched: string[];
|
||||
}
|
||||
|
||||
function serverIndexKeyForId(serverId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
if (!server) return serverId;
|
||||
return serverIndexKeyFromUrl(server.url) || serverId;
|
||||
}
|
||||
|
||||
function mapServerIdFromIndexKey(serverId: string, fallback?: string): string {
|
||||
if (fallback) return fallback;
|
||||
return resolveServerIdForIndexKey(serverId);
|
||||
}
|
||||
|
||||
function mapTracksServerId(
|
||||
tracks: LibraryTrackDto[],
|
||||
fallbackServerId?: string,
|
||||
): LibraryTrackDto[] {
|
||||
if (tracks.length === 0) return tracks;
|
||||
return tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId, fallbackServerId),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Read commands (PR-5a) ─────────────────────────────────────────────
|
||||
|
||||
export function libraryGetStatus(
|
||||
serverId: string,
|
||||
libraryScope?: string,
|
||||
): Promise<SyncStateDto> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<SyncStateDto>('library_get_status', { serverId: indexKey, libraryScope })
|
||||
.then(status => ({ ...status, serverId }));
|
||||
}
|
||||
|
||||
export function librarySearch(
|
||||
serverId: string,
|
||||
query: string,
|
||||
options?: { limit?: number; offset?: number; libraryScope?: string },
|
||||
): Promise<LibraryTracksEnvelope> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTracksEnvelope>('library_search', {
|
||||
serverId: indexKey,
|
||||
query,
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
libraryScope: options?.libraryScope,
|
||||
}).then(envelope => ({
|
||||
...envelope,
|
||||
tracks: mapTracksServerId(envelope.tracks, serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Search against the local index (§5.13). The frontend fallback
|
||||
* (PR-7 F2) decides local vs network and maps the same `LibraryFilterClause`
|
||||
* shape onto the network path; this wrapper only talks to the local builder.
|
||||
*/
|
||||
export function libraryAdvancedSearch(
|
||||
request: LibraryAdvancedSearchRequest,
|
||||
): Promise<LibraryAdvancedSearchResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryAdvancedSearchResponse>('library_advanced_search', {
|
||||
request: { ...request, serverId: indexKey },
|
||||
}).then(response => ({
|
||||
...response,
|
||||
artists: response.artists.map(artist => ({
|
||||
...artist,
|
||||
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
|
||||
})),
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryLiveSearchResponse {
|
||||
artists: LibraryArtistDto[];
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
source: 'local' | 'network' | 'mixed';
|
||||
}
|
||||
|
||||
/** Live Search dropdown — one lean FTS query (§5.9), not Advanced Search. */
|
||||
export interface LibraryLiveSearchRequest {
|
||||
serverId: string;
|
||||
query: string;
|
||||
/** Subsonic `musicFolderId` / Navidrome library id — omit for all libraries. */
|
||||
libraryScope?: string | null;
|
||||
artistLimit?: number;
|
||||
albumLimit?: number;
|
||||
songLimit?: number;
|
||||
/** UI generation — stale Rust FTS passes are dropped server-side. */
|
||||
requestEpoch?: number;
|
||||
}
|
||||
|
||||
export function libraryLiveSearch(request: LibraryLiveSearchRequest): Promise<LibraryLiveSearchResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryLiveSearchResponse>('library_live_search', {
|
||||
request: { ...request, serverId: indexKey },
|
||||
}).then(response => ({
|
||||
...response,
|
||||
artists: response.artists.map(artist => ({
|
||||
...artist,
|
||||
serverId: mapServerIdFromIndexKey(artist.serverId, request.serverId),
|
||||
})),
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsRequest {
|
||||
serverId: string;
|
||||
libraryScope?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface LibraryLosslessAlbumsResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Paginated lossless albums from the local track index. */
|
||||
export function libraryListLosslessAlbums(
|
||||
request: LibraryLosslessAlbumsRequest,
|
||||
): Promise<LibraryLosslessAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryLosslessAlbumsResponse>('library_list_lossless_albums', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseRequest {
|
||||
serverId: string;
|
||||
artistId: string;
|
||||
libraryScope?: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryArtistLosslessBrowseResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
tracks: LibraryTrackDto[];
|
||||
source: 'local';
|
||||
}
|
||||
|
||||
/** Lossless albums + tracks for one artist from the local index. */
|
||||
export function libraryGetArtistLosslessBrowse(
|
||||
request: LibraryArtistLosslessBrowseRequest,
|
||||
): Promise<LibraryArtistLosslessBrowseResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryArtistLosslessBrowseResponse>('library_get_artist_lossless_browse', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
artistId: request.artistId,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
tracks: mapTracksServerId(response.tracks, request.serverId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cross-server FTS union over the given servers, or all `ready` ones (§5.5B). */
|
||||
export function librarySearchCrossServer(args: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
servers?: string[];
|
||||
}): Promise<LibraryCrossServerSearchResponse> {
|
||||
const indexServers = args.servers?.map(serverIndexKeyForId);
|
||||
return invoke<LibraryCrossServerSearchResponse>('library_search_cross_server', {
|
||||
...args,
|
||||
servers: indexServers,
|
||||
}).then(response => ({
|
||||
...response,
|
||||
hits: mapTracksServerId(response.hits),
|
||||
fuzzy: mapTracksServerId(response.fuzzy),
|
||||
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryGetTrack(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
): Promise<LibraryTrackDto | null> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTrackDto | null>('library_get_track', { serverId: indexKey, trackId })
|
||||
.then(track => (track ? { ...track, serverId } : track));
|
||||
}
|
||||
|
||||
/** Seed library index rows from live Subsonic song payloads (pin/download cold miss). */
|
||||
export function libraryUpsertSongsFromApi(
|
||||
serverId: string,
|
||||
songs: unknown[],
|
||||
): Promise<number> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<number>('library_upsert_songs_from_api', { serverId: indexKey, songs });
|
||||
}
|
||||
|
||||
/** `library_get_tracks_batch` cap (spec §7.1). */
|
||||
export const LIBRARY_TRACKS_BATCH_LIMIT = 100;
|
||||
|
||||
export function libraryGetTracksBatch(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
const indexKeyMap = new Map<string, string>();
|
||||
const remapped = refs.map(ref => {
|
||||
const indexKey = serverIndexKeyForId(ref.serverId);
|
||||
if (!indexKeyMap.has(indexKey)) indexKeyMap.set(indexKey, ref.serverId);
|
||||
return { ...ref, serverId: indexKey };
|
||||
});
|
||||
return invoke<LibraryTrackDto[]>('library_get_tracks_batch', { refs: remapped })
|
||||
.then(tracks => tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId, indexKeyMap.get(track.serverId)),
|
||||
})));
|
||||
}
|
||||
|
||||
/** Chunked batch fetch — safe when `refs.length` exceeds {@link LIBRARY_TRACKS_BATCH_LIMIT}. */
|
||||
export async function libraryGetTracksBatchChunked(refs: TrackRefDto[]): Promise<LibraryTrackDto[]> {
|
||||
if (refs.length === 0) return [];
|
||||
const out: LibraryTrackDto[] = [];
|
||||
for (let i = 0; i < refs.length; i += LIBRARY_TRACKS_BATCH_LIMIT) {
|
||||
const chunk = refs.slice(i, i + LIBRARY_TRACKS_BATCH_LIMIT);
|
||||
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
|
||||
out.push(...batch);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function libraryGetTracksByAlbum(
|
||||
serverId: string,
|
||||
albumId: string,
|
||||
): Promise<LibraryTrackDto[]> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryTrackDto[]>('library_get_tracks_by_album', { serverId: indexKey, albumId })
|
||||
.then(tracks => mapTracksServerId(tracks, serverId));
|
||||
}
|
||||
|
||||
export function libraryGetArtifact(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
artifactKind: string,
|
||||
options?: { sourceKind?: string; sourceId?: string; format?: string },
|
||||
): Promise<TrackArtifactDto | null> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<TrackArtifactDto | null>('library_get_artifact', {
|
||||
serverId: indexKey,
|
||||
trackId,
|
||||
artifactKind,
|
||||
sourceKind: options?.sourceKind,
|
||||
sourceId: options?.sourceId,
|
||||
format: options?.format,
|
||||
}).then(artifact => (artifact ? { ...artifact, serverId } : artifact));
|
||||
}
|
||||
|
||||
export function libraryGetFacts(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
factKinds?: string[],
|
||||
): Promise<TrackFactDto[]> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<TrackFactDto[]>('library_get_facts', { serverId: indexKey, trackId, factKinds })
|
||||
.then(facts => facts.map(fact => ({ ...fact, serverId })));
|
||||
}
|
||||
|
||||
export function libraryGetOfflinePath(
|
||||
serverId: string,
|
||||
trackId: string,
|
||||
): Promise<OfflinePathDto> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<OfflinePathDto>('library_get_offline_path', { serverId: indexKey, trackId })
|
||||
.then(path => ({ ...path, serverId }));
|
||||
}
|
||||
|
||||
// ── Session + lifecycle (PR-5b) ───────────────────────────────────────
|
||||
|
||||
export function librarySyncBindSession(args: {
|
||||
serverId: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_sync_bind_session', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function librarySyncClearSession(serverId: string): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<void>('library_sync_clear_session', { serverId: indexKey });
|
||||
}
|
||||
|
||||
export type PlaybackHint = 'idle' | 'playing' | 'prefetch_active';
|
||||
|
||||
export function libraryGetPlaybackHint(): Promise<PlaybackHint> {
|
||||
return invoke<PlaybackHint>('library_get_playback_hint');
|
||||
}
|
||||
|
||||
export function librarySetPlaybackHint(hint: PlaybackHint): Promise<void> {
|
||||
return invoke<void>('library_set_playback_hint', { hint });
|
||||
}
|
||||
|
||||
export type SyncMode = 'full' | 'delta';
|
||||
|
||||
export function librarySyncStart(args: {
|
||||
serverId: string;
|
||||
mode: SyncMode;
|
||||
libraryScope?: string;
|
||||
}): Promise<SyncJobDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<SyncJobDto>('library_sync_start', { ...args, serverId: indexKey })
|
||||
.then(job => ({ ...job, serverId: args.serverId }));
|
||||
}
|
||||
|
||||
/** Forced full-budget tombstone delta — Settings → «Verify integrity». */
|
||||
export function librarySyncVerifyIntegrity(args: {
|
||||
serverId: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<SyncJobDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<SyncJobDto>('library_sync_verify_integrity', { ...args, serverId: indexKey })
|
||||
.then(job => ({ ...job, serverId: args.serverId }));
|
||||
}
|
||||
|
||||
export function librarySyncCancel(jobId?: string): Promise<void> {
|
||||
return invoke<void>('library_sync_cancel', { jobId });
|
||||
}
|
||||
|
||||
export function libraryPatchTrack(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
patch: {
|
||||
starredAt?: number | null;
|
||||
userRating?: number | null;
|
||||
playCount?: number | null;
|
||||
playedAt?: number | null;
|
||||
/** E2: playback-derived `md5_16kb` content fingerprint. Normally written
|
||||
* by the Rust analysis bridge; exposed here for contract completeness. */
|
||||
contentHash?: string | null;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_patch_track', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
/** Server favorites → `album.starred_at` (UPDATE only, no stub rows). */
|
||||
export function libraryReconcileAlbumStars(args: {
|
||||
serverId: string;
|
||||
starredAlbums: Array<{ id: string; starredAt: number }>;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_reconcile_album_stars', {
|
||||
serverId: indexKey,
|
||||
starredAlbums: args.starredAlbums.map(a => ({ id: a.id, starredAt: a.starredAt })),
|
||||
});
|
||||
}
|
||||
|
||||
export function libraryPutArtifact(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
artifact: ArtifactInputDto;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_put_artifact', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryPutFact(args: {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
fact: FactInputDto;
|
||||
}): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<void>('library_put_fact', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryPurgeServer(args: {
|
||||
serverId: string;
|
||||
includeAnalysis?: boolean;
|
||||
includeOffline?: boolean;
|
||||
}): Promise<PurgeReportDto> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<PurgeReportDto>('library_purge_server', { ...args, serverId: indexKey });
|
||||
}
|
||||
|
||||
export function libraryDeleteServerData(serverId: string): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<void>('library_delete_server_data', { serverId: indexKey });
|
||||
}
|
||||
|
||||
// ── Player stats (local listening history) ────────────────────────────
|
||||
|
||||
export type PlaySessionEndReason = 'ended' | 'skip' | 'stop' | 'switch' | 'close';
|
||||
|
||||
export type PlaySessionInput = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
positionMaxSec: number;
|
||||
endReason: PlaySessionEndReason;
|
||||
/** Player-known track duration when the library index has none. */
|
||||
durationSecHint?: number;
|
||||
};
|
||||
|
||||
export type PlaySessionYearSummary = {
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
uniqueTrackCount: number;
|
||||
listeningDayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
|
||||
export type PlaySessionHeatmapDay = {
|
||||
date: string;
|
||||
trackPlayCount: number;
|
||||
};
|
||||
|
||||
export type PlaySessionDayTrack = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
startedAtMs: number;
|
||||
};
|
||||
|
||||
export type PlaySessionDayDetail = {
|
||||
totals: {
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
tracks: PlaySessionDayTrack[];
|
||||
};
|
||||
|
||||
export type PlaySessionYearBounds = {
|
||||
minYear: number | null;
|
||||
maxYear: number | null;
|
||||
};
|
||||
|
||||
export type CatalogYearBounds = {
|
||||
minYear: number | null;
|
||||
maxYear: number | null;
|
||||
};
|
||||
|
||||
export type GenreAlbumCountRow = {
|
||||
value: string;
|
||||
albumCount: number;
|
||||
songCount: number;
|
||||
};
|
||||
|
||||
export function libraryGetCatalogYearBounds(args: { serverId: string }): Promise<CatalogYearBounds> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<CatalogYearBounds>('library_get_catalog_year_bounds', {
|
||||
serverId: indexKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function libraryGetGenreAlbumCounts(args: {
|
||||
serverId: string;
|
||||
libraryScope?: string;
|
||||
}): Promise<GenreAlbumCountRow[]> {
|
||||
const indexKey = serverIndexKeyForId(args.serverId);
|
||||
return invoke<GenreAlbumCountRow[]>('library_get_genre_album_counts', {
|
||||
serverId: indexKey,
|
||||
libraryScope: args.libraryScope,
|
||||
});
|
||||
}
|
||||
|
||||
export type LibraryGenreAlbumsRequest = {
|
||||
serverId: string;
|
||||
genre: string;
|
||||
libraryScope?: string | null;
|
||||
sort?: LibrarySortClause[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeTotal?: boolean;
|
||||
};
|
||||
|
||||
export type LibraryGenreAlbumsResponse = {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
total?: number | null;
|
||||
source: 'local';
|
||||
};
|
||||
|
||||
/** Paginated albums for one genre from the local track index. */
|
||||
export function libraryListAlbumsByGenre(
|
||||
request: LibraryGenreAlbumsRequest,
|
||||
): Promise<LibraryGenreAlbumsResponse> {
|
||||
const indexKey = serverIndexKeyForId(request.serverId);
|
||||
return invoke<LibraryGenreAlbumsResponse>('library_list_albums_by_genre', {
|
||||
request: {
|
||||
serverId: indexKey,
|
||||
genre: request.genre,
|
||||
libraryScope: request.libraryScope ?? undefined,
|
||||
sort: request.sort ?? [],
|
||||
limit: request.limit ?? 50,
|
||||
offset: request.offset ?? 0,
|
||||
includeTotal: request.includeTotal ?? false,
|
||||
},
|
||||
}).then(response => ({
|
||||
...response,
|
||||
albums: response.albums.map(album => ({
|
||||
...album,
|
||||
serverId: mapServerIdFromIndexKey(album.serverId, request.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export type PlaySessionRecentDay = {
|
||||
date: string;
|
||||
totalListenedSec: number;
|
||||
sessionCount: number;
|
||||
trackPlayCount: number;
|
||||
fullCount: number;
|
||||
partialCount: number;
|
||||
};
|
||||
|
||||
export function libraryRecordPlaySession(input: PlaySessionInput): Promise<void> {
|
||||
const indexKey = serverIndexKeyForId(input.serverId);
|
||||
return invoke<void>('library_record_play_session', { input: { ...input, serverId: indexKey } });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
|
||||
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
|
||||
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
|
||||
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso })
|
||||
.then(detail => ({
|
||||
...detail,
|
||||
tracks: detail.tracks.map(track => ({
|
||||
...track,
|
||||
serverId: mapServerIdFromIndexKey(track.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
|
||||
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
|
||||
}
|
||||
|
||||
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
|
||||
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
|
||||
}
|
||||
|
||||
export type PlaySessionRecentTrack = {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
albumId: string | null;
|
||||
coverArtId: string | null;
|
||||
startedAtMs: number;
|
||||
listenedSec: number;
|
||||
completion: 'partial' | 'full';
|
||||
};
|
||||
|
||||
export function libraryGetRecentPlaySessions(args?: {
|
||||
limit?: number;
|
||||
sinceMs?: number;
|
||||
}): Promise<PlaySessionRecentTrack[]> {
|
||||
return invoke<PlaySessionRecentTrack[]>('library_get_recent_play_sessions', {
|
||||
limit: args?.limit,
|
||||
sinceMs: args?.sinceMs,
|
||||
}).then(rows =>
|
||||
rows.map(row => ({
|
||||
...row,
|
||||
serverId: mapServerIdFromIndexKey(row.serverId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event subscriptions ───────────────────────────────────────────────
|
||||
|
||||
export interface LibrarySyncProgressPayload {
|
||||
serverId: string;
|
||||
libraryScope: string;
|
||||
/** 'phase_changed' | 'ingest_page' | 'remapped' | 'tombstoned' | 'completed' | 'error' */
|
||||
kind: string;
|
||||
phase?: string | null;
|
||||
ingestedTotal?: number | null;
|
||||
batchCount?: number | null;
|
||||
remappedCount?: number | null;
|
||||
tombstonesChecked?: number | null;
|
||||
tombstonesDeleted?: number | null;
|
||||
completedKind?: string | null;
|
||||
message?: string | null;
|
||||
/** S1 per-batch timings from the Rust ingest runner (when available). */
|
||||
ingestMetrics?: IngestBatchMetrics | null;
|
||||
}
|
||||
|
||||
export interface IngestBatchMetrics {
|
||||
offset: number;
|
||||
strategy: string;
|
||||
fetchMs: number;
|
||||
writeMs: number;
|
||||
lockWaitMs: number;
|
||||
sqlExecMs: number;
|
||||
persistMs: number;
|
||||
rowCount: number;
|
||||
bulkIngestActive: boolean;
|
||||
}
|
||||
|
||||
export interface LibrarySyncIdlePayload {
|
||||
serverId: string;
|
||||
libraryScope: string;
|
||||
kind: string; // 'initial_sync' | 'delta_sync'
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function subscribeLibrarySyncProgress(
|
||||
handler: (payload: LibrarySyncProgressPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<LibrarySyncProgressPayload>('library:sync-progress', ({ payload }) =>
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeLibrarySyncIdle(
|
||||
handler: (payload: LibrarySyncIdlePayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) =>
|
||||
handler(payload),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Genre tags startup backfill (multi-genre local index) ───────────────
|
||||
|
||||
export interface GenreTagsInspectDto {
|
||||
needed: boolean;
|
||||
totalTracks: number;
|
||||
doneTracks: number;
|
||||
}
|
||||
|
||||
export interface GenreTagsProgressEvent {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function libraryGenreTagsInspect(): Promise<GenreTagsInspectDto> {
|
||||
return invoke<GenreTagsInspectDto>('library_genre_tags_inspect');
|
||||
}
|
||||
|
||||
export function libraryGenreTagsRun(): Promise<void> {
|
||||
return invoke<void>('library_genre_tags_run');
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface NdLibrary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NdUser {
|
||||
id: string;
|
||||
userName: string;
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
libraryIds: number[];
|
||||
lastLoginAt?: string | null;
|
||||
lastAccessAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface NdLoginResult {
|
||||
token: string;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export async function ndLogin(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<NdLoginResult> {
|
||||
return invoke<NdLoginResult>('navidrome_login', { serverUrl, username, password });
|
||||
}
|
||||
|
||||
function extractLibraryIds(o: Record<string, unknown>): number[] {
|
||||
const libs = o.libraries;
|
||||
if (!Array.isArray(libs)) return [];
|
||||
const ids: number[] = [];
|
||||
for (const l of libs) {
|
||||
const id = (l as Record<string, unknown>)?.id;
|
||||
if (typeof id === 'number') ids.push(id);
|
||||
else if (typeof id === 'string' && /^\d+$/.test(id)) ids.push(Number(id));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function ndListUsers(serverUrl: string, token: string): Promise<NdUser[]> {
|
||||
const raw = await invoke<unknown>('nd_list_users', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(u => {
|
||||
const o = u as Record<string, unknown>;
|
||||
return {
|
||||
id: String(o.id ?? ''),
|
||||
userName: String(o.userName ?? ''),
|
||||
name: String(o.name ?? ''),
|
||||
email: String(o.email ?? ''),
|
||||
isAdmin: !!o.isAdmin,
|
||||
libraryIds: extractLibraryIds(o),
|
||||
lastLoginAt: (o.lastLoginAt as string | null | undefined) ?? null,
|
||||
lastAccessAt: (o.lastAccessAt as string | null | undefined) ?? null,
|
||||
createdAt: o.createdAt as string | undefined,
|
||||
updatedAt: o.updatedAt as string | undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function ndListLibraries(serverUrl: string, token: string): Promise<NdLibrary[]> {
|
||||
const raw = await invoke<unknown>('nd_list_libraries', { serverUrl, token });
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(l => {
|
||||
const o = l as Record<string, unknown>;
|
||||
const id = typeof o.id === 'number'
|
||||
? o.id
|
||||
: typeof o.id === 'string' && /^\d+$/.test(o.id) ? Number(o.id) : 0;
|
||||
return { id, name: String(o.name ?? '') };
|
||||
}).filter(l => l.id > 0);
|
||||
}
|
||||
|
||||
export async function ndSetUserLibraries(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
libraryIds: number[],
|
||||
): Promise<void> {
|
||||
await invoke('nd_set_user_libraries', { serverUrl, token, id, libraryIds });
|
||||
}
|
||||
|
||||
export async function ndCreateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<{ id: string }> {
|
||||
const raw = await invoke<unknown>('nd_create_user', { serverUrl, token, ...data });
|
||||
const o = (raw as Record<string, unknown> | null) ?? {};
|
||||
return { id: String(o.id ?? '') };
|
||||
}
|
||||
|
||||
export async function ndUpdateUser(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
id: string,
|
||||
data: { userName: string; name: string; email: string; password: string; isAdmin: boolean },
|
||||
): Promise<void> {
|
||||
await invoke('nd_update_user', { serverUrl, token, id, ...data });
|
||||
}
|
||||
|
||||
export async function ndDeleteUser(serverUrl: string, token: string, id: string): Promise<void> {
|
||||
await invoke('nd_delete_user', { serverUrl, token, id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the absolute filesystem path of a song from Navidrome's native API.
|
||||
* Subsonic `getSong.view` only returns a relative path (or none on Navidrome);
|
||||
* the native `/api/song/:id` endpoint returns the absolute path the server
|
||||
* stores the file at, the same way Feishin and the Navidrome web client get it.
|
||||
*
|
||||
* Returns `null` when the server doesn't expose the field (non-admin on some
|
||||
* Navidrome configurations) or when the call fails — the Song Info modal then
|
||||
* falls back to whatever the Subsonic response had.
|
||||
*/
|
||||
export async function ndGetSongPath(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const raw = await invoke<string | null>('nd_get_song_path', { serverUrl, username, password, id });
|
||||
return raw && raw.length > 0 ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
import { parseItemGenres } from '../utils/library/genreTags';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
/** Server-keyed Bearer token cache. Cheap to keep — Navidrome tokens are long-lived. */
|
||||
let cachedToken: { serverUrl: string; token: string } | null = null;
|
||||
|
||||
async function getToken(force = false): Promise<string> {
|
||||
const { getActiveServer, getBaseUrl } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!server || !baseUrl) throw new Error('No active server configured');
|
||||
if (!force && cachedToken?.serverUrl === baseUrl) return cachedToken.token;
|
||||
const result = await ndLogin(baseUrl, server.username, server.password);
|
||||
cachedToken = { serverUrl: baseUrl, token: result.token };
|
||||
return result.token;
|
||||
}
|
||||
|
||||
function asString(v: unknown, fallback = ''): string {
|
||||
return typeof v === 'string' ? v : (typeof v === 'number' ? String(v) : fallback);
|
||||
}
|
||||
|
||||
/** Active library scope for the current server, or null when "all libraries" is selected.
|
||||
* Mirrors the Subsonic `musicFolderId` we pipe through `libraryFilterParams()` — Navidrome
|
||||
* uses the same id space, so the same value is valid for the native API's `library_id` filter. */
|
||||
function currentLibraryId(): string | null {
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
if (!activeServerId) return null;
|
||||
const f = musicLibraryFilterByServer[activeServerId];
|
||||
return !f || f === 'all' ? null : f;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
// Navidrome's REST shape differs from Subsonic — flatten into the SubsonicSong contract.
|
||||
const id = asString(o.id ?? o.mediaFileId);
|
||||
const albumId = asString(o.albumId);
|
||||
return {
|
||||
id,
|
||||
title: asString(o.title),
|
||||
artist: asString(o.artist),
|
||||
album: asString(o.album),
|
||||
albumId,
|
||||
artistId: asString(o.artistId) || undefined,
|
||||
duration: asNumber(o.duration) !== undefined ? Math.round(asNumber(o.duration)!) : 0,
|
||||
track: asNumber(o.trackNumber),
|
||||
discNumber: asNumber(o.discNumber),
|
||||
// Navidrome usually exposes coverArtId; many builds also accept the song id directly.
|
||||
coverArt: asString(o.coverArtId) || albumId || id || undefined,
|
||||
year: asNumber(o.year),
|
||||
userRating: asNumber(o.rating),
|
||||
starred: o.starred ? asString(o.starredAt) || 'true' : undefined,
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
bitRate: asNumber(o.bitRate),
|
||||
suffix: typeof o.suffix === 'string' ? o.suffix : undefined,
|
||||
contentType: typeof o.contentType === 'string' ? o.contentType : undefined,
|
||||
size: asNumber(o.size),
|
||||
samplingRate: asNumber(o.sampleRate),
|
||||
bitDepth: asNumber(o.bitDepth),
|
||||
};
|
||||
}
|
||||
|
||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating' | 'sample_rate' | 'bit_depth';
|
||||
|
||||
/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */
|
||||
type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number };
|
||||
const songsCache = new Map<string, SongsCacheEntry>();
|
||||
|
||||
function songsCacheKey(
|
||||
baseUrl: string, start: number, end: number, sort: string, order: string,
|
||||
): string {
|
||||
return `${baseUrl}|${start}-${end}|${sort}|${order}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a sorted, paginated slice of all songs via Navidrome's native REST API.
|
||||
* Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome.
|
||||
*
|
||||
* `cacheMs` (> 0) opts in to a per-call-signature in-memory cache. Skip for
|
||||
* paginated browsing — only useful for stable-list rails (e.g. Highly Rated)
|
||||
* where a brief staleness window is acceptable in exchange for skipping the
|
||||
* roundtrip on every page revisit.
|
||||
*/
|
||||
export async function ndListSongs(
|
||||
start: number,
|
||||
end: number,
|
||||
sort: NdSongSort = 'title',
|
||||
order: 'ASC' | 'DESC' = 'ASC',
|
||||
cacheMs?: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const cacheKey = (cacheMs && cacheMs > 0)
|
||||
? songsCacheKey(baseUrl, start, end, sort, order)
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const hit = songsCache.get(cacheKey);
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.data;
|
||||
}
|
||||
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
|
||||
|
||||
let token = await getToken();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await callOnce(token);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
// Token rejected → re-auth once and retry
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
raw = await callOnce(token);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const data = raw.map(s => mapNdSong(s as Record<string, unknown>));
|
||||
|
||||
if (cacheKey && cacheMs && cacheMs > 0) {
|
||||
songsCache.set(cacheKey, { data, expiresAt: Date.now() + cacheMs });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function mapNdArtist(o: Record<string, unknown>, role?: string): SubsonicArtist {
|
||||
// Top-level `albumCount` aggregates every role the person holds. The
|
||||
// role-scoped count lives in `stats[role].albumCount` (verified empirically
|
||||
// 2026-05-06 — Navidrome exposes it as `albumCount`/`songCount`/`size`,
|
||||
// not the abbreviated `a`/`s`/… some refactor docs claim).
|
||||
const starredFlag = !!o.starred;
|
||||
const starredAt = typeof o.starredAt === 'string' ? o.starredAt : undefined;
|
||||
let albumCount: number | undefined;
|
||||
if (role && o.stats && typeof o.stats === 'object') {
|
||||
const roleStats = (o.stats as Record<string, unknown>)[role];
|
||||
if (roleStats && typeof roleStats === 'object') {
|
||||
albumCount = asNumber((roleStats as Record<string, unknown>).albumCount);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: asString(o.id),
|
||||
name: asString(o.name),
|
||||
albumCount,
|
||||
starred: starredFlag ? (starredAt ?? 'true') : undefined,
|
||||
userRating: asNumber(o.rating),
|
||||
};
|
||||
}
|
||||
|
||||
function mapNdAlbum(o: Record<string, unknown>): SubsonicAlbum {
|
||||
const id = asString(o.id);
|
||||
const starredFlag = !!o.starred;
|
||||
const starredAt = typeof o.starredAt === 'string' ? o.starredAt : undefined;
|
||||
return {
|
||||
id,
|
||||
name: asString(o.name),
|
||||
artist: asString(o.albumArtist) || asString(o.artist),
|
||||
artistId: asString(o.albumArtistId) || asString(o.artistId),
|
||||
coverArt: asString(o.coverArtId) || asString(o.embedArtPath) || id || undefined,
|
||||
songCount: asNumber(o.songCount) ?? 0,
|
||||
duration: asNumber(o.duration) ?? 0,
|
||||
year: asNumber(o.maxYear) ?? asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
starred: starredFlag ? (starredAt ?? 'true') : undefined,
|
||||
userRating: asNumber(o.rating),
|
||||
isCompilation: o.compilation === true,
|
||||
};
|
||||
}
|
||||
|
||||
export type NdArtistRole = 'composer' | 'conductor' | 'lyricist' | 'arranger'
|
||||
| 'producer' | 'director' | 'engineer' | 'mixer' | 'remixer' | 'djmixer'
|
||||
| 'performer' | 'maincredit' | 'artist' | 'albumartist';
|
||||
|
||||
export type NdArtistSort = 'name' | 'album_count' | 'song_count' | 'size';
|
||||
|
||||
/**
|
||||
* Paginated list of artists holding the given participant role on at least one
|
||||
* track — the canonical Navidrome path for "Browse by Composer/Conductor/etc."
|
||||
* Requires Navidrome 0.55.0+ (uses `library_artist.stats`). Throws on auth or
|
||||
* unsupported-server errors; caller should treat that as a capability miss.
|
||||
*/
|
||||
export async function ndListArtistsByRole(
|
||||
role: NdArtistRole,
|
||||
start: number,
|
||||
end: number,
|
||||
sort: NdArtistSort = 'name',
|
||||
order: 'ASC' | 'DESC' = 'ASC',
|
||||
): Promise<SubsonicArtist[]> {
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const libraryId = currentLibraryId();
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_artists_by_role', {
|
||||
serverUrl: baseUrl, token, role, sort, order, start, end, libraryId,
|
||||
});
|
||||
|
||||
let token = await getToken();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await callOnce(token);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
raw = await callOnce(token);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(a => mapNdArtist(a as Record<string, unknown>, role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated list of albums in which `artistId` holds the given participant role.
|
||||
* Subsonic `getArtist.view` only walks AlbumArtist relations, so composer-only
|
||||
* (or conductor-only, …) credits are unreachable through it. Navidrome's native
|
||||
* filter `role_<role>_id` covers every role from `model.AllRoles`.
|
||||
*/
|
||||
export async function ndListAlbumsByArtistRole(
|
||||
artistId: string,
|
||||
role: NdArtistRole,
|
||||
start: number,
|
||||
end: number,
|
||||
sort: 'name' | 'max_year' | 'recently_added' | 'play_count' = 'name',
|
||||
order: 'ASC' | 'DESC' = 'ASC',
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const libraryId = currentLibraryId();
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_albums_by_artist_role', {
|
||||
serverUrl: baseUrl, token, artistId, role, sort, order, start, end, libraryId,
|
||||
});
|
||||
|
||||
let token = await getToken();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await callOnce(token);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
raw = await callOnce(token);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map(a => mapNdAlbum(a as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export interface NdLosslessAlbumEntry {
|
||||
album: SubsonicAlbum;
|
||||
sampleRate: number;
|
||||
bitDepth: number;
|
||||
}
|
||||
|
||||
export interface NdLosslessPageRequest {
|
||||
/** Resume the song-cursor from a previous call. Default 0 (start fresh). */
|
||||
startSongOffset?: number;
|
||||
songsPerPage?: number;
|
||||
maxPagesPerCall?: number;
|
||||
/** Stop once this many *new* unique album ids are collected this call. */
|
||||
targetNewAlbums: number;
|
||||
/** Mutated as the call walks; keep one Set across calls so repeated invocations
|
||||
* return only albums you haven't seen yet. */
|
||||
seenAlbumIds?: Set<string>;
|
||||
/** Fires once per internal fetch with the entries discovered in that fetch.
|
||||
* Lets a paginated UI render albums progressively while the rest of the
|
||||
* call is still running — the song endpoint returns ~1 MB per 200-song
|
||||
* fetch, so a single `loadMore` that internally pages 5× otherwise stalls
|
||||
* the spinner for several seconds before any album appears. */
|
||||
onProgress?: (entries: NdLosslessAlbumEntry[]) => void;
|
||||
}
|
||||
|
||||
export interface NdLosslessPage {
|
||||
entries: NdLosslessAlbumEntry[];
|
||||
/** True when the song stream entered lossy territory or the server ran
|
||||
* out of rows — caller should stop paginating. */
|
||||
done: boolean;
|
||||
/** Pass back as `startSongOffset` on the next call to continue the walk. */
|
||||
nextSongOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of lossless albums. Walks the native API's `_sort=bit_depth`
|
||||
* song stream (descending) so all 24/32-bit tracks come first, then 16-bit,
|
||||
* then lossy formats which report `bit_depth: 0`. Dedupes to unique album
|
||||
* ids on the way down and stops as soon as the stream crosses into lossy
|
||||
* territory. `_filters` has no operators usable on quality columns so a
|
||||
* sort + walk is the only path.
|
||||
*
|
||||
* Pages through the song stream internally up to `maxPagesPerCall` so albums
|
||||
* with many tracks (compilations, big lossless box sets) don't soak up a
|
||||
* single fetch window and starve the rest. Stops the internal pagination
|
||||
* once `targetNewAlbums` unique ids are collected this call, the song stream
|
||||
* crosses into lossy, the server returns a short page, or the per-call cap
|
||||
* is hit.
|
||||
*
|
||||
* Stateful pagination (the dedicated Lossless page) reuses the returned
|
||||
* `nextSongOffset` and a long-lived `seenAlbumIds` Set on subsequent calls.
|
||||
* Single-shot pagination (the Home rail) just calls once and ignores the
|
||||
* resume hooks. Returns empty page on Subsonic-only servers — caller treats
|
||||
* that as a silent capability miss.
|
||||
*/
|
||||
/** File-extension allowlist of containers that are *only* lossless. Skips
|
||||
* ambiguous wrappers (m4a/m4b — could be ALAC or AAC, codec field is often
|
||||
* empty in Navidrome responses; wma — could be WMA Lossless or WMA Standard)
|
||||
* because they require a codec check we can't reliably perform. */
|
||||
const LOSSLESS_SUFFIXES = new Set(['flac', 'wav', 'wave', 'aiff', 'aif', 'dsf', 'dff', 'ape', 'wv', 'shn', 'tta']);
|
||||
|
||||
export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Promise<NdLosslessPage> {
|
||||
const PAGE_SIZE = req.songsPerPage ?? 200;
|
||||
const MAX_PAGES = req.maxPagesPerCall ?? 5;
|
||||
const targetAlbums = req.targetNewAlbums;
|
||||
const seen = req.seenAlbumIds ?? new Set<string>();
|
||||
let songOffset = req.startSongOffset ?? 0;
|
||||
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const fetchPage = async (start: number, end: number): Promise<unknown[]> => {
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_songs', {
|
||||
serverUrl: baseUrl,
|
||||
token,
|
||||
sort: 'bit_depth',
|
||||
order: 'DESC',
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
let token = await getToken();
|
||||
try {
|
||||
const raw = await callOnce(token);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
const raw = await callOnce(token);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const entries: NdLosslessAlbumEntry[] = [];
|
||||
let done = false;
|
||||
|
||||
for (let p = 0; p < MAX_PAGES; p++) {
|
||||
const songs = await fetchPage(songOffset, songOffset + PAGE_SIZE);
|
||||
if (songs.length === 0) { done = true; break; }
|
||||
|
||||
let belowThreshold = false;
|
||||
const pageEntries: NdLosslessAlbumEntry[] = [];
|
||||
for (const item of songs) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const o = item as Record<string, unknown>;
|
||||
const bitDepth = asNumber(o.bitDepth) ?? 0;
|
||||
if (bitDepth <= 0) { belowThreshold = true; break; }
|
||||
const suffix = (typeof o.suffix === 'string' ? o.suffix : '').toLowerCase();
|
||||
if (!LOSSLESS_SUFFIXES.has(suffix)) continue;
|
||||
const albumId = asString(o.albumId);
|
||||
if (!albumId || seen.has(albumId)) continue;
|
||||
seen.add(albumId);
|
||||
|
||||
const album: SubsonicAlbum = {
|
||||
id: albumId,
|
||||
name: asString(o.album),
|
||||
artist: asString(o.albumArtist) || asString(o.artist),
|
||||
artistId: asString(o.albumArtistId) || asString(o.artistId),
|
||||
coverArt: asString(o.coverArtId) || albumId,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
year: asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
genres: parseItemGenres(o.genres),
|
||||
};
|
||||
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
|
||||
}
|
||||
|
||||
if (pageEntries.length > 0) {
|
||||
entries.push(...pageEntries);
|
||||
req.onProgress?.(pageEntries);
|
||||
}
|
||||
|
||||
songOffset += songs.length;
|
||||
if (belowThreshold) { done = true; break; }
|
||||
if (songs.length < PAGE_SIZE) { done = true; break; }
|
||||
if (entries.length >= targetAlbums) break;
|
||||
}
|
||||
|
||||
return { entries, done, nextSongOffset: songOffset };
|
||||
}
|
||||
|
||||
/** Drop the cached token AND the songs cache — call when the active server changes. */
|
||||
export function ndClearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
songsCache.clear();
|
||||
}
|
||||
|
||||
/** Drop the songs cache only (e.g. after a rating mutation). */
|
||||
export function ndInvalidateSongsCache(): void {
|
||||
songsCache.clear();
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { ndLogin } from './navidromeAdmin';
|
||||
|
||||
export type SmartRuleOperator =
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'contains'
|
||||
| 'notContains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'gt'
|
||||
| 'lt'
|
||||
| 'inTheRange';
|
||||
|
||||
export interface SmartRuleCondition {
|
||||
field: string;
|
||||
operator: SmartRuleOperator;
|
||||
value: string | number | boolean | [number, number];
|
||||
}
|
||||
|
||||
export interface NdSmartPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
duration?: number;
|
||||
rules?: Record<string, unknown>;
|
||||
sync?: boolean;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function parseNdSmartPlaylist(raw: unknown, fallback: Partial<NdSmartPlaylist> = {}): NdSmartPlaylist {
|
||||
const o = (raw as Record<string, unknown>) ?? {};
|
||||
return {
|
||||
id: String(o.id ?? fallback.id ?? ''),
|
||||
name: String(o.name ?? fallback.name ?? ''),
|
||||
songCount: Number(o.songCount ?? fallback.songCount ?? 0),
|
||||
duration: typeof o.duration === 'number' ? o.duration : fallback.duration,
|
||||
rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record<string, unknown>) : fallback.rules,
|
||||
sync: typeof o.sync === 'boolean' ? o.sync : fallback.sync,
|
||||
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : fallback.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let authCache: {
|
||||
key: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
async function getNavidromeAuth(): Promise<{ serverUrl: string; token: string }> {
|
||||
const s = useAuthStore.getState();
|
||||
const server = s.getActiveServer();
|
||||
const serverUrl = s.getBaseUrl();
|
||||
if (!serverUrl || !server?.username || !server?.password) {
|
||||
throw new Error('No active server credentials');
|
||||
}
|
||||
const key = `${serverUrl}|${server.username}|${server.password}`;
|
||||
if (authCache && authCache.key === key && Date.now() < authCache.expiresAt) {
|
||||
return { serverUrl, token: authCache.token };
|
||||
}
|
||||
const login = await ndLogin(serverUrl, server.username, server.password);
|
||||
authCache = {
|
||||
key,
|
||||
token: login.token,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return { serverUrl, token: login.token };
|
||||
}
|
||||
|
||||
function conditionToRule(c: SmartRuleCondition): Record<string, unknown> {
|
||||
return { [c.operator]: { [c.field]: c.value } };
|
||||
}
|
||||
|
||||
export function buildSmartRules(conditions: SmartRuleCondition[], opts?: { limit?: number; sort?: string }) {
|
||||
const all = conditions.map(conditionToRule);
|
||||
const rules: Record<string, unknown> = { all };
|
||||
if (typeof opts?.limit === 'number' && opts.limit > 0) rules.limit = opts.limit;
|
||||
if (opts?.sort) rules.sort = opts.sort;
|
||||
return rules;
|
||||
}
|
||||
|
||||
export async function ndListSmartPlaylists(): Promise<NdSmartPlaylist[]> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_list_playlists', { serverUrl, token, smart: true });
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items))
|
||||
? (raw as { items: unknown[] }).items
|
||||
: [];
|
||||
return list.map((v) => parseNdSmartPlaylist(v));
|
||||
}
|
||||
|
||||
export async function ndCreateSmartPlaylist(name: string, rules: Record<string, unknown>, sync = true): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_create_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndUpdateSmartPlaylist(
|
||||
id: string,
|
||||
name: string,
|
||||
rules: Record<string, unknown>,
|
||||
sync = true,
|
||||
): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_update_playlist', {
|
||||
serverUrl,
|
||||
token,
|
||||
id,
|
||||
body: { name, rules, sync },
|
||||
});
|
||||
return parseNdSmartPlaylist(raw, { id, name, rules, sync });
|
||||
}
|
||||
|
||||
export async function ndGetSmartPlaylist(id: string): Promise<NdSmartPlaylist> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
const raw = await invoke<unknown>('nd_get_playlist', { serverUrl, token, id });
|
||||
return parseNdSmartPlaylist(raw, { id });
|
||||
}
|
||||
|
||||
export async function ndDeletePlaylist(id: string): Promise<void> {
|
||||
const { serverUrl, token } = await getNavidromeAuth();
|
||||
await invoke('nd_delete_playlist', { serverUrl, token, id });
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
/**
|
||||
* Async-endpoint contract tests for `subsonic.ts` (F3 follow-up).
|
||||
*
|
||||
* Mocks `axios` at the module boundary and verifies the response
|
||||
* normalization paths the rest of the app depends on:
|
||||
* - subsonic-response unwrap + status=ok check
|
||||
* - error mapping (no envelope, status=failed, network error)
|
||||
* - song-array vs single-object normalization (Subsonic's XML→JSON
|
||||
* emits one-element collections as a bare object)
|
||||
* - empty / missing collection fallbacks → []
|
||||
* - explicit-credential variants (pingWithCredentials)
|
||||
*
|
||||
* Pure URL builders + auth params are pinned in
|
||||
* `subsonic.contract.test.ts`. This file complements that with the
|
||||
* networking surface the structural tests skipped.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: () => true,
|
||||
shouldAttemptSubsonicForServer: () => true,
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from '@/features/album';
|
||||
import { getStarred } from './subsonicStarRating';
|
||||
import { search } from './subsonicSearch';
|
||||
import { getAlbum, getMusicDirectory, getMusicFolders, getMusicIndexes, getRandomSongs, getSong } from './subsonicLibrary';
|
||||
import { getArtists, getTopSongs } from '@/features/artist';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
function setUpServer(): string {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Test', url: 'https://music.example.com', username: 'alice', password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
function okResponse(payload: Record<string, unknown>) {
|
||||
return {
|
||||
data: {
|
||||
'subsonic-response': { status: 'ok', ...payload },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(message = 'Subsonic error', code = 50) {
|
||||
return {
|
||||
data: {
|
||||
'subsonic-response': {
|
||||
status: 'failed',
|
||||
error: { code, message },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
setUpServer();
|
||||
vi.mocked(axios.get).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(axios.get).mockReset();
|
||||
});
|
||||
|
||||
describe('api() helper — response envelope', () => {
|
||||
it('unwraps the subsonic-response envelope on status=ok', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ randomSongs: { song: [] } }),
|
||||
);
|
||||
await expect(getRandomSongs()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('throws "Invalid response" when there is no subsonic-response envelope', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: { not: 'subsonic' } });
|
||||
await expect(getRandomSongs()).rejects.toThrow(/invalid response/i);
|
||||
});
|
||||
|
||||
it('throws the server error message when status=failed', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(errorResponse('Wrong username or password'));
|
||||
await expect(getRandomSongs()).rejects.toThrow(/wrong username/i);
|
||||
});
|
||||
|
||||
it('throws a generic message when status=failed without an error.message', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { 'subsonic-response': { status: 'failed', error: {} } },
|
||||
});
|
||||
await expect(getRandomSongs()).rejects.toThrow(/subsonic api error/i);
|
||||
});
|
||||
|
||||
it('propagates network failures (axios reject) to the caller', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await expect(getRandomSongs()).rejects.toThrow(/ECONNREFUSED/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRandomSongs — pass-through behaviour', () => {
|
||||
// Navidrome's randomSongs endpoint always returns the `song` field as
|
||||
// an array (`getRandomSongs` doesn't normalize single objects). The
|
||||
// tests below pin the array + empty-fallback paths; the single-object
|
||||
// path is intentionally untested because no production server returns it.
|
||||
|
||||
it('returns an empty array when randomSongs.song is missing entirely', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: {} }));
|
||||
const songs = await getRandomSongs();
|
||||
expect(songs).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a song-array through unchanged', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
randomSongs: {
|
||||
song: [
|
||||
{ id: 'a', title: 'A', artist: 'Ar', album: 'Al', albumId: 'al-1', duration: 100 },
|
||||
{ id: 'b', title: 'B', artist: 'Ar', album: 'Al', albumId: 'al-1', duration: 110 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const songs = await getRandomSongs();
|
||||
expect(songs).toHaveLength(2);
|
||||
expect(songs.map(s => s.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('forwards a custom size to the query params', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: { song: [] } }));
|
||||
await getRandomSongs(25);
|
||||
const params = vi.mocked(axios.get).mock.calls[0]?.[1]?.params as Record<string, unknown>;
|
||||
expect(params.size).toBe(25);
|
||||
});
|
||||
|
||||
it('forwards a genre filter when provided', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ randomSongs: { song: [] } }));
|
||||
await getRandomSongs(10, 'Rock');
|
||||
const params = vi.mocked(axios.get).mock.calls[0]?.[1]?.params as Record<string, unknown>;
|
||||
expect(params.genre).toBe('Rock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbum', () => {
|
||||
it('splits the response into { album, songs }', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
album: {
|
||||
id: 'al-1', name: 'Album', artist: 'Ar', artistId: 'ar-1', songCount: 2, duration: 200,
|
||||
song: [
|
||||
{ id: 's1', title: 'S1', artist: 'Ar', album: 'Album', albumId: 'al-1', duration: 100 },
|
||||
{ id: 's2', title: 'S2', artist: 'Ar', album: 'Album', albumId: 'al-1', duration: 100 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { album, songs } = await getAlbum('al-1');
|
||||
expect(album.id).toBe('al-1');
|
||||
expect(album.songCount).toBe(2);
|
||||
expect(songs).toHaveLength(2);
|
||||
// The destructure removes `song` from the album payload.
|
||||
expect((album as unknown as Record<string, unknown>).song).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns an empty songs array when the album has no songs', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
album: { id: 'al-empty', name: 'Empty', artist: 'Ar', artistId: 'ar-1', songCount: 0, duration: 0 },
|
||||
}),
|
||||
);
|
||||
const { songs } = await getAlbum('al-empty');
|
||||
expect(songs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMusicDirectory + getMusicIndexes + getMusicFolders', () => {
|
||||
it('getMusicDirectory normalizes child to an array', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
directory: {
|
||||
id: 'd1', name: 'Music',
|
||||
// Single child as object (not array).
|
||||
child: { id: 'c1', title: 'Sub', isDir: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const dir = await getMusicDirectory('d1');
|
||||
expect(dir.id).toBe('d1');
|
||||
expect(dir.child).toHaveLength(1);
|
||||
expect(dir.child[0]?.id).toBe('c1');
|
||||
});
|
||||
|
||||
it('getMusicDirectory returns empty child[] when the field is absent', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ directory: { id: 'd2', name: 'Empty' } }),
|
||||
);
|
||||
const dir = await getMusicDirectory('d2');
|
||||
expect(dir.child).toEqual([]);
|
||||
});
|
||||
|
||||
it('getMusicIndexes flattens nested artist arrays into directory entries', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
indexes: {
|
||||
index: [
|
||||
{ name: 'A', artist: [{ id: 'ar-a1', name: 'A1' }, { id: 'ar-a2', name: 'A2' }] },
|
||||
{ name: 'B', artist: { id: 'ar-b1', name: 'B1' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const entries = await getMusicIndexes('1');
|
||||
expect(entries.map(e => e.id)).toEqual(['ar-a1', 'ar-a2', 'ar-b1']);
|
||||
});
|
||||
|
||||
it('getMusicIndexes handles a missing index field as empty', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ indexes: {} }));
|
||||
expect(await getMusicIndexes('1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('getMusicFolders coerces numeric ids to strings + defaults the name', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
musicFolders: {
|
||||
musicFolder: [
|
||||
{ id: 1, name: 'Music' },
|
||||
{ id: 2 }, // missing name → default
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const folders = await getMusicFolders();
|
||||
expect(folders).toEqual([
|
||||
{ id: '1', name: 'Music' },
|
||||
{ id: '2', name: 'Library' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSong', () => {
|
||||
it('returns the song payload', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ song: { id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 } }),
|
||||
);
|
||||
const song = await getSong('s1');
|
||||
expect(song?.id).toBe('s1');
|
||||
});
|
||||
|
||||
it('returns null on any failure (network or subsonic error)', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
await expect(getSong('whatever')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStarred', () => {
|
||||
it('returns empty arrays when starred2 has no entries', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ starred2: {} }));
|
||||
const result = await getStarred();
|
||||
expect(result.songs).toEqual([]);
|
||||
expect(result.albums).toEqual([]);
|
||||
expect(result.artists).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when starred2 itself is missing', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
const result = await getStarred();
|
||||
expect(result.songs).toEqual([]);
|
||||
expect(result.albums).toEqual([]);
|
||||
expect(result.artists).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes the three array buckets through unchanged', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
starred2: {
|
||||
song: [{ id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 }],
|
||||
album: [{ id: 'al-1', name: 'Al', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100 }],
|
||||
artist: [{ id: 'ar-1', name: 'Artist' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await getStarred();
|
||||
expect(result.songs).toHaveLength(1);
|
||||
expect(result.albums).toHaveLength(1);
|
||||
expect(result.artists).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopSongs', () => {
|
||||
it('returns an empty array when topSongs.song is absent', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ topSongs: {} }));
|
||||
expect(await getTopSongs('Nirvana')).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes a song-array through and slices to 5', async () => {
|
||||
const song = { id: 's', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 };
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ topSongs: { song: Array.from({ length: 8 }, (_, i) => ({ ...song, id: `s${i}` })) } }),
|
||||
);
|
||||
const songs = await getTopSongs('Nirvana');
|
||||
expect(songs).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('returns [] on any failure (catch swallows)', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
expect(await getTopSongs('Nirvana')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtists', () => {
|
||||
it('flattens index→artist arrays', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
artists: {
|
||||
index: [
|
||||
{ name: 'A', artist: [{ id: 'a1', name: 'Alpha' }] },
|
||||
{ name: 'B', artist: { id: 'b1', name: 'Beta' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const artists = await getArtists();
|
||||
expect(artists.map(a => a.id)).toEqual(['a1', 'b1']);
|
||||
});
|
||||
|
||||
it('returns empty when index is absent', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ artists: {} }));
|
||||
expect(await getArtists()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('returns empty arrays when searchResult3 is empty', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ searchResult3: {} }));
|
||||
const r = await search('nothing');
|
||||
expect(r.songs).toEqual([]);
|
||||
expect(r.albums).toEqual([]);
|
||||
expect(r.artists).toEqual([]);
|
||||
});
|
||||
|
||||
it('short-circuits to empty arrays for a whitespace-only query (no HTTP)', async () => {
|
||||
const r = await search(' ');
|
||||
expect(r.songs).toEqual([]);
|
||||
expect(r.albums).toEqual([]);
|
||||
expect(r.artists).toEqual([]);
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes array result types through unchanged', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({
|
||||
searchResult3: {
|
||||
song: [{ id: 's1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 100 }],
|
||||
album: [{ id: 'al-1', name: 'Al', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100 }],
|
||||
artist: [{ id: 'ar-1', name: 'Artist' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const r = await search('q');
|
||||
expect(r.songs).toHaveLength(1);
|
||||
expect(r.albums).toHaveLength(1);
|
||||
expect(r.artists).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlbumInfo2', () => {
|
||||
it('returns the albumInfo payload', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ albumInfo: { largeImageUrl: 'https://x', notes: 'about' } }),
|
||||
);
|
||||
const info = await getAlbumInfo2('al-1');
|
||||
expect(info?.largeImageUrl).toBe('https://x');
|
||||
});
|
||||
|
||||
it('returns null on any failure (server error / network)', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(errorResponse('not found'));
|
||||
expect(await getAlbumInfo2('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ping', () => {
|
||||
it('returns true when the server replies with status=ok', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
expect(await ping()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on any failure', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
expect(await ping()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pingWithCredentials — explicit URL/credentials path', () => {
|
||||
it('returns ok=true with type + serverVersion + openSubsonic when present', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okResponse({ type: 'navidrome', serverVersion: '0.55', openSubsonic: true }),
|
||||
);
|
||||
const r = await pingWithCredentials('https://music.example.com', 'u', 'p');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.type).toBe('navidrome');
|
||||
expect(r.serverVersion).toBe('0.55');
|
||||
expect(r.openSubsonic).toBe(true);
|
||||
});
|
||||
|
||||
it('prepends http:// when the URL lacks a scheme', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
await pingWithCredentials('music.local', 'u', 'p');
|
||||
const calledUrl = vi.mocked(axios.get).mock.calls[0]?.[0] as string;
|
||||
expect(calledUrl.startsWith('http://music.local')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips a trailing slash before appending /rest/ping.view', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
await pingWithCredentials('https://music.example.com/', 'u', 'p');
|
||||
const calledUrl = vi.mocked(axios.get).mock.calls[0]?.[0] as string;
|
||||
expect(calledUrl).toBe('https://music.example.com/rest/ping.view');
|
||||
});
|
||||
|
||||
it('returns ok=false (no throw) on any failure', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const r = await pingWithCredentials('https://x.test', 'u', 'p');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns ok=false when the response status is not "ok"', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(errorResponse());
|
||||
const r = await pingWithCredentials('https://x.test', 'u', 'wrong-pw');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('coerces openSubsonic=true even when the field is omitted (defaults to false)', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
const r = await pingWithCredentials('https://x.test', 'u', 'p');
|
||||
expect(r.openSubsonic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
it('sends resolved custom headers on the ping request', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
|
||||
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'http://192.168.0.10:4533',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Subsonic API contract tests (Phase F3).
|
||||
*
|
||||
* Pins the URL-builder shapes (`buildStreamUrl`, `buildCoverArtUrl`,
|
||||
* `buildDownloadUrl`, `coverArtCacheKey`) plus the small pure parsers
|
||||
* (`parseSubsonicEntityStarRating`, `libraryFilterParams`,
|
||||
* `getClient`) — the surface the rest of the frontend uses to talk to
|
||||
* Subsonic / Navidrome.
|
||||
*
|
||||
* Network-bound endpoints (`getAlbum`, `search`, etc.) require axios
|
||||
* mocking and are not in this PR.
|
||||
*/
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
buildDownloadUrl,
|
||||
buildStreamUrl,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from './subsonicStreamUrl';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { parseSubsonicEntityStarRating } from './subsonicRatings';
|
||||
import { getClient, libraryFilterParams, libraryScopeForServer } from './subsonicClient';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
function setUpServer(overrides: { url?: string; username?: string; password?: string } = {}): string {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Test',
|
||||
url: overrides.url ?? 'https://music.example.com',
|
||||
username: overrides.username ?? 'alice',
|
||||
password: overrides.password ?? 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
});
|
||||
|
||||
describe('parseSubsonicEntityStarRating', () => {
|
||||
it('reads userRating first, falls back to rating', () => {
|
||||
expect(parseSubsonicEntityStarRating({ userRating: 4 })).toBe(4);
|
||||
expect(parseSubsonicEntityStarRating({ rating: 3 })).toBe(3);
|
||||
expect(parseSubsonicEntityStarRating({ userRating: 5, rating: 2 })).toBe(5);
|
||||
});
|
||||
|
||||
it('coerces numeric strings', () => {
|
||||
expect(parseSubsonicEntityStarRating({ userRating: '4' as unknown as number })).toBe(4);
|
||||
expect(parseSubsonicEntityStarRating({ rating: '3.5' as unknown as number })).toBe(3.5);
|
||||
});
|
||||
|
||||
it('returns undefined for null / undefined / non-numeric values', () => {
|
||||
expect(parseSubsonicEntityStarRating({})).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ userRating: null as unknown as number })).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ rating: 'nope' as unknown as number })).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ userRating: Number.NaN })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryFilterParams', () => {
|
||||
it('returns an empty object when no server is active', () => {
|
||||
expect(libraryFilterParams()).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an empty object when the filter is "all" or unset', () => {
|
||||
setUpServer();
|
||||
expect(libraryFilterParams()).toEqual({});
|
||||
});
|
||||
|
||||
it('returns { musicFolderId } when the active server has a specific filter', () => {
|
||||
const serverId = setUpServer();
|
||||
useAuthStore.setState({
|
||||
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
|
||||
});
|
||||
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryScopeForServer', () => {
|
||||
it('returns undefined for all or unset filters', () => {
|
||||
const serverId = setUpServer();
|
||||
expect(libraryScopeForServer(serverId)).toBeUndefined();
|
||||
useAuthStore.setState({
|
||||
musicLibraryFilterByServer: { [serverId]: 'all' },
|
||||
});
|
||||
expect(libraryScopeForServer(serverId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the folder id when scoped', () => {
|
||||
const serverId = setUpServer();
|
||||
useAuthStore.setState({
|
||||
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
|
||||
});
|
||||
expect(libraryScopeForServer(serverId)).toBe('mf-7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('throws when no server is configured', () => {
|
||||
expect(() => getClient()).toThrow(/no server configured/i);
|
||||
});
|
||||
|
||||
it('returns baseUrl + auth params shape from the active server', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const { baseUrl, params } = getClient();
|
||||
expect(baseUrl).toBe('https://music.example.com/rest');
|
||||
expect(params).toMatchObject({
|
||||
u: 'alice',
|
||||
v: '1.16.1',
|
||||
f: 'json',
|
||||
});
|
||||
expect(params.c).toMatch(/^psysonic\//);
|
||||
expect(typeof params.t).toBe('string');
|
||||
expect(params.t).toHaveLength(32); // md5 hex
|
||||
expect(typeof params.s).toBe('string');
|
||||
expect((params.s as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rotates token + salt across calls (random per request)', () => {
|
||||
setUpServer();
|
||||
const a = getClient();
|
||||
const b = getClient();
|
||||
expect(a.params.t).not.toBe(b.params.t);
|
||||
expect(a.params.s).not.toBe(b.params.s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverArtCacheKey', () => {
|
||||
it('uses host index key + entity id + tier as a stable cache key', () => {
|
||||
setUpServer();
|
||||
expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:album:cover-1:256');
|
||||
expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:album:cover-1:200');
|
||||
});
|
||||
|
||||
it('falls back to "_" as the server-id segment when no server is active', () => {
|
||||
expect(coverArtCacheKey('cover-99')).toBe('_:cover:album:cover-99:256');
|
||||
});
|
||||
|
||||
it('does not embed the ephemeral salt or token — keys stay cacheable across calls', () => {
|
||||
setUpServer();
|
||||
const a = coverArtCacheKey('art-1', 256);
|
||||
const b = coverArtCacheKey('art-1', 256);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStreamUrl', () => {
|
||||
it('returns a /rest/stream.view URL on the configured base URL', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const url = new URL(buildStreamUrl('track-1'));
|
||||
expect(url.origin).toBe('https://music.example.com');
|
||||
expect(url.pathname).toBe('/rest/stream.view');
|
||||
});
|
||||
|
||||
it('carries the stable Subsonic auth params (id, u, t, s, v, c, f)', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const url = new URL(buildStreamUrl('track-1'));
|
||||
expect(url.searchParams.get('id')).toBe('track-1');
|
||||
expect(url.searchParams.get('u')).toBe('alice');
|
||||
expect(url.searchParams.get('v')).toBe('1.16.1');
|
||||
expect(url.searchParams.get('f')).toBe('json');
|
||||
expect(url.searchParams.get('c')?.startsWith('psysonic/')).toBe(true);
|
||||
expect(url.searchParams.get('t')).toHaveLength(32); // md5 hex
|
||||
expect(url.searchParams.get('s')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rotates t/s across calls — Rust matches playback identity by id, not by URL', () => {
|
||||
setUpServer();
|
||||
const a = new URL(buildStreamUrl('track-1'));
|
||||
const b = new URL(buildStreamUrl('track-1'));
|
||||
expect(a.searchParams.get('id')).toBe(b.searchParams.get('id'));
|
||||
expect(a.searchParams.get('t')).not.toBe(b.searchParams.get('t'));
|
||||
expect(a.searchParams.get('s')).not.toBe(b.searchParams.get('s'));
|
||||
});
|
||||
|
||||
it('URL-encodes ids with special characters once (not double-encoded)', () => {
|
||||
setUpServer();
|
||||
const id = 'AC/DC — Back in Black';
|
||||
const url = new URL(buildStreamUrl(id));
|
||||
expect(url.searchParams.get('id')).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCoverArtUrl', () => {
|
||||
it('returns a /rest/getCoverArt.view URL with size param', () => {
|
||||
setUpServer({ url: 'https://music.example.com' });
|
||||
const url = new URL(buildCoverArtUrl('cover-1', 512));
|
||||
expect(url.pathname).toBe('/rest/getCoverArt.view');
|
||||
expect(url.searchParams.get('id')).toBe('cover-1');
|
||||
expect(url.searchParams.get('size')).toBe('512');
|
||||
});
|
||||
|
||||
it('defaults size to 256 when omitted', () => {
|
||||
setUpServer();
|
||||
const url = new URL(buildCoverArtUrl('cover-1'));
|
||||
expect(url.searchParams.get('size')).toBe('256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCoverArtUrlForServer', () => {
|
||||
it('builds getCoverArt URL with explicit server credentials', () => {
|
||||
const url = new URL(buildCoverArtUrlForServer('https://remote.example', 'bob', 'secret', 'art-9', 40));
|
||||
expect(url.origin).toBe('https://remote.example');
|
||||
expect(url.pathname).toBe('/rest/getCoverArt.view');
|
||||
expect(url.searchParams.get('id')).toBe('art-9');
|
||||
expect(url.searchParams.get('size')).toBe('40');
|
||||
expect(url.searchParams.get('u')).toBe('bob');
|
||||
expect(url.searchParams.get('t')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverArtCacheKeyForServer', () => {
|
||||
it('scopes cache keys by host index key when profile is known', () => {
|
||||
const profileId = setUpServer({ url: 'https://b.example' });
|
||||
expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:album:cover-1:80');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownloadUrl', () => {
|
||||
it('returns a /rest/download.view URL with id + auth params', () => {
|
||||
setUpServer({ url: 'https://music.example.com' });
|
||||
const url = new URL(buildDownloadUrl('track-7'));
|
||||
expect(url.pathname).toBe('/rest/download.view');
|
||||
expect(url.searchParams.get('id')).toBe('track-7');
|
||||
expect(url.searchParams.get('v')).toBe('1.16.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL builders — trailing-slash + scheme handling on base URL', () => {
|
||||
it('strips trailing slash from base URL (getBaseUrl normalises)', () => {
|
||||
setUpServer({ url: 'https://music.example.com/' });
|
||||
const url = new URL(buildStreamUrl('t1'));
|
||||
expect(url.origin).toBe('https://music.example.com');
|
||||
// No `//rest` doubled-slash:
|
||||
expect(url.pathname).toBe('/rest/stream.view');
|
||||
});
|
||||
|
||||
it('prepends http:// when the configured URL lacks a scheme', () => {
|
||||
setUpServer({ url: 'music.local' });
|
||||
const url = new URL(buildStreamUrl('t1'));
|
||||
expect(url.protocol).toBe('http:');
|
||||
expect(url.host).toBe('music.local');
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicOpenSubsonic', () => ({
|
||||
fetchOpenSubsonicExtensionsWithCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { scheduleInstantMixProbeForServer } from './subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials);
|
||||
const SID = 'srv-probe';
|
||||
const id062: SubsonicServerIdentity = { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true };
|
||||
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
function reset() {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: {},
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
audiomuseNavidromeIssueByServer: {},
|
||||
openSubsonicExtensionsByServer: {},
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||
reset();
|
||||
useAuthStore.setState({
|
||||
servers: [{
|
||||
id: SID,
|
||||
name: 'Probe',
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
}],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
|
||||
url: 'https://music.example.com',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
});
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-probes when forced (user-initiated refresh)', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062, true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-probes after a prior error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(null);
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('error');
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stores the full extension list and caches both sonicSimilarity and playbackReport', async () => {
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity', 'playbackReport']);
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
await flush();
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['sonicSimilarity', 'playbackReport']);
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
});
|
||||
|
||||
it('stores the list on a non-Navidrome OpenSubsonic server without driving the AudioMuse probe', async () => {
|
||||
fetchMock.mockResolvedValue(['playbackReport']);
|
||||
const idGonic: SubsonicServerIdentity = { type: 'gonic', serverVersion: '0.16.0', openSubsonic: true };
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', idGonic);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await flush();
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.openSubsonicExtensionsByServer[SID]).toEqual(['playbackReport']);
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSubsonicServerIdentity (version-change cache invalidation)', () => {
|
||||
beforeEach(reset);
|
||||
|
||||
it('clears cached probes on a version change but keeps the opt-in', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
audiomuseNavidromeByServer: { [SID]: true },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { type: 'navidrome', serverVersion: '0.63.0', openSubsonic: true });
|
||||
|
||||
const s = useAuthStore.getState();
|
||||
expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined();
|
||||
expect(s.audiomuseNavidromeByServer[SID]).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps cached probes when the identity is unchanged', () => {
|
||||
useAuthStore.setState({
|
||||
subsonicServerIdentityByServer: { [SID]: id062 },
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
} as never);
|
||||
|
||||
useAuthStore.getState().setSubsonicServerIdentity(SID, { ...id062 });
|
||||
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import {
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
} from '../utils/server/subsonicServerIdentity';
|
||||
import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic';
|
||||
import { buildCapabilityContext } from '../serverCapabilities/context';
|
||||
import {
|
||||
PROBE_LEGACY_INSTANT_MIX,
|
||||
PROBE_OPENSUBSONIC_EXTENSIONS,
|
||||
SERVER_CAPABILITY_CATALOG,
|
||||
SONIC_SIMILARITY_EXTENSION,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { neededProbeIds } from '../serverCapabilities/resolve';
|
||||
import {
|
||||
SUBSONIC_CLIENT,
|
||||
api,
|
||||
apiWithCredentials,
|
||||
secureRandomSalt,
|
||||
type ServerHttpHeaderProfile,
|
||||
} from './subsonicClient';
|
||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function ping(): Promise<boolean> {
|
||||
try {
|
||||
await api('ping.view');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Test a connection with explicit credentials — does NOT depend on store state. */
|
||||
export async function pingWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' },
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentials failed:', serverUrl, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
|
||||
export async function pingWithCredentialsForProfile(
|
||||
profile: Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
endpointBaseUrl: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = endpointBaseUrl.startsWith('http')
|
||||
? endpointBaseUrl.replace(/\/$/, '')
|
||||
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(profile.password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: {
|
||||
u: profile.username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
},
|
||||
headers: headersForServerRequest(profile, endpointBaseUrl),
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
|
||||
/**
|
||||
* Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain).
|
||||
* Does not pass `musicFolderId` — probes the whole library as seen by the account.
|
||||
* Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone.
|
||||
*/
|
||||
export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
if (songs.length === 0) return 'skipped';
|
||||
|
||||
let anyError = false;
|
||||
for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) {
|
||||
try {
|
||||
const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
if (list.some(s => s.id !== song.id)) return 'ok';
|
||||
} catch {
|
||||
anyError = true;
|
||||
}
|
||||
}
|
||||
return anyError ? 'error' : 'empty';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful ping, run the server-capability probes needed by the catalog
|
||||
* (`serverCapabilities/`). Which probes run is decided by the strategies eligible
|
||||
* for this server generation — not by inline version checks here.
|
||||
*
|
||||
* Currently: Navidrome ≥ 0.62 → `getOpenSubsonicExtensions` (sonicSimilarity);
|
||||
* Navidrome 0.60–0.61 → legacy `getSimilarSongs` Instant Mix probe.
|
||||
*
|
||||
* Idempotent: a server's advertised capabilities are static within a session, so
|
||||
* once a definitive result is cached the probe is skipped. This is called on every
|
||||
* 120 s connection poll, so re-fetching each time would be wasteful and would flip
|
||||
* the resolved status (and the routed endpoint) through a `probing` window. Pass
|
||||
* `force` for user-initiated refreshes (add/edit/test server); a server version or
|
||||
* type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe.
|
||||
*/
|
||||
export function scheduleInstantMixProbeForServer(
|
||||
serverId: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
identity: SubsonicServerIdentity,
|
||||
force = false,
|
||||
): void {
|
||||
const ctx = buildCapabilityContext(identity);
|
||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||
const store = useAuthStore.getState();
|
||||
const headerProfile = findServerByIdOrIndexKey(serverId);
|
||||
|
||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
|
||||
// The AudioMuse `sonicSimilarity` lifecycle (with its opt-in side effects) is
|
||||
// only driven on Navidrome ≥ 0.62, so broadening the probe to all OpenSubsonic
|
||||
// servers for `playbackReport` does not disturb the legacy Instant Mix opt-in.
|
||||
const audiomuseEligible = ctx.isNavidrome && ctx.semverGte([0, 62, 0]);
|
||||
const cached = store.audiomusePluginProbeByServer[serverId];
|
||||
const listMissing = store.openSubsonicExtensionsByServer[serverId] === undefined;
|
||||
// Re-probe without a definitive cached result, on force / prior error, or when
|
||||
// the extension list is missing (self-heal for state persisted before it was
|
||||
// captured). `probing` means an in-flight fetch — skip to avoid a duplicate.
|
||||
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
|
||||
if (force || listMissing || audiomuseStale) {
|
||||
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
headerProfile,
|
||||
).then(extensions => {
|
||||
const st = useAuthStore.getState();
|
||||
if (extensions === null) {
|
||||
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
|
||||
return;
|
||||
}
|
||||
st.setOpenSubsonicExtensions(serverId, extensions);
|
||||
if (audiomuseEligible) {
|
||||
st.setAudiomusePluginProbe(serverId, extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||
const cached = store.instantMixProbeByServer[serverId];
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { version } from '../../package.json';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
||||
export type ServerHttpHeaderProfile = Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>;
|
||||
|
||||
export function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function getAuthParams(username: string, password: string) {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json' };
|
||||
}
|
||||
|
||||
export function restBaseFromUrl(serverUrl: string): string {
|
||||
const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`;
|
||||
return `${base}/rest`;
|
||||
}
|
||||
|
||||
export async function apiWithCredentials<T>(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function getClient() {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
const params = getAuthParams(server?.username ?? '', server?.password ?? '');
|
||||
return { baseUrl: `${baseUrl}/rest`, params };
|
||||
}
|
||||
|
||||
export function getServerById(serverId: string): ServerProfile | undefined {
|
||||
return findServerByIdOrIndexKey(serverId);
|
||||
}
|
||||
|
||||
/** Subsonic REST call against an explicit saved server (not necessarily the active one). */
|
||||
export async function apiForServer<T>(
|
||||
serverId: string,
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
): Promise<T> {
|
||||
const server = getServerById(serverId);
|
||||
if (!server) throw new Error(`Unknown server: ${serverId}`);
|
||||
// Dual-address: route through the cached connect URL when one has been
|
||||
// probed for this profile; otherwise the normalized primary url is the
|
||||
// same string the legacy code path used, so single-address profiles are
|
||||
// byte-identical to before.
|
||||
return apiWithCredentials(
|
||||
connectBaseUrlForServer(server),
|
||||
server.username,
|
||||
server.password,
|
||||
endpoint,
|
||||
extra,
|
||||
timeout,
|
||||
server,
|
||||
);
|
||||
}
|
||||
|
||||
export async function api<T>(
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const { baseUrl, params } = getClient();
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const connectBase = useAuthStore.getState().getBaseUrl();
|
||||
const headers =
|
||||
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||
params: { ...params, ...extra },
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
signal,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)');
|
||||
if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error');
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Optional `musicFolderId` when the user narrowed browsing to one Subsonic library (see `getMusicFolders`). */
|
||||
export function libraryFilterParams(): Record<string, string | number> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
return activeServerId ? libraryFilterParamsForServer(activeServerId) : {};
|
||||
}
|
||||
|
||||
/** Navidrome/Subsonic music folder id for the local library index, or undefined for all libraries. */
|
||||
export function libraryScopeForServer(serverId: string): string | undefined {
|
||||
const resolved = resolveServerIdForIndexKey(serverId);
|
||||
const f = useAuthStore.getState().musicLibraryFilterByServer[resolved];
|
||||
if (f === undefined || f === 'all') return undefined;
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Library folder filter for an explicit saved server (e.g. Now Playing while browsing another). */
|
||||
export function libraryFilterParamsForServer(serverId: string): Record<string, string | number> {
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
if (!scope) return {};
|
||||
return { musicFolderId: scope };
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getSongWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getSong.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbumWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getAlbum.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getArtist.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchAllSongsByGenre, getSongsByGenre } from './subsonicGenres';
|
||||
import type { SubsonicSong } from './subsonicTypes';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() }));
|
||||
|
||||
vi.mock('./subsonicClient', () => ({
|
||||
api: apiMock,
|
||||
libraryFilterParams: () => ({}),
|
||||
}));
|
||||
|
||||
function songs(n: number, startId = 0): SubsonicSong[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ id: String(startId + i), title: `t${startId + i}` }) as SubsonicSong);
|
||||
}
|
||||
|
||||
describe('getSongsByGenre', () => {
|
||||
beforeEach(() => apiMock.mockReset());
|
||||
|
||||
it('normalizes a single-song response into an array', async () => {
|
||||
apiMock.mockResolvedValue({ songsByGenre: { song: { id: '1', title: 'only' } } });
|
||||
expect(await getSongsByGenre('Metal')).toEqual([{ id: '1', title: 'only' }]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the genre has no songs', async () => {
|
||||
apiMock.mockResolvedValue({ songsByGenre: {} });
|
||||
expect(await getSongsByGenre('Empty')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAllSongsByGenre', () => {
|
||||
beforeEach(() => apiMock.mockReset());
|
||||
|
||||
it('paginates until a short page signals the end', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce({ songsByGenre: { song: songs(500) } })
|
||||
.mockResolvedValueOnce({ songsByGenre: { song: songs(30, 500) } });
|
||||
const all = await fetchAllSongsByGenre('Metal');
|
||||
expect(all).toHaveLength(530);
|
||||
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.mock.calls[0][1]).toMatchObject({ offset: 0, count: 500 });
|
||||
expect(apiMock.mock.calls[1][1]).toMatchObject({ offset: 500 });
|
||||
});
|
||||
|
||||
it('stops at the cap without fetching further pages', async () => {
|
||||
apiMock.mockResolvedValue({ songsByGenre: { song: songs(500) } });
|
||||
const all = await fetchAllSongsByGenre('Huge', 10);
|
||||
expect(all).toHaveLength(10);
|
||||
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { api, libraryFilterParams } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const raw = data.genres?.genre;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Promise<SubsonicAlbum[]> {
|
||||
const data = await api<{ albumList2: { album: SubsonicAlbum | SubsonicAlbum[] } }>('getAlbumList2.view', {
|
||||
type: 'byGenre',
|
||||
genre,
|
||||
size,
|
||||
offset,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const raw = data.albumList2?.album;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
/** Single page of songs for a genre (Subsonic `getSongsByGenre`, supported by Navidrome). */
|
||||
export async function getSongsByGenre(genre: string, count = 500, offset = 0): Promise<SubsonicSong[]> {
|
||||
const data = await api<{ songsByGenre: { song: SubsonicSong | SubsonicSong[] } }>('getSongsByGenre.view', {
|
||||
genre,
|
||||
count,
|
||||
offset,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const raw = data.songsByGenre?.song;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every song in a genre, paginated until exhausted. Capped to keep the queue and the
|
||||
* burst of server requests bounded for very large genres (a handful of sequential pages).
|
||||
*/
|
||||
export async function fetchAllSongsByGenre(genre: string, cap = 5000): Promise<SubsonicSong[]> {
|
||||
const PAGE = 500;
|
||||
const songs: SubsonicSong[] = [];
|
||||
for (let offset = 0; songs.length < cap; offset += PAGE) {
|
||||
const page = await getSongsByGenre(genre, PAGE, offset);
|
||||
songs.push(...page);
|
||||
if (page.length < PAGE) break;
|
||||
}
|
||||
return songs.length > cap ? songs.slice(0, cap) : songs;
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
shouldAttemptSubsonicForActiveServer,
|
||||
shouldAttemptSubsonicForServer,
|
||||
} from '../utils/network/subsonicNetworkGuard';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } 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[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
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[]> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return [];
|
||||
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 albumIdsInLibraryScope(serverId: string): Promise<Set<string> | null> {
|
||||
const { musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
|
||||
if (!serverId) return null;
|
||||
const folder = musicLibraryFilterByServer[serverId];
|
||||
if (folder === undefined || folder === 'all') {
|
||||
scopedLibraryAlbumIdCache = null;
|
||||
return null;
|
||||
}
|
||||
const hit = scopedLibraryAlbumIdCache;
|
||||
if (
|
||||
hit &&
|
||||
hit.serverId === serverId &&
|
||||
hit.folderId === folder &&
|
||||
hit.filterVersion === musicLibraryFilterVersion
|
||||
) {
|
||||
return hit.ids;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
const pageSize = 500;
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const albums = await getAlbumListForServer(serverId, '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,
|
||||
folderId: folder,
|
||||
filterVersion: musicLibraryFilterVersion,
|
||||
ids,
|
||||
};
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function filterSongsToServerLibrary(
|
||||
songs: SubsonicSong[],
|
||||
serverId: string,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const allowed = await albumIdsInLibraryScope(serverId);
|
||||
if (!allowed || allowed.size === 0) return songs;
|
||||
return songs.filter(s => s.albumId && allowed.has(s.albumId));
|
||||
}
|
||||
|
||||
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return songs;
|
||||
return filterSongsToServerLibrary(songs, activeServerId);
|
||||
}
|
||||
|
||||
/** Client-side album scope filter — same album-id set as {@link filterSongsToServerLibrary}. */
|
||||
export function filterAlbumsByScopedAlbumIds(
|
||||
albums: SubsonicAlbum[],
|
||||
allowed: Set<string> | null,
|
||||
): SubsonicAlbum[] {
|
||||
if (!allowed || allowed.size === 0) return albums;
|
||||
return albums.filter(a => allowed.has(a.id));
|
||||
}
|
||||
|
||||
export async function filterAlbumsToServerLibrary(
|
||||
albums: SubsonicAlbum[],
|
||||
serverId: string,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const allowed = await albumIdsInLibraryScope(serverId);
|
||||
return filterAlbumsByScopedAlbumIds(albums, allowed);
|
||||
}
|
||||
|
||||
export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Promise<SubsonicAlbum[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return albums;
|
||||
return filterAlbumsToServerLibrary(albums, activeServerId);
|
||||
}
|
||||
|
||||
/** 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 getAlbumListForServer(
|
||||
serverId: string,
|
||||
type: 'random' | 'newest' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'byYear' | 'recent' | 'starred' | 'frequent' | 'highest',
|
||||
size = 30,
|
||||
offset = 0,
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return [];
|
||||
const data = await apiForServer<{ albumList2: { album: SubsonicAlbum[] } }>(serverId, 'getAlbumList2.view', {
|
||||
type,
|
||||
size,
|
||||
offset,
|
||||
_t: Date.now(),
|
||||
...libraryFilterParamsForServer(serverId),
|
||||
...extra,
|
||||
});
|
||||
return data.albumList2?.album ?? [];
|
||||
}
|
||||
|
||||
export async function getSong(id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) return null;
|
||||
try {
|
||||
const data = await api<{ song: SubsonicSong }>('getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSongForServer(serverId: string, id: string): Promise<SubsonicSong | null> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId, id)) return null;
|
||||
try {
|
||||
const data = await apiForServer<{ song: SubsonicSong }>(serverId, 'getSong.view', { id });
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForActiveServer()) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await api<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>('getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
|
||||
export async function getAlbumForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) {
|
||||
throw new Error('Subsonic unavailable');
|
||||
}
|
||||
const data = await apiForServer<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(serverId, 'getAlbum.view', { id });
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from './subsonicTypes';
|
||||
import { filterAlbumsByScopedAlbumIds } from './subsonicLibrary';
|
||||
|
||||
const album = (id: string): SubsonicAlbum => ({
|
||||
id,
|
||||
name: id,
|
||||
artist: 'a',
|
||||
artistId: '1',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
});
|
||||
|
||||
describe('filterAlbumsByScopedAlbumIds', () => {
|
||||
it('returns all albums when scope is unset', () => {
|
||||
const albums = [album('a'), album('b')];
|
||||
expect(filterAlbumsByScopedAlbumIds(albums, null)).toEqual(albums);
|
||||
});
|
||||
|
||||
it('keeps only albums in the scoped id set', () => {
|
||||
const albums = [album('a'), album('b'), album('c')];
|
||||
expect(filterAlbumsByScopedAlbumIds(albums, new Set(['a', 'c']))).toEqual([album('a'), album('c')]);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { api } from './subsonicClient';
|
||||
import type { SubsonicStructuredLyrics } from './subsonicTypes';
|
||||
|
||||
/**
|
||||
* Fetches structured lyrics from the server's embedded tags via the
|
||||
* OpenSubsonic `getLyricsBySongId` endpoint. Returns null when the
|
||||
* server doesn't support the endpoint or the track has no embedded lyrics.
|
||||
* Prefers synced lyrics over plain when both are present.
|
||||
*/
|
||||
export async function getLyricsBySongId(id: string): Promise<SubsonicStructuredLyrics | null> {
|
||||
try {
|
||||
const data = await api<{ lyricsList: { structuredLyrics?: SubsonicStructuredLyrics[] } }>(
|
||||
'getLyricsBySongId.view',
|
||||
{ id },
|
||||
);
|
||||
const list = data.lyricsList?.structuredLyrics;
|
||||
if (!list || list.length === 0) return null;
|
||||
return list.find(l => l.synced || l.issynced) ?? list[0];
|
||||
} catch {
|
||||
// Server doesn't support the endpoint or track has no embedded lyrics
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
fetchOpenSubsonicExtensionsWithCredentials,
|
||||
hasOpenSubsonicExtension,
|
||||
parseOpenSubsonicExtensions,
|
||||
} from './subsonicOpenSubsonic';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
function okExtensions(extensions: unknown[]) {
|
||||
return {
|
||||
data: {
|
||||
'subsonic-response': {
|
||||
status: 'ok',
|
||||
openSubsonic: true,
|
||||
openSubsonicExtensions: extensions,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseOpenSubsonicExtensions', () => {
|
||||
it('parses extension names and versions', () => {
|
||||
const parsed = parseOpenSubsonicExtensions([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
{ bad: true },
|
||||
]);
|
||||
expect(parsed).toEqual([
|
||||
{ name: 'sonicSimilarity', versions: [1] },
|
||||
{ name: 'playbackReport', versions: [1, 2] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOpenSubsonicExtension', () => {
|
||||
it('detects sonicSimilarity', () => {
|
||||
const extensions = parseOpenSubsonicExtensions([{ name: 'sonicSimilarity', versions: [1] }]);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'sonicSimilarity')).toBe(true);
|
||||
expect(hasOpenSubsonicExtension(extensions, 'other')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the advertised extension names', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(
|
||||
okExtensions([{ name: 'sonicSimilarity', versions: [1] }, { name: 'playbackReport', versions: [1] }]),
|
||||
);
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual(['sonicSimilarity', 'playbackReport']);
|
||||
});
|
||||
|
||||
it('returns an empty list when none are advertised', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null on request failure', async () => {
|
||||
vi.mocked(axios.get).mockRejectedValue(new Error('boom'));
|
||||
await expect(
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('sends custom gate headers when a header profile is supplied', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||
url: 'https://music.test',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
});
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
|
||||
export interface OpenSubsonicExtension {
|
||||
name: string;
|
||||
versions: number[];
|
||||
}
|
||||
|
||||
export function parseOpenSubsonicExtensions(raw: unknown): OpenSubsonicExtension[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object' && !Array.isArray(entry))
|
||||
.map(entry => ({
|
||||
name: typeof entry.name === 'string' ? entry.name : '',
|
||||
versions: Array.isArray(entry.versions)
|
||||
? entry.versions.filter((v): v is number => typeof v === 'number')
|
||||
: [],
|
||||
}))
|
||||
.filter(entry => entry.name.length > 0);
|
||||
}
|
||||
|
||||
export function hasOpenSubsonicExtension(extensions: readonly OpenSubsonicExtension[], name: string): boolean {
|
||||
return extensions.some(ext => ext.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of OpenSubsonic extension names advertised by the server, or
|
||||
* `null` when the request fails. Shared probe for any extension-gated feature.
|
||||
*/
|
||||
export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export type PlayQueueResult = { current?: string; position?: number; songs: SubsonicSong[] };
|
||||
|
||||
function parsePlayQueueResponse(
|
||||
data: { playQueue?: { current?: string; position?: number; entry?: SubsonicSong[] } },
|
||||
): PlayQueueResult {
|
||||
const pq = data.playQueue;
|
||||
return { current: pq?.current, position: pq?.position, songs: pq?.entry ?? [] };
|
||||
}
|
||||
|
||||
export async function getPlayQueue(): Promise<PlayQueueResult> {
|
||||
try {
|
||||
const data = await api<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>('getPlayQueue.view');
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayQueueForServer(serverId: string): Promise<PlayQueueResult> {
|
||||
if (!serverId) return { songs: [] };
|
||||
try {
|
||||
const data = await apiForServer<{ playQueue: { current?: string; position?: number; entry?: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getPlayQueue.view',
|
||||
);
|
||||
return parsePlayQueueResponse(data);
|
||||
} catch {
|
||||
return { songs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePlayQueue(
|
||||
songIds: string[],
|
||||
current: string | undefined,
|
||||
position: number | undefined,
|
||||
serverId: string,
|
||||
): Promise<void> {
|
||||
if (!serverId) return;
|
||||
const params: Record<string, unknown> = {};
|
||||
if (songIds.length > 0) params.id = songIds;
|
||||
if (current !== undefined) params.current = current;
|
||||
if (position !== undefined) params.position = position;
|
||||
await apiForServer(serverId, 'savePlayQueue.view', params);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/features/artist', () => ({ getArtist: vi.fn() }));
|
||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForActiveServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { getArtist } from '@/features/artist';
|
||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getArtist).mockReset();
|
||||
invalidateEntityUserRatingCaches('art-1');
|
||||
});
|
||||
|
||||
describe('prefetchArtistUserRatings cache', () => {
|
||||
it('does not negative-cache unrated artists', async () => {
|
||||
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'art-1', name: 'Artist' }, albums: [] });
|
||||
|
||||
const first = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(first.size).toBe(0);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 1 },
|
||||
albums: [],
|
||||
});
|
||||
const second = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(second.get('art-1')).toBe(1);
|
||||
expect(getArtist).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-fetches after invalidateEntityUserRatingCaches', async () => {
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 2 },
|
||||
albums: [],
|
||||
});
|
||||
|
||||
const first = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(first.get('art-1')).toBe(2);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 4 },
|
||||
albums: [],
|
||||
});
|
||||
const cached = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(cached.get('art-1')).toBe(2);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
invalidateEntityUserRatingCaches('art-1');
|
||||
const fresh = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(fresh.get('art-1')).toBe(4);
|
||||
expect(getArtist).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import { getArtist } from '@/features/artist';
|
||||
import { getAlbum } from './subsonicLibrary';
|
||||
import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
const ratingCache = new Map<string, { value: number; expiresAt: number }>();
|
||||
|
||||
function getCachedRating(key: string): number | 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): void {
|
||||
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
|
||||
/** Drop cached entity ratings after `setRating` so mixes see fresh stars. */
|
||||
export function invalidateEntityUserRatingCaches(id: string): void {
|
||||
ratingCache.delete(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
ratingCache.delete(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
}
|
||||
|
||||
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) out.set(id, cached);
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) 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);
|
||||
if (r !== undefined && r > 0) {
|
||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
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) out.set(id, cached);
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
if (!shouldAttemptSubsonicForActiveServer()) 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);
|
||||
if (r !== undefined && r > 0) {
|
||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
out.set(id, r);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, uncached.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { reportNowPlaying, scrobbleSong } from './subsonicScrobble';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
const { apiForServerMock } = vi.hoisted(() => ({
|
||||
apiForServerMock: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: apiForServerMock,
|
||||
}));
|
||||
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
shouldAttemptSubsonicForServer: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
describe('subsonicScrobble', () => {
|
||||
beforeEach(() => {
|
||||
apiForServerMock.mockClear();
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(() => true);
|
||||
useAuthStore.setState({
|
||||
servers: [
|
||||
{ id: 'a', name: 'A', url: 'http://a.test', username: 'u', password: 'p' },
|
||||
{ id: 'b', name: 'B', url: 'http://b.test', username: 'u', password: 'p' },
|
||||
],
|
||||
activeServerId: 'b',
|
||||
isLoggedIn: true,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
queueItems: [{ serverId: 'a', trackId: 't1' }],
|
||||
queueServerId: 'a',
|
||||
queueIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('scrobbleSong targets the queue server when active server differs', async () => {
|
||||
await scrobbleSong('t1', 1_700_000_000_000, 'a');
|
||||
expect(apiForServerMock).toHaveBeenCalledWith(
|
||||
'a',
|
||||
'scrobble.view',
|
||||
expect.objectContaining({ id: 't1', submission: true, time: 1_700_000_000_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reportNowPlaying and scrobbleSong use the presence guard without trackId', async () => {
|
||||
vi.mocked(shouldAttemptSubsonicForServer).mockImplementation(
|
||||
(_serverId: string, trackId?: string) => trackId === undefined,
|
||||
);
|
||||
|
||||
await reportNowPlaying('t-local', 'a');
|
||||
await scrobbleSong('t-local', 1_700_000_000_000, 'a');
|
||||
|
||||
expect(shouldAttemptSubsonicForServer).toHaveBeenCalledWith('a');
|
||||
expect(shouldAttemptSubsonicForServer).not.toHaveBeenCalledWith('a', expect.anything());
|
||||
expect(apiForServerMock).toHaveBeenCalledTimes(2);
|
||||
expect(apiForServerMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'a',
|
||||
'scrobble.view',
|
||||
expect.objectContaining({ id: 't-local', submission: false }),
|
||||
);
|
||||
expect(apiForServerMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'a',
|
||||
'scrobble.view',
|
||||
expect.objectContaining({ id: 't-local', submission: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { api, apiForServer } from './subsonicClient';
|
||||
import type { PlaybackReportState, SubsonicNowPlaying } from './subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '../utils/library/patchOnUse';
|
||||
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
|
||||
|
||||
async function scrobbleOnServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
submission: boolean,
|
||||
time?: number,
|
||||
): Promise<void> {
|
||||
// Presence / play-count updates are not playback-byte fetches — omit trackId so
|
||||
// hot cache, offline library, and favorites-auto do not suppress Navidrome calls.
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return;
|
||||
const params: Record<string, unknown> = { id, submission };
|
||||
if (time !== undefined) params.time = time;
|
||||
await apiForServer(serverId, 'scrobble.view', params);
|
||||
}
|
||||
|
||||
export async function scrobbleSong(id: string, time: number, serverId: string): Promise<void> {
|
||||
if (!serverId) return;
|
||||
try {
|
||||
await scrobbleOnServer(serverId, id, true, time);
|
||||
// Patch-on-use (§6.5 / F3): reflect the play in the local index so the
|
||||
// "recently played" surfaces aren't stale. `play_count` is left to the next
|
||||
// sync (the patch sets absolute values; a correct increment needs the base).
|
||||
patchLibraryTrackOnUse(serverId, id, { playedAt: time });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportNowPlaying(id: string, serverId: string): Promise<void> {
|
||||
if (!serverId) return;
|
||||
try {
|
||||
await scrobbleOnServer(serverId, id, false);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReportPlaybackParams {
|
||||
mediaId: string;
|
||||
positionMs: number;
|
||||
state: PlaybackReportState;
|
||||
/** Effective playback speed; lets the server extrapolate position correctly. */
|
||||
playbackRate?: number;
|
||||
/**
|
||||
* When true, the server records live presence only and skips its scrobble /
|
||||
* play-count side effects. psysonic keeps those on the dedicated `scrobble.view`
|
||||
* channel (50% rule), so the timeline never double-counts a play.
|
||||
*/
|
||||
ignoreScrobble?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSubsonic `playbackReport` extension (Navidrome ≥ 0.62): report a point on
|
||||
* the playback timeline for rich, live now-playing. Best-effort and gated by the
|
||||
* same reachability guard as presence scrobbles; callers route through
|
||||
* `playbackReportSession` which only invokes this when the server advertises the
|
||||
* extension (otherwise the legacy `reportNowPlaying` presence call is used).
|
||||
*/
|
||||
export async function reportPlayback(serverId: string, params: ReportPlaybackParams): Promise<void> {
|
||||
if (!serverId) return;
|
||||
if (!shouldAttemptSubsonicForServer(serverId)) return;
|
||||
const query: Record<string, unknown> = {
|
||||
mediaId: params.mediaId,
|
||||
mediaType: 'song',
|
||||
positionMs: Math.max(0, Math.floor(params.positionMs)),
|
||||
state: params.state,
|
||||
};
|
||||
if (params.playbackRate !== undefined) query.playbackRate = params.playbackRate;
|
||||
if (params.ignoreScrobble !== undefined) query.ignoreScrobble = params.ignoreScrobble;
|
||||
try {
|
||||
await apiForServer(serverId, 'reportPlayback.view', query);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNowPlaying(): Promise<SubsonicNowPlaying[]> {
|
||||
try {
|
||||
const data = await api<{ nowPlaying: { entry?: SubsonicNowPlaying | SubsonicNowPlaying[] } }>('getNowPlaying.view', { _t: Date.now() });
|
||||
const raw = data.nowPlaying?.entry;
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterSearchArtistsWithNoAlbums } from './subsonicSearch';
|
||||
import type { SubsonicArtist } from './subsonicTypes';
|
||||
|
||||
describe('filterSearchArtistsWithNoAlbums', () => {
|
||||
it('removes artists with albumCount 0', () => {
|
||||
const artists: SubsonicArtist[] = [
|
||||
{ id: '1', name: 'Real', albumCount: 2 },
|
||||
{ id: '2', name: 'Ghost', albumCount: 0 },
|
||||
];
|
||||
expect(filterSearchArtistsWithNoAlbums(artists)).toEqual([artists[0]]);
|
||||
});
|
||||
|
||||
it('keeps artists when albumCount is omitted', () => {
|
||||
const artists: SubsonicArtist[] = [{ id: '1', name: 'Unknown count' }];
|
||||
expect(filterSearchArtistsWithNoAlbums(artists)).toEqual(artists);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { api, libraryFilterParams } from './subsonicClient';
|
||||
import { searchQueryIsFtsSafe } from '../utils/library/searchQueryFtsSafe';
|
||||
import type {
|
||||
SearchResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from './subsonicTypes';
|
||||
|
||||
/**
|
||||
* search3 sometimes returns duplicate or junk artist rows with **zero** albums (e.g. Navidrome indexing).
|
||||
* Drop only rows that explicitly report `albumCount === 0`; keep artists when the field is absent.
|
||||
* Thanks to zunoz for the report on the Psysonic Discord.
|
||||
*/
|
||||
export function filterSearchArtistsWithNoAlbums(artists: SubsonicArtist[]): SubsonicArtist[] {
|
||||
return artists.filter((a) => a.albumCount !== 0);
|
||||
}
|
||||
|
||||
export async function search(
|
||||
query: string,
|
||||
options?: {
|
||||
albumCount?: number;
|
||||
artistCount?: number;
|
||||
songCount?: number;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
},
|
||||
): Promise<SearchResults> {
|
||||
if (!query.trim()) return { artists: [], albums: [], songs: [] };
|
||||
if (!searchQueryIsFtsSafe(query)) return { artists: [], albums: [], songs: [] };
|
||||
const data = await api<{
|
||||
searchResult3: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}>(
|
||||
'search3.view',
|
||||
{
|
||||
query,
|
||||
artistCount: options?.artistCount ?? 5,
|
||||
albumCount: options?.albumCount ?? 5,
|
||||
songCount: options?.songCount ?? 10,
|
||||
...libraryFilterParams(),
|
||||
},
|
||||
options?.timeout ?? 15000,
|
||||
options?.signal,
|
||||
);
|
||||
const r = data.searchResult3 ?? {};
|
||||
return {
|
||||
artists: filterSearchArtistsWithNoAlbums(r.artist ?? []),
|
||||
albums: r.album ?? [],
|
||||
songs: r.song ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-only paginated search3. Tolerates empty query — Navidrome returns all songs
|
||||
* ordered by title in that case; strict Subsonic implementations may return nothing.
|
||||
* Caller handles empty results gracefully (Tracks page falls back to its random pool).
|
||||
*/
|
||||
export async function searchSongsPaged(query: string, songCount: number, songOffset: number): Promise<SubsonicSong[]> {
|
||||
if (!searchQueryIsFtsSafe(query.trim())) return [];
|
||||
const data = await api<{ searchResult3: { song?: SubsonicSong[] } }>('search3.view', {
|
||||
query,
|
||||
artistCount: 0,
|
||||
albumCount: 0,
|
||||
songCount,
|
||||
songOffset,
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
return data.searchResult3?.song ?? [];
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { invalidateEntityUserRatingCaches } from './subsonicRatings';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '../utils/library/patchOnUse';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import {
|
||||
invalidateStarredAlbumBrowse,
|
||||
refreshStarredAlbumIndexFromServer,
|
||||
} from '../utils/library/starredAlbumIndexSync';
|
||||
import type {
|
||||
EntityRatingSupportLevel,
|
||||
StarredResults,
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicSong,
|
||||
} from './subsonicTypes';
|
||||
|
||||
function parseStarred2Response(data: {
|
||||
starred2?: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}): StarredResults {
|
||||
const r = data.starred2 ?? {};
|
||||
return { artists: r.artist ?? [], albums: r.album ?? [], songs: r.song ?? [] };
|
||||
}
|
||||
|
||||
export async function getStarred(): Promise<StarredResults> {
|
||||
const data = await api<{
|
||||
starred2: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
}
|
||||
}>('getStarred2.view', { ...libraryFilterParams() });
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
/** Starred entities for an explicit saved server (not necessarily the active one). */
|
||||
export async function getStarredForServer(serverId: string): Promise<StarredResults> {
|
||||
const data = await apiForServer<{
|
||||
starred2: {
|
||||
artist?: SubsonicArtist[];
|
||||
album?: SubsonicAlbum[];
|
||||
song?: SubsonicSong[];
|
||||
};
|
||||
}>(serverId, 'getStarred2.view', { ...libraryFilterParamsForServer(serverId) });
|
||||
return parseStarred2Response(data);
|
||||
}
|
||||
|
||||
function resolveStarServerId(meta?: StarPatchMeta): string | null {
|
||||
return meta?.serverId ?? useAuthStore.getState().activeServerId;
|
||||
}
|
||||
|
||||
async function starApi(
|
||||
serverId: string | null | undefined,
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const sid = serverId ?? useAuthStore.getState().activeServerId;
|
||||
if (!sid) throw new Error('No server for star API');
|
||||
if (sid === useAuthStore.getState().activeServerId) {
|
||||
await api(endpoint, params);
|
||||
} else {
|
||||
await apiForServer(sid, endpoint, params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function star(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'star.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: Date.now() });
|
||||
} else if (type === 'album' && serverId) {
|
||||
invalidateStarredAlbumBrowse(serverId);
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('@/features/offline')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, true, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function unstar(
|
||||
id: string,
|
||||
type: 'song' | 'album' | 'artist' = 'album',
|
||||
meta?: StarPatchMeta,
|
||||
): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
if (type === 'song') params.id = id;
|
||||
if (type === 'album') params.albumId = id;
|
||||
if (type === 'artist') params.artistId = id;
|
||||
const serverId = resolveStarServerId(meta);
|
||||
await starApi(serverId, 'unstar.view', params);
|
||||
if (type === 'song') {
|
||||
patchLibraryTrackOnUse(serverId, id, { starredAt: null });
|
||||
} else if (type === 'album' && serverId) {
|
||||
invalidateStarredAlbumBrowse(serverId);
|
||||
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
||||
void refreshStarredAlbumIndexFromServer(serverId, indexEnabled).catch(() => {});
|
||||
}
|
||||
void import('@/features/offline')
|
||||
.then(m => m.onFavoritesOfflineStarChange(id, type, false, serverId ?? undefined))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function setRating(id: string, rating: number): Promise<void> {
|
||||
await api('setRating.view', { id, rating });
|
||||
// No-op in Rust when `id` is an album/artist (no track row matches).
|
||||
patchLibraryTrackOnUse(useAuthStore.getState().activeServerId, id, { userRating: rating });
|
||||
// Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become
|
||||
// stale immediately. `invalidateEntityUserRatingCaches` is static-imported:
|
||||
// mix paths already pull `subsonicRatings` (e.g. mixRatingFilter), so a
|
||||
// dynamic import would not split chunks and only triggered INEFFECTIVE_DYNAMIC_IMPORT.
|
||||
// Navidrome browse stays lazy to keep this module free of that dependency when unused.
|
||||
void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
|
||||
invalidateEntityUserRatingCaches(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe server for OpenSubsonic extensions. When `openSubsonic: true`, we treat album/artist
|
||||
* rating as supported (same `setRating.view` + entity id); otherwise track-only.
|
||||
*/
|
||||
export async function probeEntityRatingSupport(): Promise<EntityRatingSupportLevel> {
|
||||
try {
|
||||
const data = await api<{ openSubsonic?: boolean; openSubsonicExtensions?: unknown[] }>(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
8000,
|
||||
);
|
||||
if (data.openSubsonic === true) return 'full';
|
||||
if (Array.isArray(data.openSubsonicExtensions)) return 'full';
|
||||
return 'track_only';
|
||||
} catch {
|
||||
return 'track_only';
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import md5 from 'md5';
|
||||
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||
import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry';
|
||||
import type { CoverArtTier } from '../cover/types';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
|
||||
|
||||
function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams {
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
return new URLSearchParams({
|
||||
id,
|
||||
size: String(size),
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
}
|
||||
|
||||
function streamUrlFromProfile(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
): string {
|
||||
const baseUrl = restBaseFromUrl(serverUrl);
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(password + salt);
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
});
|
||||
return `${baseUrl}/stream.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function buildStreamUrlForServer(serverId: string, id: string): string {
|
||||
const server = findServerByIdOrIndexKey(serverId);
|
||||
if (!server) return buildStreamUrl(id);
|
||||
// Dual-address: route the stream through the cached connect endpoint.
|
||||
return streamUrlFromProfile(connectBaseUrlForServer(server), server.username, server.password, id);
|
||||
}
|
||||
|
||||
export function buildStreamUrl(id: string): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
if (!server || !baseUrl) return streamUrlFromProfile('', '', '', id);
|
||||
// `getBaseUrl()` already returns the cached connect URL; use it directly
|
||||
// instead of re-normalizing `server.url`, which would bypass the dual-
|
||||
// address connect cache.
|
||||
return streamUrlFromProfile(baseUrl, server.username, server.password, id);
|
||||
}
|
||||
|
||||
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||
export function coverArtCacheKey(id: string, size = 256): string {
|
||||
const entry = resolveAlbumCoverEntry(id, id);
|
||||
const ref = coverEntryToRef(entry ?? { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id });
|
||||
return coverStorageKeyFromRef(ref, size as CoverArtTier);
|
||||
}
|
||||
|
||||
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||
export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string {
|
||||
const server = findServerByIdOrIndexKey(serverIdOrKey);
|
||||
if (!server) return `${serverIdOrKey}:cover:album:${id}:${size}`;
|
||||
const entry = resolveAlbumCoverEntry(id, id);
|
||||
const ref = coverEntryToRef(
|
||||
entry ?? { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id },
|
||||
{
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
},
|
||||
);
|
||||
return coverStorageKeyFromRef(ref, size as CoverArtTier);
|
||||
}
|
||||
|
||||
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
||||
export function buildCoverArtUrl(id: string, size = 256): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const p = coverArtQueryParams(server?.username ?? '', server?.password ?? '', id, size);
|
||||
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
||||
export function buildCoverArtUrlForServer(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
size = 256,
|
||||
): string {
|
||||
const p = coverArtQueryParams(username, password, id, size);
|
||||
return `${restBaseFromUrl(serverUrl)}/getCoverArt.view?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function buildDownloadUrl(id: string): string {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5((server?.password ?? '') + salt);
|
||||
const p = new URLSearchParams({
|
||||
id,
|
||||
u: server?.username ?? '',
|
||||
t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json',
|
||||
});
|
||||
return `${baseUrl}/rest/download.view?${p.toString()}`;
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity';
|
||||
|
||||
/** OpenSubsonic `ItemGenre` on songs/albums (atomic genres from the server). */
|
||||
export interface SubsonicItemGenre {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SubsonicAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt?: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
playCount?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
starred?: string;
|
||||
recordLabel?: string;
|
||||
created?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for album-level rating. */
|
||||
userRating?: number;
|
||||
/** OpenSubsonic: true when the album is tagged as a compilation. */
|
||||
isCompilation?: boolean;
|
||||
/** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */
|
||||
releaseTypes?: string[];
|
||||
/** OpenSubsonic: structured album-artist credits (e.g. featured guests on the album). */
|
||||
artists?: SubsonicOpenArtistRef[];
|
||||
/** OpenSubsonic: single-string album-artist for display (mirrors `artists` joined). */
|
||||
displayArtist?: string;
|
||||
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
||||
discTitles?: SubsonicDiscTitle[];
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicDiscTitle {
|
||||
disc: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||
export interface SubsonicOpenArtistRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
userRating?: number;
|
||||
/** Navidrome / alternate OpenSubsonic payloads (same meaning as `userRating`). */
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicSong {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumId: string;
|
||||
artistId?: string;
|
||||
duration: number;
|
||||
track?: number;
|
||||
discNumber?: number;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
userRating?: number;
|
||||
/** Some OpenSubsonic responses attach parent ratings on child songs. */
|
||||
albumUserRating?: number;
|
||||
artistUserRating?: number;
|
||||
artists?: SubsonicOpenArtistRef[];
|
||||
albumArtists?: SubsonicOpenArtistRef[];
|
||||
// Audio technical info
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
samplingRate?: number;
|
||||
bitDepth?: number;
|
||||
channelCount?: number;
|
||||
starred?: string;
|
||||
genre?: string;
|
||||
/** OpenSubsonic atomic genres — preferred over splitting `genre`. */
|
||||
genres?: SubsonicItemGenre[];
|
||||
path?: string;
|
||||
albumArtist?: string;
|
||||
/** OpenSubsonic: single-string album-artist for display (mirrors `albumArtists` joined). */
|
||||
displayAlbumArtist?: string;
|
||||
/** ISRC code when available (e.g., Navidrome) */
|
||||
isrc?: string;
|
||||
/** Times the track has been played, surfaced by Navidrome's Subsonic API. */
|
||||
playCount?: number;
|
||||
/** ISO datetime of the last play, surfaced by Navidrome (OpenSubsonic). */
|
||||
played?: string;
|
||||
/** Beats per minute, surfaced by Navidrome when the tag is set on the file. */
|
||||
bpm?: number;
|
||||
/** Local index Advanced Search: `'tag'` (server/file tag) or `'analysis'` (measured). */
|
||||
localBpmSource?: 'tag' | 'analysis';
|
||||
replayGain?: {
|
||||
trackGain?: number;
|
||||
albumGain?: number;
|
||||
trackPeak?: number;
|
||||
albumPeak?: number;
|
||||
};
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
/** OpenSubsonic: structured composer credit (string for back-compat). */
|
||||
displayComposer?: string;
|
||||
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
|
||||
contributors?: Array<{
|
||||
role: string;
|
||||
subRole?: string;
|
||||
artist: { id?: string; name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface InternetRadioStation {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
homepageUrl?: string;
|
||||
coverArt?: string; // Navidrome v0.61.0+
|
||||
}
|
||||
|
||||
export interface RadioBrowserStation {
|
||||
stationuuid: string;
|
||||
name: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface SubsonicPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
songCount: number;
|
||||
duration: number;
|
||||
created: string;
|
||||
changed: string;
|
||||
owner?: string;
|
||||
public?: boolean;
|
||||
comment?: string;
|
||||
coverArt?: string;
|
||||
}
|
||||
|
||||
/** OpenSubsonic `playbackReport` lifecycle state, per the extension spec. */
|
||||
export type PlaybackReportState = 'starting' | 'playing' | 'paused' | 'stopped';
|
||||
|
||||
export interface SubsonicNowPlaying extends SubsonicSong {
|
||||
username: string;
|
||||
minutesAgo: number;
|
||||
playerId: number;
|
||||
playerName: string;
|
||||
/** OpenSubsonic `playbackReport`: live transport state for this stream. */
|
||||
state?: PlaybackReportState;
|
||||
/** OpenSubsonic `playbackReport`: server-extrapolated position in milliseconds. */
|
||||
positionMs?: number;
|
||||
/** OpenSubsonic `playbackReport`: effective playback speed (1.0 = normal). */
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Article-stripped lowercase sort key (local index / OpenSubsonic). */
|
||||
nameSort?: string;
|
||||
albumCount?: number;
|
||||
coverArt?: string;
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
/** Set when favorites are merged across servers (offline favorites tier). */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
value: string;
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
}
|
||||
|
||||
export interface SubsonicMusicFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SubsonicArtistInfo {
|
||||
biography?: string;
|
||||
musicBrainzId?: string;
|
||||
lastFmUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
largeImageUrl?: string;
|
||||
similarArtist?: Array<{ id: string; name: string; albumCount?: number }>;
|
||||
}
|
||||
|
||||
export interface SubsonicDirectoryEntry {
|
||||
id: string;
|
||||
parent?: string;
|
||||
title: string;
|
||||
isDir: boolean;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
track?: number;
|
||||
year?: number;
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
size?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicDirectory {
|
||||
id: string;
|
||||
parent?: string;
|
||||
name: string;
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean };
|
||||
|
||||
export interface RandomSongsFilters {
|
||||
size?: number;
|
||||
genre?: string;
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
}
|
||||
|
||||
export interface StatisticsLibraryAggregates {
|
||||
playtimeSec: number;
|
||||
albumsCounted: number;
|
||||
songsCounted: number;
|
||||
capped: boolean;
|
||||
genres: SubsonicGenre[];
|
||||
}
|
||||
|
||||
export interface StatisticsOverviewData {
|
||||
recent: SubsonicAlbum[];
|
||||
frequent: SubsonicAlbum[];
|
||||
highest: SubsonicAlbum[];
|
||||
artistCount: number;
|
||||
}
|
||||
|
||||
export interface StatisticsFormatSample {
|
||||
rows: { format: string; count: number }[];
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
}
|
||||
|
||||
export interface StarredResults {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
}
|
||||
|
||||
export type EntityRatingSupportLevel = 'track_only' | 'full';
|
||||
|
||||
export interface AlbumInfo {
|
||||
largeImageUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
smallImageUrl?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const RADIO_PAGE_SIZE = 25;
|
||||
|
||||
export interface SubsonicLyricLine {
|
||||
start?: number; // milliseconds — absent when unsynced
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SubsonicStructuredLyrics {
|
||||
/** OpenSubsonic spec field name (Navidrome ≥ 0.50.0 / any OpenSubsonic server). */
|
||||
synced?: boolean;
|
||||
/** Legacy / alternate casing used by some older Subsonic-compatible servers. */
|
||||
issynced?: boolean;
|
||||
lang?: string;
|
||||
offset?: number;
|
||||
displayArtist?: string;
|
||||
displayTitle?: string;
|
||||
line: SubsonicLyricLine[];
|
||||
}
|
||||
Reference in New Issue
Block a user