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