Something went wrong. Try again.
Self-hosted web interface and downloader for Qobuz.
downloader self-hosted music qobuz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174// Package bundle fetches app_id, secrets and private_key from the Qobuz web player.// Translated from bundle.py (originally DashLt's spoofbuz).package bundle
import ( "context" "encoding/base64" "fmt" "io" "net/http" "regexp" "strings" "time")
const baseURL = "https://play.qobuz.com"
var ( reSeedTimezone = regexp.MustCompile( `[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`, ) reAppID = regexp.MustCompile( `production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"\w{32}"`, ) // privateKey can be 6-128 chars; allow alphanum, +, /, =, -, _ // Multiple patterns cover different bundle.js formats Qobuz has shipped. rePrivateKeyPatterns = []*regexp.Regexp{ regexp.MustCompile(`privateKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`), regexp.MustCompile(`private_key:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`), regexp.MustCompile(`oauthKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`), regexp.MustCompile(`clientSecret:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`), } reBundleURL = regexp.MustCompile( `<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`, ))
// Bundle holds the scraped Qobuz bundle.type Bundle struct { content string}
// Fetch downloads the Qobuz login page and bundle.js.// ctx is used to cancel the requests; pass context.Background() if not needed.func Fetch(ctx context.Context) (*Bundle, error) { client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/login", nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("get login page: %w", err) } defer resp.Body.Close() page, err := io.ReadAll(resp.Body) if err != nil { return nil, err }
match := reBundleURL.FindSubmatch(page) if match == nil { return nil, fmt.Errorf("bundle URL not found in login page") } bundlePath := string(match[1])
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+bundlePath, nil) if err != nil { return nil, err } resp2, err := client.Do(req2) if err != nil { return nil, fmt.Errorf("get bundle.js: %w", err) } defer resp2.Body.Close() body, err := io.ReadAll(resp2.Body) if err != nil { return nil, err }
return &Bundle{content: string(body)}, nil}
// AppID extracts the application ID from bundle.js.func (b *Bundle) AppID() (string, error) { m := reAppID.FindStringSubmatch(b.content) if m == nil { return "", fmt.Errorf("app_id not found in bundle") } idx := reAppID.SubexpIndex("app_id") return m[idx], nil}
// PrivateKey extracts the OAuth private key, returns "" if absent.// Tries multiple regex patterns to handle different bundle.js formats.func (b *Bundle) PrivateKey() string { for _, re := range rePrivateKeyPatterns { m := re.FindStringSubmatch(b.content) if m != nil { return m[re.SubexpIndex("key")] } } return ""}
// capitalizeFirst upper-cases the first byte of s (ASCII only — timezone names// are always ASCII). Replaces the deprecated strings.Title.func capitalizeFirst(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:]}
// Secrets extracts the signing secrets from bundle.js.func (b *Bundle) Secrets() (map[string]string, error) { seeds := map[string][]string{}
// Collect seed + timezone pairs var tzList []string seenTZ := make(map[string]bool) for _, m := range reSeedTimezone.FindAllStringSubmatch(b.content, -1) { seed := m[reSeedTimezone.SubexpIndex("seed")] tz := m[reSeedTimezone.SubexpIndex("timezone")] seeds[tz] = append(seeds[tz], seed) if !seenTZ[tz] { seenTZ[tz] = true tzList = append(tzList, tz) } } if len(seeds) == 0 { return nil, fmt.Errorf("no seeds found in bundle") }
if len(tzList) >= 2 { // move second element to front tzList[0], tzList[1] = tzList[1], tzList[0] }
// Build regex for info/extras capitalised := make([]string, len(tzList)) for i, tz := range tzList { capitalised[i] = capitalizeFirst(tz) } reInfoExtras := regexp.MustCompile( `name:"\w+/(?P<timezone>` + strings.Join(capitalised, "|") + `)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`, )
for _, m := range reInfoExtras.FindAllStringSubmatch(b.content, -1) { tz := strings.ToLower(m[reInfoExtras.SubexpIndex("timezone")]) info := m[reInfoExtras.SubexpIndex("info")] extras := m[reInfoExtras.SubexpIndex("extras")] seeds[tz] = append(seeds[tz], info, extras) }
secrets := map[string]string{} for tz, parts := range seeds { joined := strings.Join(parts, "") if len(joined) <= 44 { continue } trimmed := joined[:len(joined)-44] // Pad to multiple of 4 to satisfy StdEncoding (Python's b64decode does this automatically) padded := trimmed + strings.Repeat("=", (4-len(trimmed)%4)%4) decoded, err := base64.StdEncoding.DecodeString(padded) if err != nil { continue } secrets[tz] = string(decoded) } return secrets, nil}