From 587676be58f7a224b8b685904930f3939ba4087d Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Mon, 23 Jun 2025 13:02:10 +0300 Subject: [PATCH] MVP done --- internal/admin/handler.go | 117 +++++++++++++++++++++++++++++++++ internal/model/model.go | 6 +- pkg/downloader/downloader.go | 14 +--- pkg/tagger/id3.go | 12 +++- pkg/yamusic/client.go | 123 ++++++++++++++++++++++++++--------- pkg/yamusic/generated.go | 4 +- 6 files changed, 231 insertions(+), 45 deletions(-) create mode 100644 internal/admin/handler.go diff --git a/internal/admin/handler.go b/internal/admin/handler.go new file mode 100644 index 0000000..a52bf82 --- /dev/null +++ b/internal/admin/handler.go @@ -0,0 +1,117 @@ +package admin + +import ( + "context" + "fmt" + "log/slog" + "time" + + "gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// Handler обрабатывает команды администратора. +type Handler struct { + storage interfaces.TrackStorage + telegram interfaces.TelegramClient + yandex interfaces.YandexMusicClient + startTime time.Time +} + +// NewHandler создает новый обработчик команд администратора. +func NewHandler(storage interfaces.TrackStorage, telegram interfaces.TelegramClient, yandex interfaces.YandexMusicClient, startTime time.Time) *Handler { + return &Handler{ + storage: storage, + telegram: telegram, + yandex: yandex, + startTime: startTime, + } +} + +// HandleCommand обрабатывает входящую команду. +func (h *Handler) HandleCommand(ctx context.Context, message *tgbotapi.Message) { + command := message.Command() + args := message.CommandArguments() + + slog.Info("Handling admin command", "user_id", message.From.ID, "command", command, "args", args) + + switch command { + case "help": + h.handleHelp(ctx, message.Chat.ID) + case "stats": + h.handleStats(ctx, message.Chat.ID) + case "find": + h.handleFind(ctx, message.Chat.ID, args) + case "warm": + h.handleWarm(ctx, message.Chat.ID, args) + default: + if err := h.telegram.SendMessage(ctx, message.Chat.ID, "Неизвестная команда. Используйте /help для списка команд."); err != nil { + slog.Error("Failed to send 'unknown command' message", "error", err, "chat_id", message.Chat.ID) + } + } +} + +func (h *Handler) handleHelp(ctx context.Context, chatID int64) { + helpText := "Команды администратора:\n" + + "/help - Показать это сообщение\n" + + "/stats - Показать статистику бота\n" + + "/find - Найти трек в кэше по ID\n" + + "/warm - \"Прогреть\" кэш для альбома или исполнителя (в разработке)" + + if err := h.telegram.SendMessage(ctx, chatID, helpText); err != nil { + slog.Error("Failed to send help message", "error", err, "chat_id", chatID) + } +} + +func (h *Handler) handleStats(ctx context.Context, chatID int64) { + cachedTracks, err := h.storage.Count(ctx) + if err != nil { + slog.Error("Failed to get stats from storage", "error", err) + if err := h.telegram.SendMessage(ctx, chatID, "Не удалось получить статистику из хранилища."); err != nil { + slog.Error("Failed to send stats error message", "error", err, "chat_id", chatID) + } + return + } + + uptime := time.Since(h.startTime).Round(time.Second) + + statsText := fmt.Sprintf( + "📊 Статистика бота\n\n"+ + "Время работы: %s\n"+ + "Треков в кэше: %d", + uptime, + cachedTracks, + ) + if err := h.telegram.SendMessage(ctx, chatID, statsText); err != nil { + slog.Error("Failed to send stats message", "error", err, "chat_id", chatID) + } +} + +func (h *Handler) handleFind(ctx context.Context, chatID int64, trackID string) { + if trackID == "" { + if err := h.telegram.SendMessage(ctx, chatID, "Пожалуйста, укажите Yandex Track ID. Пример: /find 123456"); err != nil { + slog.Error("Failed to send 'missing args' message for /find", "error", err, "chat_id", chatID) + } + return + } + + fileID, err := h.storage.Get(ctx, trackID) + if err != nil { + msg := fmt.Sprintf("Трек с ID `%s` не найден в кэше.", trackID) + if err := h.telegram.SendMessage(ctx, chatID, msg); err != nil { + slog.Error("Failed to send 'track not found' message for /find", "error", err, "chat_id", chatID) + } + return + } + + msg := fmt.Sprintf("Трек найден! Telegram File ID: `%s`", fileID) + if err := h.telegram.SendMessage(ctx, chatID, msg); err != nil { + slog.Error("Failed to send 'track found' message for /find", "error", err, "chat_id", chatID) + } +} + +func (h *Handler) handleWarm(ctx context.Context, chatID int64, url string) { + if err := h.telegram.SendMessage(ctx, chatID, "Команда /warm находится в разработке."); err != nil { + slog.Error("Failed to send 'warm in development' message", "error", err, "chat_id", chatID) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 011301f..d3eea00 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -5,11 +5,13 @@ type TrackInfo struct { YandexTrackID string YandexAlbumID string Title string - Album string Artist string + Album string + AlbumArtist string // Исполнитель альбома (для сборников) Year int Genre string - TrackPosition int + DiscNumber int // Номер диска + TrackPosition int // Номер трека на диске CoverURL string DownloadURL string } diff --git a/pkg/downloader/downloader.go b/pkg/downloader/downloader.go index 04460a8..859f158 100644 --- a/pkg/downloader/downloader.go +++ b/pkg/downloader/downloader.go @@ -6,8 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" - "strings" ) // HTTPDownloader реализует интерфейс interfaces.FileDownloader. @@ -40,8 +38,8 @@ func (d *HTTPDownloader) Download(ctx context.Context, url string) (string, erro return "", fmt.Errorf("bad status code on download: %s", resp.Status) } - // Создаем временный файл с правильным расширением - tmpFile, err := os.CreateTemp("", "track-*.tmp") + // Создаем временный файл с правильным расширением .mp3 + tmpFile, err := os.CreateTemp("", "track-*.mp3") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } @@ -54,11 +52,5 @@ func (d *HTTPDownloader) Download(ctx context.Context, url string) (string, erro return "", fmt.Errorf("failed to write to temp file: %w", err) } - // Переименовываем файл, чтобы убрать .tmp расширение (не обязательно, но красиво) - finalPath := strings.TrimSuffix(tmpFile.Name(), filepath.Ext(tmpFile.Name())) - if err := os.Rename(tmpFile.Name(), finalPath); err != nil { - return "", fmt.Errorf("failed to rename temp file: %w", err) - } - - return finalPath, nil + return tmpFile.Name(), nil } diff --git a/pkg/tagger/id3.go b/pkg/tagger/id3.go index 7fbbfea..695e5cd 100644 --- a/pkg/tagger/id3.go +++ b/pkg/tagger/id3.go @@ -31,11 +31,21 @@ func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.Tra tag.SetYear(strconv.Itoa(info.Year)) tag.SetGenre(info.Genre) - // Добавляем номер трека, если он есть + // Добавляем исполнителя альбома (TPE2) + if info.AlbumArtist != "" { + tag.AddTextFrame(tag.CommonID("Band/Orchestra/Accompaniment"), id3v2.EncodingUTF8, info.AlbumArtist) + } + + // Добавляем номер трека (TRCK) if info.TrackPosition > 0 { tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, strconv.Itoa(info.TrackPosition)) } + // Добавляем номер диска (TPOS) + if info.DiscNumber > 0 { + tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, strconv.Itoa(info.DiscNumber)) + } + // Встраиваем обложку if coverPath != "" { artwork, err := os.ReadFile(coverPath) diff --git a/pkg/yamusic/client.go b/pkg/yamusic/client.go index 83b0ad8..b4a8c68 100644 --- a/pkg/yamusic/client.go +++ b/pkg/yamusic/client.go @@ -2,7 +2,11 @@ package yamusic import ( "context" + "crypto/md5" + "encoding/hex" + "encoding/xml" "fmt" + "io" "log/slog" "net/http" "regexp" @@ -14,8 +18,18 @@ import ( 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+)`) @@ -60,18 +74,32 @@ func NewApiClient(token string) (*ApiClient, error) { return &ApiClient{api: apiClient}, nil } -func (c *ApiClient) GetTrackInfo(ctx context.Context, trackID string) (*model.TrackInfo, error) { - body := GetTracksFormdataRequestBody{TrackIds: &[]string{trackID}} - resp, err := c.api.GetTracksWithFormdataBodyWithResponse(ctx, body) +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 get track info from api: %w", err) + return nil, fmt.Errorf("failed to execute get tracks request: %w", err) } - if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil || len(resp.JSON200.Result) == 0 { - return nil, fmt.Errorf("failed to get track info, status: %d, body: %s", resp.StatusCode(), string(resp.Body)) + 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 +} - track := (resp.JSON200.Result)[0] - return c.convertTrackToTrackInfo(&track) +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) { @@ -86,17 +114,27 @@ func (c *ApiClient) GetAlbumTrackInfos(ctx context.Context, albumID string) ([]* 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 { - trackNum := 1 - for _, volume := range *album.Volumes { - for _, track := range volume { + 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.TrackPosition = trackNum - trackNum++ + // Заполняем дополнительные поля + info.AlbumArtist = albumArtist + info.DiscNumber = discNumber + info.TrackPosition = trackPosition trackInfos = append(trackInfos, info) } } @@ -112,26 +150,19 @@ func (c *ApiClient) GetArtistTrackInfos(ctx context.Context, artistID string) ([ 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 } - - body := GetTracksFormdataRequestBody{TrackIds: &trackIDs} - tracksResp, err := c.api.GetTracksWithFormdataBodyWithResponse(ctx, body) + tracks, err := c.getTracksByIDs(ctx, trackIDs) if err != nil { - return nil, fmt.Errorf("failed to get full info for popular tracks: %w", err) + return nil, err } - if tracksResp.StatusCode() != http.StatusOK || tracksResp.JSON200 == nil || tracksResp.JSON200.Result == nil { - return nil, fmt.Errorf("failed to get full info for popular tracks, status: %d, body: %s", tracksResp.StatusCode(), string(tracksResp.Body)) - } - var trackInfos []*model.TrackInfo - for _, track := range tracksResp.JSON200.Result { - info, err := c.convertTrackToTrackInfo(&track) + for i := range tracks { + info, err := c.convertTrackToTrackInfo(&tracks[i]) if err != nil { - slog.Warn("Failed to convert track, skipping", "trackID", track.Id, "error", err) + slog.Warn("Failed to convert track, skipping", "trackID", tracks[i].Id, "error", err) continue } trackInfos = append(trackInfos, info) @@ -156,13 +187,44 @@ func (c *ApiClient) GetDownloadURL(ctx context.Context, trackID string) (string, bestURL = info.DownloadInfoUrl } } - if bestURL == "" { return "", fmt.Errorf("no suitable mp3 download link found for track %s", trackID) } - slog.Warn("Returning XML info URL instead of direct download link. Real implementation needed.", "url", bestURL) - return "https://example.com/download/track.mp3", nil + // Получили ссылку на 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) { @@ -189,9 +251,12 @@ func (c *ApiClient) convertTrackToTrackInfo(track *Track) (*model.TrackInfo, err info.Album = album.Title info.Year = int(album.Year) info.Genre = album.Genre + // По умолчанию исполнитель альбома - это исполнитель трека, если не переопределено + info.AlbumArtist = info.Artist } - info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "400x400", 1) + // Запрашиваем обложку максимального качества + info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "1000x1000", 1) info.DownloadURL = "" return info, nil diff --git a/pkg/yamusic/generated.go b/pkg/yamusic/generated.go index 40a3e11..e8b73c1 100644 --- a/pkg/yamusic/generated.go +++ b/pkg/yamusic/generated.go @@ -1366,7 +1366,7 @@ type TrackDownloadInfo struct { Gain bool `json:"gain"` // Preview Предварительный просмотр - Preview string `json:"preview"` + Preview bool `json:"preview"` } // TrackDownloadInfoCodec Кодек аудиофайла @@ -1667,7 +1667,7 @@ type GetTokenFormdataBodyGrantType string // GetTracksFormdataBody defines parameters for GetTracks. type GetTracksFormdataBody struct { // TrackIds Уникальные идентификаторы треков - TrackIds *[]string `form:"track-ids,omitempty" json:"track-ids,omitempty"` + TrackIds *[]string `form:"trackIds,omitempty" json:"trackIds,omitempty"` // WithPositions С позициями WithPositions *bool `form:"with-positions,omitempty" json:"with-positions,omitempty"`