diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index c14eadde5..0ed583d41 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -167,7 +167,7 @@ pub async fn get_artist( } /// Get artist detail with albums, playlists, and appears-on tracks -/// Fetches 100 albums initially to ensure each album type section +/// Fetches 500 albums initially to ensure each album type section /// (Discography, EPs, Live, etc.) has enough albums loaded #[tauri::command] pub async fn get_artist_detail( @@ -178,7 +178,7 @@ pub async fn get_artist_detail( let client = state.client.lock().await; client - .get_artist_detail(artist_id, Some(100), Some(0)) + .get_artist_detail(artist_id, Some(500), Some(0)) .await .map_err(|e| e.to_string()) } diff --git a/src/lib/adapters/qobuzAdapters.ts b/src/lib/adapters/qobuzAdapters.ts index 422d51101..dc5f94ad5 100644 --- a/src/lib/adapters/qobuzAdapters.ts +++ b/src/lib/adapters/qobuzAdapters.ts @@ -89,6 +89,12 @@ function isLiveAlbum(album: QobuzAlbum): boolean { return /\blive\b|\blive at\b|\blive in\b|\ben concert\b|\bin concert\b|\bconcert\b|\bunplugged\b|\bsessions?\b/.test(title); } +function isOtherAlbum(album: QobuzAlbum): boolean { + const title = album.title?.toLowerCase() ?? ''; + + return /\b(greatest hits|best of|anthology|collection|box set|essentials|retrospective|very best)\b/.test(title); +} + function toArtistAlbumSummary(album: QobuzAlbum): ArtistAlbumSummary { const artwork = getQobuzImage(album.image); const quality = formatQuality( @@ -199,6 +205,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail { const albums: ArtistAlbumSummary[] = []; const epsSingles: ArtistAlbumSummary[] = []; const liveAlbums: ArtistAlbumSummary[] = []; + const others: ArtistAlbumSummary[] = []; for (const album of albumItems) { const summary = toArtistAlbumSummary(album); @@ -206,6 +213,8 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail { liveAlbums.push(summary); } else if (isEpOrSingle(album)) { epsSingles.push(summary); + } else if (isOtherAlbum(album)) { + others.push(summary); } else { albums.push(summary); } @@ -221,6 +230,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail { epsSingles, liveAlbums, compilations: buildCompilationAlbums(artist.tracks_appears_on?.items), + others, playlists: toArtistPlaylists(artist.playlists), totalAlbums: artist.albums?.total || artist.albums_count || 0, albumsFetched @@ -238,10 +248,12 @@ export function appendArtistAlbums( const existingAlbumIds = new Set(artist.albums.map(album => album.id)); const existingEpIds = new Set(artist.epsSingles.map(album => album.id)); const existingLiveIds = new Set(artist.liveAlbums.map(album => album.id)); + const existingOtherIds = new Set(artist.others.map(album => album.id)); const albums = [...artist.albums]; const epsSingles = [...artist.epsSingles]; const liveAlbums = [...artist.liveAlbums]; + const others = [...artist.others]; for (const album of newAlbums) { const summary = toArtistAlbumSummary(album); @@ -255,6 +267,11 @@ export function appendArtistAlbums( epsSingles.push(summary); existingEpIds.add(summary.id); } + } else if (isOtherAlbum(album)) { + if (!existingOtherIds.has(summary.id)) { + others.push(summary); + existingOtherIds.add(summary.id); + } } else if (!existingAlbumIds.has(summary.id)) { albums.push(summary); existingAlbumIds.add(summary.id); @@ -266,6 +283,7 @@ export function appendArtistAlbums( albums, epsSingles, liveAlbums, + others, totalAlbums: totalAlbums ?? artist.totalAlbums, albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length }; diff --git a/src/lib/components/HomeSettingsModal.svelte b/src/lib/components/HomeSettingsModal.svelte index 2b7b590ef..18576cfc1 100644 --- a/src/lib/components/HomeSettingsModal.svelte +++ b/src/lib/components/HomeSettingsModal.svelte @@ -11,6 +11,7 @@ setGreetingEnabled, setCustomGreeting, resetToDefaults, + updateLimit, type HomeSettings } from '$lib/stores/homeSettingsStore'; import { t } from '$lib/i18n'; @@ -57,6 +58,10 @@ function handleReset() { resetToDefaults(); } + + function handleLimitChange(key: keyof HomeSettings['limits'], value: number) { + updateLimit(key, value); + } @@ -98,6 +103,92 @@ {/if} + +
+
Item Limits
+

Configure how many items to show in each section

+ +
+
+ + handleLimitChange('recentAlbums', Number(e.currentTarget.value))} + /> +
+ +
+ + handleLimitChange('continueTracks', Number(e.currentTarget.value))} + /> +
+ +
+ + handleLimitChange('topArtists', Number(e.currentTarget.value))} + /> +
+ +
+ + handleLimitChange('favoriteAlbums', Number(e.currentTarget.value))} + /> +
+ +
+ + handleLimitChange('favoriteTracks', Number(e.currentTarget.value))} + /> +
+ +
+ + handleLimitChange('featuredAlbums', Number(e.currentTarget.value))} + /> +
+
+
+
{$t('homeSettings.sections')}
@@ -388,4 +479,41 @@ border-color: var(--text-muted); color: var(--text-primary); } + + .limits-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + .limit-item { + display: flex; + flex-direction: column; + gap: 6px; + } + + .limit-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + } + + .limit-input { + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--bg-tertiary); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + transition: all 150ms ease; + } + + .limit-input:focus { + outline: none; + border-color: var(--accent); + } + + .limit-input:hover { + border-color: var(--text-muted); + } diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index 687a99b98..f02474cc9 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -114,6 +114,7 @@ let epsSinglesSection = $state(null); let liveAlbumsSection = $state(null); let compilationsSection = $state(null); + let othersSection = $state(null); let playlistsSection = $state(null); let activeJumpSection = $state('about'); let jumpObserver: IntersectionObserver | null = null; @@ -149,7 +150,8 @@ ...artist.albums, ...artist.epsSingles, ...artist.liveAlbums, - ...artist.compilations + ...artist.compilations, + ...artist.others ]; loadAllAlbumDownloadStatuses(allAlbums); } @@ -185,7 +187,8 @@ ...artist.albums, ...artist.epsSingles, ...artist.liveAlbums, - ...artist.compilations + ...artist.compilations, + ...artist.others ]; await loadAllAlbumDownloadStatuses(allAlbums); } @@ -339,7 +342,7 @@ // Truncate bio for collapsed view let truncatedBio = $derived( - bioText && bioText.length > 300 ? bioText.slice(0, 300) + '...' : bioText + bioText && bioText.length > 500 ? bioText.slice(0, 500) + '...' : bioText ); let hasMoreAlbums = $derived(!!onLoadMore && artist.albumsFetched < artist.totalAlbums); @@ -347,6 +350,7 @@ let hasEpsSingles = $derived(artist.epsSingles.length > 0); let hasLiveAlbums = $derived(artist.liveAlbums.length > 0); let hasCompilations = $derived(artist.compilations.length > 0); + let hasOthers = $derived(artist.others.length > 0); let hasPlaylists = $derived(artist.playlists.length > 0); let jumpSections = $derived.by(() => [ { id: 'about', label: 'About', el: aboutSection, visible: true }, @@ -355,6 +359,7 @@ { id: 'eps', label: 'EPs & Singles', el: epsSinglesSection, visible: hasEpsSingles }, { id: 'live', label: 'Live Albums', el: liveAlbumsSection, visible: hasLiveAlbums }, { id: 'compilations', label: 'Compilations', el: compilationsSection, visible: hasCompilations }, + { id: 'others', label: 'Others', el: othersSection, visible: hasOthers }, { id: 'playlists', label: 'Playlists', el: playlistsSection, visible: hasPlaylists }, ].filter(section => section.visible)); @@ -747,6 +752,36 @@
{/if} + {#if artist.others.length > 0} +
+ +
+

Others

+
+ {#each artist.others as album} + onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} + isAlbumFullyDownloaded={isAlbumDownloaded(album.id)} + onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} + onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} + {downloadStateVersion} + onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }} + /> + {/each} +
+
+ {/if} + {#if artist.playlists.length > 0}
diff --git a/src/lib/components/views/HomeView.svelte b/src/lib/components/views/HomeView.svelte index 02473cec8..5ba08d8aa 100644 --- a/src/lib/components/views/HomeView.svelte +++ b/src/lib/components/views/HomeView.svelte @@ -107,6 +107,8 @@ featuredAlbums: 12 }; + let homeLimits = $state(getSettings().limits); + // Loading states for progressive render let isInitializing = $state(true); let isOverlayVisible = $state(true); // Overlay that fades out when ALL sections ready @@ -215,6 +217,7 @@ // Subscribe to home settings changes const unsubscribe = subscribeHomeSettings(() => { homeSettings = getSettings(); + homeLimits = getSettings().limits; }); // Load home data @@ -341,7 +344,7 @@ return Array.from(counts.entries()) .map(([artistId, playCount]) => ({ artistId, playCount })) .sort((a, b) => b.playCount - a.playCount) - .slice(0, LIMITS.topArtists); + .slice(0, homeLimits.topArtists); } async function loadHome() { @@ -373,15 +376,15 @@ // Start ML data loading FIRST (local SQLite) - this gets the seeds const mlPromise = invoke('reco_get_home_ml', { - limitRecentAlbums: LIMITS.recentAlbums, - limitContinueTracks: LIMITS.continueTracks, - limitTopArtists: LIMITS.topArtists, - limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks) + limitRecentAlbums: homeLimits.recentAlbums, + limitContinueTracks: homeLimits.continueTracks, + limitTopArtists: homeLimits.topArtists, + limitFavorites: Math.max(homeLimits.favoriteAlbums, homeLimits.favoriteTracks) }); // Start Qobuz API calls in parallel (don't await) if (isSectionVisible('newReleases')) { - fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(async albums => { + fetchFeaturedAlbums('new-releases', homeLimits.featuredAlbums).then(async albums => { newReleases = albums; await loadAllAlbumDownloadStatuses(albums); loadingNewReleases = false; @@ -392,7 +395,7 @@ } if (isSectionVisible('pressAwards')) { - fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(async albums => { + fetchFeaturedAlbums('press-awards', homeLimits.featuredAlbums).then(async albums => { pressAwards = albums; await loadAllAlbumDownloadStatuses(albums); loadingPressAwards = false; @@ -403,7 +406,7 @@ } if (isSectionVisible('mostStreamed')) { - fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(async albums => { + fetchFeaturedAlbums('most-streamed', homeLimits.featuredAlbums).then(async albums => { mostStreamed = albums; await loadAllAlbumDownloadStatuses(albums); loadingMostStreamed = false; @@ -414,7 +417,7 @@ } if (isSectionVisible('qobuzissimes')) { - fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(async albums => { + fetchFeaturedAlbums('qobuzissimes', homeLimits.featuredAlbums).then(async albums => { qobuzissimes = albums; await loadAllAlbumDownloadStatuses(albums); loadingQobuzissimes = false; @@ -425,7 +428,7 @@ } if (isSectionVisible('editorPicks')) { - fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(async albums => { + fetchFeaturedAlbums('editor-picks', homeLimits.featuredAlbums).then(async albums => { editorPicks = albums; await loadAllAlbumDownloadStatuses(albums); loadingEditorPicks = false; @@ -454,7 +457,7 @@ // Recently Played (albums) - start immediately with seeds if (isSectionVisible('recentAlbums')) { const recentAlbumIds = normalizeAlbumIds(seeds.recentlyPlayedAlbumIds); - fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(async albums => { + fetchAlbums(recentAlbumIds.slice(0, homeLimits.recentAlbums)).then(async albums => { recentAlbums = albums; await loadAllAlbumDownloadStatuses(albums); loadingRecentAlbums = false; @@ -466,7 +469,7 @@ // Top Artists if (isSectionVisible('topArtists')) { - fetchArtists(seeds.topArtistIds.slice(0, LIMITS.topArtists)).then(artists => { + fetchArtists(seeds.topArtistIds.slice(0, homeLimits.topArtists)).then(artists => { topArtists = artists; loadingTopArtists = false; markSectionFinished(); @@ -478,12 +481,12 @@ // Favorite Albums if (isSectionVisible('favoriteAlbums')) { // Get favorite track details to extract album IDs - fetchTracks(seeds.favoriteTrackIds.slice(0, LIMITS.favoriteTracks)).then(async favoriteTrackDetails => { + fetchTracks(seeds.favoriteTrackIds.slice(0, homeLimits.favoriteTracks)).then(async favoriteTrackDetails => { const favoriteAlbumIds = normalizeAlbumIds([ ...seeds.favoriteAlbumIds, ...favoriteTrackDetails.map(track => track.albumId) ]); - const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums)); + const albums = await fetchAlbums(favoriteAlbumIds.slice(0, homeLimits.favoriteAlbums)); favoriteAlbums = albums; await loadAllAlbumDownloadStatuses(albums); loadingFavoriteAlbums = false; diff --git a/src/lib/stores/homeSettingsStore.ts b/src/lib/stores/homeSettingsStore.ts index 78fc3a171..0a418091d 100644 --- a/src/lib/stores/homeSettingsStore.ts +++ b/src/lib/stores/homeSettingsStore.ts @@ -22,6 +22,7 @@ export interface HomeSection { label: string; visible: boolean; source?: 'qobuz' | 'ml'; // qobuz = editorial, ml = ML-powered recommendations + limit?: number; // configurable item count } export interface HomeSettings { @@ -30,6 +31,14 @@ export interface HomeSettings { enabled: boolean; customText: string | null; // null = use default time-based greeting }; + limits: { + recentAlbums: number; + continueTracks: number; + topArtists: number; + favoriteAlbums: number; + favoriteTracks: number; + featuredAlbums: number; + }; } const STORAGE_KEY = 'qbz-home-settings'; @@ -52,6 +61,14 @@ const DEFAULT_SETTINGS: HomeSettings = { greeting: { enabled: true, customText: null + }, + limits: { + recentAlbums: 20, + continueTracks: 10, + topArtists: 20, + favoriteAlbums: 12, + favoriteTracks: 10, + featuredAlbums: 12 } }; @@ -88,7 +105,8 @@ function loadSettings(): HomeSettings { function mergeWithDefaults(saved: Partial): HomeSettings { const result: HomeSettings = { sections: [], - greeting: saved.greeting ?? DEFAULT_SETTINGS.greeting + greeting: saved.greeting ?? DEFAULT_SETTINGS.greeting, + limits: { ...DEFAULT_SETTINGS.limits, ...(saved.limits || {}) } }; // Build section map from saved @@ -304,3 +322,21 @@ export function resetToDefaults(): void { saveSettings(); notifyListeners(); } + +/** + * Update a specific limit + */ +export function updateLimit(key: keyof HomeSettings['limits'], value: number): void { + if (value >= 1 && value <= 100) { + settings.limits[key] = value; + saveSettings(); + notifyListeners(); + } +} + +/** + * Get current limits + */ +export function getLimits(): HomeSettings['limits'] { + return settings.limits; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index c16d41793..a7d5bee0d 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -152,6 +152,13 @@ export interface ArtistDetail { year?: string; quality: string; }[]; + others: { + id: string; + title: string; + artwork: string; + year?: string; + quality: string; + }[]; playlists: { id: number; title: string;