236 lines
8.6 KiB
TypeScript
236 lines
8.6 KiB
TypeScript
// 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<MusicState>(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<string>();
|
|
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<GenreRow> {
|
|
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);
|