From 5be437dd4e1e0e7ba86fbcaaf749177ff4e98911 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 10 Jan 2026 05:51:48 -0600 Subject: [PATCH] refactor: extract Qobuz adapters and unify DisplayTrack type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create $lib/adapters/qobuzAdapters.ts with: - formatDuration/formatDurationMinutes (removed duplicates) - getQobuzImage helper - formatQuality/formatAlbumQuality helpers - convertQobuzAlbum converter - convertQobuzArtist converter - Add unified DisplayTrack type to $lib/types/index.ts - Update +page.svelte: - Import adapters instead of inline functions - Remove duplicate converter functions (−57 lines) - Add handleDisplayTrackPlay for view components - Fix TypeScript type narrowing in view closures --- src/lib/adapters/qobuzAdapters.ts | 138 ++++++++++++++++++ .../components/views/AlbumDetailView.svelte | 2 +- .../components/views/ArtistDetailView.svelte | 4 +- .../views/PlaylistDetailView.svelte | 4 +- src/lib/components/views/SearchView.svelte | 4 +- src/lib/types/index.ts | 24 +++ src/routes/+page.svelte | 99 +++---------- 7 files changed, 190 insertions(+), 85 deletions(-) create mode 100644 src/lib/adapters/qobuzAdapters.ts diff --git a/src/lib/adapters/qobuzAdapters.ts b/src/lib/adapters/qobuzAdapters.ts new file mode 100644 index 000000000..15727f897 --- /dev/null +++ b/src/lib/adapters/qobuzAdapters.ts @@ -0,0 +1,138 @@ +/** + * Qobuz Adapters + * + * Centralizes Qobuz API -> UI model conversion functions. + * Eliminates duplicate formatting logic across the codebase. + */ + +import type { + QobuzAlbum, + QobuzArtist, + AlbumDetail, + ArtistDetail +} from '$lib/types'; + +// ============ Formatting Utilities ============ + +/** + * Format duration in seconds to "M:SS" format + */ +export function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Alias for formatDuration (they were identical) + */ +export const formatDurationMinutes = formatDuration; + +/** + * Extract best available image from Qobuz image object + */ +export function getQobuzImage(image?: { large?: string; thumbnail?: string; small?: string }): string { + return image?.large || image?.thumbnail || image?.small || ''; +} + +/** + * Format quality string from bit depth and sampling rate + */ +export function formatQuality( + hires: boolean | undefined, + bitDepth: number | undefined, + samplingRate: number | undefined +): string { + if (hires && bitDepth && samplingRate) { + return `${bitDepth}bit/${samplingRate}kHz`; + } + return 'CD Quality'; +} + +/** + * Format album quality string (different format with dash) + */ +export function formatAlbumQuality( + hires: boolean | undefined, + bitDepth: number | undefined, + samplingRate: number | undefined +): string { + if (hires && bitDepth && samplingRate) { + return `${bitDepth}-Bit / ${samplingRate} kHz`; + } + return 'CD Quality'; +} + +// ============ Model Converters ============ + +/** + * Convert Qobuz API album response to UI AlbumDetail model + */ +export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail { + const artwork = getQobuzImage(album.image); + const quality = formatAlbumQuality( + album.hires_streamable, + album.maximum_bit_depth, + album.maximum_sampling_rate + ); + + return { + id: album.id, + artwork, + title: album.title, + artist: album.artist?.name || 'Unknown Artist', + artistId: album.artist?.id, + year: album.release_date_original?.split('-')[0] || '', + label: album.label?.name || '', + genre: album.genre?.name || '', + quality, + trackCount: album.tracks_count || album.tracks?.items?.length || 0, + duration: formatDuration(album.duration || 0), + tracks: album.tracks?.items?.map((track, index) => ({ + id: track.id, + number: index + 1, + title: track.title, + artist: track.performer?.name, + duration: formatDuration(track.duration), + durationSeconds: track.duration, + quality: track.hires_streamable ? 'Hi-Res' : 'CD', + hires: track.hires_streamable, + bitDepth: track.maximum_bit_depth, + samplingRate: track.maximum_sampling_rate, + albumId: album.id, + artistId: track.performer?.id ?? album.artist?.id, + isrc: track.isrc + })) || [] + }; +} + +/** + * Convert Qobuz API artist response to UI ArtistDetail model + */ +export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail { + const image = getQobuzImage(artist.image); + + return { + id: artist.id, + name: artist.name, + image, + albumsCount: artist.albums_count, + biography: artist.biography, + albums: artist.albums?.items?.map(album => { + const artwork = getQobuzImage(album.image); + const quality = formatQuality( + album.hires_streamable, + album.maximum_bit_depth, + album.maximum_sampling_rate + ); + return { + id: album.id, + title: album.title, + artwork, + year: album.release_date_original?.split('-')[0], + quality + }; + }) || [], + totalAlbums: artist.albums?.total || artist.albums_count || 0 + }; +} diff --git a/src/lib/components/views/AlbumDetailView.svelte b/src/lib/components/views/AlbumDetailView.svelte index 809f18b7d..f2f790cab 100644 --- a/src/lib/components/views/AlbumDetailView.svelte +++ b/src/lib/components/views/AlbumDetailView.svelte @@ -247,7 +247,7 @@ onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined, onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined, onGoToAlbum: onTrackGoToAlbum ? () => onTrackGoToAlbum(album.id) : undefined, - onGoToArtist: album.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(album.artistId) : undefined + onGoToArtist: album.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(album.artistId!) : undefined }} /> {/each} diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index 817da3ffa..be0500514 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -289,8 +289,8 @@ onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined} onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined} onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined} - onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album.id) : undefined} - onGoToArtist={(track.performer?.id || artist.id) && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer?.id || artist.id) : undefined} + onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album!.id) : undefined} + onGoToArtist={(track.performer?.id || artist.id) && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer?.id ?? artist.id) : undefined} /> diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index bb0e8e6b8..5f0a3685b 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -640,8 +640,8 @@ onAddToPlaylist: !track.isLocal && onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined, onShareQobuz: !track.isLocal && onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined, onShareSonglink: !track.isLocal && onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined, - onGoToAlbum: !track.isLocal && track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId) : undefined, - onGoToArtist: !track.isLocal && track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId) : undefined + onGoToAlbum: !track.isLocal && track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined, + onGoToArtist: !track.isLocal && track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined }} /> {#if track.isLocal} diff --git a/src/lib/components/views/SearchView.svelte b/src/lib/components/views/SearchView.svelte index 2880b4554..4c129d9d5 100644 --- a/src/lib/components/views/SearchView.svelte +++ b/src/lib/components/views/SearchView.svelte @@ -393,8 +393,8 @@ onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined} onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined} onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined} - onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album.id) : undefined} - onGoToArtist={track.performer?.id && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer.id) : undefined} + onGoToAlbum={track.album?.id && onTrackGoToAlbum ? (() => { const albumId = track.album!.id!; return () => onTrackGoToAlbum(albumId); })() : undefined} + onGoToArtist={track.performer?.id && onTrackGoToArtist ? (() => { const artistId = track.performer!.id!; return () => onTrackGoToArtist(artistId); })() : undefined} /> diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 0b959f4c1..91148c9ce 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -134,6 +134,30 @@ export interface PlaylistTrack { isrc?: string; } +/** + * Unified display track interface used across views + * Compatible with PlaylistTrack, FavoritesTrack, and ArtistTrack displays + */ +export interface DisplayTrack { + id: number; + number?: number; + title: string; + artist?: string; + album?: string; + albumArt?: string; + albumId?: string; + artistId?: number; + duration: string; + durationSeconds: number; + hires?: boolean; + bitDepth?: number; + samplingRate?: number; + isrc?: string; + isLocal?: boolean; + localTrackId?: number; + artworkPath?: string; +} + // ============ Local Library Types ============ export interface LocalLibraryTrack { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0ffc38a68..7cb5b68db 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -118,16 +118,26 @@ AlbumDetail, ArtistDetail, PlaylistTrack, + DisplayTrack, LocalLibraryTrack, SongLinkResponse } from '$lib/types'; + // Adapters + import { + convertQobuzAlbum, + convertQobuzArtist, + formatDuration + } from '$lib/adapters/qobuzAdapters'; + // Services import { playTrack, setToastCallback as setPlaybackToastCallback, checkTrackFavorite, toggleTrackFavorite, + showTrackNotification, + updateLastfmNowPlaying, cleanup as cleanupPlayback } from '$lib/services/playbackService'; @@ -250,41 +260,6 @@ } } - function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail { - const artwork = album.image?.large || album.image?.thumbnail || album.image?.small || ''; - const quality = album.hires_streamable && album.maximum_bit_depth && album.maximum_sampling_rate - ? `${album.maximum_bit_depth}-Bit / ${album.maximum_sampling_rate} kHz` - : 'CD Quality'; - - return { - id: album.id, - artwork, - title: album.title, - artist: album.artist?.name || 'Unknown Artist', - artistId: album.artist?.id, - year: album.release_date_original?.split('-')[0] || '', - label: album.label?.name || '', - genre: album.genre?.name || '', - quality, - trackCount: album.tracks_count || album.tracks?.items?.length || 0, - duration: formatDurationMinutes(album.duration || 0), - tracks: album.tracks?.items?.map((track, index) => ({ - id: track.id, - number: index + 1, - title: track.title, - artist: track.performer?.name, - duration: formatDuration(track.duration), - durationSeconds: track.duration, - quality: track.hires_streamable ? 'Hi-Res' : 'CD', - hires: track.hires_streamable, - bitDepth: track.maximum_bit_depth, - samplingRate: track.maximum_sampling_rate, - albumId: album.id, - artistId: track.performer?.id ?? album.artist?.id, - isrc: track.isrc - })) || [] - }; - } async function handleArtistClick(artistId: number) { try { @@ -301,43 +276,6 @@ } } - function convertQobuzArtist(artist: QobuzArtist): ArtistDetail { - const image = artist.image?.large || artist.image?.thumbnail || artist.image?.small; - - return { - id: artist.id, - name: artist.name, - image, - albumsCount: artist.albums_count, - biography: artist.biography, - albums: artist.albums?.items?.map(album => { - const artwork = album.image?.large || album.image?.thumbnail || album.image?.small || ''; - const quality = album.hires_streamable && album.maximum_bit_depth && album.maximum_sampling_rate - ? `${album.maximum_bit_depth}bit/${album.maximum_sampling_rate}kHz` - : 'CD Quality'; - return { - id: album.id, - title: album.title, - artwork, - year: album.release_date_original?.split('-')[0], - quality - }; - }) || [], - totalAlbums: artist.albums?.total || artist.albums_count || 0 - }; - } - - function formatDuration(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - } - - function formatDurationMinutes(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - } // Album-specific queue track builder (needs selectedAlbum context) function buildAlbumQueueTrack(track: Track): BackendQueueTrack { @@ -828,14 +766,19 @@ } } - async function handlePlaylistTrackPlay(track: PlaylistTrack) { - console.log('Playing playlist track:', track); + /** + * Handle playback of DisplayTrack (used by ArtistDetailView, PlaylistDetailView, FavoritesView) + * This is fire-and-forget to match view callback signatures + */ + function handleDisplayTrackPlay(track: DisplayTrack): void { + console.log('Playing display track:', track); const quality = track.hires && track.bitDepth && track.samplingRate ? `${track.bitDepth}bit/${track.samplingRate}kHz` : 'CD Quality'; - await playTrack({ + // Fire-and-forget async call + playTrack({ id: track.id, title: track.title, artist: track.artist || 'Unknown Artist', @@ -1207,7 +1150,7 @@ artist={selectedArtist} onBack={goBack} onAlbumClick={handleAlbumClick} - onTrackPlay={handlePlaylistTrackPlay} + onTrackPlay={handleDisplayTrackPlay} onTrackPlayNext={queueQobuzTrackNext} onTrackPlayLater={queueQobuzTrackLater} onTrackAddFavorite={handleAddToFavorites} @@ -1228,7 +1171,7 @@