feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s

Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
  persist the resolved stream URL + target dir on each row (migration
  017), and the pump starts up to max_concurrent and drains the rest
  automatically as slots free, instead of the frontend silently dropping
  items past the concurrency limit. Album/series/season buttons now
  enqueue rather than calling start_download directly.

Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
  cache+server union via a request-id-tagged search-event, so superseded
  queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
This commit is contained in:
2026-06-24 20:44:17 +02:00
parent dcf08f30bc
commit 17a35573a0
33 changed files with 2045 additions and 188 deletions
+134
View File
@@ -0,0 +1,134 @@
// 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 } from "$lib/api/types";
import { auth } from "./auth";
import { excludePodcasts } from "$lib/utils/podcastFilter";
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[];
// Mix of recently played + rediscover, used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
function createMusicStore() {
const initialState: MusicState = {
recentlyPlayed: [],
newlyAdded: [],
playlists: [],
rediscover: [],
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,
}));
} 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);
}
}
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);