mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
8cc022581f
Move the 5 subsonic api modules that M3 had co-located into features (subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the feature-free infra layer, and drop their feature-barrel re-exports. Consumers now import these protocol calls directly from @/lib/api/subsonic*; the feature barrels export only domain UI/hooks/state. This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol clients are feature-free infra, not per-feature) and it kills the documented api-induced feature<->feature cycles: offline->artist/playlist (getArtist/ getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*. Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper, tracked for M5, not api placement. Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green. Tooling note: barrel-imported and dynamic-import api symbols were split out of @/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import { getTopSongs } from '@/lib/api/subsonicArtists';
|
|
import { filterSongsToActiveLibrary, getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
|
|
import { resolveAlbumForActiveServer } from '@/features/offline';
|
|
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import {
|
|
filterSongsForLuckyMixRatings,
|
|
type MixMinRatingsConfig,
|
|
} from './mixRatingFilter';
|
|
|
|
export interface TopArtist {
|
|
id: string;
|
|
name: string;
|
|
totalPlays: number;
|
|
}
|
|
|
|
export const MOST_PLAYED_PAGE_SIZE = 100;
|
|
export const MOST_PLAYED_MAX_ALBUMS = 500;
|
|
export const MIX_TARGET_SIZE = 50;
|
|
export const SEED_TARGET_SIZE = 15;
|
|
|
|
export function sampleRandom<T>(items: T[], count: number): T[] {
|
|
if (count <= 0 || items.length === 0) return [];
|
|
const arr = [...items];
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr.slice(0, Math.min(count, arr.length));
|
|
}
|
|
|
|
export function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
|
const out: SubsonicSong[] = [];
|
|
const seen = new Set<string>();
|
|
for (const s of items) {
|
|
if (!s?.id || seen.has(s.id)) continue;
|
|
seen.add(s.id);
|
|
out.push(s);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
|
return uniqueBySongId([...base, ...incoming]);
|
|
}
|
|
|
|
export function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
|
const map = new Map<string, TopArtist>();
|
|
for (const a of albums) {
|
|
const plays = a.playCount ?? 0;
|
|
if (!a.artistId || !a.artist || plays <= 0) continue;
|
|
const prev = map.get(a.artistId);
|
|
if (prev) {
|
|
prev.totalPlays += plays;
|
|
continue;
|
|
}
|
|
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
|
}
|
|
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
|
}
|
|
|
|
export async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
|
const out: SubsonicAlbum[] = [];
|
|
let offset = 0;
|
|
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
|
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
|
if (!page.length) break;
|
|
out.push(...page);
|
|
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
|
offset += MOST_PLAYED_PAGE_SIZE;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function pickSongsForArtist(
|
|
artist: TopArtist,
|
|
need: number,
|
|
mixRatings: MixMinRatingsConfig,
|
|
): Promise<SubsonicSong[]> {
|
|
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
|
let pool = primary;
|
|
if (primary.length < need) {
|
|
const extra: SubsonicSong[] = [];
|
|
for (let i = 0; i < 8 && primary.length + extra.length < need * 4; i++) {
|
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
|
for (const s of rnd) {
|
|
if (s.artistId === artist.id || s.artist === artist.name) {
|
|
extra.push(s);
|
|
}
|
|
}
|
|
}
|
|
pool = uniqueBySongId([...primary, ...extra]);
|
|
}
|
|
const filtered = await filterSongsForLuckyMixRatings(pool, mixRatings);
|
|
return sampleRandom(filtered, Math.min(need, filtered.length));
|
|
}
|
|
|
|
export async function pickSongsForAlbum(
|
|
albumId: string,
|
|
need: number,
|
|
mixRatings: MixMinRatingsConfig,
|
|
): Promise<SubsonicSong[]> {
|
|
const full = await resolveAlbumForActiveServer(albumId).catch(() => null);
|
|
if (!full?.songs?.length) return [];
|
|
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
|
const unique = uniqueBySongId(scopedSongs);
|
|
const filtered = await filterSongsForLuckyMixRatings(unique, mixRatings);
|
|
return sampleRandom(filtered, Math.min(need, filtered.length));
|
|
}
|
|
|
|
export async function pickGoodRatedSongs(
|
|
existingIds: Set<string>,
|
|
need: number,
|
|
mixRatings: MixMinRatingsConfig,
|
|
): Promise<SubsonicSong[]> {
|
|
const out: SubsonicSong[] = [];
|
|
const push = (s: SubsonicSong) => {
|
|
const r = s.userRating ?? 0;
|
|
if (r < 4) return;
|
|
if (existingIds.has(s.id)) return;
|
|
if (out.some(x => x.id === s.id)) return;
|
|
out.push(s);
|
|
};
|
|
|
|
for (let i = 0; i < 14 && out.length < need * 8; i++) {
|
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
|
rnd.forEach(push);
|
|
}
|
|
|
|
const filtered = await filterSongsForLuckyMixRatings(out, mixRatings);
|
|
return sampleRandom(filtered, Math.min(need, filtered.length));
|
|
}
|