diff --git a/cmd/rip/helpers.go b/cmd/rip/helpers.go index d3a91d7..2591a6a 100644 --- a/cmd/rip/helpers.go +++ b/cmd/rip/helpers.go @@ -65,7 +65,7 @@ func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind) return false } - if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" { + if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "soundcloud" { fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw) return false } diff --git a/cmd/rip/main.go b/cmd/rip/main.go index f206754..7240a16 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -317,6 +317,8 @@ func main() { cfg.Session.Qobuz.Quality = opts.quality case "tidal": cfg.Session.Tidal.Quality = opts.quality + case "yandex": + cfg.Session.Yandex.Quality = opts.quality } } @@ -346,7 +348,7 @@ func main() { var sopts searchOptions if len(os.Args) < 5 { if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") + fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") os.Exit(2) } source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) @@ -380,6 +382,10 @@ func main() { fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist") os.Exit(2) } + if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" { + fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist") + os.Exit(2) + } if sopts.query == "" { fmt.Fprintln(os.Stderr, "search query cannot be empty") os.Exit(2) diff --git a/cmd/rip/search.go b/cmd/rip/search.go index f83dab7..9770c83 100644 --- a/cmd/rip/search.go +++ b/cmd/rip/search.go @@ -293,7 +293,7 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult, } func isAllowedSearchSource(source string) bool { - return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud" + return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "soundcloud" } func isAllowedMediaType(mediaType string) bool { @@ -318,7 +318,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e } for { - source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ") + source, err := read("Source [qobuz/tidal/deezer/yandex/soundcloud]: ") if err != nil { return "", "", searchOptions{}, err } @@ -341,6 +341,10 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e fmt.Println("SoundCloud search supports track and playlist only.") continue } + if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" { + fmt.Println("Yandex search supports track, album, playlist, and artist only.") + continue + } query, err := read("Query: ") if err != nil { @@ -544,6 +548,48 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) [] ) appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount}) } + case "yandex": + items, ok := page["items"].([]any) + if !ok { + continue + } + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + id := asString(itm["id"]) + title := asString(itm["title"]) + if title == "" { + title = asString(itm["name"]) + } + artist := nestedSearchString(itm, "artist", "name") + if artist == "" { + artist = nestedSearchString(itm, "performer", "name") + } + album := nestedSearchString(itm, "album", "title") + trackCount := firstPositiveInt( + searchInt(itm["trackCount"]), + searchInt(itm["track_count"]), + searchInt(itm["tracks_count"]), + ) + explicit := searchBool(itm["explicit"]) + date := firstNonEmpty( + asString(itm["release_date"]), + asString(itm["releaseDate"]), + nestedSearchString(itm, "album", "release_date"), + nestedSearchString(itm, "album", "releaseDate"), + ) + releases := 0 + if mediaType == "artist" { + releases = firstPositiveInt( + searchInt(itm["albums_count"]), + searchInt(itm["numberOfAlbums"]), + nestedSearchInt(itm, "albums", "total"), + ) + } + appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit}) + } } } return results diff --git a/config.toml.example b/config.toml.example index b4309d4..5747e70 100644 --- a/config.toml.example +++ b/config.toml.example @@ -68,6 +68,15 @@ password = "" # Optional cached Deezer refresh token. Managed automatically when available. refresh_token = "" +[yandex] +# Quality ladder: +# 0 = LOW (HE-AAC/AAC when available), 1 = HIGH (AAC/MP3 192), 2/3/4 = LOSSLESS when available +quality = 2 +# OAuth access token for api.music.yandex.net +access_token = "" +# Cached current account uid. Managed automatically when available. +user_id = "" + [soundcloud] # Quality is currently provider-defined (keep 0) quality = 0 diff --git a/internal/app/app.go b/internal/app/app.go index b8358f9..23b5f11 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -25,6 +25,7 @@ import ( qobuzprovider "streamrip-go/internal/provider/qobuz" soundcloudprovider "streamrip-go/internal/provider/soundcloud" tidalprovider "streamrip-go/internal/provider/tidal" + yandexprovider "streamrip-go/internal/provider/yandex" "streamrip-go/internal/store" "streamrip-go/internal/verbose" ) @@ -112,6 +113,7 @@ func New(cfg *config.Config) (*Main, error) { "qobuz": qobuzprovider.New(cfg), "tidal": tidalprovider.New(cfg), "deezer": deezerprovider.New(cfg), + "yandex": yandexprovider.New(cfg), "soundcloud": soundcloudprovider.New(cfg), } @@ -888,6 +890,9 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall } return m.DL.FileDeezerEncrypted(ctx, d.URL, outPath, trackID) } + if d.Source == "yandex" && strings.EqualFold(strings.TrimSpace(d.Cipher), "AES_CTR") { + return m.DL.FileYandexEncrypted(ctx, d.URL, outPath, d.Key) + } return m.DL.File(ctx, d.URL, outPath) } if err = downloadOnce(); err != nil { @@ -964,6 +969,8 @@ func (m *Main) qualityForSource(source string) int { return m.Config.Session.Tidal.Quality case "deezer": return m.Config.Session.Deezer.Quality + case "yandex": + return m.Config.Session.Yandex.Quality case "soundcloud": return m.Config.Session.Soundcloud.Quality default: @@ -994,6 +1001,8 @@ func (m *Main) qualityProfileForSource(source string) (int, string) { default: return 16, "44.1" } + case "yandex": + return 16, "44.1" default: return 16, "44.1" } diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go index 7c231fb..05b95fe 100644 --- a/internal/audio/tag/tagger.go +++ b/internal/audio/tag/tagger.go @@ -47,9 +47,10 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error { } ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") + forceMP4Muxer := shouldForceMP4Muxer(path, ext) tmpPath := taggedTempPath(path) runTag := func(cover string) ([]byte, error) { - args := buildFFmpegArgs(path, tmpPath, meta, cover, ext) + args := buildFFmpegArgs(path, tmpPath, meta, cover, ext, forceMP4Muxer) cmd := exec.Command("ffmpeg", args...) return cmd.CombinedOutput() } @@ -72,7 +73,7 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error { return nil } -func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string) []string { +func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string, forceMP4Muxer bool) []string { args := []string{"-y", "-i", inputPath} withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext) if withCover { @@ -101,11 +102,30 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext } args = append(args, "-metadata", k+"="+v) } + if forceMP4Muxer { + args = append(args, "-f", "mp4") + } args = append(args, outputPath) return args } +func shouldForceMP4Muxer(path, ext string) bool { + switch strings.TrimPrefix(strings.ToLower(ext), ".") { + case "m4a", "mp4": + default: + return false + } + if _, err := exec.LookPath("ffprobe"); err != nil { + return false + } + out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", path).Output() + if err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(string(out)), "flac") +} + func taggedTempPath(path string) string { ext := filepath.Ext(path) if ext == "" { diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go index 9c3d0d8..9ae9a97 100644 --- a/internal/audio/tag/tagger_test.go +++ b/internal/audio/tag/tagger_test.go @@ -66,7 +66,7 @@ func TestBuildFFmpegArgsWithCover(t *testing.T) { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { t.Fatalf("write cover: %v", err) } - args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac") + args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac", false) foundInput2 := false foundAttach := false for i := 0; i < len(args)-1; i++ { @@ -88,7 +88,7 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { t.Fatalf("write cover: %v", err) } - args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus") + args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus", false) for i := 0; i < len(args)-1; i++ { if args[i] == "-i" && args[i+1] == cover { t.Fatalf("unexpected cover input for opus: %v", args) @@ -96,6 +96,16 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) { } } +func TestBuildFFmpegArgsForcesMP4Muxer(t *testing.T) { + args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true) + for i := 0; i < len(args)-1; i++ { + if args[i] == "-f" && args[i+1] == "mp4" { + return + } + } + t.Fatalf("missing forced mp4 muxer args: %v", args) +} + func TestTaggedTempPathPreservesExtension(t *testing.T) { if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" { t.Fatalf("taggedTempPath(flac)=%q", got) diff --git a/internal/config/config.go b/internal/config/config.go index 23a8235..5b979dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type ConfigData struct { Qobuz QobuzConfig `toml:"qobuz"` Tidal TidalConfig `toml:"tidal"` Deezer DeezerConfig `toml:"deezer"` + Yandex YandexConfig `toml:"yandex"` Soundcloud SoundcloudConfig `toml:"soundcloud"` Youtube YoutubeConfig `toml:"youtube"` Database DatabaseConfig `toml:"database"` @@ -77,6 +78,12 @@ type DeezerConfig struct { RefreshToken string `toml:"refresh_token"` } +type YandexConfig struct { + Quality int `toml:"quality"` + AccessToken string `toml:"access_token"` + UserID string `toml:"user_id"` +} + type SoundcloudConfig struct { Quality int `toml:"quality"` ClientID string `toml:"client_id"` @@ -240,6 +247,9 @@ func DefaultConfigData() ConfigData { Quality: 2, LowerQualityIfNotAvailable: true, }, + Yandex: YandexConfig{ + Quality: 2, + }, Soundcloud: SoundcloudConfig{ Quality: 0, }, diff --git a/internal/download/downloader.go b/internal/download/downloader.go index f357ac7..eadedba 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -3,8 +3,10 @@ package download import ( "bufio" "context" + "crypto/aes" "crypto/cipher" "crypto/md5" + "encoding/hex" "fmt" "io" "net/http" @@ -182,6 +184,120 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP return nil } +func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error { + logDownloadStart(sourceURL, outputPath) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return err + } + keyBytes, err := hex.DecodeString(strings.TrimSpace(key)) + if err != nil { + return fmt.Errorf("invalid yandex key: %w", err) + } + if len(keyBytes) != 16 { + return fmt.Errorf("invalid yandex key length: %d", len(keyBytes)) + } + block, err := aes.NewCipher(keyBytes) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + resp, err := d.http.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: status=%d", resp.StatusCode) + } + out, err := os.Create(outputPath) + if err != nil { + return err + } + success := false + defer func() { + _ = out.Close() + if !success { + _ = os.Remove(outputPath) + } + }() + + var bar *mpb.Bar + if d.ProgressEnabled() { + d.barStarted.Store(1) + desc := shortenName(filepath.Base(outputPath), 54) + if resp.ContentLength > 0 { + bar = d.progress.AddBar( + resp.ContentLength, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + decor.Percentage(decor.WCSyncWidthR), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), + decor.Name(" | ETA ", decor.WCSyncWidth), + decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + } else { + bar = d.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 bar.SetTotal(-1, true) + } + defer func() { + if !success && bar != nil { + bar.Abort(true) + } + }() + } + + stream := cipher.NewCTR(block, make([]byte, aes.BlockSize)) + reader := &cipher.StreamReader{S: stream, R: resp.Body} + buf := make([]byte, downloadBufferSize) + totalWritten := int64(0) + for { + n, readErr := reader.Read(buf) + if n > 0 { + if _, writeErr := out.Write(buf[:n]); writeErr != nil { + return writeErr + } + totalWritten += int64(n) + if bar != nil { + bar.IncrBy(n) + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + return readErr + } + } + if resp.ContentLength > 0 && totalWritten != resp.ContentLength { + return io.ErrUnexpectedEOF + } + if err = out.Sync(); err != nil { + return err + } + success = true + return nil +} + func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { logDownloadStart(sourceURL, outputPath) if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index 5229d9e..b81fe98 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -2,8 +2,10 @@ package download import ( "context" + "crypto/aes" "crypto/cipher" "errors" + "encoding/hex" "io" "net/http" "net/http/httptest" @@ -110,6 +112,45 @@ func TestFileDeezerEncrypted(t *testing.T) { } } +func TestFileYandexEncrypted(t *testing.T) { + plain := make([]byte, 8192+333) + for i := range plain { + plain[i] = byte((i * 11) % 251) + } + keyHex := "00112233445566778899aabbccddeeff" + key, err := hex.DecodeString(keyHex) + if err != nil { + t.Fatalf("DecodeString() error = %v", err) + } + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("NewCipher() error = %v", err) + } + enc := make([]byte, len(plain)) + copy(enc, plain) + stream := cipher.NewCTR(block, make([]byte, aes.BlockSize)) + stream.XORKeyStream(enc, enc) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(enc) + })) + defer ts.Close() + + d := NewWithOptions(true, false, 0) + out := filepath.Join(t.TempDir(), "x", "a.m4a") + if err = d.FileYandexEncrypted(context.Background(), ts.URL, out, keyHex); err != nil { + t.Fatalf("FileYandexEncrypted() error = %v", err) + } + + got, err := os.ReadFile(out) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(got) != string(plain) { + t.Fatalf("decrypted file mismatch") + } +} + func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Length", "10") @@ -160,6 +201,15 @@ func TestFileDeezerEncryptedBadStatus(t *testing.T) { } } +func TestFileYandexEncryptedBadKey(t *testing.T) { + d := NewWithOptions(true, false, 0) + out := filepath.Join(t.TempDir(), "x", "a.m4a") + err := d.FileYandexEncrypted(context.Background(), "https://example.com/file", out, "abcd") + if err == nil || !strings.Contains(err.Error(), "invalid yandex key length") { + t.Fatalf("expected key length error, got %v", err) + } +} + func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cf82f72..88dbd29 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -7,6 +7,7 @@ type Downloadable struct { Extension string Source string Cipher string + Key string TrackID string Audio AudioProfile } diff --git a/internal/provider/yandex/client.go b/internal/provider/yandex/client.go new file mode 100644 index 0000000..b8f97d7 --- /dev/null +++ b/internal/provider/yandex/client.go @@ -0,0 +1,1018 @@ +package yandex + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/md5" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "streamrip-go/internal/config" + "streamrip-go/internal/jsonutil" + "streamrip-go/internal/netutil" + "streamrip-go/internal/provider" + "streamrip-go/internal/ratelimit" +) + +const ( + baseURL = "https://api.music.yandex.net" + desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2" + desktopOrigin = "music-application://desktop" + requestAttempts = 3 + desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5" + legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA" + defaultEstimatedKbps = 50000 +) + +var ErrMissingYandexToken = errors.New("missing yandex access_token") + +type Client struct { + cfg *config.Config + http *http.Client + limiter *ratelimit.Limiter + baseURL string + loggedIn bool + userID string +} + +func New(cfg *config.Config) *Client { + return &Client{ + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + baseURL: baseURL, + userID: strings.TrimSpace(cfg.Session.Yandex.UserID), + } +} + +func (c *Client) Source() string { + return "yandex" +} + +func (c *Client) LoggedIn() bool { + return c.loggedIn +} + +func (c *Client) Login(ctx context.Context) error { + if strings.TrimSpace(c.cfg.Session.Yandex.AccessToken) == "" { + return ErrMissingYandexToken + } + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/account/about", nil, nil) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("yandex login failed: status=%d body=%v", status, resp) + } + result, _ := resp["result"].(map[string]any) + if uid := strings.TrimSpace(jsonutil.StringFromAny(result["uid"])); uid != "" { + c.userID = uid + c.cfg.Session.Yandex.UserID = uid + c.cfg.File.Yandex.UserID = uid + _ = c.cfg.SaveFile() + } + c.loggedIn = true + return nil +} + +func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) { + if !c.loggedIn { + return nil, errors.New("yandex client not logged in") + } + switch mediaType { + case "track": + return c.getTrackMetadata(ctx, item) + case "album": + return c.getAlbumMetadata(ctx, item) + case "artist": + return c.getArtistMetadata(ctx, item) + case "playlist": + return c.getPlaylistMetadata(ctx, item) + default: + return nil, fmt.Errorf("unsupported yandex media type %q", mediaType) + } +} + +func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { + if !c.loggedIn { + return nil, errors.New("yandex client not logged in") + } + if limit <= 0 { + limit = 25 + } + searchType := mediaType + if mediaType == "video" || mediaType == "label" { + return nil, fmt.Errorf("unsupported yandex search media type %q", mediaType) + } + params := url.Values{} + params.Set("text", query) + params.Set("type", searchType) + params.Set("page", "0") + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/search", params, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex search failed: status=%d body=%v", status, resp) + } + result, _ := resp["result"].(map[string]any) + items := c.normalizeSearchItems(mediaType, result) + if limit < len(items) { + items = items[:limit] + } + return []map[string]any{{"items": items}}, nil +} + +func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) { + if !c.loggedIn { + return nil, errors.New("yandex client not logged in") + } + trackID, _ := splitTrackRef(item) + if trackID == "" { + return nil, errors.New("empty yandex track id") + } + if dl, err := c.getDesktopDownloadable(ctx, trackID, quality, "encraw"); err == nil { + dl.Source = "yandex" + dl.TrackID = trackID + return dl, nil + } + if dl, err := c.getDesktopDownloadable(ctx, trackID, quality, "raw"); err == nil { + dl.Source = "yandex" + dl.TrackID = trackID + return dl, nil + } + legacy, legacyErr := c.getLegacyDownloadable(ctx, trackID) + if legacyErr == nil { + legacy.Source = "yandex" + legacy.TrackID = trackID + return legacy, nil + } + return nil, legacyErr +} + +func (c *Client) Close() error { + return nil +} + +func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]any, error) { + trackRef := canonicalTrackRequestID(item) + form := url.Values{} + form.Set("trackIds", trackRef) + form.Set("withMixData", "true") + resp, status, err := c.apiRequest(ctx, http.MethodPost, "/tracks", url.Values{"with-positions": []string{"true"}}, form) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex track metadata failed: status=%d body=%v", status, resp) + } + items := mapResultSlice(resp) + if len(items) == 0 { + return nil, errors.New("yandex track metadata missing result") + } + return normalizeTrack(items[0], trackRef), nil +} + +func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]any, error) { + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/albums/"+url.PathEscape(strings.TrimSpace(item))+"/with-tracks", nil, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex album metadata failed: status=%d body=%v", status, resp) + } + result := resultMap(resp) + if len(result) == 0 { + return nil, errors.New("yandex album metadata missing result") + } + return normalizeAlbum(result), nil +} + +func (c *Client) getArtistMetadata(ctx context.Context, item string) (map[string]any, error) { + artistForm := url.Values{} + artistForm.Set("artistIds", strings.TrimSpace(item)) + artistResp, status, err := c.apiRequest(ctx, http.MethodPost, "/artists", nil, artistForm) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex artist metadata failed: status=%d body=%v", status, artistResp) + } + artistItems := mapResultSlice(artistResp) + if len(artistItems) == 0 { + return nil, errors.New("yandex artist metadata missing result") + } + albumsResp, status, err := c.apiRequest(ctx, http.MethodGet, "/artists/"+url.PathEscape(strings.TrimSpace(item))+"/direct-albums", url.Values{"page": []string{"0"}, "page-size": []string{"200"}, "sort-by": []string{"year"}}, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex artist albums failed: status=%d body=%v", status, albumsResp) + } + return normalizeArtist(artistItems[0], albumsResp), nil +} + +func (c *Client) getPlaylistMetadata(ctx context.Context, item string) (map[string]any, error) { + if owner, kind, ok := splitPlaylistRef(item); ok { + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/users/"+url.PathEscape(owner)+"/playlists/"+url.PathEscape(kind), url.Values{"rich-tracks": []string{"true"}}, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex playlist metadata failed: status=%d body=%v", status, resp) + } + result, _ := resp["result"].(map[string]any) + if len(result) == 0 { + return nil, errors.New("yandex playlist metadata missing result") + } + return normalizePlaylist(result), nil + } + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/playlist/"+url.PathEscape(strings.TrimSpace(item)), url.Values{"rich-tracks": []string{"true"}}, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex playlist metadata failed: status=%d body=%v", status, resp) + } + result, _ := resp["result"].(map[string]any) + if len(result) == 0 { + return nil, errors.New("yandex playlist metadata missing result") + } + return normalizePlaylist(result), nil +} + +func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) []any { + items := make([]any, 0) + appendItem := func(m map[string]any) { + if len(m) > 0 { + items = append(items, m) + } + } + getResults := func(key string) []any { + bucket, _ := result[key].(map[string]any) + out, _ := bucket["results"].([]any) + return out + } + switch mediaType { + case "track": + for _, raw := range getResults("tracks") { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""))) + } + case "album": + for _, raw := range getResults("albums") { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + appendItem(normalizeAlbumSearchItem(itm)) + } + case "artist": + for _, raw := range getResults("artists") { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + appendItem(normalizeArtistSearchItem(itm)) + } + case "playlist": + for _, raw := range getResults("playlists") { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + appendItem(normalizePlaylistSearchItem(itm)) + } + } + return items +} + +type legacyDownloadInfoXML struct { + Host string `xml:"host"` + Path string `xml:"path"` + TS string `xml:"ts"` + S string `xml:"s"` +} + +func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*provider.Downloadable, error) { + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/tracks/"+url.PathEscape(trackID)+"/download-info", nil, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex legacy download-info failed: status=%d body=%v", status, resp) + } + items, ok := resp["result"].([]any) + if !ok || len(items) == 0 { + return nil, errors.New("yandex legacy download-info missing result") + } + best := pickLegacyDownloadInfo(items) + if best == nil { + return nil, errors.New("yandex legacy download-info missing mp3 variant") + } + xmlURL := strings.TrimSpace(jsonutil.StringFromAny(best["downloadInfoUrl"])) + if xmlURL == "" { + return nil, errors.New("yandex legacy download-info missing downloadInfoUrl") + } + xmlInfo, err := c.fetchLegacyXML(ctx, xmlURL) + if err != nil { + return nil, err + } + directURL, err := legacyDirectURL(xmlInfo) + if err != nil { + return nil, err + } + bitrate := jsonutil.IntFromAny(best["bitrateInKbps"]) + return &provider.Downloadable{ + URL: directURL, + Extension: "mp3", + Audio: provider.AudioProfile{ + Container: "MP3", + Codec: "MP3", + Quality: "HIGH", + BitDepth: 16, + SamplingRate: "44.1", + BitrateKbps: bitrate, + }, + }, nil +} + +func (c *Client) getDesktopDownloadable(ctx context.Context, trackID string, quality int, transport string) (*provider.Downloadable, error) { + qualityParam, codecs := downloadRequestProfile(quality) + sign, ts := yandexDownloadSign(trackID, qualityParam, codecs, transport) + params := url.Values{} + params.Set("trackId", trackID) + params.Set("codecs", strings.Join(codecs, ",")) + params.Set("transports", transport) + params.Set("quality", qualityParam) + params.Set("ts", strconv.FormatInt(ts, 10)) + params.Set("sign", sign) + resp, status, err := c.apiRequest(ctx, http.MethodGet, "/get-file-info", params, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("yandex desktop get-file-info failed: transport=%s status=%d body=%v", transport, status, resp) + } + return downloadableFromModernInfo(resp) +} + +func pickLegacyDownloadInfo(items []any) map[string]any { + var best map[string]any + bestBitrate := -1 + for _, raw := range items { + itm, ok := raw.(map[string]any) + if !ok { + continue + } + if strings.TrimSpace(jsonutil.StringFromAny(itm["codec"])) != "mp3" { + continue + } + bitrate := jsonutil.IntFromAny(itm["bitrateInKbps"]) + if bitrate > bestBitrate { + best = itm + bestBitrate = bitrate + } + } + return best +} + +func (c *Client) fetchLegacyXML(ctx context.Context, xmlURL string) (*legacyDownloadInfoXML, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, xmlURL, nil) + if err != nil { + return nil, err + } + c.addHeaders(req) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("yandex legacy download xml failed: status=%d", resp.StatusCode) + } + var out legacyDownloadInfoXML + if err = xml.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out, nil +} + +func legacyDirectURL(info *legacyDownloadInfoXML) (string, error) { + if info == nil || info.Host == "" || info.Path == "" || info.TS == "" || info.S == "" { + return "", errors.New("invalid yandex legacy xml payload") + } + sum := md5.Sum([]byte(legacyMP3SignSalt + strings.TrimPrefix(info.Path, "/") + info.S)) + return "https://" + info.Host + "/get-mp3/" + hex.EncodeToString(sum[:]) + "/" + info.TS + info.Path, nil +} + +func downloadableFromModernInfo(resp map[string]any) (*provider.Downloadable, error) { + result, _ := resp["result"].(map[string]any) + downloadInfo, _ := result["downloadInfo"].(map[string]any) + streamURL := strings.TrimSpace(jsonutil.StringFromAny(downloadInfo["url"])) + if streamURL == "" { + if urls, ok := downloadInfo["urls"].([]any); ok && len(urls) > 0 { + streamURL = strings.TrimSpace(jsonutil.StringFromAny(urls[0])) + } + } + if streamURL == "" { + return nil, errors.New("yandex modern downloadInfo missing url") + } + codec := strings.TrimSpace(jsonutil.StringFromAny(downloadInfo["codec"])) + bitrate := jsonutil.IntFromAny(downloadInfo["bitrate"]) + transport := strings.TrimSpace(jsonutil.StringFromAny(downloadInfo["transport"])) + key := strings.TrimSpace(jsonutil.StringFromAny(downloadInfo["key"])) + profile, ext := audioProfileFromDownloadInfo(codec, bitrate, jsonutil.StringFromAny(downloadInfo["quality"])) + dl := &provider.Downloadable{URL: streamURL, Extension: ext, Audio: profile} + if strings.EqualFold(transport, "encraw") && key != "" { + dl.Cipher = "AES_CTR" + dl.Key = key + } + return dl, nil +} + +func (c *Client) apiRequest(ctx context.Context, method, path string, params url.Values, form url.Values) (map[string]any, int, error) { + var lastStatus int + for attempt := 0; attempt < requestAttempts; attempt++ { + if err := c.limiter.Wait(ctx); err != nil { + return nil, 0, err + } + resp, status, err := c.doRequestOnce(ctx, method, path, params, form) + lastStatus = status + if err == nil && !shouldRetryStatus(status) { + return resp, status, nil + } + if attempt+1 >= requestAttempts { + if err != nil { + return nil, status, err + } + return resp, status, nil + } + if waitErr := waitRetry(ctx, retryDelay(status, attempt)); waitErr != nil { + return nil, 0, waitErr + } + } + return map[string]any{}, lastStatus, nil +} + +func (c *Client) doRequestOnce(ctx context.Context, method, path string, params url.Values, form url.Values) (map[string]any, int, error) { + if params == nil { + params = url.Values{} + } + reqURL := strings.TrimSuffix(c.baseURL, "/") + "/" + strings.TrimPrefix(path, "/") + if len(params) > 0 { + reqURL += "?" + params.Encode() + } + body := io.Reader(nil) + if method == http.MethodPost && form != nil { + body = bytes.NewBufferString(form.Encode()) + } + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) + if err != nil { + return nil, 0, err + } + c.addHeaders(req) + if method == http.MethodPost && form != nil { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + parsed := map[string]any{} + if len(raw) > 0 { + var decoded any + if err = json.Unmarshal(raw, &decoded); err != nil { + return nil, resp.StatusCode, err + } + switch v := decoded.(type) { + case map[string]any: + parsed = v + case []any: + parsed["result"] = v + default: + parsed["result"] = v + } + } + return parsed, resp.StatusCode, nil +} + +func (c *Client) addHeaders(req *http.Request) { + req.Header.Set("Accept", "*/*") + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) YandexMusic/5.105.3 Chrome/140.0.7339.133 Electron/38.2.2 Safari/537.36") + req.Header.Set("Accept-Language", "en") + req.Header.Set("X-Request-Id", strconv.FormatInt(time.Now().UnixNano(), 10)) + req.Header.Set("X-Yandex-Music-Client", desktopClientHeader) + if strings.TrimSpace(c.cfg.Session.Yandex.AccessToken) != "" { + req.Header.Set("Authorization", "OAuth "+strings.TrimSpace(c.cfg.Session.Yandex.AccessToken)) + } + if strings.TrimSpace(c.userID) != "" { + req.Header.Set("X-Yandex-Puid", strings.TrimSpace(c.userID)) + } +} + +func mapResultSlice(resp map[string]any) []map[string]any { + result, ok := resp["result"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(result)) + for _, raw := range result { + itm, ok := raw.(map[string]any) + if ok { + out = append(out, itm) + } + } + return out +} + +func resultMap(resp map[string]any) map[string]any { + if result, ok := resp["result"].(map[string]any); ok { + return result + } + return resp +} + +func normalizeTrack(raw map[string]any, fallbackID string) map[string]any { + trackID := canonicalTrackRefFromRaw(raw, fallbackID) + albumRaw := firstAlbum(raw) + artistName := joinArtists(raw) + albumArtist := joinAlbumArtists(albumRaw) + if albumArtist == "" { + albumArtist = artistName + } + trackNumber, discNumber := trackNumbers(albumRaw) + trackTotal := jsonutil.IntFromAny(albumRaw["trackCount"]) + discTotal := firstPositiveInt( + jsonutil.IntFromAny(raw["volumesCount"]), + jsonutil.IntFromAny(albumRaw["volumesCount"]), + ) + meta := map[string]any{ + "id": trackID, + "title": jsonutil.StringFromAny(raw["title"]), + "version": jsonutil.StringFromAny(raw["version"]), + "track_number": trackNumber, + "tracks_count": trackTotal, + "media_number": discNumber, + "numberOfVolumes": discTotal, + "release_date": firstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])), + "parental_warning": isExplicit(raw), + "explicit": isExplicit(raw), + "source_track_id": jsonutil.StringFromAny(raw["realId"]), + "performer": map[string]any{"name": artistName}, + "artist": map[string]any{"name": artistName, "id": firstArtistID(raw)}, + "album": normalizeAlbumSummary(albumRaw, albumArtist), + "image": imageMapFromTrack(raw, albumRaw), + "cover": imageMapFromTrack(raw, albumRaw), + "copyright": jsonutil.StringFromAny(raw["copyright"]), + "maximum_bit_depth": 16, + "maximum_sampling_rate": "44.1", + } + return meta +} + +func normalizeAlbum(raw map[string]any) map[string]any { + artistName := joinArtists(raw) + if artistName == "" { + artistName = joinAlbumArtists(raw) + } + volumes := albumVolumes(raw) + items := make([]any, 0) + trackCount := 0 + for _, volume := range volumes { + for _, rawTrack := range volume { + track, ok := rawTrack.(map[string]any) + if !ok { + continue + } + items = append(items, map[string]any{"id": canonicalTrackRefFromRaw(track, jsonutil.StringFromAny(track["id"])+":"+jsonutil.StringFromAny(raw["id"]))}) + trackCount++ + } + } + if trackCount == 0 { + trackCount = jsonutil.IntFromAny(raw["trackCount"]) + } + return map[string]any{ + "id": jsonutil.StringFromAny(raw["id"]), + "title": jsonutil.StringFromAny(raw["title"]), + "version": jsonutil.StringFromAny(raw["version"]), + "tracks_count": trackCount, + "numberOfTracks": trackCount, + "numberOfVolumes": len(volumes), + "release_date": jsonutil.StringFromAny(raw["releaseDate"]), + "releaseDate": jsonutil.StringFromAny(raw["releaseDate"]), + "artist": map[string]any{"name": artistName}, + "image": imageMapFromURI(raw), + "tracks": map[string]any{"items": items}, + "parental_warning": isExplicit(raw), + "maximum_bit_depth": 16, + "maximum_sampling_rate": "44.1", + } +} + +func normalizeArtist(raw map[string]any, albumsResp map[string]any) map[string]any { + items := make([]any, 0) + for _, id := range collectAlbumIDs(albumsResp["result"]) { + items = append(items, map[string]any{"id": id}) + } + return map[string]any{ + "id": jsonutil.StringFromAny(raw["id"]), + "name": jsonutil.StringFromAny(raw["name"]), + "title": jsonutil.StringFromAny(raw["name"]), + "albums": map[string]any{"items": items}, + "image": imageMapFromURI(raw), + } +} + +func normalizePlaylist(raw map[string]any) map[string]any { + items := make([]any, 0) + tracks, _ := raw["tracks"].([]any) + for _, entry := range tracks { + itm, ok := entry.(map[string]any) + if !ok { + continue + } + track, ok := itm["track"].(map[string]any) + if !ok { + track = itm + } + trackID := canonicalTrackRefFromRaw(track, jsonutil.StringFromAny(track["id"])) + if trackID != "" { + items = append(items, map[string]any{"id": trackID}) + } + } + owner, _ := raw["owner"].(map[string]any) + ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"])) + return map[string]any{ + "id": firstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])), + "name": jsonutil.StringFromAny(raw["title"]), + "title": jsonutil.StringFromAny(raw["title"]), + "description": jsonutil.StringFromAny(raw["description"]), + "tracks_count": jsonutil.IntFromAny(raw["trackCount"]), + "tracks": map[string]any{"items": items}, + "artist": map[string]any{"name": ownerName}, + "image": imageMapFromURI(raw), + } +} + +func normalizeAlbumSummary(albumRaw map[string]any, artistName string) map[string]any { + if len(albumRaw) == 0 { + return map[string]any{"artist": map[string]any{"name": artistName}} + } + return map[string]any{ + "id": jsonutil.StringFromAny(albumRaw["id"]), + "title": jsonutil.StringFromAny(albumRaw["title"]), + "release_date": jsonutil.StringFromAny(albumRaw["releaseDate"]), + "artist": map[string]any{"name": artistName}, + "image": imageMapFromURI(albumRaw), + "tracks_count": jsonutil.IntFromAny(albumRaw["trackCount"]), + "numberOfTracks": jsonutil.IntFromAny(albumRaw["trackCount"]), + } +} + +func normalizeAlbumSearchItem(raw map[string]any) map[string]any { + artistName := joinArtists(raw) + return map[string]any{ + "id": jsonutil.StringFromAny(raw["id"]), + "title": jsonutil.StringFromAny(raw["title"]), + "version": jsonutil.StringFromAny(raw["version"]), + "artist": map[string]any{"name": artistName}, + "tracks_count": jsonutil.IntFromAny(raw["trackCount"]), + "release_date": jsonutil.StringFromAny(raw["releaseDate"]), + } +} + +func normalizeArtistSearchItem(raw map[string]any) map[string]any { + return map[string]any{ + "id": jsonutil.StringFromAny(raw["id"]), + "title": jsonutil.StringFromAny(raw["name"]), + "name": jsonutil.StringFromAny(raw["name"]), + "albums_count": firstPositiveInt( + jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "directAlbums")), + jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "tracks")), + ), + } +} + +func normalizePlaylistSearchItem(raw map[string]any) map[string]any { + owner, _ := raw["owner"].(map[string]any) + ownerID := firstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"])) + ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"])) + return map[string]any{ + "id": ownerID + ":" + jsonutil.StringFromAny(raw["kind"]), + "title": jsonutil.StringFromAny(raw["title"]), + "artist": map[string]any{"name": ownerName}, + "tracks_count": jsonutil.IntFromAny(raw["trackCount"]), + "release_date": jsonutil.StringFromAny(raw["modified"]), + } +} + +func albumVolumes(raw map[string]any) [][]any { + volumesAny, _ := raw["volumes"].([]any) + volumes := make([][]any, 0, len(volumesAny)) + for _, rawVolume := range volumesAny { + tracks, ok := rawVolume.([]any) + if ok { + volumes = append(volumes, tracks) + } + } + return volumes +} + +func firstAlbum(raw map[string]any) map[string]any { + albums, _ := raw["albums"].([]any) + if len(albums) == 0 { + return map[string]any{} + } + first, _ := albums[0].(map[string]any) + if first == nil { + return map[string]any{} + } + return first +} + +func firstArtistID(raw map[string]any) string { + artists, _ := raw["artists"].([]any) + if len(artists) == 0 { + return "" + } + artist, _ := artists[0].(map[string]any) + return jsonutil.StringFromAny(artist["id"]) +} + +func joinArtists(raw map[string]any) string { + artists, _ := raw["artists"].([]any) + parts := make([]string, 0, len(artists)) + for _, entry := range artists { + artist, ok := entry.(map[string]any) + if !ok { + continue + } + if name := strings.TrimSpace(jsonutil.StringFromAny(artist["name"])); name != "" { + parts = append(parts, name) + } + } + return strings.Join(parts, ", ") +} + +func joinAlbumArtists(albumRaw map[string]any) string { + if len(albumRaw) == 0 { + return "" + } + artists, _ := albumRaw["artists"].([]any) + parts := make([]string, 0, len(artists)) + for _, entry := range artists { + artist, ok := entry.(map[string]any) + if !ok { + continue + } + if name := strings.TrimSpace(jsonutil.StringFromAny(artist["name"])); name != "" { + parts = append(parts, name) + } + } + return strings.Join(parts, ", ") +} + +func trackNumbers(albumRaw map[string]any) (int, int) { + position, _ := albumRaw["trackPosition"].(map[string]any) + trackNumber := jsonutil.IntFromAny(position["index"]) + if trackNumber > 0 { + trackNumber++ + } + return trackNumber, firstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1) +} + +func canonicalTrackRequestID(item string) string { + item = strings.TrimSpace(item) + if item == "" { + return item + } + return item +} + +func canonicalTrackRefFromRaw(raw map[string]any, fallback string) string { + if strings.Contains(strings.TrimSpace(fallback), ":") { + return strings.TrimSpace(fallback) + } + trackID := firstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"])) + albumID := jsonutil.StringFromAny(firstAlbum(raw)["id"]) + if trackID == "" { + trackID = strings.TrimSpace(fallback) + } + if trackID == "" { + return "" + } + if albumID == "" { + return trackID + } + return trackID + ":" + albumID +} + +func splitTrackRef(item string) (string, string) { + item = strings.TrimSpace(item) + parts := strings.SplitN(item, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + } + return item, "" +} + +func splitPlaylistRef(item string) (string, string, bool) { + parts := strings.SplitN(strings.TrimSpace(item), ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +func collectAlbumIDs(raw any) []string { + seen := map[string]struct{}{} + out := make([]string, 0) + var walk func(any) + walk = func(v any) { + switch t := v.(type) { + case map[string]any: + id := strings.TrimSpace(jsonutil.StringFromAny(t["id"])) + if id != "" && (t["title"] != nil || t["coverUri"] != nil || t["metaType"] != nil || t["trackCount"] != nil) { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + out = append(out, id) + } + } + for _, nested := range t { + walk(nested) + } + case []any: + for _, nested := range t { + walk(nested) + } + } + } + walk(raw) + sort.Strings(out) + return out +} + +func imageMapFromTrack(raw map[string]any, albumRaw map[string]any) map[string]any { + if img := imageMapFromURI(raw); len(img) > 0 { + return img + } + return imageMapFromURI(albumRaw) +} + +func imageMapFromURI(raw map[string]any) map[string]any { + uri := firstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"])) + if uri == "" { + if cover, ok := raw["cover"].(map[string]any); ok { + uri = jsonutil.StringFromAny(cover["uri"]) + } + } + uri = strings.TrimSpace(uri) + if uri == "" { + return nil + } + uri = strings.TrimPrefix(uri, "https://") + uri = strings.TrimPrefix(uri, "http://") + return map[string]any{ + "original": "https://" + strings.ReplaceAll(uri, "%%", "1000x1000"), + "extralarge": "https://" + strings.ReplaceAll(uri, "%%", "600x600"), + "large": "https://" + strings.ReplaceAll(uri, "%%", "400x400"), + "small": "https://" + strings.ReplaceAll(uri, "%%", "200x200"), + "thumbnail": "https://" + strings.ReplaceAll(uri, "%%", "100x100"), + } +} + +func isExplicit(raw map[string]any) bool { + if strings.EqualFold(strings.TrimSpace(jsonutil.StringFromAny(raw["contentWarning"])), "explicit") { + return true + } + if disclaimers, ok := raw["disclaimers"].([]any); ok { + for _, disclaimer := range disclaimers { + if strings.EqualFold(strings.TrimSpace(jsonutil.StringFromAny(disclaimer)), "explicit") { + return true + } + } + } + return jsonutil.BoolFromAny(raw["explicit"]) +} + +func yandexDownloadSign(trackID, quality string, codecs []string, transport string) (string, int64) { + ts := time.Now().Unix() + mac := hmac.New(sha256.New, []byte(desktopWindowsSignKey)) + _, _ = mac.Write([]byte(strconv.FormatInt(ts, 10) + trackID + quality + strings.Join(codecs, "") + transport)) + return strings.TrimRight(base64.StdEncoding.EncodeToString(mac.Sum(nil)), "="), ts +} + +func downloadRequestProfile(quality int) (string, []string) { + switch { + case quality <= 0: + return "lq", []string{"he-aac", "he-aac-mp4"} + case quality == 1: + return "nq", []string{"aac", "aac-mp4"} + default: + return "lossless", []string{"flac", "flac-mp4"} + } +} + +func audioProfileFromDownloadInfo(codec string, bitrate int, quality string) (provider.AudioProfile, string) { + c := strings.ToLower(strings.TrimSpace(codec)) + switch c { + case "mp3": + return provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: bitrate}, "mp3" + case "he-aac", "he-aac-mp4": + return provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: bitrate}, "m4a" + case "aac", "aac-mp4": + return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: bitrate}, "m4a" + case "flac": + return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "flac" + case "flac-mp4": + return provider.AudioProfile{Container: "M4A", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "m4a" + default: + if strings.EqualFold(strings.TrimSpace(quality), "lossless") { + return provider.AudioProfile{Container: "M4A", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "m4a" + } + return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: bitrate}, "m4a" + } +} + +func shouldRetryStatus(status int) bool { + switch status { + case http.StatusRequestTimeout, http.StatusTooManyRequests, 498, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + default: + return false + } +} + +func retryDelay(status, attempt int) time.Duration { + var delays []time.Duration + switch status { + case http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusGatewayTimeout: + delays = []time.Duration{2 * time.Second, 5 * time.Second} + case 498, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable: + delays = []time.Duration{1 * time.Second, 3 * time.Second} + default: + delays = []time.Duration{time.Second, 2 * time.Second} + } + if attempt >= 0 && attempt < len(delays) { + return delays[attempt] + } + return delays[len(delays)-1] +} + +func waitRetry(ctx context.Context, delay time.Duration) error { + t := time.NewTimer(delay) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +func firstPositiveInt(vals ...int) int { + for _, v := range vals { + if v > 0 { + return v + } + } + return 0 +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/internal/provider/yandex/client_test.go b/internal/provider/yandex/client_test.go new file mode 100644 index 0000000..ff005d3 --- /dev/null +++ b/internal/provider/yandex/client_test.go @@ -0,0 +1,174 @@ +package yandex + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "streamrip-go/internal/config" + "streamrip-go/internal/jsonutil" +) + +func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) { + sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw") + if ts <= 0 { + t.Fatalf("timestamp = %d", ts) + } + if strings.TrimSpace(sign) == "" { + t.Fatalf("decoded sign is empty") + } + if strings.Contains(sign, "=") { + t.Fatalf("sign unexpectedly contains base64 padding: %q", sign) + } + if strings.Contains(sign, " ") { + t.Fatalf("sign unexpectedly contains space: %q", sign) + } +} + +func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) { + var gotPath string + var gotQuery url.Values + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + if r.URL.Path == "/account/about" { + _ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"uid": "123"}}) + return + } + if r.URL.Path != "/get-file-info" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": map[string]any{ + "downloadInfo": map[string]any{ + "trackId": "32038184", + "quality": "lossless", + "codec": "flac-mp4", + "transport": "encraw", + "key": "00112233445566778899aabbccddeeff", + "bitrate": 0, + "url": "https://strm.example/music-v2/crypt/x/flac-mp4", + }, + }, + }) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.RequestsPerMinute = 0 + d.Yandex.AccessToken = "token" + c := New(&config.Config{File: d, Session: d}) + c.baseURL = ts.URL + c.loggedIn = true + + dl, err := c.GetDownloadable(context.Background(), "32038184:1683700", 2) + if err != nil { + t.Fatalf("GetDownloadable() error = %v", err) + } + if gotPath != "/get-file-info" { + t.Fatalf("path = %q, want /get-file-info", gotPath) + } + if gotQuery.Get("trackId") != "32038184" { + t.Fatalf("trackId = %q, want 32038184", gotQuery.Get("trackId")) + } + if gotQuery.Get("quality") != "lossless" { + t.Fatalf("quality = %q, want lossless", gotQuery.Get("quality")) + } + if gotQuery.Get("transports") != "encraw" { + t.Fatalf("transports = %q, want encraw", gotQuery.Get("transports")) + } + if dl.Extension != "m4a" { + t.Fatalf("extension = %q, want m4a", dl.Extension) + } + if dl.Audio.Codec != "FLAC" || dl.Audio.Quality != "LOSSLESS" { + t.Fatalf("unexpected audio profile: %+v", dl.Audio) + } + if dl.TrackID != "32038184" { + t.Fatalf("track id = %q, want 32038184", dl.TrackID) + } + if dl.Cipher != "AES_CTR" || dl.Key == "" { + t.Fatalf("expected yandex cipher metadata, got cipher=%q key=%q", dl.Cipher, dl.Key) + } +} + +func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + if r.URL.Path != "/tracks" { + w.WriteHeader(http.StatusNotFound) + return + } + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "result": []map[string]any{{ + "id": "9442712", + "realId": "9442712", + "title": "Nightcall", + "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, + "albums": []map[string]any{{ + "id": "1000856", + "title": "OutRun", + "releaseDate": "2013-02-25T00:00:00+04:00", + "trackCount": 13, + "artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}}, + "trackPosition": map[string]any{ + "index": 0, + "volume": 1, + }, + }}, + }}, + }) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.RequestsPerMinute = 0 + d.Yandex.AccessToken = "token" + c := New(&config.Config{File: d, Session: d}) + c.baseURL = ts.URL + c.loggedIn = true + + meta, err := c.GetMetadata(context.Background(), "9442712:1000856", "track") + if err != nil { + t.Fatalf("GetMetadata() error = %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/tracks" { + t.Fatalf("unexpected request: %s %s", gotMethod, gotPath) + } + if !strings.Contains(gotBody, "trackIds=9442712%3A1000856") { + t.Fatalf("body = %q", gotBody) + } + if meta["id"] != "9442712:1000856" { + t.Fatalf("id = %v", meta["id"]) + } + if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" { + t.Fatalf("unexpected album: %+v", album) + } +} + +func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) { + url, err := legacyDirectURL(&legacyDownloadInfoXML{ + Host: "example.test", + Path: "/abc123", + TS: "1234567890", + S: "tailxyz", + }) + if err != nil { + t.Fatalf("legacyDirectURL() error = %v", err) + } + want := "https://example.test/get-mp3/248c1c6ff5daf481560d3bd9f24e8058/1234567890/abc123" + if url != want { + t.Fatalf("legacyDirectURL() = %q, want %q", url, want) + } +} diff --git a/internal/urlparse/parse.go b/internal/urlparse/parse.go index daf556e..64325ba 100644 --- a/internal/urlparse/parse.go +++ b/internal/urlparse/parse.go @@ -45,6 +45,8 @@ func Parse(raw string) *ParsedURL { switch { case isQobuzHost(host): return parseQobuz(raw, parts) + case isYandexHost(host): + return parseYandex(raw, parts) case isTidalHost(host): return parseTidal(raw, parts) case isDeezerHost(host): @@ -80,6 +82,42 @@ func parseQobuz(raw string, parts []string) *ParsedURL { return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric} } +func parseYandex(raw string, parts []string) *ParsedURL { + if len(parts) < 2 { + return nil + } + + switch parts[0] { + case "track": + if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { + return nil + } + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[1], Kind: KindGeneric} + case "album": + if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" { + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "album", ID: parts[1], Kind: KindGeneric} + } + if len(parts) == 4 && parts[2] == "track" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" { + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[3] + ":" + parts[1], Kind: KindGeneric} + } + case "artist": + if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { + return nil + } + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "artist", ID: parts[1], Kind: KindGeneric} + case "users": + if len(parts) == 4 && parts[2] == "playlists" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" { + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1] + ":" + parts[3], Kind: KindGeneric} + } + case "playlists": + if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" { + return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1], Kind: KindGeneric} + } + } + + return nil +} + func parseTidal(raw string, parts []string) *ParsedURL { if len(parts) < 2 { return nil @@ -177,6 +215,10 @@ func isQobuzHost(host string) bool { return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com" } +func isYandexHost(host string) bool { + return host == "music.yandex.ru" || host == "music.yandex.com" || host == "music.yandex.kz" || host == "music.yandex.by" +} + func isTidalHost(host string) bool { return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com" } diff --git a/internal/urlparse/parse_test.go b/internal/urlparse/parse_test.go index cac7fc4..09cb288 100644 --- a/internal/urlparse/parse_test.go +++ b/internal/urlparse/parse_test.go @@ -27,6 +27,30 @@ func TestQobuzAlbumURL(t *testing.T) { } } +func TestYandexURLs(t *testing.T) { + tests := []struct { + url string + mediaType string + id string + }{ + {url: "https://music.yandex.ru/track/9442712", mediaType: "track", id: "9442712"}, + {url: "https://music.yandex.ru/album/1000856", mediaType: "album", id: "1000856"}, + {url: "https://music.yandex.ru/album/1000856/track/9442712", mediaType: "track", id: "9442712:1000856"}, + {url: "https://music.yandex.ru/artist/1433871", mediaType: "artist", id: "1433871"}, + {url: "https://music.yandex.ru/users/yandexmusic/playlists/1635", mediaType: "playlist", id: "yandexmusic:1635"}, + {url: "https://music.yandex.ru/playlists/4ae45ac1-0972-734f-8537-769490399170", mediaType: "playlist", id: "4ae45ac1-0972-734f-8537-769490399170"}, + } + for _, tc := range tests { + result := Parse(tc.url) + if result == nil { + t.Fatalf("expected parse for %q", tc.url) + } + if result.Source != "yandex" || result.MediaType != tc.mediaType || result.ID != tc.id { + t.Fatalf("unexpected parse result for %q: %+v", tc.url, result) + } + } +} + func TestTidalTrackURL(t *testing.T) { inputs := []string{ "https://tidal.com/browse/track/3083287",