mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(lucky-mix): extract pure helpers into luckyMixHelpers.ts (#686)
Move the sampling / dedup / pool-fetching helpers and their constants out of luckyMix.ts into luckyMixHelpers.ts: - sampleRandom, uniqueBySongId, uniqueAppend - deriveTopArtistsFromFrequentAlbums, fetchFrequentAlbumsPool - pickSongsForArtist, pickSongsForAlbum, pickGoodRatedSongs - TopArtist + the MOST_PLAYED / MIX / SEED size constants luckyMix.ts keeps the buildAndPlayLuckyMix orchestration. Verbatim code-move. The LuckyMix page imports buildAndPlayLuckyMix unchanged.
This commit is contained in:
committed by
GitHub
parent
85e9ee6a94
commit
f419081a2a
+14
-126
@@ -1,5 +1,5 @@
|
|||||||
import { getSimilarSongs, getTopSongs } from '../api/subsonicArtists';
|
import { getSimilarSongs } from '../api/subsonicArtists';
|
||||||
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
import { filterSongsToActiveLibrary, getRandomSongs } from '../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
@@ -13,8 +13,19 @@ import { showToast } from './toast';
|
|||||||
import {
|
import {
|
||||||
filterSongsForLuckyMixRatings,
|
filterSongsForLuckyMixRatings,
|
||||||
getMixMinRatingsConfigFromAuth,
|
getMixMinRatingsConfigFromAuth,
|
||||||
type MixMinRatingsConfig,
|
|
||||||
} from './mixRatingFilter';
|
} from './mixRatingFilter';
|
||||||
|
import {
|
||||||
|
MIX_TARGET_SIZE,
|
||||||
|
SEED_TARGET_SIZE,
|
||||||
|
sampleRandom,
|
||||||
|
uniqueBySongId,
|
||||||
|
uniqueAppend,
|
||||||
|
deriveTopArtistsFromFrequentAlbums,
|
||||||
|
fetchFrequentAlbumsPool,
|
||||||
|
pickSongsForArtist,
|
||||||
|
pickSongsForAlbum,
|
||||||
|
pickGoodRatedSongs,
|
||||||
|
} from './luckyMixHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
||||||
@@ -28,129 +39,6 @@ class LuckyMixCancelled extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TopArtist {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
totalPlays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOST_PLAYED_PAGE_SIZE = 100;
|
|
||||||
const MOST_PLAYED_MAX_ALBUMS = 500;
|
|
||||||
const MIX_TARGET_SIZE = 50;
|
|
||||||
const SEED_TARGET_SIZE = 15;
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
|
||||||
return uniqueBySongId([...base, ...incoming]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickSongsForAlbum(
|
|
||||||
albumId: string,
|
|
||||||
need: number,
|
|
||||||
mixRatings: MixMinRatingsConfig,
|
|
||||||
): Promise<SubsonicSong[]> {
|
|
||||||
const full = await getAlbum(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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildAndPlayLuckyMix(): Promise<void> {
|
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||||
const lucky = useLuckyMixStore.getState();
|
const lucky = useLuckyMixStore.getState();
|
||||||
if (lucky.isRolling) return;
|
if (lucky.isRolling) return;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { getTopSongs } from '../api/subsonicArtists';
|
||||||
|
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../api/subsonicLibrary';
|
||||||
|
import type { SubsonicAlbum, SubsonicSong } from '../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 getAlbum(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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user