package qobuz import ( "context" "fmt" "math/rand/v2" "slices" "strconv" "sync" "time" "github.com/bjarneo/cliamp/applog" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" ) // Compile-time interface checks. var ( _ playlist.Provider = (*QobuzProvider)(nil) _ playlist.Authenticator = (*QobuzProvider)(nil) _ playlist.Refresher = (*QobuzProvider)(nil) _ provider.Searcher = (*QobuzProvider)(nil) _ provider.ArtistBrowser = (*QobuzProvider)(nil) _ provider.AlbumBrowser = (*QobuzProvider)(nil) _ provider.AlbumTrackLoader = (*QobuzProvider)(nil) _ provider.Closer = (*QobuzProvider)(nil) ) // favoriteTracksID is the synthetic playlist ID for the user's favorite tracks. const favoriteTracksID = "favorites/tracks" // randomTracksID is the synthetic playlist ID for a random sample of tracks // drawn from across all of the user's playlists (deduplicated). const randomTracksID = "playlists/random" // resolveConcurrency bounds how many track/getFileUrl calls run in parallel // when resolving a playlist's streaming URLs. const resolveConcurrency = 8 // playlistFetchConcurrency bounds how many playlist/get calls run in parallel // when gathering tracks for the Random Tracks entry. const playlistFetchConcurrency = 8 // favoritesPageSize is the page size for favorite album/artist browsing. const favoritesPageSize = 100 // randomTracksLimit caps the synthetic Random Tracks list. Each track costs one // track/getFileUrl call to resolve a (short-lived) stream URL, so resolving an // unbounded library would be slow and wasteful. When the deduplicated library // exceeds this, a random sample is taken so it stays a fair cross-section. // Matches the favorite tracks cap. const randomTracksLimit = 500 // albumSortTypes is the static sort list for Qobuz album browsing. Qobuz has no // global catalog listing, so browsing surfaces the user's favorite albums. var albumSortTypes = []provider.SortType{ {ID: "favorites", Label: "Favorite Albums"}, } // QobuzProvider implements playlist.Provider backed by the Qobuz API. Streaming // URLs are resolved per track via track/getFileUrl and routed through the // player's buffered pipeline (see stream.go). type QobuzProvider struct { quality int mu sync.Mutex client *client authCancel context.CancelFunc listCache []playlist.PlaylistInfo trackCache map[string][]playlist.Track } // New creates a QobuzProvider. Authentication is deferred until the user first // selects the provider. quality is the preferred Qobuz format_id. func New(quality int) *QobuzProvider { if !validQuality(quality) { quality = defaultQuality } return &QobuzProvider{ quality: quality, trackCache: make(map[string][]playlist.Track), } } func (p *QobuzProvider) Name() string { return "Qobuz" } // ensureClient builds an authenticated client from stored credentials only // (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed. func (p *QobuzProvider) ensureClient() (*client, error) { p.mu.Lock() if p.client != nil { c := p.client p.mu.Unlock() return c, nil } p.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() c, err := newClientSilent(ctx) if err != nil { applog.Debug("qobuz: silent auth failed, prompting sign-in: %v", err) return nil, playlist.ErrNeedsAuth } p.mu.Lock() p.client = c p.mu.Unlock() return c, nil } // Authenticate runs the interactive OAuth sign-in flow (opens a browser, waits // for the redirect). Implements playlist.Authenticator. func (p *QobuzProvider) Authenticate() error { p.mu.Lock() if p.client != nil { p.mu.Unlock() return nil } if p.authCancel != nil { p.authCancel() p.authCancel = nil } p.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) p.mu.Lock() p.authCancel = cancel p.mu.Unlock() c, err := newClientInteractive(ctx) p.mu.Lock() p.authCancel = nil p.mu.Unlock() cancel() if err != nil { return err } p.mu.Lock() p.client = c p.mu.Unlock() return nil } // Close cancels any in-progress sign-in. Implements provider.Closer. func (p *QobuzProvider) Close() { p.mu.Lock() defer p.mu.Unlock() if p.authCancel != nil { p.authCancel() p.authCancel = nil } } // Refresh clears cached playlists and tracks so the next call re-fetches and // re-resolves streaming URLs (which expire). Implements playlist.Refresher. func (p *QobuzProvider) Refresh() { p.mu.Lock() p.listCache = nil p.trackCache = make(map[string][]playlist.Track) p.mu.Unlock() } // Playlists returns the user's Qobuz playlists plus synthetic Favorite Tracks // and Random Tracks entries. func (p *QobuzProvider) Playlists() ([]playlist.PlaylistInfo, error) { c, err := p.ensureClient() if err != nil { return nil, err } p.mu.Lock() if p.listCache != nil { cached := slices.Clone(p.listCache) p.mu.Unlock() return cached, nil } p.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() pls, err := c.userPlaylists(ctx) if err != nil { return nil, err } lists := []playlist.PlaylistInfo{ { ID: favoriteTracksID, Name: "Favorite Tracks", Section: "Library", }, { ID: randomTracksID, Name: "Random Tracks", Section: "Library", }, } for _, pl := range pls { lists = append(lists, playlist.PlaylistInfo{ ID: pl.ID.String(), Name: pl.Name, TrackCount: pl.TracksCount, DurationSecs: pl.Duration, Section: "Your playlists", }) } p.mu.Lock() p.listCache = lists p.mu.Unlock() return slices.Clone(lists), nil } // Tracks returns the tracks of a playlist (or the synthetic Favorite Tracks / // Random Tracks entries), each with a resolved streaming URL. func (p *QobuzProvider) Tracks(playlistID string) ([]playlist.Track, error) { c, err := p.ensureClient() if err != nil { return nil, err } p.mu.Lock() if cached, ok := p.trackCache[playlistID]; ok { tracks := slices.Clone(cached) p.mu.Unlock() return tracks, nil } p.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() var apiTracks []apiTrack switch playlistID { case favoriteTracksID: apiTracks, err = c.favoriteTracks(ctx, 0, 500) case randomTracksID: apiTracks, err = p.randomTracks(ctx, c) default: apiTracks, err = c.playlistTracks(ctx, playlistID) } if err != nil { return nil, err } tracks := p.resolveTracks(ctx, c, apiTracks, nil) p.mu.Lock() p.trackCache[playlistID] = tracks p.mu.Unlock() return slices.Clone(tracks), nil } // randomTracks aggregates the tracks of every user playlist (fetched // concurrently), drops tracks that appear in more than one playlist, and // returns a random sample of at most randomTracksLimit. Sampling (rather than // truncating) keeps the entry a fair cross-section of the whole library; // refreshing the provider picks a new sample. func (p *QobuzProvider) randomTracks(ctx context.Context, c *client) ([]apiTrack, error) { pls, err := c.userPlaylists(ctx) if err != nil { return nil, fmt.Errorf("qobuz: list playlists: %w", err) } // Fetch each playlist's tracks in parallel, then merge in playlist order so // dedupe (first occurrence wins) stays deterministic. lists := make([][]apiTrack, len(pls)) errs := make([]error, len(pls)) sem := make(chan struct{}, playlistFetchConcurrency) var wg sync.WaitGroup for i := range pls { wg.Add(1) sem <- struct{}{} go func(idx int) { defer wg.Done() defer func() { <-sem }() lists[idx], errs[idx] = c.playlistTracks(ctx, pls[idx].ID.String()) }(i) } wg.Wait() var all []apiTrack for i := range pls { if errs[i] != nil { return nil, fmt.Errorf("qobuz: playlist %s: %w", pls[i].ID, errs[i]) } all = append(all, lists[i]...) } return sampleTracks(dedupeTracksByID(all), randomTracksLimit, rand.Shuffle), nil } // sampleTracks shuffles a copy of in and returns up to n of the result. The // list is always randomized because that's the whole point of the Random Tracks // entry. When the library is larger than n, the shuffle makes it a fair // sample of the whole library rather than its first n. The shuffle func is // injected so tests stay deterministic; production passes rand.Shuffle, which // is safe for concurrent use. func sampleTracks(in []apiTrack, n int, shuffle func(n int, swap func(i, j int))) []apiTrack { out := slices.Clone(in) shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) if len(out) > n { out = out[:n] } return out } // dedupeTracksByID returns tracks with duplicate Qobuz IDs removed, keeping the // first occurrence. Tracks with an empty ID are always kept. func dedupeTracksByID(in []apiTrack) []apiTrack { seen := make(map[string]bool, len(in)) out := make([]apiTrack, 0, len(in)) for _, t := range in { id := t.ID.String() if id != "" { if seen[id] { continue } seen[id] = true } out = append(out, t) } return out } // SearchTracks searches the Qobuz catalog. Implements provider.Searcher. func (p *QobuzProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) { c, err := p.ensureClient() if err != nil { return nil, err } if limit <= 0 { limit = 50 } apiTracks, err := c.searchTracks(ctx, query, limit) if err != nil { return nil, err } return p.resolveTracks(ctx, c, apiTracks, nil), nil } // Artists returns the user's favorite artists. Implements provider.ArtistBrowser. func (p *QobuzProvider) Artists() ([]provider.ArtistInfo, error) { c, err := p.ensureClient() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var artists []provider.ArtistInfo for offset := 0; ; offset += favoritesPageSize { page, err := c.favoriteArtists(ctx, offset, favoritesPageSize) if err != nil { return nil, err } for _, a := range page { artists = append(artists, provider.ArtistInfo{ ID: a.ID.String(), Name: a.Name, AlbumCount: a.AlbumsCount, }) } if len(page) < favoritesPageSize { break } } return artists, nil } // ArtistAlbums returns the albums of an artist. Implements provider.ArtistBrowser. func (p *QobuzProvider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) { c, err := p.ensureClient() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() albums, err := c.artistAlbums(ctx, artistID) if err != nil { return nil, err } out := make([]provider.AlbumInfo, 0, len(albums)) for _, a := range albums { out = append(out, albumInfo(a)) } return out, nil } // AlbumList returns the user's favorite albums (Qobuz has no global album // catalog to browse). Implements provider.AlbumBrowser. func (p *QobuzProvider) AlbumList(_ string, offset, size int) ([]provider.AlbumInfo, error) { c, err := p.ensureClient() if err != nil { return nil, err } if size <= 0 { size = favoritesPageSize } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() albums, err := c.favoriteAlbums(ctx, offset, size) if err != nil { return nil, err } out := make([]provider.AlbumInfo, 0, len(albums)) for _, a := range albums { out = append(out, albumInfo(a)) } return out, nil } func (p *QobuzProvider) AlbumSortTypes() []provider.SortType { return albumSortTypes } func (p *QobuzProvider) DefaultAlbumSort() string { return "favorites" } // AlbumTracks returns the tracks of an album. Implements provider.AlbumTrackLoader. func (p *QobuzProvider) AlbumTracks(albumID string) ([]playlist.Track, error) { c, err := p.ensureClient() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() album, err := c.albumGet(ctx, albumID) if err != nil { return nil, err } var tracks []apiTrack if album.Tracks != nil { tracks = album.Tracks.Items } return p.resolveTracks(ctx, c, tracks, &album), nil } // resolveTracks converts API tracks into playable tracks, resolving a signed // streaming URL for each in parallel. albumFallback supplies album metadata for // tracks that lack it (album/get nests tracks without an album field). Tracks // that are not streamable or fail URL resolution are returned as unplayable. func (p *QobuzProvider) resolveTracks(ctx context.Context, c *client, in []apiTrack, albumFallback *apiAlbum) []playlist.Track { out := make([]playlist.Track, len(in)) sem := make(chan struct{}, resolveConcurrency) var wg sync.WaitGroup for i := range in { wg.Add(1) sem <- struct{}{} go func(idx int) { defer wg.Done() defer func() { <-sem }() out[idx] = p.buildTrack(ctx, c, in[idx], albumFallback) }(i) } wg.Wait() return out } // buildTrack maps a single API track to a playlist.Track, resolving its stream // URL unless the track is not streamable. func (p *QobuzProvider) buildTrack(ctx context.Context, c *client, t apiTrack, albumFallback *apiAlbum) playlist.Track { album := t.Album if album == nil { album = albumFallback } track := playlist.Track{ Title: t.Title, Artist: trackArtist(t, album), TrackNumber: t.TrackNumber, DurationSecs: t.Duration, Stream: true, ProviderMeta: map[string]string{provider.MetaQobuzID: t.ID.String()}, } if album != nil { track.Album = album.Title track.Genre = album.Genre.Name track.Year = parseYear(album.ReleaseDateOriginal) } if !t.Streamable { track.Unplayable = true return track } file, err := c.trackFileURL(ctx, t.ID.String(), p.quality, "") if err != nil || file.URL == "" { if err != nil { applog.Debug("qobuz: resolve stream url for track %s: %v", t.ID.String(), err) } track.Unplayable = true return track } registerStreamURL(file.URL) track.Path = file.URL return track } // trackArtist picks the best available artist name for a track. func trackArtist(t apiTrack, album *apiAlbum) string { if t.Performer.Name != "" { return t.Performer.Name } if album != nil { return album.Artist.Name } return "" } // albumInfo maps a Qobuz album to provider.AlbumInfo. func albumInfo(a apiAlbum) provider.AlbumInfo { return provider.AlbumInfo{ ID: a.ID, Name: a.Title, Artist: a.Artist.Name, ArtistID: a.Artist.ID.String(), Year: parseYear(a.ReleaseDateOriginal), TrackCount: a.TracksCount, Genre: a.Genre.Name, } } // parseYear extracts the year from a Qobuz "YYYY-MM-DD" date string. func parseYear(date string) int { if len(date) < 4 { return 0 } y, err := strconv.Atoi(date[:4]) if err != nil { return 0 } return y }