Files
jellytau/src/lib/stores/music.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

225 lines
8.3 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);