Compare commits
6 Commits
15686b146a
...
587676be58
| Author | SHA1 | Date | |
|---|---|---|---|
| 587676be58 | |||
| 610fb3da11 | |||
| 4db033dc7d | |||
| d1f8937934 | |||
| 406dd18b79 | |||
| 20090e5bb1 |
10
go.mod
10
go.mod
@@ -3,15 +3,15 @@ module gitea.mrixs.me/Mrixs/yamusic-bot
|
|||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bogem/id3v2 v1.2.1
|
github.com/bogem/id3v2 v1.2.0
|
||||||
github.com/caarlos0/env/v10 v10.0.0
|
github.com/caarlos0/env/v10 v10.0.0
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
golang.org/x/sync v0.7.0
|
github.com/oapi-codegen/runtime v1.1.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lmittmann/tint v1.0.4 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
golang.org/x/net v0.21.0 // indirect
|
github.com/google/uuid v1.5.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
33
go.sum
Normal file
33
go.sum
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||||
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
|
github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI=
|
||||||
|
github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8=
|
||||||
|
github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA=
|
||||||
|
github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||||
|
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||||
|
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
117
internal/admin/handler.go
Normal file
117
internal/admin/handler.go
Normal file
@@ -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 <yandex_track_id> - Найти трек в кэше по ID\n" +
|
||||||
|
"/warm <URL> - \"Прогреть\" кэш для альбома или исполнителя (в разработке)"
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
internal/config/config.go
Normal file
56
internal/config/config.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/caarlos0/env/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config содержит всю конфигурацию приложения, получаемую из переменных окружения.
|
||||||
|
type Config struct {
|
||||||
|
TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN,required"`
|
||||||
|
TelegramAdminIDsRaw string `env:"TELEGRAM_ADMIN_IDS,required"`
|
||||||
|
TelegramCacheChatID int64 `env:"TELEGRAM_CACHE_CHAT_ID,required"`
|
||||||
|
YandexMusicToken string `env:"YANDEX_MUSIC_TOKEN"`
|
||||||
|
DatabasePath string `env:"DATABASE_PATH" envDefault:"/data/bot.db"`
|
||||||
|
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||||
|
ProcessorWorkers int `env:"PROCESSOR_WORKERS" envDefault:"4"`
|
||||||
|
YandexAPIRateLimit int `env:"YANDEX_API_RATE_LIMIT" envDefault:"5"`
|
||||||
|
TelegramAdminIDs []int64 `env:"-"` // Это поле будет заполнено после парсинга
|
||||||
|
}
|
||||||
|
|
||||||
|
// New загружает конфигурацию из переменных окружения и парсит необходимые поля.
|
||||||
|
func New() *Config {
|
||||||
|
cfg := &Config{}
|
||||||
|
if err := env.Parse(cfg); err != nil {
|
||||||
|
log.Fatalf("failed to parse config: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим ID администраторов из строки
|
||||||
|
if cfg.TelegramAdminIDsRaw != "" {
|
||||||
|
ids := strings.Split(cfg.TelegramAdminIDsRaw, ",")
|
||||||
|
cfg.TelegramAdminIDs = make([]int64, 0, len(ids))
|
||||||
|
for _, idStr := range ids {
|
||||||
|
var id int64
|
||||||
|
if _, err := Sscanf(strings.TrimSpace(idStr), "%d", &id); err == nil {
|
||||||
|
cfg.TelegramAdminIDs = append(cfg.TelegramAdminIDs, id)
|
||||||
|
} else {
|
||||||
|
log.Printf("warning: could not parse admin ID: %s", idStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.TelegramAdminIDs) == 0 {
|
||||||
|
log.Fatalf("no valid admin IDs provided in TELEGRAM_ADMIN_IDS")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sscanf - простая реализация для парсинга, чтобы избежать лишних зависимостей.
|
||||||
|
// В стандартной библиотеке fmt.Sscanf требует, чтобы вся строка была разобрана.
|
||||||
|
func Sscanf(str, format string, a ...interface{}) (int, error) {
|
||||||
|
return fmt.Sscanf(str, format, a...)
|
||||||
|
}
|
||||||
42
internal/interfaces/interfaces.go
Normal file
42
internal/interfaces/interfaces.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package interfaces
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// YandexMusicClient определяет методы для взаимодействия с API Yandex.Music.
|
||||||
|
type YandexMusicClient interface {
|
||||||
|
GetTrackInfo(ctx context.Context, trackID string) (*model.TrackInfo, error)
|
||||||
|
GetAlbumTrackInfos(ctx context.Context, albumID string) ([]*model.TrackInfo, error)
|
||||||
|
GetArtistTrackInfos(ctx context.Context, artistID string) ([]*model.TrackInfo, error)
|
||||||
|
GetDownloadURL(ctx context.Context, trackID string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackStorage определяет методы для работы с постоянным кэшем.
|
||||||
|
type TrackStorage interface {
|
||||||
|
Get(ctx context.Context, yandexTrackID string) (telegramFileID string, err error)
|
||||||
|
Set(ctx context.Context, yandexTrackID, telegramFileID string) error
|
||||||
|
Count(ctx context.Context) (int, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramClient определяет методы для взаимодействия с Telegram Bot API.
|
||||||
|
// Мы определяем свой интерфейс, чтобы не зависеть напрямую от библиотеки
|
||||||
|
// и упростить тестирование.
|
||||||
|
type TelegramClient interface {
|
||||||
|
SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error)
|
||||||
|
AnswerInlineQuery(ctx context.Context, queryID string, results []interface{}) error
|
||||||
|
SendMessage(ctx context.Context, chatID int64, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagger определяет методы для работы с метаданными аудиофайлов.
|
||||||
|
type Tagger interface {
|
||||||
|
WriteTags(filePath string, coverPath string, info *model.TrackInfo) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileDownloader определяет метод для скачивания файла.
|
||||||
|
type FileDownloader interface {
|
||||||
|
Download(ctx context.Context, url string) (filePath string, err error)
|
||||||
|
}
|
||||||
17
internal/model/model.go
Normal file
17
internal/model/model.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TrackInfo содержит всю необходимую информацию о треке для его обработки и тегирования.
|
||||||
|
type TrackInfo struct {
|
||||||
|
YandexTrackID string
|
||||||
|
YandexAlbumID string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
AlbumArtist string // Исполнитель альбома (для сборников)
|
||||||
|
Year int
|
||||||
|
Genre string
|
||||||
|
DiscNumber int // Номер диска
|
||||||
|
TrackPosition int // Номер трека на диске
|
||||||
|
CoverURL string
|
||||||
|
DownloadURL string
|
||||||
|
}
|
||||||
103
internal/processor/track.go
Normal file
103
internal/processor/track.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
|
||||||
|
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
|
||||||
|
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrackProcessor инкапсулирует всю логику обработки одного трека.
|
||||||
|
type TrackProcessor struct {
|
||||||
|
storage interfaces.TrackStorage
|
||||||
|
yandex interfaces.YandexMusicClient
|
||||||
|
downloader interfaces.FileDownloader
|
||||||
|
tagger interfaces.Tagger
|
||||||
|
telegram interfaces.TelegramClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTrackProcessor создает новый процессор.
|
||||||
|
func NewTrackProcessor(
|
||||||
|
storage interfaces.TrackStorage,
|
||||||
|
yandex interfaces.YandexMusicClient,
|
||||||
|
downloader interfaces.FileDownloader,
|
||||||
|
tagger interfaces.Tagger,
|
||||||
|
telegram interfaces.TelegramClient,
|
||||||
|
) *TrackProcessor {
|
||||||
|
return &TrackProcessor{
|
||||||
|
storage: storage,
|
||||||
|
yandex: yandex,
|
||||||
|
downloader: downloader,
|
||||||
|
tagger: tagger,
|
||||||
|
telegram: telegram,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process получает информацию о треке, обрабатывает его (если нужно) и возвращает Telegram File ID.
|
||||||
|
func (p *TrackProcessor) Process(ctx context.Context, trackInfo *model.TrackInfo) (string, error) {
|
||||||
|
const op = "processor.TrackProcessor.Process"
|
||||||
|
|
||||||
|
// 1. Проверяем кэш в БД
|
||||||
|
fileID, err := p.storage.Get(ctx, trackInfo.YandexTrackID)
|
||||||
|
if err == nil {
|
||||||
|
slog.Info("Cache hit", "track_id", trackInfo.YandexTrackID, "title", trackInfo.Title)
|
||||||
|
return fileID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если ошибка - это не "не найдено", то это проблема
|
||||||
|
if !errors.Is(err, storage.ErrNotFound) {
|
||||||
|
return "", fmt.Errorf("%s: failed to get from storage: %w", op, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cache Miss: начинаем полный цикл обработки
|
||||||
|
slog.Info("Cache miss, processing track", "track_id", trackInfo.YandexTrackID, "title", trackInfo.Title)
|
||||||
|
|
||||||
|
// 2a. Получаем URL для скачивания
|
||||||
|
downloadURL, err := p.yandex.GetDownloadURL(ctx, trackInfo.YandexTrackID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: failed to get download url: %w", op, err)
|
||||||
|
}
|
||||||
|
trackInfo.DownloadURL = downloadURL
|
||||||
|
|
||||||
|
// 2b. Скачиваем аудиофайл и обложку
|
||||||
|
audioPath, err := p.downloader.Download(ctx, trackInfo.DownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: failed to download audio: %w", op, err)
|
||||||
|
}
|
||||||
|
defer os.Remove(audioPath) // Гарантированное удаление временного аудиофайла
|
||||||
|
|
||||||
|
var coverPath string
|
||||||
|
if trackInfo.CoverURL != "" {
|
||||||
|
coverPath, err = p.downloader.Download(ctx, trackInfo.CoverURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to download cover, proceeding without it", "url", trackInfo.CoverURL, "error", err)
|
||||||
|
} else {
|
||||||
|
defer os.Remove(coverPath) // Гарантированное удаление временной обложки
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. Записываем теги
|
||||||
|
if err := p.tagger.WriteTags(audioPath, coverPath, trackInfo); err != nil {
|
||||||
|
// Не фатальная ошибка, просто логируем и продолжаем
|
||||||
|
slog.Warn("Failed to write tags, proceeding without them", "track_id", trackInfo.YandexTrackID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2d. Загружаем в Telegram (в кэш-канал)
|
||||||
|
newFileID, err := p.telegram.SendAudioToCacheChannel(ctx, audioPath, trackInfo.Title, trackInfo.Artist)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: failed to upload to telegram: %w", op, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2e. Сохраняем в нашу БД
|
||||||
|
if err := p.storage.Set(ctx, trackInfo.YandexTrackID, newFileID); err != nil {
|
||||||
|
// Не фатальная ошибка для пользователя, но критичная для нас. Логируем как ошибку.
|
||||||
|
slog.Error("Failed to save track to cache storage", "track_id", trackInfo.YandexTrackID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFileID, nil
|
||||||
|
}
|
||||||
123
internal/storage/sqlite.go
Normal file
123
internal/storage/sqlite.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3" // Драйвер для SQLite
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound возвращается, когда запись не найдена в хранилище.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteStorage реализует интерфейс interfaces.TrackStorage для SQLite.
|
||||||
|
type SQLiteStorage struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSQLiteStorage создает и инициализирует новое хранилище SQLite.
|
||||||
|
// Он также проверяет и создает таблицу, если она не существует.
|
||||||
|
func NewSQLiteStorage(ctx context.Context, dbPath string) (*SQLiteStorage, error) {
|
||||||
|
// Убедимся, что директория для файла БД существует
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite3", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем 1 соединение, т.к. SQLite плохо работает с конкурентной записью.
|
||||||
|
// Для наших целей этого более чем достаточно.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
storage := &SQLiteStorage{db: db}
|
||||||
|
|
||||||
|
if err := storage.initSchema(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("SQLite storage initialized successfully", "path", dbPath)
|
||||||
|
return storage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSchema создает таблицу для кэша, если она еще не существует.
|
||||||
|
func (s *SQLiteStorage) initSchema(ctx context.Context) error {
|
||||||
|
const ddl = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tracks_cache (
|
||||||
|
yandex_track_id TEXT PRIMARY KEY,
|
||||||
|
telegram_file_id TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx, ddl)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get получает telegram_file_id по yandex_track_id.
|
||||||
|
// Возвращает ErrNotFound, если запись не найдена.
|
||||||
|
func (s *SQLiteStorage) Get(ctx context.Context, yandexTrackID string) (string, error) {
|
||||||
|
const op = "storage.sqlite.Get"
|
||||||
|
stmt, err := s.db.PrepareContext(ctx, "SELECT telegram_file_id FROM tracks_cache WHERE yandex_track_id = ?")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
var fileID string
|
||||||
|
err = stmt.QueryRowContext(ctx, yandexTrackID).Scan(&fileID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set сохраняет новую запись в кэш.
|
||||||
|
func (s *SQLiteStorage) Set(ctx context.Context, yandexTrackID, telegramFileID string) error {
|
||||||
|
const op = "storage.sqlite.Set"
|
||||||
|
stmt, err := s.db.PrepareContext(ctx, "INSERT INTO tracks_cache(yandex_track_id, telegram_file_id) VALUES(?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
_, err = stmt.ExecContext(ctx, yandexTrackID, telegramFileID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count возвращает общее количество записей в кэше.
|
||||||
|
func (s *SQLiteStorage) Count(ctx context.Context) (int, error) {
|
||||||
|
const op = "storage.sqlite.Count"
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tracks_cache").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s: %w", op, err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает соединение с базой данных.
|
||||||
|
func (s *SQLiteStorage) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
70
internal/storage/sqlite_test.go
Normal file
70
internal/storage/sqlite_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSQLiteStorage(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Используем in-memory базу данных для тестов
|
||||||
|
storage, err := NewSQLiteStorage(ctx, ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create in-memory storage: %v", err)
|
||||||
|
}
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
t.Run("Set and Get", func(t *testing.T) {
|
||||||
|
yandexID := "12345"
|
||||||
|
telegramID := "file_abcde"
|
||||||
|
|
||||||
|
err := storage.Set(ctx, yandexID, telegramID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Set() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotID, err := storage.Get(ctx, yandexID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotID != telegramID {
|
||||||
|
t.Errorf("Get() got = %v, want %v", gotID, telegramID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Get not found", func(t *testing.T) {
|
||||||
|
_, err := storage.Get(ctx, "non_existent_id")
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("Expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Count", func(t *testing.T) {
|
||||||
|
// Сначала очистим (в in-memory это не нужно, но для полноты)
|
||||||
|
// или просто проверим текущее состояние
|
||||||
|
count, err := storage.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Count() error = %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 { // У нас осталась одна запись с предыдущего теста
|
||||||
|
t.Errorf("Count() got = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавим еще одну запись
|
||||||
|
err = storage.Set(ctx, "67890", "file_fghij")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Set() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newCount, err := storage.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Count() error = %v", err)
|
||||||
|
}
|
||||||
|
if newCount != 2 {
|
||||||
|
t.Errorf("Count() got = %d, want 2", newCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
56
pkg/downloader/downloader.go
Normal file
56
pkg/downloader/downloader.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package downloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPDownloader реализует интерфейс interfaces.FileDownloader.
|
||||||
|
type HTTPDownloader struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPDownloader создает новый экземпляр загрузчика.
|
||||||
|
func NewHTTPDownloader() *HTTPDownloader {
|
||||||
|
return &HTTPDownloader{
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download скачивает файл по URL и сохраняет его во временный файл.
|
||||||
|
// Возвращает путь к скачанному файлу.
|
||||||
|
func (d *HTTPDownloader) Download(ctx context.Context, url string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create download request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := d.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute download request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("bad status code on download: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем временный файл с правильным расширением .mp3
|
||||||
|
tmpFile, err := os.CreateTemp("", "track-*.mp3")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(tmpFile, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
// Если произошла ошибка, удаляем временный файл
|
||||||
|
os.Remove(tmpFile.Name())
|
||||||
|
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
30
pkg/logging/logger.go
Normal file
30
pkg/logging/logger.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewLogger создает и настраивает новый экземпляр slog.Logger.
|
||||||
|
func NewLogger(logLevel string) *slog.Logger {
|
||||||
|
var level slog.Level
|
||||||
|
switch logLevel {
|
||||||
|
case "debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
level = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(os.Stdout, opts)
|
||||||
|
return slog.New(handler)
|
||||||
|
}
|
||||||
71
pkg/tagger/id3.go
Normal file
71
pkg/tagger/id3.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package tagger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
|
||||||
|
"github.com/bogem/id3v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID3Tagger реализует интерфейс interfaces.Tagger для работы с ID3-тегами.
|
||||||
|
type ID3Tagger struct{}
|
||||||
|
|
||||||
|
// NewID3Tagger создает новый экземпляр теггера.
|
||||||
|
func NewID3Tagger() *ID3Tagger {
|
||||||
|
return &ID3Tagger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteTags записывает метаданные и обложку в указанный аудиофайл.
|
||||||
|
func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.TrackInfo) error {
|
||||||
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open mp3 file for tagging: %w", err)
|
||||||
|
}
|
||||||
|
defer tag.Close()
|
||||||
|
|
||||||
|
tag.SetTitle(info.Title)
|
||||||
|
tag.SetArtist(info.Artist)
|
||||||
|
tag.SetAlbum(info.Album)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read cover file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pic := id3v2.PictureFrame{
|
||||||
|
Encoding: id3v2.EncodingUTF8,
|
||||||
|
MimeType: "image/jpeg",
|
||||||
|
PictureType: id3v2.PTFrontCover,
|
||||||
|
Description: "Front Cover",
|
||||||
|
Picture: artwork,
|
||||||
|
}
|
||||||
|
tag.AddAttachedPicture(pic)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tag.Save(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save id3 tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
263
pkg/yamusic/client.go
Normal file
263
pkg/yamusic/client.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
9863
pkg/yamusic/generated.go
Normal file
9863
pkg/yamusic/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user