diff --git a/backend/qobuz.go b/backend/qobuz.go index 0117a2b..b5273ab 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -72,21 +73,41 @@ func NewQobuzDownloader() *QobuzDownloader { client: &http.Client{ Timeout: 60 * time.Second, }, - appID: "798273057", + appID: qobuzDefaultAPIAppID, } } func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) { - apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query=" - url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID) + if strings.HasPrefix(isrc, "qobuz_") { + trackID := 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) + } + defer resp.Body.Close() - resp, err := q.client.Get(url) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var trackResp QobuzTrack + if err := json.NewDecoder(resp.Body).Decode(&trackResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &trackResp, nil + } + + resp, err := doQobuzSignedRequest(http.MethodGet, "track/search", url.Values{ + "query": {isrc}, + "limit": {"1"}, + }, q.client) if err != nil { return nil, fmt.Errorf("failed to search track: %w", err) } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status %d", resp.StatusCode) } diff --git a/backend/qobuz_api.go b/backend/qobuz_api.go new file mode 100644 index 0000000..de5bfb9 --- /dev/null +++ b/backend/qobuz_api.go @@ -0,0 +1,407 @@ +package backend + +import ( + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + qobuzAPIBaseURL = "https://www.qobuz.com/api.json/0.2" + qobuzDefaultAPIAppID = "712109809" + qobuzDefaultAPIAppSecret = "589be88e4538daea11f509d29e4a23b1" + qobuzDefaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" + qobuzCredentialsCacheFile = "qobuz-api-credentials.json" + qobuzCredentialsCacheTTL = 24 * time.Hour + qobuzCredentialsProbeTrackISRC = "USUM71703861" + qobuzOpenTrackProbeURL = "https://open.qobuz.com/track/1" +) + +var ( + qobuzCredentialsMu sync.Mutex + qobuzCachedCredentials *qobuzAPICredentials + qobuzOpenBundleScriptPattern = regexp.MustCompile(`]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`) + qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P\d{9})",app_secret:"(?P[a-f0-9]{32})"`) +) + +type qobuzAPICredentials struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + Source string `json:"source,omitempty"` + FetchedAtUnix int64 `json:"fetched_at_unix"` +} + +type qobuzCredentialProbeResponse struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` +} + +func defaultQobuzAPICredentials() *qobuzAPICredentials { + return &qobuzAPICredentials{ + AppID: qobuzDefaultAPIAppID, + AppSecret: qobuzDefaultAPIAppSecret, + Source: "embedded-default", + FetchedAtUnix: time.Now().Unix(), + } +} + +func qobuzCredentialsCachePath() (string, error) { + appDir, err := GetFFmpegDir() + if err != nil { + return "", err + } + return filepath.Join(appDir, qobuzCredentialsCacheFile), nil +} + +func loadQobuzCachedCredentials() (*qobuzAPICredentials, error) { + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return nil, err + } + + body, err := os.ReadFile(cachePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read qobuz credentials cache: %w", err) + } + + var creds qobuzAPICredentials + if err := json.Unmarshal(body, &creds); err != nil { + return nil, fmt.Errorf("failed to parse qobuz credentials cache: %w", err) + } + + if strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials cache is incomplete") + } + + return &creds, nil +} + +func saveQobuzCachedCredentials(creds *qobuzAPICredentials) error { + if creds == nil { + return fmt.Errorf("qobuz credentials are required") + } + + cachePath, err := qobuzCredentialsCachePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + return fmt.Errorf("failed to create qobuz credentials cache directory: %w", err) + } + + body, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(cachePath, body, 0o644); err != nil { + return fmt.Errorf("failed to write qobuz credentials cache: %w", err) + } + + return nil +} + +func qobuzCredentialsCacheIsFresh(creds *qobuzAPICredentials) bool { + if creds == nil || creds.FetchedAtUnix == 0 || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return false + } + return time.Since(time.Unix(creds.FetchedAtUnix, 0)) < qobuzCredentialsCacheTTL +} + +func scrapeQobuzOpenCredentials(client *http.Client) (*qobuzAPICredentials, error) { + req, err := http.NewRequest(http.MethodGet, qobuzOpenTrackProbeURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", qobuzDefaultUA) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch open.qobuz.com shell: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("open.qobuz.com returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(preview))) + } + + htmlBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read open.qobuz.com shell: %w", err) + } + + scriptMatch := qobuzOpenBundleScriptPattern.FindStringSubmatch(string(htmlBody)) + if len(scriptMatch) < 2 { + return nil, fmt.Errorf("qobuz open bundle URL not found") + } + + bundleURL := strings.TrimSpace(scriptMatch[1]) + if strings.HasPrefix(bundleURL, "/") { + bundleURL = "https://open.qobuz.com" + bundleURL + } + if bundleURL == "" { + return nil, fmt.Errorf("qobuz open bundle URL is empty") + } + + bundleReq, err := http.NewRequest(http.MethodGet, bundleURL, nil) + if err != nil { + return nil, err + } + bundleReq.Header.Set("User-Agent", qobuzDefaultUA) + + bundleResp, err := client.Do(bundleReq) + if err != nil { + return nil, fmt.Errorf("failed to fetch qobuz open bundle: %w", err) + } + defer bundleResp.Body.Close() + + if bundleResp.StatusCode != http.StatusOK { + preview, _ := io.ReadAll(io.LimitReader(bundleResp.Body, 512)) + return nil, fmt.Errorf("qobuz open bundle returned status %d: %s", bundleResp.StatusCode, strings.TrimSpace(string(preview))) + } + + bundleBody, err := io.ReadAll(bundleResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read qobuz open bundle: %w", err) + } + + configMatch := qobuzOpenAPIConfigPattern.FindStringSubmatch(string(bundleBody)) + if len(configMatch) < 3 { + return nil, fmt.Errorf("qobuz api app_id/app_secret pair not found in open bundle") + } + + return &qobuzAPICredentials{ + AppID: strings.TrimSpace(configMatch[1]), + AppSecret: strings.TrimSpace(configMatch[2]), + Source: bundleURL, + FetchedAtUnix: time.Now().Unix(), + }, nil +} + +func qobuzNormalizedPath(path string) string { + return strings.Trim(strings.TrimSpace(path), "/") +} + +func qobuzSignaturePayload(path string, params url.Values, timestamp string, secret string) string { + normalizedPath := strings.ReplaceAll(qobuzNormalizedPath(path), "/", "") + keys := make([]string, 0, len(params)) + for key := range params { + switch key { + case "app_id", "request_ts", "request_sig": + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + var builder strings.Builder + builder.WriteString(normalizedPath) + for _, key := range keys { + values := params[key] + if len(values) == 0 { + builder.WriteString(key) + continue + } + for _, value := range values { + builder.WriteString(key) + builder.WriteString(value) + } + } + builder.WriteString(timestamp) + builder.WriteString(secret) + return builder.String() +} + +func qobuzRequestSignature(path string, params url.Values, timestamp string, secret string) string { + sum := md5.Sum([]byte(qobuzSignaturePayload(path, params, timestamp, secret))) + return hex.EncodeToString(sum[:]) +} + +func newQobuzSignedRequestWithCredentials(method string, path string, params url.Values, creds *qobuzAPICredentials) (*http.Request, error) { + normalizedPath := qobuzNormalizedPath(path) + if normalizedPath == "" { + return nil, fmt.Errorf("qobuz request path is empty") + } + if creds == nil || strings.TrimSpace(creds.AppID) == "" || strings.TrimSpace(creds.AppSecret) == "" { + return nil, fmt.Errorf("qobuz credentials are incomplete") + } + + clonedParams := url.Values{} + for key, values := range params { + for _, value := range values { + clonedParams.Add(key, value) + } + } + + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + clonedParams.Set("app_id", creds.AppID) + clonedParams.Set("request_ts", timestamp) + clonedParams.Set("request_sig", qobuzRequestSignature(normalizedPath, params, timestamp, creds.AppSecret)) + + reqURL := fmt.Sprintf("%s/%s?%s", qobuzAPIBaseURL, normalizedPath, clonedParams.Encode()) + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", qobuzDefaultUA) + req.Header.Set("Accept", "application/json") + req.Header.Set("X-App-Id", creds.AppID) + + return req, nil +} + +func qobuzCredentialsSupportSignedMetadata(client *http.Client, creds *qobuzAPICredentials) bool { + if creds == nil { + return false + } + + req, err := newQobuzSignedRequestWithCredentials(http.MethodGet, "track/search", url.Values{ + "query": {qobuzCredentialsProbeTrackISRC}, + "limit": {"1"}, + }, creds) + if err != nil { + return false + } + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false + } + + var payload qobuzCredentialProbeResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return false + } + + return payload.Tracks.Total > 0 +} + +func getQobuzAPICredentials(forceRefresh bool) (*qobuzAPICredentials, error) { + qobuzCredentialsMu.Lock() + defer qobuzCredentialsMu.Unlock() + + if !forceRefresh && qobuzCredentialsCacheIsFresh(qobuzCachedCredentials) { + return qobuzCachedCredentials, nil + } + + cachedFromDisk, diskErr := loadQobuzCachedCredentials() + if diskErr != nil { + fmt.Printf("Warning: failed to read Qobuz credentials cache: %v\n", diskErr) + } + if !forceRefresh && qobuzCredentialsCacheIsFresh(cachedFromDisk) { + qobuzCachedCredentials = cachedFromDisk + return qobuzCachedCredentials, nil + } + + client := &http.Client{Timeout: 30 * time.Second} + scrapedCreds, scrapeErr := scrapeQobuzOpenCredentials(client) + if scrapeErr == nil { + if qobuzCredentialsSupportSignedMetadata(client, scrapedCreds) { + qobuzCachedCredentials = scrapedCreds + if err := saveQobuzCachedCredentials(scrapedCreds); err != nil { + fmt.Printf("Warning: failed to write Qobuz credentials cache: %v\n", err) + } + fmt.Printf("Loaded fresh Qobuz credentials from %s (app_id=%s)\n", scrapedCreds.Source, scrapedCreds.AppID) + return qobuzCachedCredentials, nil + } + scrapeErr = fmt.Errorf("scraped qobuz credentials did not pass validation") + } + + if cachedFromDisk != nil { + qobuzCachedCredentials = cachedFromDisk + fmt.Printf("Warning: failed to refresh Qobuz credentials, using cached credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + if qobuzCachedCredentials != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using in-memory credentials: %v\n", scrapeErr) + return qobuzCachedCredentials, nil + } + + fallback := defaultQobuzAPICredentials() + qobuzCachedCredentials = fallback + if scrapeErr != nil { + fmt.Printf("Warning: failed to refresh Qobuz credentials, using embedded fallback: %v\n", scrapeErr) + } + return qobuzCachedCredentials, nil +} + +func qobuzShouldRefreshCredentials(statusCode int) bool { + return statusCode == http.StatusBadRequest || statusCode == http.StatusUnauthorized +} + +func newQobuzSignedRequest(method string, path string, params url.Values) (*http.Request, error) { + creds, err := getQobuzAPICredentials(false) + if err != nil { + return nil, err + } + return newQobuzSignedRequestWithCredentials(method, path, params, creds) +} + +func doQobuzSignedRequest(method string, path string, params url.Values, client *http.Client) (*http.Response, error) { + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + + call := func(forceRefresh bool) (*http.Response, error) { + creds, err := getQobuzAPICredentials(forceRefresh) + if err != nil { + return nil, err + } + req, err := newQobuzSignedRequestWithCredentials(method, path, params, creds) + if err != nil { + return nil, err + } + return client.Do(req) + } + + resp, err := call(false) + if err != nil { + return nil, err + } + + if qobuzShouldRefreshCredentials(resp.StatusCode) { + resp.Body.Close() + return call(true) + } + + return resp, nil +} + +func doQobuzSignedJSONRequest(path string, params url.Values, target interface{}) error { + resp, err := doQobuzSignedRequest(http.MethodGet, path, params, &http.Client{Timeout: 20 * time.Second}) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("qobuz request failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) + } + + return json.NewDecoder(resp.Body).Decode(target) +} diff --git a/backend/songlink.go b/backend/songlink.go index 919fa39..b56a299 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -129,31 +129,16 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAv } func checkQobuzAvailability(isrc string) bool { - client := &http.Client{Timeout: 10 * time.Second} - appID := "798273057" - - searchURL := fmt.Sprintf( - "https://www.qobuz.com/api.json/0.2/track/search?query=%s&limit=1&app_id=%s", - url.QueryEscape(strings.TrimSpace(isrc)), - appID, - ) - - resp, err := client.Get(searchURL) - if err != nil { - return false - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return false - } - var searchResp struct { Tracks struct { Total int `json:"total"` } `json:"tracks"` } - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { + + if err := doQobuzSignedJSONRequest("track/search", url.Values{ + "query": {strings.TrimSpace(isrc)}, + "limit": {"1"}, + }, &searchResp); err != nil { return false }