Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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
}