// Music library landing page data store. // Powers the focused music landing: hero + horizontal sliders. // TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 import { writable, derived } from "svelte/store"; import type { MediaItem, Genre } from "$lib/api/types"; import { auth } from "./auth"; import { excludePodcasts } from "$lib/utils/podcastFilter"; import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity"; /** A single "by genre" row: the genre name plus the albums in it. */ export interface GenreRow { id: string; name: string; items: MediaItem[]; } interface MusicState { // Albums grouped from recently played tracks (backend handles grouping). recentlyPlayed: MediaItem[]; // Most recently added albums in the music library. newlyAdded: MediaItem[]; // User playlists. playlists: MediaItem[]; // Albums the user has played but hasn't returned to in a while. rediscover: MediaItem[]; // One slider per genre (top genres by album count). genreRows: GenreRow[]; // Mix of recently played + rediscover, used for the hero banner. heroItems: MediaItem[]; isLoading: boolean; error: string | null; } const SECTION_LIMIT = 16; // How many genre sliders to show, and how many genres to probe to find them. const MAX_GENRE_ROWS = 8; // Probe a wide pool so the diverse-selection step has genres from across the // whole (alphabetical) list to choose between, not just the first handful. const MAX_GENRES_PROBED = 40; function createMusicStore() { const initialState: MusicState = { recentlyPlayed: [], newlyAdded: [], playlists: [], rediscover: [], genreRows: [], heroItems: [], isLoading: false, error: null, }; const { subscribe, set, update } = writable(initialState); /** * Build the hero rotation from a mix of recently played and rediscover * albums, interleaved so the banner alternates "fresh in your ears" with * "remember this?". De-duplicates by id and prefers items that have artwork. */ function buildHero(recent: MediaItem[], rediscover: MediaItem[]): MediaItem[] { const hasArt = (i: MediaItem) => !!i.primaryImageTag || !!(i.backdropImageTags && i.backdropImageTags.length > 0); const recentPool = recent.filter(hasArt); const rediscoverPool = rediscover.filter(hasArt); const result: MediaItem[] = []; const seen = new Set(); const maxLen = Math.max(recentPool.length, rediscoverPool.length); for (let i = 0; i < maxLen && result.length < 6; i++) { for (const candidate of [recentPool[i], rediscoverPool[i]]) { if (candidate && !seen.has(candidate.id)) { seen.add(candidate.id); result.push(candidate); } } } return result.slice(0, 6); } async function loadSections(libraryId: string) { update(s => ({ ...s, isLoading: s.recentlyPlayed.length === 0 && s.newlyAdded.length === 0, error: null, })); try { const repo = auth.getRepository(); const [recentlyPlayed, newlyAdded, playlistsResult, rediscover] = await Promise.all([ repo.getRecentlyPlayedAudio(SECTION_LIMIT), repo.getItems(libraryId, { includeItemTypes: ["MusicAlbum"], sortBy: "DateCreated", sortOrder: "Descending", recursive: true, limit: SECTION_LIMIT, }), repo.getItems(libraryId, { includeItemTypes: ["Playlist"], sortBy: "SortName", sortOrder: "Ascending", recursive: true, limit: SECTION_LIMIT, }), repo.getRediscoverAlbums(libraryId, SECTION_LIMIT), ]); // HACK: drop the "Podcasts" folder that lives inside the music library. const recentlyPlayedAlbums = excludePodcasts(recentlyPlayed); const newlyAddedAlbums = excludePodcasts(newlyAdded.items); const playlistItems = excludePodcasts(playlistsResult.items); const rediscoverAlbums = excludePodcasts(rediscover); const heroItems = buildHero(recentlyPlayedAlbums, rediscoverAlbums); update(s => ({ ...s, recentlyPlayed: recentlyPlayedAlbums, newlyAdded: newlyAddedAlbums, playlists: playlistItems, rediscover: rediscoverAlbums, heroItems, isLoading: false, })); // Genre rows are secondary — load them after the main sections paint so // the page isn't blocked on N per-genre queries. loadGenreRows(libraryId); } catch (error) { const message = error instanceof Error ? error.message : "Failed to load music sections"; update(s => ({ ...s, isLoading: false, error: message })); console.error("Failed to load music sections:", error); } } /** Fetch up to SECTION_LIMIT albums for one genre as a slider row. */ async function loadGenreRow(libraryId: string, genre: Genre): Promise { const repo = auth.getRepository(); try { const result = await repo.getItems(libraryId, { includeItemTypes: ["MusicAlbum"], genres: [genre.name], sortBy: "SortName", sortOrder: "Ascending", recursive: true, limit: SECTION_LIMIT, }); // HACK: drop the "Podcasts" folder that lives in the music library. return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) }; } catch (e) { console.warn(`Failed to load genre row "${genre.name}":`, e); return { id: genre.id, name: genre.name, items: [] }; } } /** * Build one slider per genre, showing albums in each. We pick a *diverse* set * rather than just the most populous — otherwise a cluster of near-synonyms * ("Rock", "Hard Rock", "Classic Rock", ...) crowds out musically distinct * genres. See selectDiverseGenres. Album genres rarely carry community * ratings, so we order each row by name. * * When the backend reports per-genre album counts (online), we rank and pick * before fetching, so we only query albums for the genres we'll actually * show. When counts are missing (offline), we fall back to probing a wide * pool, dropping empties, then ranking by what came back. */ async function loadGenreRows(libraryId: string) { try { const repo = auth.getRepository(); const genres = await repo.getGenres(libraryId); if (genres.length === 0) return; // Counts are only *useful* if they actually differentiate genres. Some // servers return Fields=ItemCounts but populate every genre with the same // value (or 0), which leaves the list in its original alphabetical order // after "ranking" — so diversity selection seeds on the first genre and we // get a wall of A-genres ("avangard", "avan-gard", ...) with no Rock. // Treat that as "no usable counts" and fall through to the probe path, // which ranks by genres' real album counts instead. const positiveCounts = genres .map(g => g.albumCount) .filter((c): c is number => c != null && c > 0); const hasUsefulCounts = new Set(positiveCounts).size > 1; let genreRows: GenreRow[]; if (hasUsefulCounts) { // Rank by reported count, pick a diverse subset, then fetch only those. const ranked = [...genres].sort( (a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0) ); const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS); genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter( row => row.items.length > 0 ); } else { // No usable counts (offline, a server that ignores Fields=ItemCounts, // or one that returns uniform/zero counts — see hasUsefulCounts above). // The genre list is alphabetical, so probing the first N would only // ever surface A-genres. Sample at an even stride across the whole // list instead, so the probe pool spans A→Z; then drop empties, rank // by what came back, and pick a diverse subset. const probed = sampleAcross(genres, MAX_GENRES_PROBED); const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g))); const populated = rows .filter(row => row.items.length > 0) .sort((a, b) => b.items.length - a.items.length); genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS); } update(s => ({ ...s, genreRows })); } catch (e) { console.warn("Failed to load music genre rows:", e); } } function reset() { set(initialState); } return { subscribe, loadSections, reset, }; } export const music = createMusicStore(); export const musicHeroItems = derived(music, $m => $m.heroItems); export const isMusicLoading = derived(music, $m => $m.isLoading);