Replaces a hardcoded filter that dropped anything named "Podcasts" from music results — one user's library layout compiled into the shipped product, keyed on an English literal, applied only at the six call sites someone had remembered. Exclusion is now a user setting stored in Rust and applied at the repository layer's convergence points, so scope is decided once and is the same on every screen. It matches on folder id rather than name: a title is not what an item is, which is why an album legitimately called "Podcasts" used to vanish. Deliberately not filtered: get_item (an id asked for by name was navigated to on purpose, and refusing it would break playback of anything inside a hidden folder), get_downloaded_items (hiding a download would leave the user unable to delete a file whose disk usage they can still see), and the offline cache (an exclusion is a view preference and must be reversible without a re-crawl). Also removes src/lib/utils/validation.ts — six exported validators with no caller outside their own test file, which made the module read as covered input validation while guarding nothing. TRACES: UR-076 | DR-209 | UT-203
225 lines
8.2 KiB
TypeScript
225 lines
8.2 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 { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
|
|
import { buildHeroMix } from "$lib/utils/heroMix";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("MusicStore");
|
|
|
|
/** 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);
|
|
|
|
/** Artwork check for hero candidates: needs a primary image or backdrop. */
|
|
const hasArt = (i: MediaItem) =>
|
|
!!i.imageId || !!(i.backdropImageTags && i.backdropImageTags.length > 0);
|
|
|
|
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, surprise] = 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),
|
|
// Random pool so the hero rotation changes between visits (SortBy=Random
|
|
// shuffles server-side online, and via SQLite RANDOM() offline).
|
|
repo
|
|
.getItems(libraryId, {
|
|
includeItemTypes: ["MusicAlbum"],
|
|
sortBy: "Random",
|
|
recursive: true,
|
|
limit: SECTION_LIMIT,
|
|
})
|
|
.then(r => r.items)
|
|
.catch(() => [] as MediaItem[]),
|
|
]);
|
|
|
|
// Nothing is filtered here: folders the user chose to hide are already
|
|
// gone, dropped by the repository layer that answered these queries.
|
|
// TRACES: UR-076 | DR-209
|
|
|
|
// Mix the hero: fresh-in-your-ears first, then "remember this?", then
|
|
// random albums from across the library.
|
|
const heroItems = buildHeroMix([recentlyPlayed, rediscover, surprise], hasArt);
|
|
|
|
update(s => ({
|
|
...s,
|
|
recentlyPlayed,
|
|
newlyAdded: newlyAdded.items,
|
|
playlists: playlistsResult.items,
|
|
rediscover,
|
|
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 }));
|
|
log.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,
|
|
});
|
|
return { id: genre.id, name: genre.name, items: result.items };
|
|
} catch (e) {
|
|
log.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) {
|
|
log.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);
|