mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 23:25:30 +02:00
1019 lines
32 KiB
Go
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 ""
|
|
}
|