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) }