540 lines
14 KiB
Go
540 lines
14 KiB
Go
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
|
|
}
|