Compare commits

..

2 Commits

Author SHA1 Message Date
1852c5025e add rate limiter to telegram
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 12:40:42 +03:00
afacaf4f14 add reading tags from pre-cached
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 07:51:03 +03:00
7 changed files with 92 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# Yandex.Music Downloader Bot # Yandex.Music Downloader Bot
[![Build Status](https://drone.mrixs.me/api/badges/Mrixs/yamusic-bot/status.svg?branch=master)](https://drone.mrixs.me/Mrixs/yamusic-bot?branch=master) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Build Status](https://drone.mrixs.me/api/badges/Mrixs/yamusic-bot/status.svg?branch=dev)](https://drone.mrixs.me/Mrixs/yamusic-bot?branch=dev) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Удобный и быстрый Telegram-бот для получения аудиофайлов из сервиса Yandex.Music. Работает в inline-режиме, позволяя отправлять музыку в любой чат. Поддерживает поиск, а также ссылки на треки, альбомы и исполнителей. Удобный и быстрый Telegram-бот для получения аудиофайлов из сервиса Yandex.Music. Работает в inline-режиме, позволяя отправлять музыку в любой чат. Поддерживает поиск, а также ссылки на треки, альбомы и исполнителей.

View File

@@ -1,3 +1,5 @@
// yamusic-bot/cmd/bot/main.go
package main package main
import ( import (
@@ -15,6 +17,7 @@ import (
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/tagger" "gitea.mrixs.me/Mrixs/yamusic-bot/pkg/tagger"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic" "gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/time/rate"
) )
func main() { func main() {
@@ -54,11 +57,24 @@ func main() {
// 4. Инициализация компонентов // 4. Инициализация компонентов
downloaderComponent := downloader.NewHTTPDownloader() downloaderComponent := downloader.NewHTTPDownloader()
taggerComponent := tagger.NewID3Tagger() taggerComponent := tagger.NewID3Tagger()
telegramClient := bot.NewTelegramClientAdapter(tgAPI, cfg.TelegramCacheChatID)
// Создаем БЫСТРЫЙ лимитер для общих вызовов API (в секунду)
fastLimiter := rate.NewLimiter(rate.Limit(cfg.TelegramAPIRateLimit), cfg.TelegramAPIRateLimit)
slog.Info("General Telegram API rate limit set", "requests_per_second", cfg.TelegramAPIRateLimit)
// Создаем МЕДЛЕННЫЙ лимитер для кэш-канала (в минуту)
// rate.Limit измеряется в событиях/секунду, поэтому конвертируем
// Burst size = 1, чтобы избежать отправки пачки сообщений и долгого ожидания
cacheRPS := float64(cfg.TelegramCacheRateLimitPerMinute) / 60.0
cacheLimiter := rate.NewLimiter(rate.Limit(cacheRPS), 1)
slog.Info("Cache channel Telegram API rate limit set", "requests_per_minute", cfg.TelegramCacheRateLimitPerMinute)
// Передаем оба лимитера в адаптер
telegramClient := bot.NewTelegramClientAdapter(tgAPI, cfg.TelegramCacheChatID, fastLimiter, cacheLimiter)
trackProcessor := processor.NewTrackProcessor(db, yandexClient, downloaderComponent, taggerComponent, telegramClient) trackProcessor := processor.NewTrackProcessor(db, yandexClient, downloaderComponent, taggerComponent, telegramClient)
adminHandler := admin.NewHandler(db, telegramClient, yandexClient, startTime) adminHandler := admin.NewHandler(db, telegramClient, yandexClient, taggerComponent, startTime)
inlineHandler := bot.NewInlineHandler(yandexClient, trackProcessor, telegramClient) inlineHandler := bot.NewInlineHandler(yandexClient, trackProcessor, telegramClient)
// 5. Создание и запуск приложения // 5. Создание и запуск приложения

View File

@@ -18,15 +18,17 @@ type Handler struct {
storage interfaces.TrackStorage storage interfaces.TrackStorage
telegram interfaces.TelegramClient telegram interfaces.TelegramClient
yandex interfaces.YandexMusicClient yandex interfaces.YandexMusicClient
tagger interfaces.Tagger
startTime time.Time startTime time.Time
} }
// NewHandler создает новый обработчик команд администратора. // NewHandler создает новый обработчик команд администратора.
func NewHandler(storage interfaces.TrackStorage, telegram interfaces.TelegramClient, yandex interfaces.YandexMusicClient, startTime time.Time) *Handler { func NewHandler(storage interfaces.TrackStorage, telegram interfaces.TelegramClient, yandex interfaces.YandexMusicClient, tagger interfaces.Tagger, startTime time.Time) *Handler {
return &Handler{ return &Handler{
storage: storage, storage: storage,
telegram: telegram, telegram: telegram,
yandex: yandex, yandex: yandex,
tagger: tagger,
startTime: startTime, startTime: startTime,
} }
} }
@@ -165,17 +167,24 @@ func (h *Handler) handleWarmFromDir(ctx context.Context, chatID int64, dirPath s
continue continue
} }
// 2. Загружаем в Telegram // 2. Читаем метатеги из файла
// Поскольку метатеги уже вшиты, для отображения в кэш-канале можно использовать простые title/performer title, artist, err := h.tagger.ReadMetadata(fullPath)
if err != nil {
slog.Warn("Failed to read metadata from file, using fallback", "path", fullPath, "error", err)
title = "" // Используем ID как заголовок
artist = "" // Используем заглушку как исполнителя
}
// 3. Загружаем в Telegram с корректными метаданными
slog.Debug("Uploading track to cache channel", "track_id", trackID, "path", fullPath) slog.Debug("Uploading track to cache channel", "track_id", trackID, "path", fullPath)
fileID, err := h.telegram.SendAudioToCacheChannel(ctx, fullPath, trackID, "Pre-cached") fileID, err := h.telegram.SendAudioToCacheChannel(ctx, fullPath, title, artist)
if err != nil { if err != nil {
slog.Error("Failed to upload pre-cached file", "track_id", trackID, "error", err) slog.Error("Failed to upload pre-cached file", "track_id", trackID, "error", err)
errorCount++ errorCount++
continue continue
} }
// 3. Сохраняем в БД // 4. Сохраняем в БД
err = h.storage.Set(ctx, trackID, fileID) err = h.storage.Set(ctx, trackID, fileID)
if err != nil { if err != nil {
slog.Error("Failed to save pre-cached file to storage", "track_id", trackID, "error", err) slog.Error("Failed to save pre-cached file to storage", "track_id", trackID, "error", err)

View File

@@ -6,24 +6,35 @@ import (
"log/slog" "log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"golang.org/x/time/rate"
) )
// TelegramClientAdapter адаптирует библиотеку tgbotapi под наш интерфейс interfaces.TelegramClient. // TelegramClientAdapter адаптирует библиотеку tgbotapi под наш интерфейс interfaces.TelegramClient.
type TelegramClientAdapter struct { type TelegramClientAdapter struct {
api *tgbotapi.BotAPI api *tgbotapi.BotAPI
cacheChatID int64 cacheChatID int64
fastLimiter *rate.Limiter // Для общих быстрых запросов
cacheLimiter *rate.Limiter // Для медленных запросов в кэш-канал
} }
// NewTelegramClientAdapter создает новый адаптер. // NewTelegramClientAdapter создает новый адаптер.
func NewTelegramClientAdapter(api *tgbotapi.BotAPI, cacheChatID int64) *TelegramClientAdapter { func NewTelegramClientAdapter(api *tgbotapi.BotAPI, cacheChatID int64, fastLimiter, cacheLimiter *rate.Limiter) *TelegramClientAdapter {
return &TelegramClientAdapter{ return &TelegramClientAdapter{
api: api, api: api,
cacheChatID: cacheChatID, cacheChatID: cacheChatID,
fastLimiter: fastLimiter,
cacheLimiter: cacheLimiter,
} }
} }
// SendAudioToCacheChannel загружает аудиофайл в кэш-канал и возвращает его FileID. // SendAudioToCacheChannel загружает аудиофайл в кэш-канал и возвращает его FileID.
// ИСПОЛЬЗУЕТ МЕДЛЕННЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error) { func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error) {
// Ждем, пока МЕДЛЕННЫЙ лимитер разрешит выполнить запрос
if err := t.cacheLimiter.Wait(ctx); err != nil {
return "", err
}
audio := tgbotapi.NewAudio(t.cacheChatID, tgbotapi.FilePath(audioPath)) audio := tgbotapi.NewAudio(t.cacheChatID, tgbotapi.FilePath(audioPath))
audio.Title = title audio.Title = title
audio.Performer = performer audio.Performer = performer
@@ -41,11 +52,17 @@ func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, aud
} }
// AnswerInlineQuery отвечает на inline-запрос. // AnswerInlineQuery отвечает на inline-запрос.
// ИСПОЛЬЗУЕТ БЫСТРЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) AnswerInlineQuery(ctx context.Context, queryID string, results []interface{}) error { func (t *TelegramClientAdapter) AnswerInlineQuery(ctx context.Context, queryID string, results []interface{}) error {
// Ждем, пока БЫСТРЫЙ лимитер разрешит выполнить запрос
if err := t.fastLimiter.Wait(ctx); err != nil {
return err
}
inlineConfig := tgbotapi.InlineConfig{ inlineConfig := tgbotapi.InlineConfig{
InlineQueryID: queryID, InlineQueryID: queryID,
Results: results, Results: results,
CacheTime: 1, // Кэшируем результат на стороне Telegram на 1 секунду CacheTime: 1,
} }
if _, err := t.api.Request(inlineConfig); err != nil { if _, err := t.api.Request(inlineConfig); err != nil {
@@ -55,7 +72,13 @@ func (t *TelegramClientAdapter) AnswerInlineQuery(ctx context.Context, queryID s
} }
// SendMessage отправляет текстовое сообщение. // SendMessage отправляет текстовое сообщение.
// ИСПОЛЬЗУЕТ БЫСТРЫЙ ЛИМИТЕР.
func (t *TelegramClientAdapter) SendMessage(ctx context.Context, chatID int64, text string) error { func (t *TelegramClientAdapter) SendMessage(ctx context.Context, chatID int64, text string) error {
// Ждем, пока БЫСТРЫЙ лимитер разрешит выполнить запрос
if err := t.fastLimiter.Wait(ctx); err != nil {
return err
}
msg := tgbotapi.NewMessage(chatID, text) msg := tgbotapi.NewMessage(chatID, text)
if _, err := t.api.Send(msg); err != nil { if _, err := t.api.Send(msg); err != nil {
return fmt.Errorf("failed to send message: %w", err) return fmt.Errorf("failed to send message: %w", err)

View File

@@ -10,15 +10,17 @@ import (
// Config содержит всю конфигурацию приложения, получаемую из переменных окружения. // Config содержит всю конфигурацию приложения, получаемую из переменных окружения.
type Config struct { type Config struct {
TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN,required"` TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN,required"`
TelegramAdminIDsRaw string `env:"TELEGRAM_ADMIN_IDS,required"` TelegramAdminIDsRaw string `env:"TELEGRAM_ADMIN_IDS,required"`
TelegramCacheChatID int64 `env:"TELEGRAM_CACHE_CHAT_ID,required"` TelegramCacheChatID int64 `env:"TELEGRAM_CACHE_CHAT_ID,required"`
YandexMusicToken string `env:"YANDEX_MUSIC_TOKEN"` YandexMusicToken string `env:"YANDEX_MUSIC_TOKEN"`
DatabasePath string `env:"DATABASE_PATH" envDefault:"/data/bot.db"` DatabasePath string `env:"DATABASE_PATH" envDefault:"/data/bot.db"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
ProcessorWorkers int `env:"PROCESSOR_WORKERS" envDefault:"4"` ProcessorWorkers int `env:"PROCESSOR_WORKERS" envDefault:"4"`
YandexAPIRateLimit int `env:"YANDEX_API_RATE_LIMIT" envDefault:"5"` YandexAPIRateLimit int `env:"YANDEX_API_RATE_LIMIT" envDefault:"5"`
TelegramAdminIDs []int64 `env:"-"` // Это поле будет заполнено после парсинга TelegramAPIRateLimit int `env:"TELEGRAM_API_RATE_LIMIT" envDefault:"25"` // Общий лимит в секунду
TelegramCacheRateLimitPerMinute int `env:"TELEGRAM_CACHE_RATE_LIMIT_PER_MINUTE" envDefault:"20"` // Лимит для кэш-канала в минуту
TelegramAdminIDs []int64 `env:"-"`
} }
// New загружает конфигурацию из переменных окружения и парсит необходимые поля. // New загружает конфигурацию из переменных окружения и парсит необходимые поля.

View File

@@ -34,6 +34,7 @@ type TelegramClient interface {
// Tagger определяет методы для работы с метаданными аудиофайлов. // Tagger определяет методы для работы с метаданными аудиофайлов.
type Tagger interface { type Tagger interface {
WriteTags(filePath string, coverPath string, info *model.TrackInfo) error WriteTags(filePath string, coverPath string, info *model.TrackInfo) error
ReadMetadata(filePath string) (title, artist string, err error)
} }
// FileDownloader определяет метод для скачивания файла. // FileDownloader определяет метод для скачивания файла.

View File

@@ -17,6 +17,24 @@ func NewID3Tagger() *ID3Tagger {
return &ID3Tagger{} return &ID3Tagger{}
} }
// ReadMetadata читает основные метаданные (название, исполнитель) из аудиофайла.
func (t *ID3Tagger) ReadMetadata(filePath string) (string, string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", "", fmt.Errorf("failed to open mp3 file for reading tags: %w", err)
}
defer tag.Close()
title := tag.Title()
artist := tag.Artist()
if title == "" || artist == "" {
return "", "", fmt.Errorf("title or artist tag is empty")
}
return title, artist, nil
}
// WriteTags записывает метаданные и обложку в указанный аудиофайл. // WriteTags записывает метаданные и обложку в указанный аудиофайл.
func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.TrackInfo) error { func (t *ID3Tagger) WriteTags(filePath string, coverPath string, info *model.TrackInfo) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})