diff --git a/internal/app/app.go b/internal/app/app.go index 888f6a4..7bcc419 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -91,6 +91,10 @@ type videoDownloadableProvider interface { GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) } +type trackFallbackDownloader interface { + DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) error +} + func New(cfg *config.Config) (*Main, error) { var db store.Database if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled { @@ -885,11 +889,21 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall if err = downloadOnce(); err != nil { m.logf("retry: %s (%v)\n", filepath.Base(outPath), err) if err = downloadOnce(); err != nil { + if fallbackProvider, ok := p.(trackFallbackDownloader); ok { + m.logf("fallback: %s via provider backup flow\n", filepath.Base(outPath)) + if fbErr := fallbackProvider.DownloadTrackFallback(ctx, id, m.qualityForSource(source), outPath); fbErr == nil { + goto downloaded + } else { + m.logf("fallback failed: %s (%v)\n", filepath.Base(outPath), fbErr) + } + } _ = m.Store.MarkFailed(ctx, source, "track", id) return fmt.Errorf("id=%s title=%q download: %w", id, title, err) } } +downloaded: + embedCoverPath := opts.albumEmbedCover if opts.forPlaylist { parent := opts.albumFolder diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 4daa53d..9c5ca5b 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -129,6 +129,11 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP ) defer bar.SetTotal(-1, true) } + defer func() { + if !success && bar != nil { + bar.Abort(true) + } + }() } block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) @@ -249,6 +254,11 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all ) defer bar.SetTotal(-1, true) } + defer func() { + if !success && bar != nil { + bar.Abort(true) + } + }() buf := make([]byte, downloadBufferSize) totalWritten := int64(0) for { diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go index 7d16db9..0b78bd4 100644 --- a/internal/provider/qobuz/client.go +++ b/internal/provider/qobuz/client.go @@ -1,20 +1,28 @@ package qobuz import ( + "bytes" "context" + "crypto/aes" + "crypto/cipher" "crypto/md5" + "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" + "hash" "io" "net/http" "net/url" + "os" + "path/filepath" "regexp" "sort" "strconv" "strings" + "sync" "time" "streamrip-go/internal/config" @@ -22,10 +30,28 @@ import ( "streamrip-go/internal/netutil" "streamrip-go/internal/provider" "streamrip-go/internal/ratelimit" + + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + "golang.org/x/crypto/hkdf" ) const baseURL = "https://www.qobuz.com/api.json/0.2" +const ( + mobileAppID = "312369995" + mobileAppSecret = "e79f8b9be485692b0e5f9dd895826368" + mobileUserAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717" + mobileAppVersion = "9.7.0.3" + mobileSessionProf = "qbz-1" + mobileSegmentTries = 3 +) + +var qobuzUUIDBytes = []byte{ + 0x3b, 0x42, 0x12, 0x92, 0x56, 0xf3, 0x5f, 0x75, + 0x92, 0x36, 0x63, 0xb6, 0x9a, 0x1f, 0x52, 0xb2, +} + var ( errMissingCredentials = errors.New("missing qobuz credentials") errNotLoggedIn = errors.New("qobuz client not logged in") @@ -40,6 +66,23 @@ type Client struct { loggedIn bool secret string uat string + + mobileMu sync.Mutex + mobileAccessToken string + mobileSessionID string + mobileSessionInfo string + mobileKEK []byte +} + +type mobileFileURL struct { + URL string `json:"url"` + URLTemplate string `json:"url_template"` + NSegments int `json:"n_segments"` + FormatID int `json:"format_id"` + MimeType string `json:"mime_type"` + Sampling float64 `json:"sampling_rate"` + BitDepth int `json:"bits_depth"` + Key string `json:"key"` } func New(cfg *config.Config) *Client { @@ -399,6 +442,122 @@ func (c *Client) Close() error { return nil } +func (c *Client) DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) error { + q := &c.cfg.Session.Qobuz + if strings.TrimSpace(q.EmailOrUserID) == "" || strings.TrimSpace(q.PasswordOrToken) == "" || q.UseAuthToken { + return errors.New("qobuz mobile fallback requires email/password credentials") + } + if quality < 1 || quality > 4 { + quality = q.Quality + } + formatID := qualityMap(quality) + + if err := c.ensureMobileSession(ctx); err != nil { + return err + } + fileURL, err := c.mobileGetFileURL(ctx, trackID, formatID) + if err != nil { + return err + } + + if err = os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + out, err := os.Create(outputPath) + if err != nil { + return err + } + success := false + defer func() { + _ = out.Close() + if !success { + _ = os.Remove(outputPath) + } + }() + + progress := mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) + defer progress.Wait() + desc := shortenName(filepath.Base(outputPath), 54) + bar := progress.AddSpinner( + 0, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + defer func() { + if !success { + bar.Abort(true) + } + bar.SetTotal(-1, true) + }() + + if strings.TrimSpace(fileURL.URL) != "" && fileURL.NSegments == 0 { + err = c.mobileCopyURLToWriter(ctx, strings.TrimSpace(fileURL.URL), out, bar) + if err != nil { + return err + } + success = true + return nil + } + if strings.TrimSpace(fileURL.URLTemplate) == "" { + return errors.New("qobuz mobile fallback: no download URL available") + } + + var trackKey []byte + if strings.TrimSpace(fileURL.Key) != "" { + trackKey, err = c.mobileDeriveTrackKey(fileURL.Key) + if err != nil { + return fmt.Errorf("derive mobile track key: %w", err) + } + } + + nSegs := fileURL.NSegments + if nSegs == 0 { + nSegs = 1 + } + initURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", "0", 1) + initData, err := c.mobileDownloadSegment(ctx, initURL) + if err != nil { + return fmt.Errorf("download init segment: %w", err) + } + + if hdr := extractFLACHeader(initData); hdr != nil { + if _, err = out.Write(hdr); err != nil { + return err + } + bar.IncrBy(len(hdr)) + } else { + if _, err = out.Write(initData); err != nil { + return err + } + bar.IncrBy(len(initData)) + } + + for seg := 1; seg <= nSegs; seg++ { + segURL := strings.Replace(fileURL.URLTemplate, "$SEGMENT$", strconv.Itoa(seg), 1) + data, dlErr := c.mobileDownloadSegment(ctx, segURL) + if dlErr != nil { + return fmt.Errorf("download segment %d: %w", seg, dlErr) + } + frames := extractFrames(data, trackKey) + if _, err = out.Write(frames); err != nil { + return err + } + bar.IncrBy(len(frames)) + } + if err = out.Sync(); err != nil { + return err + } + success = true + return nil +} + func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) { pageLimit := 500 params := url.Values{} @@ -820,3 +979,460 @@ func sortedKeys(m map[string][]string) []string { sort.Strings(keys) return keys } + +func (c *Client) ensureMobileSession(ctx context.Context) error { + c.mobileMu.Lock() + defer c.mobileMu.Unlock() + if c.mobileAccessToken != "" && c.mobileSessionID != "" && c.mobileSessionInfo != "" { + return nil + } + q := &c.cfg.Session.Qobuz + if err := c.mobileLogin(ctx, q.EmailOrUserID, q.PasswordOrToken); err != nil { + return err + } + if err := c.mobileStartSession(ctx); err != nil { + c.mobileAccessToken = "" + return err + } + return nil +} + +func (c *Client) mobileLogin(ctx context.Context, username, password string) error { + ts := time.Now().Unix() + params := url.Values{} + params.Set("app_id", mobileAppID) + params.Set("username", username) + params.Set("password", password) + params.Set("request_ts", strconv.FormatInt(ts, 10)) + params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts)) + + reqURL := baseURL + "/oauth2/login?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return err + } + c.setMobileHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("mobile login request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mobile login failed: status=%d body=%s", resp.StatusCode, string(body)) + } + var parsed struct { + OAuth2 struct { + AccessToken string `json:"access_token"` + } `json:"oauth2"` + } + if err = json.Unmarshal(body, &parsed); err != nil { + return fmt.Errorf("mobile login parse failed: %w", err) + } + if strings.TrimSpace(parsed.OAuth2.AccessToken) == "" { + return errors.New("mobile login returned empty token") + } + c.mobileAccessToken = strings.TrimSpace(parsed.OAuth2.AccessToken) + return nil +} + +func (c *Client) mobileStartSession(ctx context.Context) error { + ts := time.Now().Unix() + params := url.Values{} + params.Set("app_id", mobileAppID) + params.Set("request_ts", strconv.FormatInt(ts, 10)) + params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts)) + + reqURL := baseURL + "/session/start?" + params.Encode() + form := url.Values{} + form.Set("profile", mobileSessionProf) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + c.setMobileHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("mobile session request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mobile session start failed: status=%d body=%s", resp.StatusCode, string(body)) + } + var parsed struct { + SessionID string `json:"session_id"` + Infos string `json:"infos"` + } + if err = json.Unmarshal(body, &parsed); err != nil { + return fmt.Errorf("mobile session parse failed: %w", err) + } + if strings.TrimSpace(parsed.SessionID) == "" || strings.TrimSpace(parsed.Infos) == "" { + return errors.New("mobile session start returned incomplete session data") + } + c.mobileSessionID = strings.TrimSpace(parsed.SessionID) + c.mobileSessionInfo = strings.TrimSpace(parsed.Infos) + c.mobileKEK = nil + return nil +} + +func (c *Client) mobileGetFileURL(ctx context.Context, trackID string, formatID int) (*mobileFileURL, error) { + ts := time.Now().Unix() + params := url.Values{} + params.Set("app_id", mobileAppID) + params.Set("track_id", trackID) + params.Set("format_id", strconv.Itoa(formatID)) + params.Set("intent", "stream") + params.Set("request_ts", strconv.FormatInt(ts, 10)) + params.Set("request_sig", mobileSignRequest("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts)) + reqURL := baseURL + "/file/url?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + c.setMobileHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mobile file url failed: status=%d body=%s", resp.StatusCode, string(body)) + } + var parsed mobileFileURL + if err = json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("mobile file url parse failed: %w", err) + } + return &parsed, nil +} + +func (c *Client) mobileCopyURLToWriter(ctx context.Context, sourceURL string, out io.Writer, bar *mpb.Bar) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + c.setMobileHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mobile fallback download failed: status=%d", resp.StatusCode) + } + written, err := io.Copy(out, &countingReader{r: resp.Body, onRead: func(n int) { + if bar != nil && n > 0 { + bar.IncrBy(n) + } + }}) + if err != nil { + return err + } + if resp.ContentLength > 0 && written != resp.ContentLength { + return io.ErrUnexpectedEOF + } + return nil +} + +type countingReader struct { + r io.Reader + onRead func(int) +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if n > 0 && c.onRead != nil { + c.onRead(n) + } + return n, err +} + +func shortenName(name string, max int) string { + if max <= 0 { + return name + } + r := []rune(name) + if len(r) <= max { + return name + } + if max <= 3 { + return string(r[:max]) + } + return string(r[:max-3]) + "..." +} + +func (c *Client) mobileDownloadSegment(ctx context.Context, sourceURL string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt < mobileSegmentTries; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, err + } + c.setMobileHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) + continue + } + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("status=%d", resp.StatusCode) + _ = resp.Body.Close() + time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) + continue + } + data, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + time.Sleep(time.Duration(500*(attempt+1)) * time.Millisecond) + continue + } + return data, nil + } + if lastErr == nil { + lastErr = errors.New("unknown segment error") + } + return nil, fmt.Errorf("mobile segment download failed after retries: %w", lastErr) +} + +func (c *Client) setMobileHeaders(req *http.Request) { + req.Header.Set("User-Agent", mobileUserAgent) + req.Header.Set("X-App-Id", mobileAppID) + req.Header.Set("X-App-Version", mobileAppVersion) + req.Header.Set("X-Device-Platform", "android") + req.Header.Set("X-Device-Model", "Nexus 6P") + req.Header.Set("X-Device-Os-Version", "9") + if c.mobileAccessToken != "" { + req.Header.Set("Authorization", "Bearer "+c.mobileAccessToken) + } + if c.mobileSessionID != "" { + req.Header.Set("X-Session-Id", c.mobileSessionID) + } +} + +func mobileSignRequest(endpoint string, params []kv, ts int64) string { + method := strings.ReplaceAll(endpoint, "/", "") + sortKVs(params) + var sb strings.Builder + sb.WriteString(method) + for _, p := range params { + sb.WriteString(p.Key) + sb.WriteString(p.Value) + } + sb.WriteString(strconv.FormatInt(ts, 10)) + sb.WriteString(mobileAppSecret) + h := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(h[:]) +} + +type kv struct { + Key string + Value string +} + +func sortKVs(s []kv) { + for i := 0; i < len(s); i++ { + for j := i + 1; j < len(s); j++ { + if s[j].Key < s[i].Key { + s[i], s[j] = s[j], s[i] + } + } + } +} + +func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) { + if len(c.mobileKEK) == 16 { + return unwrapQobuzTrackKey(encryptedKey, c.mobileKEK) + } + parts := strings.SplitN(c.mobileSessionInfo, ".", 2) + if len(parts) != 2 { + return nil, errors.New("invalid mobile session infos format") + } + salt, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("decode mobile salt: %w", err) + } + info, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode mobile info: %w", err) + } + reader := hkdf.New(func() hash.Hash { return sha256.New() }, hexDecodeOrNil(mobileAppSecret), salt, info) + kek := make([]byte, 16) + if _, err = io.ReadFull(reader, kek); err != nil { + return nil, fmt.Errorf("mobile hkdf derive failed: %w", err) + } + c.mobileKEK = kek + return unwrapQobuzTrackKey(encryptedKey, kek) +} + +func hexDecodeOrNil(s string) []byte { + b, _ := hex.DecodeString(s) + return b +} + +func unwrapQobuzTrackKey(encryptedKey string, kek []byte) ([]byte, error) { + parts := strings.SplitN(encryptedKey, ".", 3) + if len(parts) != 3 || parts[0] != mobileSessionProf { + return nil, errors.New("invalid qobuz track key format") + } + encKey, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode encrypted key failed: %w", err) + } + iv, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("decode key iv failed: %w", err) + } + block, err := aes.NewCipher(kek) + if err != nil { + return nil, err + } + decrypted := make([]byte, len(encKey)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decrypted, encKey) + if len(decrypted) < 16 { + return nil, errors.New("decrypted key too short") + } + return decrypted[:16], nil +} + +func extractFLACHeader(data []byte) []byte { + blocks := findDFLABlocks(data) + if blocks == nil { + return nil + } + out := make([]byte, 4+len(blocks)) + copy(out, "fLaC") + copy(out[4:], blocks) + return out +} + +func findDFLABlocks(data []byte) []byte { + pos := 0 + for pos+8 <= len(data) { + size := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3])) + if size < 8 || pos+size > len(data) { + break + } + t := data[pos+4 : pos+8] + if string(t) == "dfLa" { + body := data[pos+8 : pos+size] + if len(body) > 4 { + return body[4:] + } + } + var inner []byte + switch string(t) { + case "moov", "trak", "mdia", "minf", "stbl": + inner = data[pos+8 : pos+size] + case "stsd": + if pos+16 <= pos+size { + inner = data[pos+16 : pos+size] + } + case "fLaC": + if pos+36 <= pos+size { + inner = data[pos+36 : pos+size] + } + } + if inner != nil { + if result := findDFLABlocks(inner); result != nil { + return result + } + } + pos += size + } + return nil +} + +func extractFrames(data []byte, key []byte) []byte { + var frames []byte + pos := 0 + for pos+8 <= len(data) { + boxSize := int(uint32(data[pos])<<24 | uint32(data[pos+1])<<16 | uint32(data[pos+2])<<8 | uint32(data[pos+3])) + if boxSize < 8 || pos+boxSize > len(data) { + break + } + if string(data[pos+4:pos+8]) == "uuid" && boxSize >= 36 { + if pos+24 > len(data) { + pos += boxSize + continue + } + if bytes.Equal(data[pos+8:pos+24], qobuzUUIDBytes) { + f := parseUUIDBox(data, pos, boxSize, key) + frames = append(frames, f...) + } + } + pos += boxSize + } + return frames +} + +func parseUUIDBox(data []byte, boxStart, boxSize int, key []byte) []byte { + bodyOff := boxStart + 24 + if bodyOff+12 > len(data) { + return nil + } + rawOffset := readU32BE(data, bodyOff+4) + numSamples := int(readU24BE(data, bodyOff+9)) + if numSamples == 0 || numSamples > 10000 { + return nil + } + tableOff := bodyOff + 12 + sampleDataOff := boxStart + int(rawOffset) + var frames []byte + offset := sampleDataOff + for i := 0; i < numSamples; i++ { + et := tableOff + i*16 + if et+16 > len(data) || offset >= len(data) { + break + } + size := readU32BE(data, et) + encFlag := data[et+6] != 0 || data[et+7] != 0 + end := offset + int(size) + if end > len(data) { + break + } + if encFlag && len(key) == 16 { + iv := make([]byte, 16) + copy(iv[:8], data[et+8:et+16]) + block, err := aes.NewCipher(key) + if err != nil { + return frames + } + stream := cipher.NewCTR(block, iv) + decrypted := make([]byte, end-offset) + stream.XORKeyStream(decrypted, data[offset:end]) + frames = append(frames, decrypted...) + } else { + frames = append(frames, data[offset:end]...) + } + offset = end + } + return frames +} + +func readU32BE(data []byte, off int) uint32 { + if off+4 > len(data) { + return 0 + } + return uint32(data[off])<<24 | uint32(data[off+1])<<16 | uint32(data[off+2])<<8 | uint32(data[off+3]) +} + +func readU24BE(data []byte, off int) uint32 { + if off+3 > len(data) { + return 0 + } + return uint32(data[off])<<16 | uint32(data[off+1])<<8 | uint32(data[off+2]) +}