refactor: extract Qobuz adapters and unify DisplayTrack type

- 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
This commit is contained in:
vicrodh committed 2026-01-10 05:51:48 -06:00
1 parent 30a33cfc95
commit 5be437dd4e
7 files changed
+190 -85

No files matched your search

+138
View File
@@ -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
};
}
@@ -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}
@@ -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}
/>
</div>
</div>
@@ -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}
+2 -2
View File
@@ -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}
/>
</div>
</div>
+24
View File
@@ -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 {
+21 -78
View File
@@ -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 @@
<PlaylistDetailView
playlistId={selectedPlaylistId}
onBack={goBack}
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlay={handleDisplayTrackPlay}
onTrackPlayNext={queuePlaylistTrackNext}
onTrackPlayLater={queuePlaylistTrackLater}
onTrackAddFavorite={handleAddToFavorites}
@@ -1248,7 +1191,7 @@
{:else if activeView === 'favorites'}
<FavoritesView
onAlbumClick={handleAlbumClick}
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlay={handleDisplayTrackPlay}
onArtistClick={handleArtistClick}
onTrackPlayNext={queuePlaylistTrackNext}
onTrackPlayLater={queuePlaylistTrackLater}