feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s

- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
This commit is contained in:
2026-06-25 19:18:06 +02:00
parent 62874564ff
commit 1836615dc0
38 changed files with 1009 additions and 207 deletions
+91 -1
View File
@@ -2,9 +2,17 @@
// 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 } from "$lib/api/types";
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).
@@ -15,6 +23,8 @@ interface MusicState {
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;
@@ -22,6 +32,11 @@ interface MusicState {
}
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 = {
@@ -29,6 +44,7 @@ function createMusicStore() {
newlyAdded: [],
playlists: [],
rediscover: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
@@ -110,6 +126,10 @@ function createMusicStore() {
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 }));
@@ -117,6 +137,76 @@ function createMusicStore() {
}
}
/** 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;
const hasCounts = genres.some(g => g.albumCount != null);
let genreRows: GenreRow[];
if (hasCounts) {
// 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 counts (offline, or a server that ignores Fields=ItemCounts).
// 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);
}