Files
psysonic/src/api/navidromeBrowse.ts
T
Frank Stellmacher 72030f17fd refactor(api): F.48 — extract subsonic types + client primitives (#613)
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:

- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
  (album/song/artist/playlist/directory/genre/now-playing/radio,
  random-songs filters, three statistics shapes, search + starred
  results, AlbumInfo, structured-lyrics types, etc.) plus the
  `RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
  `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
  `SUBSONIC_CLIENT`. The credential-bearing API helpers
  (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
  `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
  they could move into the client module in a follow-up.

66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.

subsonic.ts: 1333 → 1078 LOC (−255).
2026-05-13 00:05:58 +02:00

414 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
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,
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,
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,
};
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();
}