refactor(api): F.49 — extract library + artists + ratings (#614)

Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:

- `subsonicLibrary.ts` — browse + random + per-song fetch
  (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
  `getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
  `getRandomSongsFiltered`, `getSong`, `getAlbum`,
  `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
  the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
  `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
  `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
  `similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
  `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
  with the shared 7-min cache. Calls back into Library/Artists for
  the per-id fetch.

51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).

subsonic.ts: 1078 → 762 LOC (−316).
This commit is contained in:
Frank Stellmacher
2026-05-13 00:23:53 +02:00
committed by GitHub
parent 72030f17fd
commit 006635de4b
56 changed files with 481 additions and 401 deletions
+107
View File
@@ -0,0 +1,107 @@
import { getArtist } from './subsonicArtists';
import { getAlbum } from './subsonicLibrary';
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
function getCachedRating(key: string): number | undefined | null {
const entry = ratingCache.get(key);
if (!entry) return null; // cache miss
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
return entry.value;
}
function setCachedRating(key: string, value: number | undefined): void {
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
}
function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
}
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
export function parseSubsonicEntityStarRating(entity: {
userRating?: unknown;
rating?: unknown;
}): number | undefined {
return parseEntityUserRating(entity.userRating ?? entity.rating);
}
/** Bump when rating parse keys change so stale cache entries are not reused. */
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { artist } = await getArtist(id);
const r = parseSubsonicEntityStarRating(artist);
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
export async function prefetchAlbumUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { album } = await getAlbum(id);
const r = parseSubsonicEntityStarRating(album);
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}