v7.1.1
This commit is contained in:
+476
-121
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,17 +23,6 @@ import (
|
||||
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
}
|
||||
|
||||
type QobuzSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Tracks struct {
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
type QobuzTrack struct {
|
||||
@@ -69,10 +61,6 @@ type QobuzTrack struct {
|
||||
} `json:"album"`
|
||||
}
|
||||
|
||||
type QobuzStreamResponse struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type qobuzMusicDLRequest struct {
|
||||
URL string `json:"url"`
|
||||
Quality string `json:"quality"`
|
||||
@@ -89,12 +77,20 @@ type qobuzMusicDLResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
const qobuzMusicDLProbeTrackID int64 = 341032040
|
||||
type qobuzPublicSearchResponse struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
Items []QobuzTrack `json:"items"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
const qobuzProbeTrackID int64 = 341032040
|
||||
|
||||
var (
|
||||
qobuzMusicDLDebugKeyOnce sync.Once
|
||||
qobuzMusicDLDebugKey string
|
||||
qobuzMusicDLDebugKeyErr error
|
||||
qobuzStreamingURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\)]+`)
|
||||
)
|
||||
|
||||
var qobuzMusicDLDebugKeySeedParts = [][]byte{
|
||||
@@ -129,7 +125,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
appID: qobuzDefaultAPIAppID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,112 +179,464 @@ func getQobuzMusicDLDebugKey() (string, error) {
|
||||
return qobuzMusicDLDebugKey, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) {
|
||||
func firstNonEmptyQobuzValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeQobuzSearchValue(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", " and ",
|
||||
"feat.", " ",
|
||||
"ft.", " ",
|
||||
"/", " ",
|
||||
"-", " ",
|
||||
"_", " ",
|
||||
)
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
normalized = replacer.Replace(normalized)
|
||||
return strings.Join(strings.Fields(normalized), " ")
|
||||
}
|
||||
|
||||
func qobuzTrackDisplayArtist(track QobuzTrack) string {
|
||||
return firstNonEmptyQobuzValue(track.Performer.Name, track.Album.Artist.Name)
|
||||
}
|
||||
|
||||
func qobuzTrackSupportsHiRes(track QobuzTrack) bool {
|
||||
if track.Hires || track.HiresStreamable {
|
||||
return true
|
||||
}
|
||||
return track.MaximumBitDepth >= 24 || track.MaximumSamplingRate > 48
|
||||
}
|
||||
|
||||
func scoreQobuzSearchCandidate(track QobuzTrack, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) int {
|
||||
score := 0
|
||||
|
||||
titleNeedle := normalizeQobuzSearchValue(spotifyTrackName)
|
||||
titleHaystack := normalizeQobuzSearchValue(track.Title)
|
||||
switch {
|
||||
case titleNeedle != "" && titleHaystack == titleNeedle:
|
||||
score += 1000
|
||||
case titleNeedle != "" && (strings.Contains(titleHaystack, titleNeedle) || strings.Contains(titleNeedle, titleHaystack)):
|
||||
score += 500
|
||||
}
|
||||
|
||||
artistNeedle := normalizeQobuzSearchValue(spotifyArtistName)
|
||||
artistHaystack := normalizeQobuzSearchValue(qobuzTrackDisplayArtist(track))
|
||||
switch {
|
||||
case artistNeedle != "" && artistHaystack == artistNeedle:
|
||||
score += 300
|
||||
case artistNeedle != "" && artistHaystack != "" && (strings.Contains(artistHaystack, artistNeedle) || strings.Contains(artistNeedle, artistHaystack)):
|
||||
score += 180
|
||||
}
|
||||
|
||||
albumNeedle := normalizeQobuzSearchValue(spotifyAlbumName)
|
||||
albumHaystack := normalizeQobuzSearchValue(track.Album.Title)
|
||||
switch {
|
||||
case albumNeedle != "" && albumHaystack == albumNeedle:
|
||||
score += 150
|
||||
case albumNeedle != "" && albumHaystack != "" && (strings.Contains(albumHaystack, albumNeedle) || strings.Contains(albumNeedle, albumHaystack)):
|
||||
score += 90
|
||||
}
|
||||
|
||||
if qobuzTrackSupportsHiRes(track) {
|
||||
score += 40
|
||||
} else if track.MaximumBitDepth >= 16 {
|
||||
score += 20
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func mapQobuzWJHEQuality(quality string) (int, string) {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return 2000, "flac"
|
||||
case "", "6":
|
||||
return 1000, "flac"
|
||||
default:
|
||||
return 320, "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
func buildQobuzWJHEDownloadURL(trackID int64, quality string) string {
|
||||
wjheQuality, wjheFormat := mapQobuzWJHEQuality(quality)
|
||||
params := url.Values{
|
||||
"ID": {strconv.FormatInt(trackID, 10)},
|
||||
"quality": {strconv.Itoa(wjheQuality)},
|
||||
"format": {wjheFormat},
|
||||
}
|
||||
return GetQobuzWJHEStreamAPIURL() + "?" + params.Encode()
|
||||
}
|
||||
|
||||
func qobuzURLLooksStreamable(raw string) bool {
|
||||
candidate := strings.TrimSpace(raw)
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(candidate)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||
}
|
||||
|
||||
func findQobuzStreamingURLInPayload(payload interface{}) string {
|
||||
switch value := payload.(type) {
|
||||
case string:
|
||||
candidate := strings.ReplaceAll(strings.TrimSpace(value), `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range value {
|
||||
if url := findQobuzStreamingURLInPayload(item); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"download_url", "url", "play_url", "stream_url", "link", "file"} {
|
||||
if nested, ok := value[key]; ok {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, nested := range value {
|
||||
if url := findQobuzStreamingURLInPayload(nested); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractQobuzStreamingURL(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var directResp struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &directResp); err == nil {
|
||||
for _, candidate := range []string{
|
||||
directResp.DownloadURL,
|
||||
directResp.URL,
|
||||
directResp.Data.DownloadURL,
|
||||
directResp.Data.URL,
|
||||
} {
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var genericPayload interface{}
|
||||
if err := json.Unmarshal(body, &genericPayload); err == nil {
|
||||
if streamURL := findQobuzStreamingURLInPayload(genericPayload); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
|
||||
if openIdx := strings.Index(trimmed, "("); openIdx >= 0 {
|
||||
if closeIdx := strings.LastIndex(trimmed, ")"); closeIdx > openIdx+1 {
|
||||
callbackBody := strings.TrimSpace(trimmed[openIdx+1 : closeIdx])
|
||||
if streamURL := extractQobuzStreamingURL([]byte(callbackBody)); streamURL != "" {
|
||||
return streamURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range qobuzStreamingURLPattern.FindAllString(trimmed, -1) {
|
||||
candidate := strings.ReplaceAll(match, `\/`, `/`)
|
||||
if qobuzURLLooksStreamable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func newQobuzNoRedirectClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
return &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 {
|
||||
cloned.Timeout = 20 * time.Second
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) searchByISRC(isrc string, spotifyTrackName string, spotifyArtistName string, spotifyAlbumName string) (*QobuzTrack, error) {
|
||||
if strings.HasPrefix(isrc, "qobuz_") {
|
||||
trackID := strings.TrimPrefix(isrc, "qobuz_")
|
||||
trackID := strings.TrimSpace(strings.TrimPrefix(isrc, "qobuz_"))
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/get", url.Values{"track_id": {trackID}}, q.client)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch track: %w", err)
|
||||
return nil, fmt.Errorf("failed to fetch track from Qobuz public API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("Qobuz public API track/get returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var trackResp QobuzTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode Qobuz public track/get response: %w", err)
|
||||
}
|
||||
|
||||
return &trackResp, nil
|
||||
}
|
||||
|
||||
resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{
|
||||
"query": {isrc},
|
||||
"limit": {"1"},
|
||||
}, q.client)
|
||||
queries := []string{strings.TrimSpace(isrc)}
|
||||
if fallbackQuery := strings.TrimSpace(strings.Join([]string{spotifyTrackName, spotifyArtistName}, " ")); fallbackQuery != "" {
|
||||
queries = append(queries, fallbackQuery)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var searchResp qobuzPublicSearchResponse
|
||||
if err := doQobuzSignedJSONRequest("track/search", url.Values{
|
||||
"query": {strings.TrimSpace(query)},
|
||||
"limit": {"10"},
|
||||
}, &searchResp); err != nil {
|
||||
lastErr = fmt.Errorf("failed to search Qobuz public API: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if searchResp.Tracks.Total == 0 || len(searchResp.Tracks.Items) == 0 {
|
||||
lastErr = fmt.Errorf("track not found for query: %s", query)
|
||||
continue
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for idx, candidate := range searchResp.Tracks.Items {
|
||||
score := scoreQobuzSearchCandidate(candidate, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if idx == 0 || score > bestScore {
|
||||
bestIndex = idx
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
selected := searchResp.Tracks.Items[bestIndex]
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromWJHE(trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzWJHEDownloadURL(trackID, quality)
|
||||
client := newQobuzNoRedirectClient(q.client)
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodHead, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search track: %w", err)
|
||||
return "", fmt.Errorf("failed to create WJHE request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
||||
resp.Body.Close()
|
||||
req, err = NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create WJHE fallback request: %w", err)
|
||||
}
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reach WJHE with GET fallback: %w", err)
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
if location := strings.TrimSpace(resp.Header.Get("Location")); qobuzURLLooksStreamable(location) {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
var searchResp QobuzSearchResponse
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
return "", fmt.Errorf("failed to read WJHE response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
if streamURL := extractQobuzStreamingURL(body); streamURL != "" {
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 200 {
|
||||
bodyStr = bodyStr[:200] + "..."
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
if streamURL := strings.TrimSpace(resp.Request.URL.String()); streamURL != "" && streamURL != apiURL && qobuzURLLooksStreamable(streamURL) {
|
||||
return streamURL, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
||||
}
|
||||
|
||||
if len(searchResp.Tracks.Items) == 0 {
|
||||
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||||
return "", fmt.Errorf("WJHE returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
return &searchResp.Tracks.Items[0], nil
|
||||
return "", fmt.Errorf("WJHE response did not include a stream URL")
|
||||
}
|
||||
|
||||
func buildQobuzAPIURL(apiBase string, trackID int64, quality string) string {
|
||||
return fmt.Sprintf("%s%d&quality=%s", apiBase, trackID, quality)
|
||||
func qobuzGDStudioPaddedVersion() string {
|
||||
parts := strings.Split(GetQobuzGDStudioVersion(), ".")
|
||||
for idx, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len(part) == 1 {
|
||||
part = "0" + part
|
||||
}
|
||||
parts[idx] = part
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromStandard(apiBase string, trackID int64, quality string) (string, error) {
|
||||
apiURL := buildQobuzAPIURL(apiBase, trackID, quality)
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil)
|
||||
func qobuzGDStudioEscapedValue(value string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(strings.TrimSpace(value)), "+", "%20")
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) getQobuzGDStudioTS9(apiURL string) string {
|
||||
fallback := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
if len(fallback) >= 9 {
|
||||
fallback = fallback[:9]
|
||||
}
|
||||
|
||||
client := q.client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodGet, fmt.Sprintf("https://%s/time", signatureHost), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return fallback
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
timestamp := strings.TrimSpace(string(body))
|
||||
if len(timestamp) >= 9 {
|
||||
return timestamp[:9]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
func buildQobuzGDStudioSignature(apiURL string, value string, ts9 string) string {
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
signatureBase := fmt.Sprintf("%s|%s|%s|%s", signatureHost, qobuzGDStudioPaddedVersion(), ts9, qobuzGDStudioEscapedValue(value))
|
||||
sum := md5.Sum([]byte(signatureBase))
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
return strings.ToUpper(digest[len(digest)-8:])
|
||||
}
|
||||
|
||||
func mapQobuzGDStudioBitrate(quality string) string {
|
||||
switch strings.TrimSpace(quality) {
|
||||
case "27", "7":
|
||||
return "999"
|
||||
case "", "6":
|
||||
return "740"
|
||||
default:
|
||||
return "320"
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromGDStudio(trackID int64, quality string, apiURL string) (string, error) {
|
||||
apiURL = strings.TrimSpace(apiURL)
|
||||
if apiURL == "" {
|
||||
apiURL = GetQobuzGDStudioPrimaryAPIURL()
|
||||
}
|
||||
|
||||
signatureHost := GetQobuzGDStudioSignatureHost(apiURL)
|
||||
if signatureHost == "" {
|
||||
return "", fmt.Errorf("GDStudio API URL is invalid: %s", apiURL)
|
||||
}
|
||||
|
||||
trackIDString := strconv.FormatInt(trackID, 10)
|
||||
ts9 := q.getQobuzGDStudioTS9(apiURL)
|
||||
payload := url.Values{
|
||||
"types": {"url"},
|
||||
"id": {trackIDString},
|
||||
"source": {"qobuz"},
|
||||
"br": {mapQobuzGDStudioBitrate(quality)},
|
||||
"s": {buildQobuzGDStudioSignature(apiURL, trackIDString, ts9)},
|
||||
}
|
||||
|
||||
req, err := NewRequestWithDefaultHeaders(http.MethodPost, apiURL, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GDStudio request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("Origin", fmt.Sprintf("https://%s", signatureHost))
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/", signatureHost))
|
||||
|
||||
resp, err := q.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to reach GDStudio: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed to read GDStudio response: %w", err)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", fmt.Errorf("empty body")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GDStudio returned status %d: %s", resp.StatusCode, previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var streamResp QobuzStreamResponse
|
||||
if err := json.Unmarshal(body, &streamResp); err == nil && streamResp.URL != "" {
|
||||
return streamResp.URL, nil
|
||||
streamURL := extractQobuzStreamingURL(body)
|
||||
if streamURL == "" {
|
||||
return "", fmt.Errorf("GDStudio response did not include a stream URL: %s", previewQobuzResponseBody(body, 256))
|
||||
}
|
||||
|
||||
var nestedResp struct {
|
||||
Data struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &nestedResp); err == nil && nestedResp.Data.URL != "" {
|
||||
return nestedResp.Data.URL, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid response")
|
||||
return streamURL, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (string, error) {
|
||||
@@ -357,14 +704,46 @@ func (q *QobuzDownloader) DownloadFromMusicDL(trackID int64, quality string) (st
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||
func CheckQobuzMusicDLStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client, appID: qobuzDefaultAPIAppID}
|
||||
_, err := downloader.DownloadFromMusicDL(qobuzMusicDLProbeTrackID, "27")
|
||||
return err == nil
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromMusicDL(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzMusicDLStatus(client *http.Client) bool {
|
||||
return CheckQobuzMusicDLStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatusDetailed(client *http.Client) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromWJHE(qobuzProbeTrackID, "27")
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzWJHEStatus(client *http.Client) bool {
|
||||
return CheckQobuzWJHEStatusDetailed(client) == nil
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatusDetailed(client *http.Client, apiURL string) error {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
}
|
||||
|
||||
downloader := &QobuzDownloader{client: client}
|
||||
_, err := downloader.DownloadFromGDStudio(qobuzProbeTrackID, "27", apiURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func CheckQobuzGDStudioAPIStatus(client *http.Client, apiURL string) bool {
|
||||
return CheckQobuzGDStudioAPIStatusDetailed(client, apiURL) == nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFallback bool) (string, error) {
|
||||
@@ -376,65 +755,35 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string, allowFal
|
||||
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
|
||||
|
||||
downloadFunc := func(qual string) (string, error) {
|
||||
type Provider struct {
|
||||
Name string
|
||||
API string
|
||||
Func func() (string, error)
|
||||
}
|
||||
|
||||
providerMap := make(map[string]Provider)
|
||||
providerIDs := []string{GetQobuzMusicDLDownloadAPIURL()}
|
||||
|
||||
providerMap[GetQobuzMusicDLDownloadAPIURL()] = Provider{
|
||||
Name: "MusicDL",
|
||||
API: GetQobuzMusicDLDownloadAPIURL(),
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromMusicDL(trackID, qual)
|
||||
},
|
||||
}
|
||||
|
||||
for _, api := range GetQobuzStreamAPIBaseURLs() {
|
||||
currentAPI := api
|
||||
providerIDs = append(providerIDs, currentAPI)
|
||||
providerMap[currentAPI] = Provider{
|
||||
Name: "Standard(" + currentAPI + ")",
|
||||
API: currentAPI,
|
||||
Func: func() (string, error) {
|
||||
return q.DownloadFromStandard(currentAPI, trackID, qual)
|
||||
},
|
||||
attemptMap := make(map[string]qobuzProviderAttempt)
|
||||
attemptIDs := make([]string, 0, len(GetQobuzDownloadProviderURLs()))
|
||||
for _, provider := range q.getQobuzDownloadProviders() {
|
||||
for _, attempt := range provider.Attempts(trackID, qual) {
|
||||
attemptMap[attempt.ID] = attempt
|
||||
attemptIDs = append(attemptIDs, attempt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
orderedProviderIDs := prioritizeProviders("qobuz", providerIDs)
|
||||
primaryProviderID := GetQobuzMusicDLDownloadAPIURL()
|
||||
if len(orderedProviderIDs) > 1 && orderedProviderIDs[0] != primaryProviderID {
|
||||
reordered := []string{primaryProviderID}
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
if providerID == primaryProviderID {
|
||||
continue
|
||||
}
|
||||
reordered = append(reordered, providerID)
|
||||
}
|
||||
orderedProviderIDs = reordered
|
||||
}
|
||||
orderedProviderIDs := prioritizeProviders("qobuz", attemptIDs)
|
||||
orderedProviderIDs = moveQobuzAttemptIDsLast(orderedProviderIDs, GetQobuzMusicDLDownloadAPIURL())
|
||||
var lastErr error
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
p, ok := providerMap[providerID]
|
||||
attempt, ok := attemptMap[providerID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", p.Name, qual)
|
||||
fmt.Printf("Trying Provider: %s (Quality: %s)...\n", attempt.Name, qual)
|
||||
|
||||
url, err := p.Func()
|
||||
url, err := attempt.Download()
|
||||
if err == nil {
|
||||
fmt.Printf("✓ Success\n")
|
||||
recordProviderSuccess("qobuz", p.API)
|
||||
recordProviderSuccess("qobuz", attempt.ID)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
fmt.Printf("Provider failed: %v\n", err)
|
||||
recordProviderFailure("qobuz", p.API)
|
||||
recordProviderFailure("qobuz", attempt.ID)
|
||||
lastErr = err
|
||||
}
|
||||
return "", lastErr
|
||||
@@ -647,7 +996,7 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
}
|
||||
}
|
||||
|
||||
track, err := q.searchByISRC(isrc)
|
||||
track, err := q.searchByISRC(isrc, spotifyTrackName, spotifyArtistName, spotifyAlbumName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -661,7 +1010,13 @@ func (q *QobuzDownloader) DownloadTrackWithISRC(isrc, outputDir, quality, filena
|
||||
|
||||
qualityInfo := "Standard"
|
||||
if track.Hires {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
if track.MaximumBitDepth > 0 && track.MaximumSamplingRate > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res (%d-bit / %.1f kHz)", track.MaximumBitDepth, track.MaximumSamplingRate)
|
||||
} else if track.MaximumBitDepth > 0 {
|
||||
qualityInfo = fmt.Sprintf("Hi-Res available (%d-bit)", track.MaximumBitDepth)
|
||||
} else {
|
||||
qualityInfo = "Hi-Res available"
|
||||
}
|
||||
}
|
||||
fmt.Printf("Quality: %s\n", qualityInfo)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user