Files
streamrip-go/internal/provider/yandex/client.go
2026-06-10 12:58:04 +02:00

1019 lines
32 KiB
Go

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