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:
Psychotoxical
2026-06-30 07:53:25 +02:00
parent 429e632598
commit 7e9cb60763
392 changed files with 652 additions and 652 deletions
+1 -1
View File
@@ -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. */
-945
View File
@@ -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');
}
-133
View File
@@ -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;
}
}
-417
View File
@@ -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();
}
-129
View File
@@ -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 });
}
-478
View File
@@ -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();
});
});
-246
View File
@@ -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');
});
});
-125
View File
@@ -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');
});
});
-232
View File
@@ -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.600.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),
);
}
}
}
-138
View File
@@ -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 };
}
-65
View File
@@ -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 ?? [] };
}
-50
View File
@@ -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);
});
});
-54
View File
@@ -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;
}
-267
View File
@@ -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 ?? [] };
}
-24
View File
@@ -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')]);
});
});
-23
View File
@@ -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;
}
}
-83
View File
@@ -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');
});
});
-49
View File
@@ -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;
}
}
-47
View File
@@ -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);
}
-57
View File
@@ -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);
});
});
-120
View File
@@ -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;
}
-71
View File
@@ -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 }),
);
});
});
-90
View File
@@ -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 [];
}
}
-18
View File
@@ -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);
});
});
-73
View File
@@ -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 ?? [];
}
-146
View File
@@ -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';
}
}
-122
View File
@@ -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()}`;
}
-291
View File
@@ -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[];
}