408 lines
12 KiB
Go
408 lines
12 KiB
Go
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(`<script[^>]+src="([^"]+/js/main\.js|/resources/[^"]+/js/main\.js)"`)
|
|
qobuzOpenAPIConfigPattern = regexp.MustCompile(`app_id:"(?P<app_id>\d{9})",app_secret:"(?P<app_secret>[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)
|
|
}
|