diff --git a/src/utils/luckyMix.ts b/src/utils/luckyMix.ts index dee4b325..e99811ad 100644 --- a/src/utils/luckyMix.ts +++ b/src/utils/luckyMix.ts @@ -1,5 +1,5 @@ -import { getSimilarSongs, getTopSongs } from '../api/subsonicArtists'; -import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../api/subsonicLibrary'; +import { getSimilarSongs } from '../api/subsonicArtists'; +import { filterSongsToActiveLibrary, getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; import type { Track } from '../store/playerStoreTypes'; import { songToTrack } from '../utils/songToTrack'; @@ -13,8 +13,19 @@ import { showToast } from './toast'; import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth, - type MixMinRatingsConfig, } 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` @@ -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(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(); - 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(); - 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 { - 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 { - 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 { - 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, - need: number, - mixRatings: MixMinRatingsConfig, -): Promise { - 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 { const lucky = useLuckyMixStore.getState(); if (lucky.isRolling) return; diff --git a/src/utils/luckyMixHelpers.ts b/src/utils/luckyMixHelpers.ts new file mode 100644 index 00000000..b1b4c079 --- /dev/null +++ b/src/utils/luckyMixHelpers.ts @@ -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(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(); + 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(); + 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 { + 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 { + 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 { + 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, + need: number, + mixRatings: MixMinRatingsConfig, +): Promise { + 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)); +}