package yamusic import ( "context" "crypto/md5" "encoding/hex" "encoding/xml" "fmt" "io" "log/slog" "net/http" "regexp" "strconv" "strings" "gitea.mrixs.me/Mrixs/yamusic-bot/internal/model" ) const ( yandexMusicAPIHost = "https://api.music.yandex.net" downloadSalt = "XGRlBW9FXlekgbPrRHuSiA" // "Магическая" соль для подписи ссылки ) // DownloadInfoXML описывает структуру XML-файла с информацией для скачивания. type DownloadInfoXML struct { XMLName xml.Name `xml:"download-info"` Host string `xml:"host"` Path string `xml:"path"` Ts string `xml:"ts"` S string `xml:"s"` } var ( trackURLRegex = regexp.MustCompile(`/album/(\d+)/track/(\d+)`) albumURLRegex = regexp.MustCompile(`/album/(\d+)`) artistURLRegex = regexp.MustCompile(`/artist/(\d+)`) ) type URLInfo struct { Type string ArtistID string AlbumID string TrackID string } func ParseYandexURL(url string) (*URLInfo, error) { if trackMatches := trackURLRegex.FindStringSubmatch(url); len(trackMatches) == 3 { return &URLInfo{Type: "track", AlbumID: trackMatches[1], TrackID: trackMatches[2]}, nil } if albumMatches := albumURLRegex.FindStringSubmatch(url); len(albumMatches) == 2 { return &URLInfo{Type: "album", AlbumID: albumMatches[1]}, nil } if artistMatches := artistURLRegex.FindStringSubmatch(url); len(artistMatches) == 2 { return &URLInfo{Type: "artist", ArtistID: artistMatches[1]}, nil } return nil, fmt.Errorf("unsupported yandex music url") } type ApiClient struct { api *ClientWithResponses } func NewApiClient(token string) (*ApiClient, error) { authInterceptor := func(ctx context.Context, req *http.Request) error { if token != "" { req.Header.Set("Authorization", "OAuth "+token) } return nil } apiClient, err := NewClientWithResponses(yandexMusicAPIHost, WithRequestEditorFn(authInterceptor)) if err != nil { return nil, fmt.Errorf("failed to create yandex music api client: %w", err) } return &ApiClient{api: apiClient}, nil } func (c *ApiClient) getTracksByIDs(ctx context.Context, trackIDs []string) ([]Track, error) { formPayload := "track-ids=" + strings.Join(trackIDs, ",") bodyReader := strings.NewReader(formPayload) rawResp, err := c.api.ClientInterface.GetTracksWithBody(ctx, "application/x-www-form-urlencoded", bodyReader) if err != nil { return nil, fmt.Errorf("failed to execute get tracks request: %w", err) } resp, err := ParseGetTracksResponse(rawResp) if err != nil { return nil, fmt.Errorf("failed to parse get tracks response: %w", err) } if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil { return nil, fmt.Errorf("failed to get tracks, status: %d, body: %s", resp.StatusCode(), string(resp.Body)) } return resp.JSON200.Result, nil } func (c *ApiClient) GetTrackInfo(ctx context.Context, trackID string) (*model.TrackInfo, error) { tracks, err := c.getTracksByIDs(ctx, []string{trackID}) if err != nil { return nil, err } if len(tracks) == 0 { return nil, fmt.Errorf("no track info returned for id %s", trackID) } return c.convertTrackToTrackInfo(&tracks[0]) } func (c *ApiClient) GetAlbumTrackInfos(ctx context.Context, albumID string) ([]*model.TrackInfo, error) { albumIDFloat, _ := strconv.ParseFloat(albumID, 32) resp, err := c.api.GetAlbumsWithTracksWithResponse(ctx, float32(albumIDFloat)) if err != nil { return nil, fmt.Errorf("failed to get album info from api: %w", err) } if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result.Id == 0 { return nil, fmt.Errorf("failed to get album info, status: %d, body: %s", resp.StatusCode(), string(resp.Body)) } album := resp.JSON200.Result var trackInfos []*model.TrackInfo // Определяем исполнителя альбома var albumArtist string if len(album.Artists) > 0 { albumArtist = album.Artists[0].Name } if album.Volumes != nil { for i, volume := range *album.Volumes { discNumber := i + 1 for j, track := range volume { trackPosition := j + 1 info, err := c.convertTrackToTrackInfo(&track) if err != nil { slog.Warn("Failed to convert track, skipping", "trackID", track.Id, "error", err) continue } // Заполняем дополнительные поля info.AlbumArtist = albumArtist info.DiscNumber = discNumber info.TrackPosition = trackPosition trackInfos = append(trackInfos, info) } } } return trackInfos, nil } func (c *ApiClient) GetArtistTrackInfos(ctx context.Context, artistID string) ([]*model.TrackInfo, error) { resp, err := c.api.GetPopularTracksWithResponse(ctx, artistID) if err != nil { return nil, fmt.Errorf("failed to get artist popular tracks from api: %w", err) } if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil { return nil, fmt.Errorf("failed to get artist popular tracks, status: %d, body: %s", resp.StatusCode(), string(resp.Body)) } trackIDs := resp.JSON200.Result.Tracks if len(trackIDs) == 0 { return []*model.TrackInfo{}, nil } tracks, err := c.getTracksByIDs(ctx, trackIDs) if err != nil { return nil, err } var trackInfos []*model.TrackInfo for i := range tracks { info, err := c.convertTrackToTrackInfo(&tracks[i]) if err != nil { slog.Warn("Failed to convert track, skipping", "trackID", tracks[i].Id, "error", err) continue } trackInfos = append(trackInfos, info) } return trackInfos, nil } func (c *ApiClient) GetDownloadURL(ctx context.Context, trackID string) (string, error) { resp, err := c.api.GetDownloadInfoWithResponse(ctx, trackID) if err != nil { return "", fmt.Errorf("failed to get download info from api: %w", err) } if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil || len(resp.JSON200.Result) == 0 { return "", fmt.Errorf("failed to get download info, status: %d, body: %s", resp.StatusCode(), string(resp.Body)) } var bestURL string var maxBitrate int = 0 for _, info := range resp.JSON200.Result { if info.Codec == "mp3" && int(info.BitrateInKbps) > maxBitrate { maxBitrate = int(info.BitrateInKbps) bestURL = info.DownloadInfoUrl } } if bestURL == "" { return "", fmt.Errorf("no suitable mp3 download link found for track %s", trackID) } // Получили ссылку на XML, теперь скачиваем и парсим его xmlReq, err := http.NewRequestWithContext(ctx, "GET", bestURL, nil) if err != nil { return "", fmt.Errorf("failed to create request for download info xml: %w", err) } xmlResp, err := http.DefaultClient.Do(xmlReq) if err != nil { return "", fmt.Errorf("failed to get download info xml: %w", err) } defer xmlResp.Body.Close() if xmlResp.StatusCode != http.StatusOK { return "", fmt.Errorf("bad status on getting download info xml: %s", xmlResp.Status) } xmlBody, err := io.ReadAll(xmlResp.Body) if err != nil { return "", fmt.Errorf("failed to read download info xml body: %w", err) } var infoXML DownloadInfoXML if err := xml.Unmarshal(xmlBody, &infoXML); err != nil { return "", fmt.Errorf("failed to unmarshal download info xml: %w", err) } // Генерируем финальную ссылку signData := []byte(downloadSalt + strings.TrimPrefix(infoXML.Path, "/") + infoXML.S) sign := md5.Sum(signData) hexSign := hex.EncodeToString(sign[:]) finalURL := fmt.Sprintf("https://%s/get-mp3/%s/%s%s", infoXML.Host, hexSign, infoXML.Ts, infoXML.Path) slog.Debug("Constructed final download URL", "url", finalURL) return finalURL, nil } func (c *ApiClient) convertTrackToTrackInfo(track *Track) (*model.TrackInfo, error) { if track == nil || track.Id == "" { return nil, fmt.Errorf("invalid track data") } info := &model.TrackInfo{ YandexTrackID: track.Id, Title: track.Title, } if len(track.Artists) > 0 { var artists []string for _, artist := range track.Artists { artists = append(artists, artist.Name) } info.Artist = strings.Join(artists, ", ") } if len(track.Albums) > 0 { album := track.Albums[0] info.YandexAlbumID = strconv.FormatFloat(float64(album.Id), 'f', -1, 32) info.Album = album.Title info.Year = int(album.Year) info.Genre = album.Genre // По умолчанию исполнитель альбома - это исполнитель трека, если не переопределено info.AlbumArtist = info.Artist } // Запрашиваем обложку максимального качества info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "1000x1000", 1) info.DownloadURL = "" return info, nil }