mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 17:10:29 +02:00
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg) - Propagate displayNameHint to all format scanners so fd numbers (214, 207) no longer appear as track names when /proc/self/fd/ paths are used - Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after first failure, reducing error log noise and improving scan speed on Samsung - Fix Qobuz download returning wrong album cover when track exists on multiple albums by preferring req.CoverURL over API default - Fix FFmpeg M4A metadata save failing with 'codec not currently supported in container' by forcing mp4 muxer instead of ipod when cover art present - Clean up FLAC SAF temp file after metadata write-back (was leaking) - Update LRC lyrics tag to credit Paxsenix API - Remove log message truncation, defer to UI preview truncation instead
2689 lines
76 KiB
Go
2689 lines
76 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type QobuzDownloader struct {
|
|
client *http.Client
|
|
appID string
|
|
apiURL string
|
|
}
|
|
|
|
var (
|
|
globalQobuzDownloader *QobuzDownloader
|
|
qobuzDownloaderOnce sync.Once
|
|
qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
|
return q.GetTrackByID(trackID)
|
|
}
|
|
qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec)
|
|
}
|
|
qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec)
|
|
}
|
|
songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
|
return client.CheckTrackAvailability(spotifyTrackID, isrc)
|
|
}
|
|
)
|
|
|
|
const (
|
|
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
|
|
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
|
|
qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id="
|
|
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
|
|
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
|
|
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
|
|
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
|
|
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
|
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
|
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
|
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
|
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
|
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
|
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
|
qobuzDebugKeyXORMask = byte(0x5A)
|
|
)
|
|
|
|
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
|
|
var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
|
|
var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
|
|
|
|
var qobuzDebugKeyObfuscated = []byte{
|
|
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
|
|
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
|
|
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
|
|
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
|
|
0x3f,
|
|
}
|
|
|
|
type QobuzTrack struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
ISRC string `json:"isrc"`
|
|
Duration int `json:"duration"`
|
|
TrackNumber int `json:"track_number"`
|
|
MediaNumber int `json:"media_number"`
|
|
MaximumBitDepth int `json:"maximum_bit_depth"`
|
|
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
|
|
Version string `json:"version"`
|
|
Album struct {
|
|
ID string `json:"id"`
|
|
QobuzID int64 `json:"qobuz_id"`
|
|
TracksCount int `json:"tracks_count"`
|
|
Title string `json:"title"`
|
|
ReleaseDate string `json:"release_date_original"`
|
|
ProductType string `json:"product_type"`
|
|
ReleaseType string `json:"release_type"`
|
|
Artist struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"artist"`
|
|
Artists []qobuzArtistRef `json:"artists"`
|
|
Image struct {
|
|
Thumbnail string `json:"thumbnail"`
|
|
Small string `json:"small"`
|
|
Large string `json:"large"`
|
|
} `json:"image"`
|
|
} `json:"album"`
|
|
Performer struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"performer"`
|
|
}
|
|
|
|
type qobuzImageSet struct {
|
|
Thumbnail string `json:"thumbnail"`
|
|
Small string `json:"small"`
|
|
Large string `json:"large"`
|
|
}
|
|
|
|
type qobuzArtistRef struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
|
|
type qobuzLabelRef struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type qobuzGenreRef struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type qobuzAlbumDetails struct {
|
|
ID string `json:"id"`
|
|
QobuzID int64 `json:"qobuz_id"`
|
|
Title string `json:"title"`
|
|
ReleaseDateOriginal string `json:"release_date_original"`
|
|
TracksCount int `json:"tracks_count"`
|
|
ProductType string `json:"product_type"`
|
|
ReleaseType string `json:"release_type"`
|
|
Image qobuzImageSet `json:"image"`
|
|
Artist qobuzArtistRef `json:"artist"`
|
|
Artists []qobuzArtistRef `json:"artists"`
|
|
Genre qobuzGenreRef `json:"genre"`
|
|
Label qobuzLabelRef `json:"label"`
|
|
Copyright string `json:"copyright"`
|
|
Tracks struct {
|
|
Items []QobuzTrack `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
type qobuzArtistDetails struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Image qobuzImageSet `json:"image"`
|
|
}
|
|
|
|
type qobuzPlaylistDetails struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
ImageRectangle []string `json:"image_rectangle"`
|
|
ImageRectangleMini []string `json:"image_rectangle_mini"`
|
|
TracksCount int `json:"tracks_count"`
|
|
Owner struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"owner"`
|
|
Tracks struct {
|
|
Total int `json:"total"`
|
|
Offset int `json:"offset"`
|
|
Limit int `json:"limit"`
|
|
Items []QobuzTrack `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
|
|
func qobuzFirstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func qobuzPrefixedID(id string) string {
|
|
trimmed := strings.TrimSpace(id)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(trimmed, "qobuz:") {
|
|
return trimmed
|
|
}
|
|
return "qobuz:" + trimmed
|
|
}
|
|
|
|
func qobuzPrefixedNumericID(id int64) string {
|
|
if id <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("qobuz:%d", id)
|
|
}
|
|
|
|
func qobuzNormalizeReleaseDate(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if _, err := time.Parse("2006-01-02", trimmed); err == nil {
|
|
return trimmed
|
|
}
|
|
if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil {
|
|
return parsed.Format("2006-01-02")
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string {
|
|
kind := strings.ToLower(strings.TrimSpace(releaseType))
|
|
if kind == "" {
|
|
kind = strings.ToLower(strings.TrimSpace(productType))
|
|
}
|
|
switch kind {
|
|
case "album", "single", "ep", "compilation":
|
|
return kind
|
|
}
|
|
if totalTracks > 0 && totalTracks <= 3 {
|
|
return "single"
|
|
}
|
|
return "album"
|
|
}
|
|
|
|
func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string {
|
|
names := make([]string, 0, len(artists))
|
|
seen := make(map[string]struct{}, len(artists))
|
|
for _, artist := range artists {
|
|
name := strings.TrimSpace(artist.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(name)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
names = append(names, name)
|
|
}
|
|
if len(names) == 0 {
|
|
return strings.TrimSpace(fallback)
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
func qobuzTrackDisplayTitle(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
title := strings.TrimSpace(track.Title)
|
|
version := strings.TrimSpace(track.Version)
|
|
if title == "" || version == "" {
|
|
return title
|
|
}
|
|
return fmt.Sprintf("%s (%s)", title, version)
|
|
}
|
|
|
|
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
|
|
|
|
func qobuzUpscaleImageURL(url string) string {
|
|
if url == "" {
|
|
return ""
|
|
}
|
|
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
|
|
}
|
|
|
|
func qobuzTrackAlbumImage(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
|
track.Album.Image.Large,
|
|
track.Album.Image.Small,
|
|
track.Album.Image.Thumbnail,
|
|
))
|
|
}
|
|
|
|
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
|
|
if album == nil {
|
|
return ""
|
|
}
|
|
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
|
|
album.Image.Large,
|
|
album.Image.Small,
|
|
album.Image.Thumbnail,
|
|
))
|
|
}
|
|
|
|
func qobuzTrackArtistID(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
if track.Performer.ID > 0 {
|
|
return qobuzPrefixedNumericID(track.Performer.ID)
|
|
}
|
|
return qobuzPrefixedNumericID(track.Album.Artist.ID)
|
|
}
|
|
|
|
func qobuzTrackArtistName(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(track.Performer.Name)
|
|
}
|
|
|
|
func qobuzTrackAlbumArtist(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return ""
|
|
}
|
|
return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name)
|
|
}
|
|
|
|
func qobuzTrackAlbumType(track *QobuzTrack) string {
|
|
if track == nil {
|
|
return "album"
|
|
}
|
|
return qobuzNormalizeAlbumType(
|
|
track.Album.ReleaseType,
|
|
track.Album.ProductType,
|
|
track.Album.TracksCount,
|
|
)
|
|
}
|
|
|
|
func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
|
|
if track == nil {
|
|
return TrackMetadata{}
|
|
}
|
|
return TrackMetadata{
|
|
SpotifyID: qobuzPrefixedNumericID(track.ID),
|
|
Artists: qobuzTrackArtistName(track),
|
|
Name: qobuzTrackDisplayTitle(track),
|
|
AlbumName: strings.TrimSpace(track.Album.Title),
|
|
AlbumArtist: qobuzTrackAlbumArtist(track),
|
|
DurationMS: track.Duration * 1000,
|
|
Images: qobuzTrackAlbumImage(track),
|
|
ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
|
|
TrackNumber: track.TrackNumber,
|
|
TotalTracks: track.Album.TracksCount,
|
|
DiscNumber: track.MediaNumber,
|
|
ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
|
|
ISRC: strings.TrimSpace(track.ISRC),
|
|
AlbumID: qobuzPrefixedID(track.Album.ID),
|
|
ArtistID: qobuzTrackArtistID(track),
|
|
AlbumType: qobuzTrackAlbumType(track),
|
|
}
|
|
}
|
|
|
|
func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
|
|
if track == nil {
|
|
return AlbumTrackMetadata{}
|
|
}
|
|
return AlbumTrackMetadata{
|
|
SpotifyID: qobuzPrefixedNumericID(track.ID),
|
|
Artists: qobuzTrackArtistName(track),
|
|
Name: qobuzTrackDisplayTitle(track),
|
|
AlbumName: strings.TrimSpace(track.Album.Title),
|
|
AlbumArtist: qobuzTrackAlbumArtist(track),
|
|
DurationMS: track.Duration * 1000,
|
|
Images: qobuzTrackAlbumImage(track),
|
|
ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
|
|
TrackNumber: track.TrackNumber,
|
|
TotalTracks: track.Album.TracksCount,
|
|
DiscNumber: track.MediaNumber,
|
|
ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
|
|
ISRC: strings.TrimSpace(track.ISRC),
|
|
AlbumID: qobuzPrefixedID(track.Album.ID),
|
|
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
|
|
AlbumType: qobuzTrackAlbumType(track),
|
|
}
|
|
}
|
|
|
|
func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata {
|
|
if album == nil {
|
|
return AlbumInfoMetadata{}
|
|
}
|
|
return AlbumInfoMetadata{
|
|
TotalTracks: album.TracksCount,
|
|
Name: strings.TrimSpace(album.Title),
|
|
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
|
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
|
ArtistId: qobuzPrefixedNumericID(album.Artist.ID),
|
|
Images: qobuzAlbumImage(album),
|
|
Genre: strings.TrimSpace(album.Genre.Name),
|
|
Label: strings.TrimSpace(album.Label.Name),
|
|
Copyright: strings.TrimSpace(album.Copyright),
|
|
}
|
|
}
|
|
|
|
func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata {
|
|
if album == nil {
|
|
return ArtistAlbumMetadata{}
|
|
}
|
|
return ArtistAlbumMetadata{
|
|
ID: qobuzPrefixedID(album.ID),
|
|
Name: strings.TrimSpace(album.Title),
|
|
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
|
TotalTracks: album.TracksCount,
|
|
Images: qobuzAlbumImage(album),
|
|
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
|
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
|
}
|
|
}
|
|
|
|
func qobuzSplitPathSegments(path string) []string {
|
|
rawSegments := strings.Split(strings.TrimSpace(path), "/")
|
|
segments := make([]string, 0, len(rawSegments))
|
|
for _, segment := range rawSegments {
|
|
trimmed := strings.TrimSpace(segment)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
segments = append(segments, trimmed)
|
|
}
|
|
if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) {
|
|
return segments[1:]
|
|
}
|
|
return segments
|
|
}
|
|
|
|
func qobuzResourceTypeFromSegment(segment string) string {
|
|
switch strings.ToLower(strings.TrimSpace(segment)) {
|
|
case "album":
|
|
return "album"
|
|
case "interpreter", "artist":
|
|
return "artist"
|
|
case "playlist", "playlists":
|
|
return "playlist"
|
|
case "track":
|
|
return "track"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func parseQobuzURL(input string) (string, string, error) {
|
|
raw := strings.TrimSpace(input)
|
|
if raw == "" {
|
|
return "", "", fmt.Errorf("empty Qobuz URL")
|
|
}
|
|
|
|
if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
resourceType := qobuzResourceTypeFromSegment(parsed.Host)
|
|
resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
|
if resourceType == "" || resourceID == "" {
|
|
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
|
|
}
|
|
return resourceType, resourceID, nil
|
|
}
|
|
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Host == "" {
|
|
if !strings.Contains(raw, "://") {
|
|
parsed, err = url.Parse("https://" + raw)
|
|
}
|
|
}
|
|
if err != nil || parsed == nil || parsed.Host == "" {
|
|
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
|
|
}
|
|
|
|
host := strings.ToLower(parsed.Host)
|
|
if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" {
|
|
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
|
|
}
|
|
|
|
segments := qobuzSplitPathSegments(parsed.Path)
|
|
if len(segments) < 2 {
|
|
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
|
|
}
|
|
|
|
resourceType := qobuzResourceTypeFromSegment(segments[0])
|
|
resourceID := strings.TrimSpace(segments[len(segments)-1])
|
|
if resourceType == "" || resourceID == "" {
|
|
return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
|
|
}
|
|
|
|
return resourceType, resourceID, nil
|
|
}
|
|
|
|
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|
normExpected := normalizeLooseArtistName(expectedArtist)
|
|
normFound := normalizeLooseArtistName(foundArtist)
|
|
|
|
if normExpected == normFound {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
return true
|
|
}
|
|
|
|
expectedArtists := qobuzSplitArtists(normExpected)
|
|
foundArtists := qobuzSplitArtists(normFound)
|
|
|
|
for _, exp := range expectedArtists {
|
|
for _, fnd := range foundArtists {
|
|
if exp == fnd {
|
|
return true
|
|
}
|
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
|
return true
|
|
}
|
|
if qobuzSameWordsUnordered(exp, fnd) {
|
|
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
|
if expectedLatin != foundLatin {
|
|
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func qobuzSplitArtists(artists string) []string {
|
|
normalized := artists
|
|
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
|
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
|
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
|
|
|
parts := strings.Split(normalized, "|")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func qobuzSameWordsUnordered(a, b string) bool {
|
|
wordsA := strings.Fields(a)
|
|
wordsB := strings.Fields(b)
|
|
|
|
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
|
return false
|
|
}
|
|
|
|
sortedA := make([]string, len(wordsA))
|
|
sortedB := make([]string, len(wordsB))
|
|
copy(sortedA, wordsA)
|
|
copy(sortedB, wordsB)
|
|
|
|
for i := 0; i < len(sortedA)-1; i++ {
|
|
for j := i + 1; j < len(sortedA); j++ {
|
|
if sortedA[i] > sortedA[j] {
|
|
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
|
}
|
|
if sortedB[i] > sortedB[j] {
|
|
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := range sortedA {
|
|
if sortedA[i] != sortedB[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
|
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
|
|
|
if normExpected == normFound {
|
|
return true
|
|
}
|
|
|
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
return true
|
|
}
|
|
|
|
cleanExpected := qobuzCleanTitle(normExpected)
|
|
cleanFound := qobuzCleanTitle(normFound)
|
|
|
|
if cleanExpected == cleanFound {
|
|
return true
|
|
}
|
|
|
|
if cleanExpected != "" && cleanFound != "" {
|
|
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
coreExpected := qobuzExtractCoreTitle(normExpected)
|
|
coreFound := qobuzExtractCoreTitle(normFound)
|
|
|
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
|
return true
|
|
}
|
|
|
|
looseExpected := normalizeLooseTitle(normExpected)
|
|
looseFound := normalizeLooseTitle(normFound)
|
|
if looseExpected != "" && looseFound != "" {
|
|
if looseExpected == looseFound {
|
|
return true
|
|
}
|
|
if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Emoji/symbol-only titles must be matched strictly to avoid false positives
|
|
// like mapping "🪐" to unrelated textual tracks.
|
|
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
|
|
strings.TrimSpace(expectedTitle) != "" &&
|
|
strings.TrimSpace(foundTitle) != "" {
|
|
expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle)
|
|
foundSymbols := normalizeSymbolOnlyTitle(foundTitle)
|
|
if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols {
|
|
GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return true
|
|
}
|
|
GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return false
|
|
}
|
|
|
|
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
|
foundLatin := qobuzIsLatinScript(foundTitle)
|
|
if expectedLatin != foundLatin {
|
|
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func qobuzExtractCoreTitle(title string) string {
|
|
parenIdx := strings.Index(title, "(")
|
|
bracketIdx := strings.Index(title, "[")
|
|
dashIdx := strings.Index(title, " - ")
|
|
|
|
cutIdx := len(title)
|
|
if parenIdx > 0 && parenIdx < cutIdx {
|
|
cutIdx = parenIdx
|
|
}
|
|
if bracketIdx > 0 && bracketIdx < cutIdx {
|
|
cutIdx = bracketIdx
|
|
}
|
|
if dashIdx > 0 && dashIdx < cutIdx {
|
|
cutIdx = dashIdx
|
|
}
|
|
|
|
return strings.TrimSpace(title[:cutIdx])
|
|
}
|
|
|
|
func qobuzCleanTitle(title string) string {
|
|
cleaned := title
|
|
|
|
versionPatterns := []string{
|
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
|
"album version", "radio edit", "original mix", "extended",
|
|
"club mix", "remix", "live", "acoustic", "demo",
|
|
}
|
|
|
|
for {
|
|
startParen := strings.LastIndex(cleaned, "(")
|
|
endParen := strings.LastIndex(cleaned, ")")
|
|
if startParen >= 0 && endParen > startParen {
|
|
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
for {
|
|
startBracket := strings.LastIndex(cleaned, "[")
|
|
endBracket := strings.LastIndex(cleaned, "]")
|
|
if startBracket >= 0 && endBracket > startBracket {
|
|
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
|
isVersionIndicator := false
|
|
for _, pattern := range versionPatterns {
|
|
if strings.Contains(content, pattern) {
|
|
isVersionIndicator = true
|
|
break
|
|
}
|
|
}
|
|
if isVersionIndicator {
|
|
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
|
continue
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
dashPatterns := []string{
|
|
" - remaster", " - remastered", " - single version", " - radio edit",
|
|
" - live", " - acoustic", " - demo", " - remix",
|
|
}
|
|
for _, pattern := range dashPatterns {
|
|
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
|
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
|
}
|
|
}
|
|
|
|
for strings.Contains(cleaned, " ") {
|
|
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
|
}
|
|
|
|
return strings.TrimSpace(cleaned)
|
|
}
|
|
|
|
func qobuzIsLatinScript(s string) bool {
|
|
for _, r := range s {
|
|
if r < 128 {
|
|
continue
|
|
}
|
|
if (r >= 0x0100 && r <= 0x024F) ||
|
|
(r >= 0x1E00 && r <= 0x1EFF) ||
|
|
(r >= 0x00C0 && r <= 0x00FF) {
|
|
continue
|
|
}
|
|
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
|
(r >= 0x3040 && r <= 0x309F) ||
|
|
(r >= 0x30A0 && r <= 0x30FF) ||
|
|
(r >= 0xAC00 && r <= 0xD7AF) ||
|
|
(r >= 0x0600 && r <= 0x06FF) ||
|
|
(r >= 0x0400 && r <= 0x04FF) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsQueryQobuz(queries []string, query string) bool {
|
|
for _, q := range queries {
|
|
if q == query {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func NewQobuzDownloader() *QobuzDownloader {
|
|
qobuzDownloaderOnce.Do(func() {
|
|
globalQobuzDownloader = &QobuzDownloader{
|
|
client: NewHTTPClientWithTimeout(DefaultTimeout),
|
|
appID: "798273057",
|
|
}
|
|
})
|
|
return globalQobuzDownloader
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
|
trackURL := fmt.Sprintf("%s%d&app_id=%s", qobuzTrackGetBaseURL, trackID, q.appID)
|
|
|
|
req, err := http.NewRequest("GET", trackURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var track QobuzTrack
|
|
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &track, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error {
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
return json.NewDecoder(resp.Body).Decode(target)
|
|
}
|
|
|
|
func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) {
|
|
req, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string {
|
|
matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
albumIDs := make([]string, 0, len(matches))
|
|
seen := make(map[string]struct{}, len(matches))
|
|
for _, match := range matches {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
albumID := strings.TrimSpace(string(match[1]))
|
|
if albumID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[albumID]; ok {
|
|
continue
|
|
}
|
|
seen[albumID] = struct{}{}
|
|
albumIDs = append(albumIDs, albumID)
|
|
}
|
|
return albumIDs
|
|
}
|
|
|
|
func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) {
|
|
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID)
|
|
var album qobuzAlbumDetails
|
|
if err := q.getQobuzJSON(requestURL, &album); err != nil {
|
|
return nil, err
|
|
}
|
|
return &album, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) {
|
|
requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID)
|
|
var artist qobuzArtistDetails
|
|
if err := q.getQobuzJSON(requestURL, &artist); err != nil {
|
|
return nil, err
|
|
}
|
|
return &artist, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
|
|
requestURL := fmt.Sprintf(
|
|
"%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s",
|
|
qobuzPlaylistGetBaseURL,
|
|
url.QueryEscape(strings.TrimSpace(playlistID)),
|
|
limit,
|
|
offset,
|
|
q.appID,
|
|
)
|
|
var playlist qobuzPlaylistDetails
|
|
if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
|
|
return nil, err
|
|
}
|
|
return &playlist, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) {
|
|
artist, err := q.getArtistDetails(artistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slug := strings.TrimSpace(artist.Slug)
|
|
if slug == "" {
|
|
slug = "artist"
|
|
}
|
|
requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID)
|
|
body, err := q.getQobuzBody(requestURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
albumIDs := extractQobuzAlbumIDsFromArtistHTML(body)
|
|
if len(albumIDs) == 0 {
|
|
return nil, fmt.Errorf("artist page did not contain album IDs")
|
|
}
|
|
return albumIDs, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
|
|
trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
|
|
if err != nil || trackID <= 0 {
|
|
return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID)
|
|
}
|
|
|
|
track, err := q.GetTrackByID(trackID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) {
|
|
album, err := q.getAlbumDetails(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
|
for i := range album.Tracks.Items {
|
|
track := &album.Tracks.Items[i]
|
|
track.Album.ID = album.ID
|
|
track.Album.Title = album.Title
|
|
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
|
track.Album.Image = qobuzImageSet{
|
|
Thumbnail: album.Image.Thumbnail,
|
|
Small: album.Image.Small,
|
|
Large: album.Image.Large,
|
|
}
|
|
track.Album.TracksCount = album.TracksCount
|
|
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
|
}
|
|
|
|
return &AlbumResponsePayload{
|
|
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
|
TrackList: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) {
|
|
const pageSize = 50
|
|
|
|
offset := 0
|
|
var playlistInfo PlaylistInfoMetadata
|
|
tracks := make([]AlbumTrackMetadata, 0, pageSize)
|
|
|
|
for {
|
|
page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if offset == 0 {
|
|
total := page.Tracks.Total
|
|
if total == 0 {
|
|
total = page.TracksCount
|
|
}
|
|
playlistInfo.Tracks.Total = total
|
|
playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name)
|
|
playlistInfo.Owner.Name = strings.TrimSpace(page.Name)
|
|
playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...)
|
|
}
|
|
|
|
for i := range page.Tracks.Items {
|
|
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i]))
|
|
}
|
|
|
|
if len(page.Tracks.Items) == 0 ||
|
|
offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total ||
|
|
len(page.Tracks.Items) < pageSize {
|
|
break
|
|
}
|
|
offset += len(page.Tracks.Items)
|
|
}
|
|
|
|
return &PlaylistResponsePayload{
|
|
PlaylistInfo: playlistInfo,
|
|
TrackList: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) {
|
|
artist, err := q.getArtistDetails(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
albumIDs, err := q.getArtistAlbumIDs(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
albums := make([]ArtistAlbumMetadata, 0, len(albumIDs))
|
|
for _, albumID := range albumIDs {
|
|
album, albumErr := q.getAlbumDetails(albumID)
|
|
if albumErr != nil {
|
|
GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr)
|
|
continue
|
|
}
|
|
albums = append(albums, qobuzAlbumToArtistAlbum(album))
|
|
}
|
|
|
|
return &ArtistResponsePayload{
|
|
ArtistInfo: ArtistInfoMetadata{
|
|
ID: qobuzPrefixedNumericID(artist.ID),
|
|
Name: strings.TrimSpace(artist.Name),
|
|
Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail),
|
|
},
|
|
Albums: albums,
|
|
}, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
|
return []string{
|
|
qobuzDownloadAPIURL,
|
|
qobuzDabMusicAPIURL,
|
|
qobuzDeebAPIURL,
|
|
qobuzAfkarAPIURL,
|
|
qobuzSquidAPIURL,
|
|
}
|
|
}
|
|
|
|
type qobuzAPIProvider struct {
|
|
Name string
|
|
URL string
|
|
Kind string
|
|
}
|
|
|
|
const (
|
|
qobuzAPIKindMusicDL = "musicdl"
|
|
qobuzAPIKindStandard = "standard"
|
|
)
|
|
|
|
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
|
return []qobuzAPIProvider{
|
|
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
|
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
|
// "deeb" is mapped from the legacy reference fallback endpoint.
|
|
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
|
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
|
|
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
|
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
|
|
}
|
|
}
|
|
|
|
type qobuzDownloadInfo struct {
|
|
DownloadURL string
|
|
BitDepth int
|
|
SampleRate int
|
|
}
|
|
|
|
func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err)
|
|
}
|
|
|
|
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("%s", detail)
|
|
}
|
|
|
|
if success, ok := raw["success"].(bool); ok && !success {
|
|
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("%s", msg)
|
|
}
|
|
return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false")
|
|
}
|
|
|
|
info := qobuzDownloadInfo{
|
|
BitDepth: qobuzParseBitDepth(raw["bit_depth"]),
|
|
SampleRate: qobuzParseSampleRate(raw["sampling_rate"]),
|
|
}
|
|
if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(urlVal)
|
|
return info, nil
|
|
}
|
|
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(urlVal)
|
|
return info, nil
|
|
}
|
|
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(linkVal)
|
|
return info, nil
|
|
}
|
|
|
|
if data, ok := raw["data"].(map[string]any); ok {
|
|
if info.BitDepth == 0 {
|
|
info.BitDepth = qobuzParseBitDepth(data["bit_depth"])
|
|
}
|
|
if info.SampleRate == 0 {
|
|
info.SampleRate = qobuzParseSampleRate(data["sampling_rate"])
|
|
}
|
|
if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(urlVal)
|
|
return info, nil
|
|
}
|
|
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(urlVal)
|
|
return info, nil
|
|
}
|
|
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
|
|
info.DownloadURL = strings.TrimSpace(linkVal)
|
|
return info, nil
|
|
}
|
|
}
|
|
|
|
return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response")
|
|
}
|
|
|
|
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
|
|
info, err := extractQobuzDownloadInfoFromBody(body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return info.DownloadURL, nil
|
|
}
|
|
|
|
func qobuzParseBitDepth(value any) int {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return int(v)
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case json.Number:
|
|
n, _ := v.Int64()
|
|
return int(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func qobuzParseSampleRate(value any) int {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
if v > 0 && v < 1000 {
|
|
return int(v * 1000)
|
|
}
|
|
return int(v)
|
|
case int:
|
|
if v > 0 && v < 1000 {
|
|
return v * 1000
|
|
}
|
|
return v
|
|
case int64:
|
|
if v > 0 && v < 1000 {
|
|
return int(v * 1000)
|
|
}
|
|
return int(v)
|
|
case json.Number:
|
|
if n, err := v.Float64(); err == nil {
|
|
if n > 0 && n < 1000 {
|
|
return int(n * 1000)
|
|
}
|
|
return int(n)
|
|
}
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func normalizeQobuzQualityCode(quality string) string {
|
|
switch strings.ToLower(strings.TrimSpace(quality)) {
|
|
case "", "5", "6", "cd", "lossless":
|
|
return "6"
|
|
case "7", "hi-res":
|
|
return "7"
|
|
case "27", "hi-res-max":
|
|
return "27"
|
|
default:
|
|
return "6"
|
|
}
|
|
}
|
|
|
|
func mapQobuzQualityCodeToAPI(qualityCode string) string {
|
|
switch normalizeQobuzQualityCode(qualityCode) {
|
|
case "27":
|
|
return "hi-res-max"
|
|
case "7":
|
|
return "hi-res"
|
|
default:
|
|
return "cd"
|
|
}
|
|
}
|
|
|
|
func getQobuzDebugKey() string {
|
|
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
|
|
for i, b := range qobuzDebugKeyObfuscated {
|
|
decoded[i] = b ^ qobuzDebugKeyXORMask
|
|
}
|
|
return string(decoded)
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range candidates {
|
|
if candidates[i].ISRC == isrc {
|
|
return &candidates[i], nil
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
|
}
|
|
|
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
|
|
|
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates))
|
|
|
|
var isrcMatches []*QobuzTrack
|
|
for i := range candidates {
|
|
if candidates[i].ISRC == isrc {
|
|
isrcMatches = append(isrcMatches, &candidates[i])
|
|
}
|
|
}
|
|
|
|
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
|
|
|
if len(isrcMatches) > 0 {
|
|
if expectedDurationSec > 0 {
|
|
var durationVerifiedMatches []*QobuzTrack
|
|
for _, track := range isrcMatches {
|
|
durationDiff := track.Duration - expectedDurationSec
|
|
if durationDiff < 0 {
|
|
durationDiff = -durationDiff
|
|
}
|
|
if durationDiff <= 10 {
|
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
|
}
|
|
}
|
|
|
|
if len(durationVerifiedMatches) > 0 {
|
|
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
|
return durationVerifiedMatches[0], nil
|
|
}
|
|
|
|
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
|
expectedDurationSec, isrcMatches[0].Duration)
|
|
}
|
|
|
|
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
|
return isrcMatches[0], nil
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
|
}
|
|
|
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
|
cleanQuery := strings.TrimSpace(query)
|
|
if cleanQuery == "" {
|
|
return nil, fmt.Errorf("empty qobuz search query")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results := make([]ExtTrackMetadata, 0, len(tracks))
|
|
for i := range tracks {
|
|
results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz"))
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
|
|
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
|
func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
|
GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
|
|
|
cleanQuery := strings.TrimSpace(query)
|
|
if cleanQuery == "" {
|
|
return nil, fmt.Errorf("empty qobuz search query")
|
|
}
|
|
|
|
albumLimit := 5
|
|
|
|
if filter != "" {
|
|
switch filter {
|
|
case "track":
|
|
trackLimit = 50
|
|
artistLimit = 0
|
|
albumLimit = 0
|
|
case "artist":
|
|
trackLimit = 0
|
|
artistLimit = 20
|
|
albumLimit = 0
|
|
case "album":
|
|
trackLimit = 0
|
|
artistLimit = 0
|
|
albumLimit = 20
|
|
}
|
|
}
|
|
|
|
result := &SearchAllResult{
|
|
Tracks: make([]TrackMetadata, 0, trackLimit),
|
|
Artists: make([]SearchArtistResult, 0, artistLimit),
|
|
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
|
Playlists: make([]SearchPlaylistResult, 0),
|
|
}
|
|
|
|
if trackLimit > 0 {
|
|
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
|
|
if err != nil {
|
|
GoLog("[Qobuz] Track search failed: %v\n", err)
|
|
return nil, fmt.Errorf("qobuz track search failed: %w", err)
|
|
}
|
|
GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
|
|
for i := range tracks {
|
|
result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
|
|
}
|
|
}
|
|
|
|
if artistLimit > 0 {
|
|
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
|
|
url.QueryEscape(cleanQuery), artistLimit, q.appID)
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err == nil {
|
|
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
|
if reqErr == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == 200 {
|
|
var artistResp struct {
|
|
Artists struct {
|
|
Items []struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Image qobuzImageSet `json:"image"`
|
|
} `json:"items"`
|
|
} `json:"artists"`
|
|
}
|
|
if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
|
|
GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
|
|
for _, artist := range artistResp.Artists.Items {
|
|
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
|
|
result.Artists = append(result.Artists, SearchArtistResult{
|
|
ID: qobuzPrefixedNumericID(artist.ID),
|
|
Name: strings.TrimSpace(artist.Name),
|
|
Images: imageURL,
|
|
})
|
|
}
|
|
} else {
|
|
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
|
|
}
|
|
}
|
|
} else {
|
|
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
if albumLimit > 0 {
|
|
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
|
url.QueryEscape(cleanQuery), albumLimit, q.appID)
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err == nil {
|
|
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
|
if reqErr == nil {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == 200 {
|
|
var albumResp struct {
|
|
Albums struct {
|
|
Items []qobuzAlbumDetails `json:"items"`
|
|
} `json:"albums"`
|
|
}
|
|
if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
|
|
GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
|
|
for i := range albumResp.Albums.Items {
|
|
album := &albumResp.Albums.Items[i]
|
|
result.Albums = append(result.Albums, SearchAlbumResult{
|
|
ID: qobuzPrefixedID(album.ID),
|
|
Name: strings.TrimSpace(album.Title),
|
|
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
|
Images: qobuzAlbumImage(album),
|
|
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
|
TotalTracks: album.TracksCount,
|
|
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
|
})
|
|
}
|
|
} else {
|
|
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
|
|
}
|
|
}
|
|
} else {
|
|
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
|
return result, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
|
queries := []string{}
|
|
|
|
if artistName != "" && trackName != "" {
|
|
queries = append(queries, artistName+" "+trackName)
|
|
}
|
|
|
|
if trackName != "" {
|
|
queries = append(queries, trackName)
|
|
}
|
|
|
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
|
romajiTrack := JapaneseToRomaji(trackName)
|
|
romajiArtist := JapaneseToRomaji(artistName)
|
|
|
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
|
|
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
|
if !containsQueryQobuz(queries, romajiQuery) {
|
|
queries = append(queries, romajiQuery)
|
|
GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
|
}
|
|
}
|
|
|
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
|
if !containsQueryQobuz(queries, cleanRomajiTrack) {
|
|
queries = append(queries, cleanRomajiTrack)
|
|
}
|
|
}
|
|
}
|
|
|
|
if artistName != "" {
|
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
|
if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) {
|
|
queries = append(queries, artistOnly)
|
|
}
|
|
}
|
|
|
|
var allTracks []QobuzTrack
|
|
searchedQueries := make(map[string]bool)
|
|
seenTrackIDs := make(map[int64]struct{})
|
|
|
|
for _, query := range queries {
|
|
cleanQuery := strings.TrimSpace(query)
|
|
if cleanQuery == "" || searchedQueries[cleanQuery] {
|
|
continue
|
|
}
|
|
searchedQueries[cleanQuery] = true
|
|
|
|
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
|
|
|
result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50)
|
|
if err != nil {
|
|
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
|
continue
|
|
}
|
|
|
|
if len(result) > 0 {
|
|
GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery)
|
|
for i := range result {
|
|
trackID := result[i].ID
|
|
if trackID <= 0 {
|
|
allTracks = append(allTracks, result[i])
|
|
continue
|
|
}
|
|
if _, ok := seenTrackIDs[trackID]; ok {
|
|
continue
|
|
}
|
|
seenTrackIDs[trackID] = struct{}{}
|
|
allTracks = append(allTracks, result[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(allTracks) == 0 {
|
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
|
}
|
|
|
|
var titleMatches []*QobuzTrack
|
|
for i := range allTracks {
|
|
track := &allTracks[i]
|
|
if qobuzTitlesMatch(trackName, track.Title) {
|
|
titleMatches = append(titleMatches, track)
|
|
}
|
|
}
|
|
|
|
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
|
|
|
tracksToCheck := titleMatches
|
|
if len(titleMatches) == 0 {
|
|
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
|
for i := range allTracks {
|
|
tracksToCheck = append(tracksToCheck, &allTracks[i])
|
|
}
|
|
}
|
|
|
|
if expectedDurationSec > 0 {
|
|
var durationMatches []*QobuzTrack
|
|
for _, track := range tracksToCheck {
|
|
durationDiff := track.Duration - expectedDurationSec
|
|
if durationDiff < 0 {
|
|
durationDiff = -durationDiff
|
|
}
|
|
if durationDiff <= 10 {
|
|
durationMatches = append(durationMatches, track)
|
|
}
|
|
}
|
|
|
|
if len(durationMatches) > 0 {
|
|
for _, track := range durationMatches {
|
|
if track.MaximumBitDepth >= 24 {
|
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
|
track.Title, track.Performer.Name)
|
|
return track, nil
|
|
}
|
|
}
|
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n",
|
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
|
return durationMatches[0], nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
|
}
|
|
|
|
for _, track := range tracksToCheck {
|
|
if track.MaximumBitDepth >= 24 {
|
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n",
|
|
track.Title, track.Performer.Name)
|
|
return track, nil
|
|
}
|
|
}
|
|
|
|
if len(tracksToCheck) > 0 {
|
|
GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n",
|
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
|
return tracksToCheck[0], nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
|
}
|
|
|
|
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
|
|
if track == nil {
|
|
return false
|
|
}
|
|
|
|
exactISRCMatch := req.ISRC != "" &&
|
|
track.ISRC != "" &&
|
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
|
|
|
|
if !exactISRCMatch && !skipNameVerification {
|
|
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
|
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
|
logPrefix, source, req.ArtistName, track.Performer.Name)
|
|
return false
|
|
}
|
|
|
|
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
|
|
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
|
|
logPrefix, source, req.TrackName, track.Title)
|
|
return false
|
|
}
|
|
}
|
|
|
|
expectedDurationSec := req.DurationMS / 1000
|
|
if expectedDurationSec > 0 && track.Duration > 0 {
|
|
durationDiff := track.Duration - expectedDurationSec
|
|
if durationDiff < 0 {
|
|
durationDiff = -durationDiff
|
|
}
|
|
if durationDiff > 10 {
|
|
GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n",
|
|
logPrefix, source, expectedDurationSec, track.Duration)
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) {
|
|
searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID)
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var result struct {
|
|
Tracks struct {
|
|
Items []QobuzTrack `json:"items"`
|
|
} `json:"tracks"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Tracks.Items, nil
|
|
}
|
|
|
|
type qobuzTrackSearchCandidate struct {
|
|
score int
|
|
track QobuzTrack
|
|
}
|
|
|
|
func qobuzNormalizedSearchText(value string) string {
|
|
return normalizeLooseArtistName(value)
|
|
}
|
|
|
|
func qobuzSearchTokens(value string) []string {
|
|
normalized := qobuzNormalizedSearchText(value)
|
|
if normalized == "" {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Fields(normalized)
|
|
tokens := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for _, part := range parts {
|
|
if len(part) < 2 {
|
|
continue
|
|
}
|
|
if _, ok := seen[part]; ok {
|
|
continue
|
|
}
|
|
seen[part] = struct{}{}
|
|
tokens = append(tokens, part)
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int {
|
|
if track == nil {
|
|
return 0
|
|
}
|
|
|
|
queryNorm := qobuzNormalizedSearchText(query)
|
|
if queryNorm == "" {
|
|
return 0
|
|
}
|
|
|
|
titleNorm := qobuzNormalizedSearchText(track.Title)
|
|
displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track))
|
|
artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track))
|
|
albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title))
|
|
|
|
score := 0
|
|
|
|
if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) {
|
|
score += 900
|
|
}
|
|
|
|
switch {
|
|
case queryNorm == titleNorm, queryNorm == displayNorm:
|
|
score += 1200
|
|
case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) ||
|
|
(displayNorm != "" && strings.Contains(displayNorm, queryNorm)):
|
|
score += 420
|
|
case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) ||
|
|
(displayNorm != "" && strings.Contains(queryNorm, displayNorm)):
|
|
score += 260
|
|
}
|
|
|
|
if artistNorm != "" && strings.Contains(queryNorm, artistNorm) {
|
|
score += 180
|
|
}
|
|
if albumNorm != "" && strings.Contains(queryNorm, albumNorm) {
|
|
score += 100
|
|
}
|
|
|
|
for _, token := range qobuzSearchTokens(query) {
|
|
switch {
|
|
case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token):
|
|
score += 180
|
|
case strings.Contains(artistNorm, token):
|
|
score += 70
|
|
case strings.Contains(albumNorm, token):
|
|
score += 35
|
|
}
|
|
}
|
|
|
|
if track.ISRC != "" {
|
|
score += 15
|
|
}
|
|
if track.MaximumBitDepth >= 24 {
|
|
score += 10
|
|
}
|
|
if track.MaximumSamplingRate >= 88.2 {
|
|
score += 10
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
func selectQobuzTracksFromAlbumSearchResults(
|
|
query string,
|
|
limit int,
|
|
albumSummaries []qobuzAlbumDetails,
|
|
loadAlbum func(string) (*qobuzAlbumDetails, error),
|
|
) ([]QobuzTrack, error) {
|
|
if strings.TrimSpace(query) == "" {
|
|
return nil, fmt.Errorf("empty qobuz album-search fallback query")
|
|
}
|
|
if len(albumSummaries) == 0 {
|
|
return nil, fmt.Errorf("album search returned no albums")
|
|
}
|
|
|
|
candidates := make([]qobuzTrackSearchCandidate, 0, limit)
|
|
seenTrackIDs := make(map[int64]struct{})
|
|
|
|
for _, summary := range albumSummaries {
|
|
albumID := strings.TrimSpace(summary.ID)
|
|
if albumID == "" {
|
|
continue
|
|
}
|
|
|
|
album, err := loadAlbum(albumID)
|
|
if err != nil || album == nil {
|
|
continue
|
|
}
|
|
|
|
for i := range album.Tracks.Items {
|
|
track := album.Tracks.Items[i]
|
|
track.Album.ID = album.ID
|
|
track.Album.QobuzID = album.QobuzID
|
|
track.Album.Title = album.Title
|
|
track.Album.ReleaseDate = album.ReleaseDateOriginal
|
|
track.Album.TracksCount = album.TracksCount
|
|
track.Album.ProductType = album.ProductType
|
|
track.Album.ReleaseType = album.ReleaseType
|
|
track.Album.Artist.ID = album.Artist.ID
|
|
track.Album.Artist.Name = album.Artist.Name
|
|
track.Album.Artists = album.Artists
|
|
track.Album.Image = album.Image
|
|
|
|
if track.ID > 0 {
|
|
if _, ok := seenTrackIDs[track.ID]; ok {
|
|
continue
|
|
}
|
|
seenTrackIDs[track.ID] = struct{}{}
|
|
}
|
|
|
|
score := qobuzScoreTrackSearchCandidate(query, &track)
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
|
|
candidates = append(candidates, qobuzTrackSearchCandidate{
|
|
score: score,
|
|
track: track,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("album-search fallback returned no scored track candidates")
|
|
}
|
|
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if candidates[i].score != candidates[j].score {
|
|
return candidates[i].score > candidates[j].score
|
|
}
|
|
if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth {
|
|
return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth
|
|
}
|
|
return candidates[i].track.ID < candidates[j].track.ID
|
|
})
|
|
|
|
if limit > 0 && len(candidates) > limit {
|
|
candidates = candidates[:limit]
|
|
}
|
|
|
|
tracks := make([]QobuzTrack, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
tracks = append(tracks, candidate.track)
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) {
|
|
albumLimit := limit
|
|
if albumLimit < 3 {
|
|
albumLimit = 3
|
|
}
|
|
if albumLimit > 8 {
|
|
albumLimit = 8
|
|
}
|
|
|
|
searchURL := fmt.Sprintf(
|
|
"https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
|
url.QueryEscape(strings.TrimSpace(query)),
|
|
albumLimit,
|
|
q.appID,
|
|
)
|
|
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var albumResp struct {
|
|
Albums struct {
|
|
Items []qobuzAlbumDetails `json:"items"`
|
|
} `json:"albums"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return selectQobuzTracksFromAlbumSearchResults(
|
|
query,
|
|
limit,
|
|
albumResp.Albums.Items,
|
|
q.getAlbumDetails,
|
|
)
|
|
}
|
|
|
|
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
|
|
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
trackIDs := make([]int64, 0, len(matches))
|
|
seen := make(map[int64]struct{}, len(matches))
|
|
for _, match := range matches {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
id, err := strconv.ParseInt(string(match[1]), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
trackIDs = append(trackIDs, id)
|
|
}
|
|
return trackIDs
|
|
}
|
|
|
|
func (q *QobuzDownloader) searchQobuzTracksViaStore(query string, limit int) ([]QobuzTrack, error) {
|
|
searchURL := qobuzStoreSearchBaseURL + url.PathEscape(query)
|
|
req, err := http.NewRequest("GET", searchURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("store search failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
trackIDs := extractQobuzTrackIDsFromStoreSearchHTML(body)
|
|
if len(trackIDs) == 0 {
|
|
return nil, fmt.Errorf("store search did not contain track IDs")
|
|
}
|
|
|
|
if limit > 0 && len(trackIDs) > limit {
|
|
trackIDs = trackIDs[:limit]
|
|
}
|
|
|
|
tracks := make([]QobuzTrack, 0, len(trackIDs))
|
|
for _, id := range trackIDs {
|
|
track, trackErr := q.GetTrackByID(id)
|
|
if trackErr != nil || track == nil {
|
|
continue
|
|
}
|
|
tracks = append(tracks, *track)
|
|
}
|
|
|
|
if len(tracks) == 0 {
|
|
return nil, fmt.Errorf("store fallback returned IDs but no track metadata could be loaded")
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) ([]QobuzTrack, error) {
|
|
apiTracks, apiErr := q.searchQobuzTracksViaAPI(query, limit)
|
|
if apiErr == nil {
|
|
if len(apiTracks) > 0 {
|
|
return apiTracks, nil
|
|
}
|
|
GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query)
|
|
} else {
|
|
GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr)
|
|
}
|
|
|
|
albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit)
|
|
if albumErr == nil && len(albumTracks) > 0 {
|
|
GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query)
|
|
return albumTracks, nil
|
|
}
|
|
if albumErr != nil {
|
|
GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr)
|
|
}
|
|
|
|
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
|
|
if storeErr == nil && len(storeTracks) > 0 {
|
|
GoLog("[Qobuz] Store fallback returned %d candidate tracks for '%s'\n", len(storeTracks), query)
|
|
return storeTracks, nil
|
|
}
|
|
|
|
if apiErr != nil && albumErr != nil && storeErr != nil {
|
|
return nil, fmt.Errorf(
|
|
"api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)",
|
|
apiErr,
|
|
albumErr,
|
|
storeErr,
|
|
)
|
|
}
|
|
if albumErr == nil && len(albumTracks) == 0 && storeErr != nil {
|
|
return nil, storeErr
|
|
}
|
|
if storeErr != nil {
|
|
if albumErr != nil {
|
|
return nil, albumErr
|
|
}
|
|
return nil, storeErr
|
|
}
|
|
return nil, fmt.Errorf("no tracks found for query: %s", query)
|
|
}
|
|
|
|
type qobuzAPIResult struct {
|
|
provider qobuzAPIProvider
|
|
info qobuzDownloadInfo
|
|
err error
|
|
duration time.Duration
|
|
}
|
|
|
|
// Mobile networks are more unstable, so we use longer timeouts
|
|
const (
|
|
qobuzAPITimeoutMobile = 25 * time.Second
|
|
qobuzMaxRetries = 2
|
|
qobuzRetryDelay = 500 * time.Millisecond
|
|
)
|
|
|
|
func getQobuzAPITimeout() time.Duration {
|
|
// The Go backend is only used on mobile (Android/iOS)
|
|
return qobuzAPITimeoutMobile
|
|
}
|
|
|
|
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
|
|
func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) {
|
|
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
|
}
|
|
|
|
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
|
|
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
|
payload := map[string]any{
|
|
"quality": requestQuality,
|
|
"upload_to_r2": false,
|
|
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
|
var lastErr error
|
|
retryDelay := qobuzRetryDelay
|
|
var payloadBytes []byte
|
|
if provider.Kind == qobuzAPIKindMusicDL {
|
|
var err error
|
|
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
|
|
if err != nil {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
|
|
}
|
|
}
|
|
|
|
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay)
|
|
time.Sleep(retryDelay)
|
|
retryDelay *= 2
|
|
}
|
|
|
|
client := NewHTTPClientWithTimeout(timeout)
|
|
reqURL := provider.URL
|
|
if country != "" {
|
|
reqURL += "?country=" + url.QueryEscape(country)
|
|
}
|
|
|
|
var (
|
|
req *http.Request
|
|
err error
|
|
)
|
|
if provider.Kind == qobuzAPIKindStandard {
|
|
separator := "&"
|
|
if !strings.Contains(reqURL, "?") {
|
|
separator = "?"
|
|
}
|
|
reqURL = fmt.Sprintf(
|
|
"%s%d%squality=%s",
|
|
reqURL,
|
|
trackID,
|
|
separator,
|
|
url.QueryEscape(normalizeQobuzQualityCode(quality)),
|
|
)
|
|
req, err = http.NewRequest("GET", reqURL, nil)
|
|
} else {
|
|
req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes))
|
|
}
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if provider.Kind == qobuzAPIKindMusicDL {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(client, req)
|
|
if err != nil {
|
|
lastErr = err
|
|
// Check for retryable errors (timeout, connection reset)
|
|
errStr := strings.ToLower(err.Error())
|
|
if strings.Contains(errStr, "timeout") ||
|
|
strings.Contains(errStr, "reset") ||
|
|
strings.Contains(errStr, "connection refused") ||
|
|
strings.Contains(errStr, "eof") {
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
continue
|
|
}
|
|
|
|
// 429 rate limit - wait and retry
|
|
if resp.StatusCode == 429 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("rate limited")
|
|
retryDelay = 2 * time.Second
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if len(body) > 0 && body[0] == '<' {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON")
|
|
}
|
|
|
|
info, parseErr := extractQobuzDownloadInfoFromBody(body)
|
|
if parseErr == nil {
|
|
return info, nil
|
|
}
|
|
lastErr = parseErr
|
|
continue
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return qobuzDownloadInfo{}, lastErr
|
|
}
|
|
return qobuzDownloadInfo{}, fmt.Errorf("all retries failed")
|
|
}
|
|
|
|
func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) {
|
|
if len(providers) == 0 {
|
|
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available")
|
|
}
|
|
|
|
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers))
|
|
|
|
resultChan := make(chan qobuzAPIResult, len(providers))
|
|
startTime := time.Now()
|
|
timeout := getQobuzAPITimeout()
|
|
|
|
for _, provider := range providers {
|
|
go func(provider qobuzAPIProvider) {
|
|
reqStart := time.Now()
|
|
info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout)
|
|
resultChan <- qobuzAPIResult{
|
|
provider: provider,
|
|
info: info,
|
|
err: err,
|
|
duration: time.Since(reqStart),
|
|
}
|
|
}(provider)
|
|
}
|
|
|
|
var errors []string
|
|
|
|
for i := 0; i < len(providers); i++ {
|
|
result := <-resultChan
|
|
if result.err == nil {
|
|
GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration)
|
|
|
|
go func(remaining int) {
|
|
for j := 0; j < remaining; j++ {
|
|
<-resultChan
|
|
}
|
|
}(len(providers) - i - 1)
|
|
|
|
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
|
return result.provider, result.info, nil
|
|
}
|
|
errMsg := result.err.Error()
|
|
if len(errMsg) > 50 {
|
|
errMsg = errMsg[:50] + "..."
|
|
}
|
|
errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg))
|
|
}
|
|
|
|
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime))
|
|
return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors)
|
|
}
|
|
|
|
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) {
|
|
providers := q.GetAvailableProviders()
|
|
if len(providers) == 0 {
|
|
return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available")
|
|
}
|
|
|
|
qualityCode := normalizeQobuzQualityCode(quality)
|
|
|
|
downloadFunc := func(qual string) (qobuzDownloadInfo, error) {
|
|
provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual)
|
|
if err != nil {
|
|
return qobuzDownloadInfo{}, err
|
|
}
|
|
GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name)
|
|
return info, nil
|
|
}
|
|
|
|
downloadInfo, err := downloadFunc(qualityCode)
|
|
if err == nil {
|
|
return downloadInfo, nil
|
|
}
|
|
|
|
currentQuality := qualityCode
|
|
if currentQuality == "27" {
|
|
GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n")
|
|
downloadInfo, err = downloadFunc("7")
|
|
if err == nil {
|
|
return downloadInfo, nil
|
|
}
|
|
currentQuality = "7"
|
|
}
|
|
|
|
if currentQuality == "7" {
|
|
GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n")
|
|
downloadInfo, err = downloadFunc("6")
|
|
if err == nil {
|
|
return downloadInfo, nil
|
|
}
|
|
}
|
|
|
|
return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err)
|
|
}
|
|
|
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
ctx := context.Background()
|
|
|
|
if itemID != "" {
|
|
StartItemProgress(itemID)
|
|
defer CompleteItemProgress(itemID)
|
|
ctx = initDownloadCancel(itemID)
|
|
defer clearDownloadCancel(itemID)
|
|
}
|
|
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
|
if err != nil {
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
expectedSize := resp.ContentLength
|
|
if expectedSize > 0 && itemID != "" {
|
|
SetItemBytesTotal(itemID, expectedSize)
|
|
}
|
|
|
|
out, err := openOutputForWrite(outputPath, outputFD)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
|
|
var written int64
|
|
if itemID != "" {
|
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
|
written, err = io.Copy(progressWriter, resp.Body)
|
|
} else {
|
|
written, err = io.Copy(bufWriter, resp.Body)
|
|
}
|
|
|
|
flushErr := bufWriter.Flush()
|
|
closeErr := out.Close()
|
|
|
|
if err != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
if isDownloadCancelled(itemID) {
|
|
return ErrDownloadCancelled
|
|
}
|
|
return fmt.Errorf("download interrupted: %w", err)
|
|
}
|
|
if flushErr != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
}
|
|
if closeErr != nil {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
}
|
|
|
|
if expectedSize > 0 && written != expectedSize {
|
|
cleanupOutputOnError(outputPath, outputFD)
|
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type QobuzDownloadResult struct {
|
|
FilePath string
|
|
BitDepth int
|
|
SampleRate int
|
|
Title string
|
|
Artist string
|
|
Album string
|
|
ReleaseDate string
|
|
TrackNumber int
|
|
DiscNumber int
|
|
ISRC string
|
|
CoverURL string
|
|
LyricsLRC string
|
|
}
|
|
|
|
func parseQobuzRequestTrackID(raw string) int64 {
|
|
trimmed := strings.TrimSpace(raw)
|
|
trimmed = strings.TrimPrefix(trimmed, "qobuz:")
|
|
if trimmed == "" {
|
|
return 0
|
|
}
|
|
var trackID int64
|
|
if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 {
|
|
return 0
|
|
}
|
|
return trackID
|
|
}
|
|
|
|
func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) {
|
|
if downloader == nil {
|
|
downloader = NewQobuzDownloader()
|
|
}
|
|
if strings.TrimSpace(logPrefix) == "" {
|
|
logPrefix = "Qobuz"
|
|
}
|
|
|
|
expectedDurationSec := req.DurationMS / 1000
|
|
|
|
var track *QobuzTrack
|
|
var err error
|
|
|
|
// Strategy 1: Use Qobuz ID from request payload (fastest, most accurate)
|
|
if req.QobuzID != "" {
|
|
GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID)
|
|
if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 {
|
|
track, err = qobuzGetTrackByIDFunc(downloader, trackID)
|
|
if err != nil {
|
|
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
|
|
track = nil
|
|
} else if track != nil {
|
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
|
|
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
|
} else {
|
|
track = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
|
|
if track == nil && req.ISRC != "" {
|
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
|
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID)
|
|
track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID)
|
|
if err != nil {
|
|
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
|
|
track = nil
|
|
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
|
|
track = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC
|
|
if track == nil && req.SpotifyID != "" && req.QobuzID == "" && req.ISRC == "" {
|
|
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
|
|
songLinkClient := NewSongLinkClient()
|
|
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
|
|
if slErr == nil && availability != nil && availability.QobuzID != "" {
|
|
var trackID int64
|
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
|
GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID)
|
|
track, err = qobuzGetTrackByIDFunc(downloader, trackID)
|
|
if err != nil {
|
|
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
|
|
track = nil
|
|
} else if track != nil {
|
|
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
|
|
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
|
|
if req.ISRC != "" {
|
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
|
}
|
|
} else {
|
|
track = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strategy 4: ISRC search with duration verification
|
|
if track == nil && req.ISRC != "" {
|
|
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
|
|
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
|
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
|
|
track = nil
|
|
}
|
|
}
|
|
|
|
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
|
if track == nil {
|
|
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
|
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
|
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
|
|
track = nil
|
|
}
|
|
}
|
|
|
|
if track == nil {
|
|
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
|
if err != nil {
|
|
errMsg = err.Error()
|
|
}
|
|
return nil, fmt.Errorf("qobuz search failed: %s", errMsg)
|
|
}
|
|
|
|
GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration)
|
|
if req.ISRC != "" {
|
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
|
}
|
|
|
|
return track, nil
|
|
}
|
|
|
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|
downloader := NewQobuzDownloader()
|
|
|
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
if !isSafOutput {
|
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
}
|
|
}
|
|
|
|
track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz")
|
|
if err != nil {
|
|
return QobuzDownloadResult{}, err
|
|
}
|
|
|
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
"title": req.TrackName,
|
|
"artist": req.ArtistName,
|
|
"album": req.AlbumName,
|
|
"track": req.TrackNumber,
|
|
"year": extractYear(req.ReleaseDate),
|
|
"date": req.ReleaseDate,
|
|
"disc": req.DiscNumber,
|
|
})
|
|
var outputPath string
|
|
if isSafOutput {
|
|
outputPath = strings.TrimSpace(req.OutputPath)
|
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
}
|
|
} else {
|
|
filename = sanitizeFilename(filename) + ".flac"
|
|
outputPath = filepath.Join(req.OutputDir, filename)
|
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
|
}
|
|
}
|
|
|
|
qobuzQuality := "27"
|
|
switch req.Quality {
|
|
case "LOSSLESS":
|
|
qobuzQuality = "6"
|
|
case "HI_RES":
|
|
qobuzQuality = "7"
|
|
case "HI_RES_LOSSLESS", "", "DEFAULT":
|
|
qobuzQuality = "27"
|
|
}
|
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
|
|
|
actualBitDepth := track.MaximumBitDepth
|
|
actualSampleRate := int(track.MaximumSamplingRate * 1000)
|
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
|
|
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
|
if err != nil {
|
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
}
|
|
if downloadInfo.BitDepth > 0 {
|
|
actualBitDepth = downloadInfo.BitDepth
|
|
}
|
|
if downloadInfo.SampleRate > 0 {
|
|
actualSampleRate = downloadInfo.SampleRate
|
|
}
|
|
if actualBitDepth > 0 || actualSampleRate > 0 {
|
|
GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate)
|
|
}
|
|
|
|
var parallelResult *ParallelDownloadResult
|
|
parallelDone := make(chan struct{})
|
|
go func() {
|
|
defer close(parallelDone)
|
|
coverURL := strings.TrimSpace(req.CoverURL)
|
|
if coverURL == "" {
|
|
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
|
}
|
|
embedLyrics := req.EmbedLyrics
|
|
if !req.EmbedMetadata {
|
|
coverURL = ""
|
|
embedLyrics = false
|
|
}
|
|
parallelResult = FetchCoverAndLyricsParallel(
|
|
coverURL,
|
|
req.EmbedMaxQualityCover,
|
|
req.SpotifyID,
|
|
req.TrackName,
|
|
req.ArtistName,
|
|
embedLyrics,
|
|
int64(req.DurationMS),
|
|
)
|
|
}()
|
|
|
|
if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
if errors.Is(err, ErrDownloadCancelled) {
|
|
return QobuzDownloadResult{}, ErrDownloadCancelled
|
|
}
|
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
}
|
|
|
|
<-parallelDone
|
|
|
|
if req.ItemID != "" {
|
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
SetItemFinalizing(req.ItemID)
|
|
}
|
|
|
|
albumName := track.Album.Title
|
|
if req.AlbumName != "" {
|
|
albumName = req.AlbumName
|
|
}
|
|
releaseDate := track.Album.ReleaseDate
|
|
if req.ReleaseDate != "" {
|
|
releaseDate = req.ReleaseDate
|
|
}
|
|
|
|
actualTrackNumber := req.TrackNumber
|
|
if actualTrackNumber == 0 {
|
|
actualTrackNumber = track.TrackNumber
|
|
}
|
|
|
|
metadata := Metadata{
|
|
Title: track.Title,
|
|
Artist: req.ArtistName,
|
|
Album: albumName,
|
|
AlbumArtist: req.AlbumArtist,
|
|
ArtistTagMode: req.ArtistTagMode,
|
|
Date: releaseDate,
|
|
TrackNumber: actualTrackNumber,
|
|
TotalTracks: req.TotalTracks,
|
|
DiscNumber: req.DiscNumber,
|
|
ISRC: track.ISRC,
|
|
Genre: req.Genre,
|
|
Label: req.Label,
|
|
Copyright: req.Copyright,
|
|
}
|
|
|
|
var coverData []byte
|
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
|
coverData = parallelResult.CoverData
|
|
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
}
|
|
|
|
if isSafOutput || !req.EmbedMetadata {
|
|
if !req.EmbedMetadata {
|
|
GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
|
} else {
|
|
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
|
}
|
|
} else {
|
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
|
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
|
}
|
|
|
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
lyricsMode := req.LyricsMode
|
|
if lyricsMode == "" {
|
|
lyricsMode = "embed"
|
|
}
|
|
|
|
if lyricsMode == "external" || lyricsMode == "both" {
|
|
GoLog("[Qobuz] Saving external LRC file...\n")
|
|
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
} else {
|
|
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
|
|
}
|
|
}
|
|
|
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
|
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
} else {
|
|
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
|
}
|
|
}
|
|
} else if req.EmbedLyrics {
|
|
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
|
|
}
|
|
}
|
|
|
|
if !isSafOutput {
|
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
|
}
|
|
|
|
lyricsLRC := ""
|
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
lyricsLRC = parallelResult.LyricsLRC
|
|
}
|
|
|
|
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
|
req,
|
|
track.Album.Title,
|
|
track.Album.ReleaseDate,
|
|
actualTrackNumber,
|
|
req.DiscNumber,
|
|
)
|
|
|
|
// Prefer the cover URL the frontend sent (user-selected album) over the
|
|
// track's default album cover returned by the Qobuz track/get API, which
|
|
// may belong to a different album when the same track appears on multiple
|
|
// releases.
|
|
resultCoverURL := strings.TrimSpace(req.CoverURL)
|
|
if resultCoverURL == "" {
|
|
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
|
}
|
|
|
|
return QobuzDownloadResult{
|
|
FilePath: outputPath,
|
|
BitDepth: actualBitDepth,
|
|
SampleRate: actualSampleRate,
|
|
Title: track.Title,
|
|
Artist: track.Performer.Name,
|
|
Album: resultAlbum,
|
|
ReleaseDate: resultReleaseDate,
|
|
TrackNumber: resultTrackNumber,
|
|
DiscNumber: resultDiscNumber,
|
|
ISRC: track.ISRC,
|
|
CoverURL: resultCoverURL,
|
|
LyricsLRC: lyricsLRC,
|
|
}, nil
|
|
}
|