Compare commits

...

16 Commits

Author SHA1 Message Date
a4a778619c 2nd change Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 06:44:30 +03:00
43dd2e49d8 change Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-24 06:39:31 +03:00
c9672d093b add /warm feature and some improvements for repository
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 22:47:07 +03:00
eef26aba0a feat(ci): add build cache
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-06-23 22:19:01 +03:00
14c54ee737 feat(admin): add cache warming from local directory via /warm --from-dir
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 21:52:57 +03:00
e13557059c Add LICENSE, update README.md, .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 21:30:30 +03:00
2507a1531e trying to fix dockerci lint step
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-23 18:38:28 +03:00
2d4d7c2813 fix gitignore
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 16:38:45 +03:00
4005371767 fixed gitignore
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 16:29:45 +03:00
0d5fcabebd fix drone ci lint
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 14:04:46 +03:00
587676be58 MVP done
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-23 13:02:10 +03:00
610fb3da11 implemennted downloader 2025-06-23 09:45:42 +03:00
4db033dc7d api client implementation 2025-06-23 09:29:46 +03:00
d1f8937934 implemented storage 2025-06-23 09:03:39 +03:00
406dd18b79 added logger and tagger 2025-06-23 08:43:51 +03:00
20090e5bb1 contracts and models 2025-06-23 08:30:27 +03:00
24 changed files with 11478 additions and 33 deletions

View File

@@ -5,31 +5,103 @@ name: default
trigger: trigger:
branch: branch:
- master - master
- dev
event: event:
- push - push
- pull_request - pull_request
# --- НАЧАЛО БЛОКА ЛОКАЛЬНОГО КЭШИРОВАНИЯ ---
volumes:
- name: go-cache # Логическое имя кэша для Go
host:
# Путь на сервере, где запущен Drone Agent.
# Убедитесь, что эта директория существует и у Drone есть права на запись.
path: /var/cache/drone/gocache
- name: lint-cache # Отдельный кэш для линтера
host:
path: /var/cache/drone/lintcache
# --- КОНЕЦ БЛОКА ЛОКАЛЬНОГО КЭШИРОВАНИЯ ---
steps: steps:
- name: lint # Общие шаги для всех веток
image: golangci/golangci-lint:v1.59-alpine - name: deps
image: golang:1.24-alpine
volumes:
# Монтируем наш go-cache в стандартный GOPATH контейнера
- name: go-cache
path: /go
commands: commands:
- golangci-lint run ./... # Теперь go mod download будет сразу использовать и сохранять кэш на хост-машине
- go mod download
- go mod tidy
- name: lint
image: golangci/golangci-lint:v1.64-alpine
volumes:
# Монтируем кэш линтера
- name: lint-cache
path: /root/.cache
commands:
# Линтер автоматически подхватит кэш из /root/.cache
- golangci-lint run --timeout=5m --verbose ./...
- name: test - name: test
image: golang:1.24-alpine image: golang:1.24-alpine
volumes:
# Также монтируем go-cache, чтобы тесты использовали скачанные модули и кэш сборки
- name: go-cache
path: /go
commands: commands:
- go test -race -cover ./... - apk add --no-cache build-base
# Шаг сборки и публикации будет добавлен позже # CGO_ENABLED=1 go test теперь будет значительно быстрее при повторных запусках
# - name: build-and-publish - CGO_ENABLED=1 go test -race -cover ./...
# image: plugins/docker
# settings: # Шаги сборки и публикации остаются почти без изменений.
# repo: gitea.mrixs.me/mrixs/yamusic-bot # Кэширование Docker-слоев (`cache_from`) - это отдельный механизм, и его стоит оставить.
# registry: gitea.mrixs.me # Он дополняет кэширование зависимостей, ускоряя саму сборку Docker-образа.
# username: - name: build-and-publish-master
# from_secret: gitea_username image: plugins/docker
# password: settings:
# from_secret: gitea_password repo: gitea.mrixs.me/mrixs/yamusic-bot
# auto_tag: true registry: gitea.mrixs.me
# platforms: username:
# - linux/amd64 from_secret: gitea_username
# - linux/arm64 password:
from_secret: gitea_password
auto_tag: true
# Оставляем кэширование Docker-слоев, это очень эффективно
cache_from:
- gitea.mrixs.me/mrixs/yamusic-bot:latest
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
when:
branch:
- master
event:
- push
- name: build-and-publish-dev
image: plugins/docker
settings:
repo: gitea.mrixs.me/mrixs/yamusic-bot
registry: gitea.mrixs.me
username:
from_secret: gitea_username
password:
from_secret: gitea_password
tags:
- dev
- dev-${DRONE_COMMIT_SHA:0:7}
cache_from:
- gitea.mrixs.me/mrixs/yamusic-bot:dev
platforms:
- linux/amd64
- linux/arm64
- linux/arm/v7
when:
branch:
- dev
event:
- push

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# Binary # Binary
bot bot
!cmd/bot
!internal/bot
yamusic-bot yamusic-bot
# Data # Data

View File

@@ -4,7 +4,7 @@ FROM golang:1.24-alpine AS builder
# Устанавливаем рабочую директорию # Устанавливаем рабочую директорию
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base
# Копируем файлы зависимостей и загружаем их # Копируем файлы зависимостей и загружаем их
# Это позволяет кэшировать слой с зависимостями, если они не менялись # Это позволяет кэшировать слой с зависимостями, если они не менялись
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -18,7 +18,7 @@ COPY . .
# CGO_ENABLED=0 необходимо для статической линковки и использования from scratch. # CGO_ENABLED=0 необходимо для статической линковки и использования from scratch.
# TARGETARCH будет автоматически подставлен Docker Buildx (amd64 или arm64). # TARGETARCH будет автоматически подставлен Docker Buildx (amd64 или arm64).
ARG TARGETARCH ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /app/bot ./cmd/bot RUN CGO_ENABLED=1 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-w -s" -o /app/bot ./cmd/bot
# ---- Final Stage ---- # ---- Final Stage ----
# На этом этапе мы создаем финальный образ # На этом этапе мы создаем финальный образ

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 Vladimir Zagainov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

108
README.md
View File

@@ -1,17 +1,109 @@
# Yandex.Music Downloader Bot # Yandex.Music Downloader Bot
[![Build Status](https://drone.mrixs.me/api/badges/Mrixs/yamusic-bot/status.svg)](https://drone.mrixs.me/Mrixs/yamusic-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)
Удобный и быстрый Telegram-бот для получения аудиофайлов треков из сервиса Yandex.Music по ссылке. Удобный и быстрый Telegram-бот для получения аудиофайлов из сервиса Yandex.Music. Работает в inline-режиме, позволяя отправлять музыку в любой чат. Поддерживает поиск, а также ссылки на треки, альбомы и исполнителей.
## Конфигурация ## 🚀 Основные возможности
_(Будет заполнено позже)_ * **Inline-режим:** Используйте бота в любом чате, просто упомянув его `@username`.
* **Поддержка ссылок:** Распознает и обрабатывает ссылки на треки, альбомы и страницы исполнителей.
* **Поиск:** Если введенный текст не является ссылкой, бот выполнит поиск треков по этому тексту.
* **Метаданные:** Автоматически встраивает в аудиофайлы ID3-теги (название, исполнитель, альбом, год) и обложку.
* **Кэширование:** Мгновенная отправка уже обработанных треков благодаря системе кэширования на базе SQLite и приватного Telegram-канала.
* **Администрирование:** Предоставляет набор команд для администраторов для мониторинга и управления ботом.
## Запуск ## ⚙️ Конфигурация
_(Будет заполнено позже)_ Бот настраивается с помощью переменных окружения.
## Использование | Переменная | Описание | Пример | Обязательно |
| ------------------------- | ------------------------------------------------------ | ----------------------------- | ----------- |
| `TELEGRAM_BOT_TOKEN` | Токен, полученный от @BotFather. | `12345:ABC-DEF` | **Да** |
| `TELEGRAM_ADMIN_IDS` | Список Telegram ID администраторов через запятую. | `1234567,9876543` | **Да** |
| `TELEGRAM_CACHE_CHAT_ID` | ID приватного канала/чата для хранения файлов. | `-100123456789` | **Да** |
| `YANDEX_MUSIC_TOKEN` | OAuth-токен для доступа к Yandex.Music API. | `y0_...` | **Да** |
| `DATABASE_PATH` | Путь к файлу базы данных SQLite. | `/data/bot.db` | Нет (`/data/bot.db`) |
| `LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warn`, `error`). | `info` | Нет (`info`) |
| `PROCESSOR_WORKERS` | Количество воркеров для обработки треков. | `4` | Нет (`4`) |
| `YANDEX_API_RATE_LIMIT` | Запросов в секунду к Yandex API. | `5` | Нет (`5`) |
_(Будет заполнено позже)_ ## ▶️ Запуск
### С помощью Docker 🐳
Рекомендуемый способ запуска. Убедитесь, что у вас установлен Docker.
1. Создайте директорию для хранения базы данных: `mkdir -p ./data`
2. Запустите контейнер:
```bash
docker run -d --name yamusic-bot \
-v $(pwd)/data:/data \
-e TELEGRAM_BOT_TOKEN="ВАШ_ТОКЕН" \
-e TELEGRAM_ADMIN_IDS="ВАШ_ID,ID_ДРУГА" \
-e TELEGRAM_CACHE_CHAT_ID="-100..." \
-e YANDEX_MUSIC_TOKEN="y0_..." \
--restart always \
gitea.mrixs.me/mrixs/yamusic-bot:dev
```
*Примечание: Используйте тег `:latest` для стабильной версии из ветки `master` или `:dev` для последней сборки из ветки `dev`.*
### С помощью Docker Compose
1. Создайте файл `docker-compose.yml`:
```yaml
version: '3.8'
services:
bot:
image: gitea.mrixs.me/mrixs/yamusic-bot:dev
container_name: yamusic-bot
restart: always
volumes:
- ./data:/data
environment:
- TELEGRAM_BOT_TOKEN=
- TELEGRAM_ADMIN_IDS=
- TELEGRAM_CACHE_CHAT_ID=
- YANDEX_MUSIC_TOKEN=
- LOG_LEVEL=info
```
2. Заполните переменные окружения в файле или создайте рядом `.env` файл.
3. Запустите: `docker-compose up -d`
## 🕹️ Использование
### Для пользователей
Начните вводить в любом чате `@bot_username`, а затем вставьте ссылку или напишите поисковый запрос.
- **Ссылка на трек:** `@bot_username https://music.yandex.ru/album/123/track/456`
- **Ссылка на альбом:** `@bot_username https://music.yandex.ru/album/123`
- **Поиск:** `@bot_username Rammstein - Sonne`
### Для администраторов
Отправьте команду в личные сообщения боту.
- `/help` — Показать список команд.
- `/stats` — Показать статистику работы бота.
- `/find <yandex_track_id>` — Найти трек в кэше по ID.
- `/warm <URL>` — "Прогреть" кэш для альбома или исполнителя.
## 🗺️ План разработки (Dev Roadmap)
| Статус | Задача | Комментарий |
| :----: | -------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| ✅ | Базовый CI/CD пайплайн (lint, test) | Настроен в `.drone.yml`. |
| ✅ | Основная логика обработки URL (трек, альбом, артист) | Реализовано в `internal/bot/handler.go`. |
| ✅ | Система кэширования (SQLite + Telegram) | Реализовано в `internal/storage` и `internal/processor`. |
| ✅ | Базовая структура проекта и конфигурация | Вся структура соответствует ТЗ. |
| ✅ | Административные команды `/help`, `/stats`, `/find` | Основной функционал реализован в `internal/admin/handler.go`. |
| ✅ | Публикация Docker-образов в CI/CD | Шаги `build-and-publish-*` активны в `.drone.yml` для `master` и `dev`. |
| ⏳ | Расширение тестового покрытия | Есть тесты для `storage`, но нужны для `processor`, `bot`, `admin`. |
| ⏳ | Финализация документации | Этот `README.md` является частью задачи. |
| ❌ | Реализация логики команды `/warm` | Существует только заглушка, фоновая обработка не реализована. |
| ❌ | Ограничение частоты запросов (Rate Limiting) к Yandex API | Требуется внедрение `rate.Limiter`. |
| ❌ | Поддержка текстового поиска и коротких URL | Задача из нового ТЗ, требуется реализация в `handler` и `yamusic client`. |
## 📄 Лицензия
Проект распространяется под лицензией MIT. См. файл `LICENSE` для получения дополнительной информации.

69
cmd/bot/main.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"context"
"log/slog"
"time"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/admin"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/bot"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/config"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/processor"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/storage"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/downloader"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/logging"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/tagger"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func main() {
startTime := time.Now()
ctx := context.Background()
// 1. Инициализация конфигурации
cfg := config.New()
// 2. Инициализация логгера
logger := logging.NewLogger(cfg.LogLevel)
slog.SetDefault(logger)
slog.Info("Starting Yandex.Music Downloader Bot...", "version", "1.0")
// 3. Инициализация зависимостей
db, err := storage.NewSQLiteStorage(ctx, cfg.DatabasePath)
if err != nil {
slog.Error("Failed to initialize storage", "error", err)
return
}
yandexClient, err := yamusic.NewApiClient(cfg.YandexMusicToken)
if err != nil {
slog.Error("Failed to initialize yandex client", "error", err)
return
}
tgAPI, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken)
if err != nil {
slog.Error("Failed to initialize telegram bot api", "error", err)
return
}
tgAPI.Debug = cfg.LogLevel == "debug"
slog.Info("Authorized on account", "username", tgAPI.Self.UserName)
// 4. Инициализация компонентов
downloaderComponent := downloader.NewHTTPDownloader()
taggerComponent := tagger.NewID3Tagger()
telegramClient := bot.NewTelegramClientAdapter(tgAPI, cfg.TelegramCacheChatID)
trackProcessor := processor.NewTrackProcessor(db, yandexClient, downloaderComponent, taggerComponent, telegramClient)
adminHandler := admin.NewHandler(db, telegramClient, yandexClient, startTime)
inlineHandler := bot.NewInlineHandler(yandexClient, trackProcessor, telegramClient)
// 5. Создание и запуск приложения
app := bot.NewApp(cfg, tgAPI, db, adminHandler, inlineHandler)
app.Run(ctx)
slog.Info("Bot stopped.")
}

8
example.env Normal file
View File

@@ -0,0 +1,8 @@
TELEGRAM_BOT_TOKEN=aaa:bbb
TELEGRAM_ADMIN_IDS=ccc
TELEGRAM_CACHE_CHAT_ID=ddd
YANDEX_MUSIC_TOKEN=eee
DATABASE_PATH="/data/bot.db"
LOG_LEVEL="info"
PROCESSOR_WORKERS=4
YANDEX_API_RATE_LIMIT=5

10
go.mod
View File

@@ -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
View 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=

206
internal/admin/handler.go Normal file
View File

@@ -0,0 +1,206 @@
package admin
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"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> - \"Прогреть\" кэш для альбома или исполнителя (в разработке)\n" +
"/warm --from-dir <path> - Прогреть кэш из локальной директории внутри контейнера"
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, args string) {
const fromDirPrefix = "--from-dir "
if strings.HasPrefix(args, fromDirPrefix) {
dirPath := strings.TrimPrefix(args, fromDirPrefix)
h.handleWarmFromDir(ctx, chatID, dirPath)
return
}
// Здесь будет логика для прогрева по URL
if err := h.telegram.SendMessage(ctx, chatID, "Прогрев по URL находится в разработке."); err != nil {
slog.Error("Failed to send 'warm in development' message", "error", err, "chat_id", chatID)
}
}
// handleWarmFromDir запускает фоновую задачу прогрева кэша из локальной директории.
func (h *Handler) handleWarmFromDir(ctx context.Context, chatID int64, dirPath string) {
msg := fmt.Sprintf("Принято в обработку. Начинаю прогрев кэша из директории: `%s`", dirPath)
if err := h.telegram.SendMessage(ctx, chatID, msg); err != nil {
slog.Error("Failed to send 'warm from dir started' message", "error", err, "chat_id", chatID)
return
}
go func() {
slog.Info("Starting cache warming from directory", "path", dirPath)
files, err := os.ReadDir(dirPath)
if err != nil {
slog.Error("Failed to read directory for warming", "path", dirPath, "error", err)
errMsg := fmt.Sprintf("Ошибка: не удалось прочитать директорию `%s`. Убедитесь, что она существует и доступна.", dirPath)
_ = h.telegram.SendMessage(context.Background(), chatID, errMsg)
return
}
var addedCount, skippedCount, errorCount int
totalFiles := len(files)
for i, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".mp3") {
continue
}
trackID := strings.TrimSuffix(file.Name(), ".mp3")
fullPath := filepath.Join(dirPath, file.Name())
// 1. Проверяем, есть ли трек в кэше
_, err := h.storage.Get(ctx, trackID)
if err == nil {
slog.Debug("Skipping already cached track", "track_id", trackID)
skippedCount++
continue
}
// 2. Загружаем в Telegram
// Поскольку метатеги уже вшиты, для отображения в кэш-канале можно использовать простые title/performer
slog.Debug("Uploading track to cache channel", "track_id", trackID, "path", fullPath)
fileID, err := h.telegram.SendAudioToCacheChannel(ctx, fullPath, trackID, "Pre-cached")
if err != nil {
slog.Error("Failed to upload pre-cached file", "track_id", trackID, "error", err)
errorCount++
continue
}
// 3. Сохраняем в БД
err = h.storage.Set(ctx, trackID, fileID)
if err != nil {
slog.Error("Failed to save pre-cached file to storage", "track_id", trackID, "error", err)
errorCount++
continue
}
addedCount++
slog.Info("Successfully cached track from local file", "track_id", trackID, "file_id", fileID)
// Опционально: отправляем прогресс каждые N файлов
if (i+1)%1000 == 0 {
progressMsg := fmt.Sprintf("Прогресс: обработано %d из %d файлов...", i+1, totalFiles)
_ = h.telegram.SendMessage(context.Background(), chatID, progressMsg)
}
}
finalMsg := fmt.Sprintf(
"✅ Прогрев кэша из директории `%s` завершен.\n\n"+
"Новых треков добавлено: %d\n"+
"Треков пропущено (уже в кэше): %d\n"+
"Ошибок при обработке: %d",
dirPath, addedCount, skippedCount, errorCount,
)
_ = h.telegram.SendMessage(context.Background(), chatID, finalMsg)
slog.Info("Finished cache warming from directory", "path", dirPath, "added", addedCount, "skipped", skippedCount, "errors", errorCount)
}()
}

77
internal/bot/app.go Normal file
View File

@@ -0,0 +1,77 @@
package bot
import (
"context"
"log/slog"
"os"
"os/signal"
"slices"
"syscall"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/admin"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/config"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// App - главное приложение бота.
type App struct {
cfg *config.Config
api *tgbotapi.BotAPI
storage interfaces.TrackStorage
adminHandler *admin.Handler
inlineHandler *InlineHandler
}
// NewApp создает новый экземпляр приложения.
func NewApp(cfg *config.Config, api *tgbotapi.BotAPI, storage interfaces.TrackStorage, adminHandler *admin.Handler, inlineHandler *InlineHandler) *App {
return &App{
cfg: cfg,
api: api,
storage: storage,
adminHandler: adminHandler,
inlineHandler: inlineHandler,
}
}
// Run запускает основной цикл бота.
func (a *App) Run(ctx context.Context) {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := a.api.GetUpdatesChan(u)
slog.Info("Bot is running and waiting for updates...")
for {
select {
case update := <-updates:
a.handleUpdate(ctx, update)
case <-ctx.Done():
slog.Info("Shutting down...")
a.api.StopReceivingUpdates()
if err := a.storage.Close(); err != nil {
slog.Error("Failed to close storage", "error", err)
}
return
}
}
}
func (a *App) handleUpdate(ctx context.Context, update tgbotapi.Update) {
if update.InlineQuery != nil {
go a.inlineHandler.HandleInlineQuery(ctx, update.InlineQuery)
} else if update.Message != nil && update.Message.IsCommand() {
if a.isAdmin(update.Message.From.ID) {
go a.adminHandler.HandleCommand(ctx, update.Message)
} else {
slog.Warn("Unauthorized command attempt", "user_id", update.Message.From.ID)
}
}
}
func (a *App) isAdmin(userID int64) bool {
return slices.Contains(a.cfg.TelegramAdminIDs, userID)
}

121
internal/bot/handler.go Normal file
View File

@@ -0,0 +1,121 @@
package bot
import (
"context"
"log/slog"
"strconv"
"sync"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/interfaces"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/processor"
"gitea.mrixs.me/Mrixs/yamusic-bot/pkg/yamusic"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// InlineHandler обрабатывает inline-запросы.
type InlineHandler struct {
yandex interfaces.YandexMusicClient
processor *processor.TrackProcessor
telegram interfaces.TelegramClient
}
// NewInlineHandler создает новый обработчик inline-запросов.
func NewInlineHandler(yandex interfaces.YandexMusicClient, processor *processor.TrackProcessor, telegram interfaces.TelegramClient) *InlineHandler {
return &InlineHandler{
yandex: yandex,
processor: processor,
telegram: telegram,
}
}
// HandleInlineQuery обрабатывает входящий inline-запрос.
func (h *InlineHandler) HandleInlineQuery(ctx context.Context, query *tgbotapi.InlineQuery) {
slog.Info("Handling inline query", "user_id", query.From.ID, "query", query.Query)
urlInfo, err := yamusic.ParseYandexURL(query.Query)
if err != nil {
h.answerWithError(ctx, query.ID, "Неверный формат ссылки. Поддерживаются ссылки на треки, альбомы и исполнителей Yandex.Music.")
return
}
var trackInfos []*model.TrackInfo
switch urlInfo.Type {
case "track":
info, err := h.yandex.GetTrackInfo(ctx, urlInfo.TrackID)
if err != nil {
slog.Error("Failed to get track info", "track_id", urlInfo.TrackID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию о треке.")
return
}
trackInfos = append(trackInfos, info)
case "album":
infos, err := h.yandex.GetAlbumTrackInfos(ctx, urlInfo.AlbumID)
if err != nil {
slog.Error("Failed to get album info", "album_id", urlInfo.AlbumID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию об альбоме.")
return
}
trackInfos = infos
case "artist":
infos, err := h.yandex.GetArtistTrackInfos(ctx, urlInfo.ArtistID)
if err != nil {
slog.Error("Failed to get artist info", "artist_id", urlInfo.ArtistID, "error", err)
h.answerWithError(ctx, query.ID, "Не удалось получить информацию об исполнителе.")
return
}
trackInfos = infos
}
if len(trackInfos) == 0 {
h.answerWithError(ctx, query.ID, "По этой ссылке ничего не найдено.")
return
}
h.processAndAnswer(ctx, query.ID, trackInfos)
}
func (h *InlineHandler) processAndAnswer(ctx context.Context, queryID string, trackInfos []*model.TrackInfo) {
var wg sync.WaitGroup
resultsChan := make(chan interface{}, len(trackInfos))
for i, info := range trackInfos {
wg.Add(1)
go func(trackInfo *model.TrackInfo, resultID int) {
defer wg.Done()
fileID, err := h.processor.Process(ctx, trackInfo)
if err != nil {
slog.Error("Failed to process track", "track_id", trackInfo.YandexTrackID, "error", err)
return
}
result := tgbotapi.NewInlineQueryResultAudio(strconv.Itoa(resultID), fileID, trackInfo.Title)
result.Performer = trackInfo.Artist
resultsChan <- result
}(info, i)
}
wg.Wait()
close(resultsChan)
var finalResults []interface{}
for result := range resultsChan {
finalResults = append(finalResults, result)
}
if len(finalResults) == 0 {
slog.Warn("No results to send after processing", "query_id", queryID)
h.answerWithError(ctx, queryID, "Не удалось обработать ни один трек.")
return
}
if err := h.telegram.AnswerInlineQuery(ctx, queryID, finalResults); err != nil {
slog.Error("Failed to send final answer to inline query", "error", err)
}
}
func (h *InlineHandler) answerWithError(ctx context.Context, queryID, message string) {
article := tgbotapi.NewInlineQueryResultArticle(queryID, "Ошибка", message)
if err := h.telegram.AnswerInlineQuery(ctx, queryID, []interface{}{article}); err != nil {
slog.Error("Failed to answer with error", "error", err)
}
}

64
internal/bot/telegram.go Normal file
View File

@@ -0,0 +1,64 @@
package bot
import (
"context"
"fmt"
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// TelegramClientAdapter адаптирует библиотеку tgbotapi под наш интерфейс interfaces.TelegramClient.
type TelegramClientAdapter struct {
api *tgbotapi.BotAPI
cacheChatID int64
}
// NewTelegramClientAdapter создает новый адаптер.
func NewTelegramClientAdapter(api *tgbotapi.BotAPI, cacheChatID int64) *TelegramClientAdapter {
return &TelegramClientAdapter{
api: api,
cacheChatID: cacheChatID,
}
}
// SendAudioToCacheChannel загружает аудиофайл в кэш-канал и возвращает его FileID.
func (t *TelegramClientAdapter) SendAudioToCacheChannel(ctx context.Context, audioPath, title, performer string) (string, error) {
audio := tgbotapi.NewAudio(t.cacheChatID, tgbotapi.FilePath(audioPath))
audio.Title = title
audio.Performer = performer
msg, err := t.api.Send(audio)
if err != nil {
return "", fmt.Errorf("failed to send audio to cache channel: %w", err)
}
if msg.Audio == nil {
return "", fmt.Errorf("sent message does not contain audio")
}
slog.Debug("Audio sent to cache channel", "file_id", msg.Audio.FileID)
return msg.Audio.FileID, nil
}
// AnswerInlineQuery отвечает на inline-запрос.
func (t *TelegramClientAdapter) AnswerInlineQuery(ctx context.Context, queryID string, results []interface{}) error {
inlineConfig := tgbotapi.InlineConfig{
InlineQueryID: queryID,
Results: results,
CacheTime: 1, // Кэшируем результат на стороне Telegram на 1 секунду
}
if _, err := t.api.Request(inlineConfig); err != nil {
return fmt.Errorf("failed to answer inline query: %w", err)
}
return nil
}
// SendMessage отправляет текстовое сообщение.
func (t *TelegramClientAdapter) SendMessage(ctx context.Context, chatID int64, text string) error {
msg := tgbotapi.NewMessage(chatID, text)
if _, err := t.api.Send(msg); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}

56
internal/config/config.go Normal file
View 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...)
}

View 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
View 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
View 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
View 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()
}

View 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)
}
})
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff