mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
006635de4b
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).
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../api/subsonicRatings';
|
||
import { getRandomSongs } from '../api/subsonicLibrary';
|
||
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||
import { useAuthStore } from '../store/authStore';
|
||
|
||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||
/** Subsonic spec caps `getRandomSongs` at 500 per call. */
|
||
const RANDOM_MIX_BATCH_HARD_CAP = 500;
|
||
const RANDOM_MIX_BATCH_FLOOR = 50;
|
||
/** Per-call batch size depends on whether a filter is active.
|
||
* - No filter: ask for the full target in one call (server ORDER BY random LIMIT N is one query).
|
||
* - With filter: stick to small 50-track batches — large batches waste bandwidth when only a small
|
||
* fraction of candidates pass the filter, and they make every loop iteration slow on the server. */
|
||
function batchSizeFor(targetSize: number, filterActive: boolean): number {
|
||
if (filterActive) return RANDOM_MIX_BATCH_FLOOR;
|
||
return Math.min(RANDOM_MIX_BATCH_HARD_CAP, Math.max(RANDOM_MIX_BATCH_FLOOR, targetSize));
|
||
}
|
||
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme).
|
||
* Filtered mode is generous because a selective filter (e.g. only ≥3★ in a library where 5% of tracks are
|
||
* rated) needs many candidates to find a target's worth of passes. Unfiltered mode just tops up the
|
||
* fast-path's first call. */
|
||
function maxBatchesFor(targetSize: number, batchSize: number, filterActive: boolean): number {
|
||
if (filterActive) {
|
||
return Math.max(40, Math.ceil((targetSize * 8) / batchSize));
|
||
}
|
||
return Math.max(8, Math.ceil((targetSize * 4) / batchSize));
|
||
}
|
||
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set).
|
||
* Scales with target size: at 50 a 6-batch floor is fine; at 150 we tolerate ~25 empty-novel batches
|
||
* before giving up so libraries with weakly-shuffled random endpoints can still fill the larger requests.
|
||
* Without this, "All Songs" mixes at 150 stalled around 120–145 while Genre mixes (smaller candidate pool,
|
||
* lower repeat rate per batch) could reach 150 cleanly. */
|
||
function dupStreakBudget(targetSize: number): number {
|
||
return Math.max(8, Math.ceil(targetSize / 6));
|
||
}
|
||
|
||
export interface MixMinRatingsConfig {
|
||
enabled: boolean;
|
||
minSong: number;
|
||
minAlbum: number;
|
||
minArtist: number;
|
||
}
|
||
|
||
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
|
||
const s = useAuthStore.getState();
|
||
return {
|
||
enabled: s.mixMinRatingFilterEnabled,
|
||
minSong: s.mixMinRatingSong,
|
||
minAlbum: s.mixMinRatingAlbum,
|
||
minArtist: s.mixMinRatingArtist,
|
||
};
|
||
}
|
||
|
||
function numRating(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;
|
||
}
|
||
|
||
type OpenArtistRefLike = { id?: string; userRating?: unknown; rating?: unknown };
|
||
|
||
function refStarRating(a: OpenArtistRefLike | undefined): number | undefined {
|
||
return numRating(a?.userRating ?? a?.rating);
|
||
}
|
||
|
||
function ratingFromArtistRefs(
|
||
list: OpenArtistRefLike[] | undefined,
|
||
preferId?: string,
|
||
): number | undefined {
|
||
if (!list?.length) return undefined;
|
||
if (preferId) {
|
||
const m = list.find(x => x.id === preferId);
|
||
const r = refStarRating(m);
|
||
if (r !== undefined) return r;
|
||
}
|
||
for (const a of list) {
|
||
const r = refStarRating(a);
|
||
if (r !== undefined) return r;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
const CONTRIBUTOR_ROLES_FOR_ARTIST_ID =
|
||
/^(artist|album[\s_-]*artist|performer|track[\s_-]*artist|albumartist)$/i;
|
||
|
||
/**
|
||
* Entity id for artist-level mix rating: canonical `artistId`, else OpenSubsonic `artists[].id`,
|
||
* else Navidrome `contributors[].artist.id` when list payloads omit the former.
|
||
*/
|
||
function artistEntityIdForMixRating(song: SubsonicSong): string | undefined {
|
||
if (song.artistId) return song.artistId;
|
||
const fromArtists = song.artists?.find(a => a.id)?.id;
|
||
if (fromArtists) return fromArtists;
|
||
const cList = song.contributors;
|
||
if (cList?.length) {
|
||
const byRole = cList.find(
|
||
c => c.artist?.id && CONTRIBUTOR_ROLES_FOR_ARTIST_ID.test((c.role || '').trim()),
|
||
);
|
||
if (byRole?.artist?.id) return byRole.artist.id;
|
||
const anyId = cList.find(c => c.artist?.id);
|
||
if (anyId?.artist?.id) return anyId.artist.id;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
|
||
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
|
||
const d = numRating(song.artistUserRating);
|
||
if (d !== undefined) return d;
|
||
const prefer = artistEntityIdForMixRating(song);
|
||
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
|
||
if (fromArtists !== undefined) return fromArtists;
|
||
return ratingFromArtistRefs(song.albumArtists, prefer);
|
||
}
|
||
|
||
/** Song-level album (parent) rating when the server puts it on the child payload. */
|
||
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
|
||
const x = song as SubsonicSong & { albumRating?: unknown };
|
||
return numRating(song.albumUserRating ?? x.albumRating);
|
||
}
|
||
|
||
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
|
||
const x = song as SubsonicSong & { rating?: unknown };
|
||
return numRating(song.userRating ?? x.rating);
|
||
}
|
||
|
||
/**
|
||
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
|
||
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
|
||
*/
|
||
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
||
if (!c.enabled) return true;
|
||
if (c.minSong > 0) {
|
||
const r = songTrackStarRatingForMix(song);
|
||
if (r !== undefined && r > 0 && r <= c.minSong) return false;
|
||
}
|
||
if (c.minAlbum > 0) {
|
||
const r = effectiveAlbumRatingOnSong(song);
|
||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||
}
|
||
if (c.minArtist > 0) {
|
||
const r = effectiveArtistRatingForFilter(song);
|
||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export interface MixAlbumFilterExtra {
|
||
/** From `getArtist` when list payloads omit artist rating. */
|
||
artistUserRating?: number;
|
||
/** From `getAlbum` when list payloads omit album `userRating`. */
|
||
albumUserRating?: number;
|
||
}
|
||
|
||
/**
|
||
* Random album lists: album `userRating` when present; optional extra from entity fetches.
|
||
* Song axis is not on this payload. `0` / missing = unrated, keep.
|
||
*/
|
||
export function passesMixMinRatingsForAlbum(
|
||
album: SubsonicAlbum,
|
||
c: MixMinRatingsConfig,
|
||
extra?: MixAlbumFilterExtra,
|
||
): boolean {
|
||
if (!c.enabled) return true;
|
||
if (c.minAlbum > 0) {
|
||
const r =
|
||
parseSubsonicEntityStarRating(album as SubsonicAlbum & { rating?: unknown })
|
||
?? numRating(extra?.albumUserRating);
|
||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||
}
|
||
if (c.minArtist > 0) {
|
||
const r = numRating(extra?.artistUserRating);
|
||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
|
||
*/
|
||
export async function filterAlbumsByMixRatings(
|
||
albums: SubsonicAlbum[],
|
||
c: MixMinRatingsConfig,
|
||
): Promise<SubsonicAlbum[]> {
|
||
if (!c.enabled) return albums;
|
||
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
|
||
const needArtist = c.minArtist > 0;
|
||
const needAlbum = c.minAlbum > 0;
|
||
let byArtist = new Map<string, number>();
|
||
let byAlbum = new Map<string, number>();
|
||
if (needArtist) {
|
||
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
|
||
byArtist = await prefetchArtistUserRatings(ids);
|
||
}
|
||
if (needAlbum) {
|
||
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
|
||
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
|
||
}
|
||
return albums.filter(a =>
|
||
passesMixMinRatingsForAlbum(a, c, {
|
||
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
|
||
albumUserRating: byAlbum.get(a.id),
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** Enrich when needed, then drop songs excluded by Settings → Ratings → filter-by-rating. */
|
||
export async function filterSongsForLuckyMixRatings(
|
||
songs: SubsonicSong[],
|
||
c: MixMinRatingsConfig,
|
||
): Promise<SubsonicSong[]> {
|
||
if (!c.enabled) return songs;
|
||
const enriched = await enrichSongsForMixRatingFilter(songs, c);
|
||
return enriched.filter(s => passesMixMinRatings(s, c));
|
||
}
|
||
|
||
/**
|
||
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
|
||
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
|
||
*/
|
||
export async function enrichSongsForMixRatingFilter(
|
||
songs: SubsonicSong[],
|
||
c: MixMinRatingsConfig,
|
||
): Promise<SubsonicSong[]> {
|
||
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
|
||
const artistIds =
|
||
c.minArtist > 0
|
||
? [
|
||
...new Set(
|
||
songs
|
||
.filter(
|
||
s =>
|
||
s.artistUserRating === undefined
|
||
&& effectiveArtistRatingForFilter(s) === undefined
|
||
&& artistEntityIdForMixRating(s),
|
||
)
|
||
.map(s => artistEntityIdForMixRating(s)!),
|
||
),
|
||
]
|
||
: [];
|
||
const albumIds =
|
||
c.minAlbum > 0
|
||
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
|
||
: [];
|
||
const [byArtist, byAlbum] = await Promise.all([
|
||
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
|
||
]);
|
||
if (!byArtist.size && !byAlbum.size) return songs;
|
||
return songs.map(s => {
|
||
const aid = artistEntityIdForMixRating(s);
|
||
const artistPatch =
|
||
s.artistUserRating === undefined && aid && byArtist.has(aid)
|
||
? { artistUserRating: byArtist.get(aid)! }
|
||
: {};
|
||
const albumPatch =
|
||
s.albumUserRating === undefined && s.albumId && byAlbum.has(s.albumId)
|
||
? { albumUserRating: byAlbum.get(s.albumId)! }
|
||
: {};
|
||
return { ...s, ...artistPatch, ...albumPatch };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Loads random songs in batches until `targetSize` (or `RANDOM_MIX_TARGET_SIZE`) songs
|
||
* pass `passesMixMinRatings` (after enrich), or batch/duplicate limits are hit.
|
||
*
|
||
* When NO filter is active (neither `enabled` nor `onlyRatedMinStars`), a single
|
||
* batch fast-path is used and capped to a single batch — for `targetSize` greater
|
||
* than `RANDOM_MIX_BATCH_SIZE` (50) the loop path is taken so we can issue multiple
|
||
* `getRandomSongs` calls.
|
||
*/
|
||
export async function fetchRandomMixSongsUntilFull(
|
||
c: MixMinRatingsConfig,
|
||
opts?: { genre?: string; timeout?: number; targetSize?: number },
|
||
): Promise<SubsonicSong[]> {
|
||
const timeout = opts?.timeout ?? 15000;
|
||
const genre = opts?.genre;
|
||
const targetSize = opts?.targetSize ?? RANDOM_MIX_TARGET_SIZE;
|
||
const filterActive = c.enabled;
|
||
const batchSize = batchSizeFor(targetSize, filterActive);
|
||
|
||
// Fast-path: no filter — one call asking for the full target, slice, done. The server-side
|
||
// `ORDER BY random() LIMIT N` returns N distinct rows, so a single round-trip usually fills
|
||
// the request without dup-streak gymnastics.
|
||
if (!filterActive) {
|
||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||
if (raw.length >= targetSize) return raw.slice(0, targetSize);
|
||
// Library smaller than target, or random endpoint returned fewer — fall through to the
|
||
// batched loop below so we can top up via additional calls (deduped by id).
|
||
}
|
||
|
||
const maxBatches = maxBatchesFor(targetSize, batchSize, filterActive);
|
||
const maxDupStreak = dupStreakBudget(targetSize);
|
||
|
||
const out: SubsonicSong[] = [];
|
||
const outIds = new Set<string>();
|
||
const seenFromApi = new Set<string>();
|
||
let dupStreak = 0;
|
||
|
||
for (let b = 0; b < maxBatches && out.length < targetSize; b++) {
|
||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||
if (!raw.length) break;
|
||
|
||
const novel = raw.filter(s => !seenFromApi.has(s.id));
|
||
for (const s of raw) seenFromApi.add(s.id);
|
||
|
||
if (!novel.length) {
|
||
if (++dupStreak >= maxDupStreak) break;
|
||
continue;
|
||
}
|
||
dupStreak = 0;
|
||
|
||
const enriched = filterActive
|
||
? await enrichSongsForMixRatingFilter(novel, c)
|
||
: novel;
|
||
for (const s of enriched) {
|
||
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
|
||
outIds.add(s.id);
|
||
out.push(s);
|
||
if (out.length >= targetSize) break;
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|