diff --git a/go.mod b/go.mod index 40c5f9e..3ec7bf5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/bogem/id3v2 v1.2.0 github.com/caarlos0/env/v10 v10.0.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/mattn/go-sqlite3 v1.14.28 github.com/oapi-codegen/runtime v1.1.1 ) diff --git a/go.sum b/go.sum index 27020eb..017db97 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9a 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= diff --git a/internal/processor/track.go b/internal/processor/track.go new file mode 100644 index 0000000..fcedbbf --- /dev/null +++ b/internal/processor/track.go @@ -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 +} diff --git a/pkg/downloader/downloader.go b/pkg/downloader/downloader.go new file mode 100644 index 0000000..04460a8 --- /dev/null +++ b/pkg/downloader/downloader.go @@ -0,0 +1,64 @@ +package downloader + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// 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) + } + + // Создаем временный файл с правильным расширением + tmpFile, err := os.CreateTemp("", "track-*.tmp") + 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) + } + + // Переименовываем файл, чтобы убрать .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 +}