commit af098e6ec41749f1f0875c95b159fedd67a035ad Author: roman.nikolsky Date: Sat Jul 4 01:56:10 2026 +0300 add workflow to build diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..c572e56 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,43 @@ +name: Сборка образа +on: + workflow_dispatch: + inputs: + image_tag: + description: "Тег образа (по умолчанию: latest)" + required: false + default: "latest" + type: string + +env: + IMAGE_NAME: ytdlp-navidrome + IMAGE_TAG: ${{ github.event.inputs.image_tag || 'latest' }} + +jobs: + build: + runs-on: ubuntu + steps: + - name: Клонирование репозитория + uses: actions/checkout@v4 + + - name: Установка Docker (если не установлен) + run: | + apt-get update + apt-get install -y docker.io + + - name: Сборка образа + run: | + docker build -t ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} . + + - name: Сохранение образа в архив + run: | + mkdir -p artifacts + docker save ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} | gzip > artifacts/${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}.tar.gz + echo "Размер архива:" + ls -lh artifacts/ + + - name: Загрузка артефакта + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }} + path: artifacts/${{ env.IMAGE_NAME }}-${{ env.IMAGE_TAG }}.tar.gz + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8696c81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tmp +secret.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02cb4c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Stage 1: Build Go binary +FROM golang:1.26-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ytdlp-navidrome . + +# Stage 2: Install yt-dlp and ffmpeg +FROM alpine:latest AS tools + +RUN apk add --no-cache \ + python3 \ + py3-pip \ + ffmpeg \ + ca-certificates + +RUN pip3 install --break-system-packages yt-dlp + +# Stage 3: Final image +FROM alpine:latest + +RUN apk add --no-cache \ + ca-certificates \ + ffmpeg \ + python3 \ + libc6-compat + +# Copy Python + packages from tools stage +COPY --from=tools /usr/lib/python3.12 /usr/lib/python3.12 +COPY --from=tools /usr/bin/yt-dlp /usr/bin/yt-dlp +COPY --from=tools /usr/bin/ffmpeg /usr/bin/ffmpeg +COPY --from=tools /usr/bin/ffprobe /usr/bin/ffprobe + +# Copy Go binary +COPY --from=builder /build/ytdlp-navidrome /usr/local/bin/ytdlp-navidrome + +# Create directories +RUN mkdir -p /music/ytdlp /tmp/ytdlp-previews + +ENTRYPOINT ["/usr/local/bin/ytdlp-navidrome"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3116a7c --- /dev/null +++ b/README.md @@ -0,0 +1,437 @@ +# ytdlp-navidrome — сервис поиска и загрузки музыки + +## Назначение + +Сервис-компаньон к Navidrome. Интерфейс — **Matrix-бот**. Позволяет: +1. Искать музыку по названию трека или имени исполнителя. +2. Отправлять превью (файл аудио) в чат для прослушивания. +3. По подтверждению пользователя скачивать трек в коллекцию Navidrome. + +Весь трафик наружу (yt-dlp и Matrix) проходит через локальный ByeByeDPI-прокси (обход DPI). + +Инфраструктура: k3s на Raspberry Pi. Музыка хранится на SSD: +- **Рабочая директория** (превью): `/mnt/ssd/k3s/services/ytdlp_navidrome/tmp` +- **Коллекция Navidrome** (финальный файл): `/mnt/ssd/k3s/services/navidrome/music/ytdlp` + +--- + +## 1. Архитектура + +``` +┌────────────┐ ┌────────────────────────────────┐ +│ Matrix │ Matrix CS API │ ytdlp-navidrome pod │ +│ homeserver │◄──────────────────►│ │ +│ │ │ ┌────────────┐ ┌───────────┐ │ +└────────────┘ │ │ Go-сервис │ │ ByeByeDPI │ │ + │ │ (Matrix │ │ (sidecar │ │ + │ │ бот + │ │ SOCKS5 │ │ + │ │ yt-dlp) │ │ прокси) │ │ + │ └─────┬──────┘ └─────▲─────┘ │ + │ │ │ :1080 │ + │ │ --proxy │ │ + │ └──────────────┘ │ + └───────────────┬────────────────┘ + │ + ┌──────────────────────────┼──────────────┐ + │ /mnt/ssd (hostPath) │ │ + │ ▼ │ + │ ytdlp_navidrome/tmp/ navidrome/music/ytdlp/ + │ (превью, очищается) (финальные файлы) + └─────────────────────────────────────────┘ +``` + +### Стек + +| Компонент | Технология | Обоснование | +|-----------|-----------|-------------| +| Язык | Go | Статический бинарник, минимум зависимостей для ARM. | +| Фреймворк | Echo GO | Простой, быстрый | +| Matrix SDK | [mautrix-go](https://github.com/mautrix/go) | Де-факто стандартный Go SDK для Matrix. | +| Поиск | yt-dlp `--flat-playlist --print` | Только метаданные, без скачивания. | +| Скачивание | yt-dlp + ffmpeg | Поддержка всех музыкальных источников. | +| Прокси | ByeByeDPI (sidecar) | Обход DPI без внешнего VPN. Локальный SOCKS5. | +| Хранение | hostPath | Общие директории с Navidrome и для превью. | + +### Контейнеры в поде + +| Контейнер | Образ | Порты | Назначение | +|-----------|-------|-------|------------| +| `ytdlp-bot` | Собственный (Go + yt-dlp + ffmpeg) | — | Основной сервис | +| `byedpi` | `ghcr.io/hufrea/byedpi:latest` (или собственный) | `1080` (SOCKS5) | Обход DPI, локальный прокси | + +--- + +## 2. Функциональные требования + +### 2.1 Диалог с пользователем (Matrix-бот) + +Пользователь общается с ботом в личном или групповом чате Matrix. + +``` +Пользователь: /search Radiohead Creep +Бот: Найдено 5 результатов: + 1. Radiohead - Creep (3:58) + 2. Radiohead - Creep [Live] (4:21) + 3. Radiohead - Creep [Acoustic] (3:45) + ... + Ответьте номером для превью. + +Пользователь: 1 +Бот: [прикреплённый аудиофайл] + Добавить в коллекцию Navidrome? (да/нет) + +Пользователь: да +Бот: ✅ Добавлено: Radiohead - Creep.opus +``` + +#### Команды бота + +| Команда | Описание | +|---------|----------| +| `/search <запрос>` | Искать музыку. Возвращает список результатов с номерами. | +| `<номер>` | Отправить аудиопревью трека из результатов поиска. | +| `да` / `yes` / `добавить` | Сохранить трек в коллекцию Navidrome. | +| `нет` / `no` / `отмена` | Отменить. Удалить превью. | +| `/status` | Показать статус: место на диске, время работы, версия. | +| `/proxy` | Показать текущие настройки прокси и статус ByeByeDPI. | +| `/help` | Справка. | + +#### Состояние диалога + +Бот хранит состояние per-user (в памяти, не персистентное в v1): + +| Состояние | Данные | +|-----------|--------| +| `idle` | — | +| `search_results` | Запрос, список результатов, timestamp | +| `preview_sent` | video_id, путь к превью-файлу, timestamp | + +Таймаут состояния: 10 минут. После таймаута превью удаляется из tmp/. + +### 2.2 Поиск (внутренний) + +1. Бот формирует запрос к yt-dlp: + ``` + yt-dlp "ytsearch{limit}:{query}" \ + --flat-playlist --print "%(id)s\t%(title)s\t%(duration_string)s\t%(channel)s" \ + --proxy socks5://byedpi:1080 + ``` +2. Парсит вывод, возвращает пронумерованный список. +3. Таймаут: `SEARCH_TIMEOUT` (по умолчанию `10s`). + +### 2.3 Превью аудио (внутренний) + +1. Указанный трек скачивается во временную директорию: + ``` + yt-dlp "https://www.youtube.com/watch?v={id}" \ + -x --audio-format opus --audio-quality 5 \ + --output "{tmp_dir}/{id}.%(ext)s" \ + --proxy socks5://byedpi:1080 + ``` + Качество `5` (среднее) — превью, сэкономить трафик и место. +2. Бот отправляет файл в Matrix-чат как `m.audio`. +3. Файл сохраняется в `tmp_dir` до решения пользователя (добавить / отменить). + +### 2.4 Добавление в коллекцию (внутренний) + +1. Окончательное скачивание (или конвертация превью) в коллекцию: + ``` + yt-dlp "https://www.youtube.com/watch?v={id}" \ + -x --audio-format opus --audio-quality 0 \ + --embed-thumbnail --add-metadata \ + --output "{music_dir}/{artist} - {title}.%(ext)s" \ + --proxy socks5://byedpi:1080 + ``` + Качество `0` (лучшее) — финальный файл для Navidrome. +2. Файл переименовывается в формат ` - .<ext>`. +3. Превью из tmp/ удаляется. +4. Navidrome обнаружит файл при следующем сканировании (раз в 6ч). + +### 2.5 Health check + +**Эндпоинт:** `GET /healthz` (для Kubernetes liveness/readiness probes) + +**Ответ:** `200 OK` `{"status": "ok"}` — если: +- Go-сервис запущен +- yt-dlp доступен +- ByeByeDPI sidecar отвечает (проверка через socks5://byedpi:1080) + +--- + +## 3. Конфигурация + +### 3.1 Переменные окружения + +#### Matrix + +| Переменная | Обязательная | По умолчанию | Описание | +|------------|:------------:|--------------|----------| +| `MATRIX_HOMESERVER` | да | — | URL homeserver (например `https://matrix.example.com`) | +| `MATRIX_USER_ID` | да | — | `@bot:example.com` — ID бота | +| `MATRIX_ACCESS_TOKEN` | да | — | Access token бота | +| `MATRIX_DEVICE_ID` | нет | `YTDLBOT` | Device ID | +| `MATRIX_ENCRYPTION` | нет | `false` | Использовать E2EE (требует libolm/curve25519-dalek) | + +#### Поиск и скачивание + +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `SEARCH_LIMIT` | `10` | Максимум результатов поиска | +| `SEARCH_TIMEOUT` | `10s` | Таймаут на yt-dlp search | +| `DOWNLOAD_TIMEOUT` | `120s` | Таймаут на скачивание | +| `AUDIO_FORMAT` | `opus` | Целевой формат аудио | +| `AUDIO_QUALITY_PREVIEW` | `5` | Качество превью (0=лучшее, 10=худшее) | +| `AUDIO_QUALITY_FINAL` | `0` | Качество финального файла | +| `PREVIEW_TTL` | `10m` | Время жизни превью-файла до автоудаления | +| `ALLOWED_USERS` | — | Список Matrix ID через запятую (пустой = все) | + +#### Прокси (пробрасывается в yt-dlp через `--proxy`) + +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `PROXY_ENABLED` | `true` | Включить прокси для yt-dlp. `false` = без прокси. | +| `PROXY_URL` | `socks5://localhost:1080` | URL прокси (SOCKS5 / HTTP). Если запущен sidecar — `socks5://byedpi:1080` | + +#### ByeByeDPI (sidecar-контейнер) + +Параметры ByeByeDPI передаются через `COMMAND` в Deployment (см. §4.2). + +| Флаг | По умолчанию (рекомендация) | Описание | +|------|---------------------------|----------| +| `--ip` | `0.0.0.0` | Адрес для прослушивания | +| `--port` | `1080` | Порт SOCKS5 | +| `--disorder` | `1-1` | Fake packet для браузеров/доменов | +| `--auto` | *(включён)* | Автоматический обход DPI | +| `--ttl` | `3` | TTL для fake-пакетов | + +Все флаги настраиваются в Deployment-манифесте. Если нужен другой обходчик DPI (naiveproxy, xray, sing-box), +поменяйте image и `PROXY_URL`. + +#### Пути + +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `MUSIC_DIR` | `/music` | mountPath к коллекции Navidrome | +| `MUSIC_SUBDIR` | `ytdlp` | Поддиректория для финальных файлов | +| `TMP_DIR` | `/tmp/ytdlp-previews` | Рабочая директория для превью | +| `YTDLP_PATH` | `yt-dlp` | Путь к бинарнику | + +### 3.2 Kubernetes-манифесты + +Каталог: `rasp/k3s/services/ytdlp_navidrome/` + +``` +├── README.md # ← этот документ +├── main.go # исходный код сервиса +├── Dockerfile # мультистейдж: Go build + yt-dlp + ffmpeg +├── service.yaml # Namespace + Deployment + PersistentVolumes +└── go.mod +``` + +#### Deployment — ключевые моменты + +```yaml +spec: + containers: + - name: ytdlp-bot + image: ytdlp-navidrome:latest # Собственный образ + env: + - name: MATRIX_HOMESERVER + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: homeserver + - name: MATRIX_USER_ID + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: user-id + - name: MATRIX_ACCESS_TOKEN # секрет + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: access-token + - name: PROXY_URL + value: "socks5://localhost:1080" # sidecar + - name: MUSIC_DIR + value: "/music" + volumeMounts: + - name: music + mountPath: /music/ytdlp # коллекция (Read-Write) + - name: tmp + mountPath: /tmp/ytdlp-previews + resources: + requests: { memory: "64Mi", cpu: "50m" } + limits: { memory: "128Mi", cpu: "500m" } + + - name: byedpi # ◀ sidecar + image: ghcr.io/hufrea/byedpi:latest + command: ["ciadpi"] + args: + - "--ip=0.0.0.0" + - "--port=1080" + - "--auto" + - "--disorder=1-1" + - "--ttl=3" + ports: + - containerPort: 1080 + name: sock5 + resources: + requests: { memory: "16Mi", cpu: "10m" } + limits: { memory: "32Mi", cpu: "100m" } + # network=host не нужен — sidecar общается с основным + # контейнером через localhost (shared network namespace в поде) + + volumes: + - name: music + hostPath: + path: /mnt/ssd/k3s/services/navidrome/music + type: DirectoryOrCreate + - name: tmp + emptyDir: { medium: Memory, sizeLimit: 512Mi } + # Превью хранятся в tmpfs — не нагружают SSD, + # ограничены 512 MiB для безопасности +``` + +**Service:** не нужен для бота (исходящие подключения к Matrix homeserver). +Если нужен health endpoint для мониторинга — ClusterIP на порт 8080. + +**Secret:** +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: ytdlp-matrix-secret + namespace: ytdlp-navidrome +type: Opaque +stringData: + homeserver: "https://matrix.example.com" + user-id: "@bot:example.com" + access-token: "syt_..." # ← ваш токен бота +``` + +--- + +## 4. Нефункциональные требования + +### 4.1 Формат аудио + +| Параметр | Превью | Финальный файл | +|----------|--------|----------------| +| Кодек | Opus | Opus | +| Качество | `5` (среднее) | `0` (лучшее) | +| Обложка | нет | `--embed-thumbnail` | +| Теги | минимальные | `--add-metadata` (title, artist, album) | +| Пример размера | ~1–2 МБ на трек | ~3–5 МБ на трек | + +Альтернатива: MP3 V0, если клиенты не поддерживают Opus. + +### 4.2 Именование файлов + +Директория на хосте: `/mnt/ssd/k3s/services/navidrome/music/ytdlp/` + +Формат имени: `<artist> - <title>.<ext>` + +Примеры: +- `Radiohead - Creep.opus` +- `Massive Attack - Teardrop.opus` +- `unknown - dQw4w9WgXcQ.opus` (метаданные неизвестны) + +Запрещённые символы в имени файла (`/ \ : * ? " < > |`) заменяются на `_`. + +### 4.3 Производительность + +| Метрика | Целевое значение | +|---------|-----------------| +| Поиск (10 результатов) | ≤ 5 секунд | +| Превью (1 трек) | ≤ 30 секунд | +| Финальное скачивание (1 трек) | ≤ 60 секунд | +| Потребление RAM (простой) | ≤ 64 MiB | +| Потребление RAM ( ByeByeDPI ) | ≤ 16 MiB | +| Потребление CPU (простой) | ≤ 50m + 10m (bot + proxy) | + +### 4.4 Доступ из Matrix + +- Бот запускается как зарегистрированный пользователь homeserver. +- Бот слушает события `m.room.message` и `/search`, реагирует на `m.text`. +- Бот может быть ограничен `ALLOWED_USERS` — список Matrix ID через запятую (если пусто — отвечает всем). +- В v1 без E2EE. E2EE поддерживается mautrix-go (опциональное включение через `MATRIX_ENCRYPTION=true`). + +### 4.5 Безопасность + +| Вопрос | Решение | +|--------|---------| +| Токен бота в секрете | `ytdlp-matrix-secret` — не в env-файле кластера | +| Доступ к боту | Опционально `ALLOWED_USERS` | +| Прокси — только для yt-dlp | ByeByeDPI слушает на localhost, не проброшен наружу | +| Запись в коллекцию | runAsUser совпадает с Navidrome (совместимость прав) | + +### 4.6 Ограничения (scope v1) + +| Feature | Статус в v1 | Примечание | +|---------|:-----------:|-----------| +| Matrix-бот | ✅ входит | Основной интерфейс | +| E2EE | ⚠️ опционально | По флагу `MATRIX_ENCRYPTION` | +| REST API | ❌ не входит | Только Matrix. REST — при необходимости позже. | +| Web UI | ❌ не входит | — | +| Групповые чаты | ⚠️ basic | Бот отвечает на mention в группах | +| Очередь загрузок | ❌ не входит | Синхронная загрузка | +| SoundCloud/Bandcamp | ⚠️ best-effort | yt-dlp поддерживает, поиск может не работать | +| Изменение прокси на лету | ❌ не входит | Требуется перезапуск пода | + +--- + +## 5. Логирование + +Формат: текстовый, в stdout. + +``` +2026-07-02T14:30:00Z INFO search user=@alice:example.com query="Radiohead Creep" results=10 dur=2.1s +2026-07-02T14:30:05Z INFO preview user=@alice:example.com video_id=XF2YMYBLnSw size=1.2MB dur=18s +2026-07-02T14:30:20Z INFO download user=@alice:example.com file="Radiohead - Creep.opus" size=3.7MB dur=12s +2026-07-02T14:30:20Z INFO cleanup preview=XF2YMYBLnSw removed=true +2026-07-02T14:31:00Z WARN preview_ttl_expired video_id=dQw4w9WgXcQ +2026-07-02T14:32:00Z INFO byedpi_check status=ok latency=3ms +``` + +Уровень логирования: настраивается через `LOG_LEVEL`. + +--- + +## 6. Тестирование + +| Уровень | Что проверять | +|---------|--------------| +| Unit | Парсинг вывода yt-dlp, формирование имён файлов, санитизация, FSM состояний | +| Integration | Запуск yt-dlp в subprocess с `/dev/null` прокси, проверка формата вывода | +| Matrix | Взаимодействие с тестовым homeserver (Dendrite/Conduit) | +| Manual | Полный сценарий: /search → номер → превью → да → файл в navidrome | + +--- + +## 7. Стадии реализации + +### Stage 1 — MVP +- [ ] Matrix-бот: подключение через mautrix-go +- [ ] Команда `/search` — парсинг вывода yt-dlp, отправка результатов +- [ ] Превью: скачивание трека в tmp/, отправка аудио в чат +- [ ] Добавление: финальное скачивание в коллекцию, очистка tmp +- [ ] ByeByeDPI sidecar в Deployment +- [ ] `--proxy` везде где yt-dlp обращается наружу +- [ ] Dockerfile (Go + yt-dlp + ffmpeg) +- [ ] service.yaml (Deployment + Secret) +- [ ] Smoke-тест: /search → preview → да → файл появился в файловой системе + +### Stage 2 — Улучшения +- [ ] Авторизация через ALLOWED_USERS +- [ ] E2EE (опционально) +- [ ] Автоочистка tmp/ по расписанию (cronJob или timer в Go) +- [ ] `/status` и `/proxy` команды +- [ ] Обработка ошибок: не найдено, таймаут превью, нет места +- [ ] Батч-загрузка (серия треков по команде) + +### Stage 3 — Интеграция +- [ ] Принудительное сканирование Navidrome после загрузки (Navidrome API) +- [ ] Метрики Prometheus +- [ ] Адаптация прокси под разные обходчики (naiveproxy, xray, sing-box) +- [ ] REST-эндпоинты для альтернативного доступа (опционально) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61123f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/user/ytdlp-navidrome + +go 1.26 + +require ( + github.com/labstack/echo/v4 v4.12.0 + maunium.net/go/mautrix v0.18.0 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.32.0 // indirect + github.com/tidwall/gjson v1.17.1 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.mau.fi/util v0.4.1 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..451e135 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.mau.fi/util v0.4.1 h1:3EC9KxIXo5+h869zDGf5OOZklRd/FjeVnimTwtm3owg= +go.mau.fi/util v0.4.1/go.mod h1:GjkTEBsehYZbSh2LlE6cWEn+6ZIZTGrTMM/5DMNlmFY= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f h1:3CW0unweImhOzd5FmYuRsD4Y4oQFKZIjAnKbjV4WIrw= +golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.18.0 h1:sNsApeSWB8x0hLjGcdmi5JqO6Tvp2PVkiSStz+Yas6k= +maunium.net/go/mautrix v0.18.0/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..330a670 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,379 @@ +package bot + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/user/ytdlp-navidrome/internal/config" + "github.com/user/ytdlp-navidrome/internal/preview" + "github.com/user/ytdlp-navidrome/internal/ytdlp" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Bot represents the Matrix bot. +type Bot struct { + cfg *config.Config + client *mautrix.Client + sessionMgr *preview.Manager + startTime time.Time +} + +// New creates a new Bot. +func New(cfg *config.Config) *Bot { + return &Bot{ + cfg: cfg, + sessionMgr: preview.NewManager(cfg), + startTime: time.Now(), + } +} + +// Start connects to Matrix and starts the event listener. +func (b *Bot) Start(ctx context.Context) error { + client, err := mautrix.NewClient(b.cfg.MatrixHomeserver, id.UserID(b.cfg.MatrixUserID), b.cfg.MatrixAccessToken) + if err != nil { + return fmt.Errorf("failed to create Matrix client: %w", err) + } + b.client = client + + // Set device ID + if b.cfg.MatrixDeviceID != "" { + client.DeviceID = id.DeviceID(b.cfg.MatrixDeviceID) + } + + // Ensure tmp dir exists + if err := os.MkdirAll(b.cfg.TmpDir, 0755); err != nil { + return fmt.Errorf("failed to create tmp dir %s: %w", b.cfg.TmpDir, err) + } + + // Ensure music collection dir exists + if err := os.MkdirAll(b.cfg.MusicCollectionDir(), 0755); err != nil { + return fmt.Errorf("failed to create music dir %s: %w", b.cfg.MusicCollectionDir(), err) + } + + log.Printf("Connected as %s to %s", b.cfg.MatrixUserID, b.cfg.MatrixHomeserver) + + // Start preview cleanup in background + go b.sessionMgr.CleanupOldPreviews(ctx) + + // Start syncing + syncer := client.Syncer.(*mautrix.DefaultSyncer) + syncer.OnEventType(event.EventMessage, b.handleMessage) + syncer.OnEventType(event.EventEncrypted, b.handleEncrypted) + + return client.Sync() +} + +func (b *Bot) isAllowed(userID string) bool { + if len(b.cfg.AllowedUsers) == 0 { + return true + } + for _, allowed := range b.cfg.AllowedUsers { + if allowed == userID { + return true + } + } + return false +} + +func (b *Bot) handleMessage(ctx context.Context, evt *event.Event) { + if evt.Sender == "" { + return + } + + sender := evt.Sender.String() + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok || content == nil { + return + } + + // Ignore messages from self + if sender == b.cfg.MatrixUserID { + return + } + + // Check access + if !b.isAllowed(sender) { + return + } + + // In group chats, only respond to messages that mention the bot + // If we're in a DM, respond always. + isDM := b.isDirectMessage(ctx, evt.RoomID) + if !isDM { + // Group chat — check if bot is mentioned or reply to bot's message + if !b.isMentioned(content) { + return + } + } + + body := strings.TrimSpace(content.Body) + b.processMessage(ctx, evt.RoomID, sender, body) +} + +func (b *Bot) handleEncrypted(ctx context.Context, evt *event.Event) { + // E2EE not supported in MVP + log.Printf("Received encrypted event from %s, ignoring (E2EE not enabled)", evt.Sender) +} + +func (b *Bot) isDirectMessage(ctx context.Context, roomID id.RoomID) bool { + // Fetch room members — if only 2, it's a DM + members, err := b.client.JoinedMembers(ctx, roomID) + if err != nil { + // If we can't fetch, assume DM to be safe + return true + } + return len(members.Joined) <= 2 +} + +func (b *Bot) isMentioned(content *event.MessageEventContent) bool { + // Check for @bot:example.com mention in body + mention := "@" + strings.SplitN(b.cfg.MatrixUserID, ":", 2)[0] + return strings.Contains(content.Body, mention) +} + +func (b *Bot) processMessage(ctx context.Context, roomID id.RoomID, sender, body string) { + session := b.sessionMgr.GetSession(sender) + + switch { + case strings.HasPrefix(body, "/search "): + b.handleSearch(ctx, roomID, sender, strings.TrimPrefix(body, "/search ")) + case body == "/help": + b.handleHelp(ctx, roomID) + case body == "/status": + b.handleStatus(ctx, roomID) + case body == "/proxy": + b.handleProxy(ctx, roomID) + case session != nil && session.State == preview.StateSearchResults: + b.handleResultSelection(ctx, roomID, sender, body, session) + case session != nil && session.State == preview.StatePreviewSent: + b.handleConfirmDecision(ctx, roomID, sender, body, session) + default: + b.sendText(ctx, roomID, "Неизвестная команда. Напишите /help для справки.") + } +} + +func (b *Bot) handleSearch(ctx context.Context, roomID id.RoomID, sender, query string) { + log.Printf("search user=%s query=%q", sender, query) + + proxy := "" + if b.cfg.ProxyEnabled { + proxy = b.cfg.ProxyURL + } + + results, err := ytdlp.RunSearch( + ctx, + b.cfg.YtdlpPath, + query, + b.cfg.SearchLimit, + proxy, + int(b.cfg.SearchTimeout.Seconds()), + ) + if err != nil { + log.Printf("search error user=%s query=%q err=%v", sender, query, err) + b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка поиска: %v", err)) + return + } + + if len(results) == 0 { + b.sendText(ctx, roomID, "😕 Ничего не найдено по запросу: "+query) + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🔍 Найдено %d результатов:\n\n", len(results))) + for i, r := range results { + duration := r.Duration + if duration == "" || duration == "0:00" { + duration = "?" + } + sb.WriteString(fmt.Sprintf("%d. %s — %s [%s]\n", i+1, r.Channel, r.Title, duration)) + } + sb.WriteString(fmt.Sprintf("\nОтветьте номером (1-%d) для прослушивания превью.", len(results))) + + b.sessionMgr.SetSearchResults(sender, query, results) + b.sendText(ctx, roomID, sb.String()) +} + +func (b *Bot) handleResultSelection(ctx context.Context, roomID id.RoomID, sender, body string, session *preview.UserSession) { + idx, err := strconv.Atoi(strings.TrimSpace(body)) + if err != nil || idx < 1 || idx > len(session.Results) { + b.sendText(ctx, roomID, fmt.Sprintf("Пожалуйста, введите номер от 1 до %d.", len(session.Results))) + return + } + + result := session.Results[idx-1] + log.Printf("preview user=%s video_id=%s title=%q", sender, result.ID, result.Title) + + proxy := "" + if b.cfg.ProxyEnabled { + proxy = b.cfg.ProxyURL + } + + b.sendText(ctx, roomID, fmt.Sprintf("⏳ Скачиваю превью: %s — %s...", result.Channel, result.Title)) + + previewPath, err := ytdlp.DownloadPreview( + ctx, + b.cfg.YtdlpPath, + result.ID, + b.cfg.TmpDir, + b.cfg.AudioFormat, + b.cfg.AudioQualityPreview, + int(b.cfg.DownloadTimeout.Seconds()), + proxy, + ) + if err != nil { + log.Printf("preview error user=%s video_id=%s err=%v", sender, result.ID, err) + b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка скачивания превью: %v", err)) + return + } + + // Upload and send as m.audio + fileInfo, err := os.Stat(previewPath) + if err != nil { + log.Printf("preview stat error: %v", err) + b.sendText(ctx, roomID, "❌ Ошибка чтения превью-файла.") + return + } + + // Read the file for upload + fileBytes, err := os.ReadFile(previewPath) + if err != nil { + log.Printf("preview read error: %v", err) + b.sendText(ctx, roomID, "❌ Ошибка чтения файла превью.") + return + } + + uploadResp, err := b.client.UploadMedia(ctx, mautrix.ReqUploadMedia{ + ContentBytes: fileBytes, + ContentType: "audio/opus", + }) + if err != nil { + log.Printf("upload error: %v", err) + b.sendText(ctx, roomID, "❌ Ошибка загрузки аудио в Matrix.") + return + } + + // Send as m.audio message + filename := filepath.Base(previewPath) + _, err = b.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgAudio, + Body: filename, + URL: uploadResp.ContentURI.CUString(), + Info: &event.FileInfo{ + MimeType: "audio/opus", + Size: int(fileInfo.Size()), + }, + }) + if err != nil { + log.Printf("send audio error: %v", err) + b.sendText(ctx, roomID, "❌ Ошибка отправки аудио в чат.") + return + } + + b.sessionMgr.SetPreviewSent(sender, result.ID, previewPath) + b.sendText(ctx, roomID, "Добавить в коллекцию Navidrome? (да/нет)") +} + +func (b *Bot) handleConfirmDecision(ctx context.Context, roomID id.RoomID, sender, body string, session *preview.UserSession) { + body = strings.TrimSpace(strings.ToLower(body)) + + switch { + case body == "да", body == "yes", body == "добавить": + b.handleConfirmDownload(ctx, roomID, sender, session) + case body == "нет", body == "no", body == "отмена": + b.handleCancelPreview(ctx, roomID, sender, session) + default: + b.sendText(ctx, roomID, "Пожалуйста, ответьте \"да\" или \"нет\".") + } +} + +func (b *Bot) handleConfirmDownload(ctx context.Context, roomID id.RoomID, sender string, session *preview.UserSession) { + log.Printf("download user=%s video_id=%s", sender, session.VideoID) + + proxy := "" + if b.cfg.ProxyEnabled { + proxy = b.cfg.ProxyURL + } + + b.sendText(ctx, roomID, "⏳ Скачиваю трек в коллекцию...") + + _, err := ytdlp.DownloadFinal( + ctx, + b.cfg.YtdlpPath, + session.VideoID, + b.cfg.MusicCollectionDir(), + b.cfg.AudioFormat, + b.cfg.AudioQualityFinal, + int(b.cfg.DownloadTimeout.Seconds()), + proxy, + ) + if err != nil { + log.Printf("download error user=%s video_id=%s err=%v", sender, session.VideoID, err) + b.sendText(ctx, roomID, fmt.Sprintf("❌ Ошибка скачивания: %v", err)) + return + } + + // Clean up preview + if session.PreviewPath != "" { + os.Remove(session.PreviewPath) + } + + b.sessionMgr.ResetSession(sender) + // The actual file was downloaded by yt-dlp with the template pattern + b.sendText(ctx, roomID, "✅ Трек добавлен в коллекцию Navidrome.") +} + +func (b *Bot) handleCancelPreview(ctx context.Context, roomID id.RoomID, sender string, session *preview.UserSession) { + if session.PreviewPath != "" { + os.Remove(session.PreviewPath) + } + b.sessionMgr.ResetSession(sender) + b.sendText(ctx, roomID, "❌ Отменено.") +} + +func (b *Bot) handleHelp(ctx context.Context, roomID id.RoomID) { + help := `🎵 ytdlp-navidrome бот + +Доступные команды: + +/search <запрос> — поиск музыки +<номер> — выбрать результат для превью +да/нет — подтвердить или отменить добавление +/status — статус сервиса +/proxy — статус прокси +/help — эта справка` + b.sendText(ctx, roomID, help) +} + +func (b *Bot) handleStatus(ctx context.Context, roomID id.RoomID) { + uptime := time.Since(b.startTime).Round(time.Second) + msg := fmt.Sprintf("Статус:\nВремя работы: %s\nВерсия: MVP", uptime) + b.sendText(ctx, roomID, msg) +} + +func (b *Bot) handleProxy(ctx context.Context, roomID id.RoomID) { + status := "отключен" + if b.cfg.ProxyEnabled { + status = "включен" + } + msg := fmt.Sprintf("Прокси:\nСтатус: %s\nURL: %s", status, b.cfg.ProxyURL) + b.sendText(ctx, roomID, msg) +} + +func (b *Bot) sendText(ctx context.Context, roomID id.RoomID, text string) { + _, err := b.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: text, + }) + if err != nil { + log.Printf("send text error room=%s err=%v", roomID, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..00d03a6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,133 @@ +package config + +import ( + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + // Matrix + MatrixHomeserver string + MatrixUserID string + MatrixAccessToken string + MatrixDeviceID string + MatrixEncryption bool + + // Search & Download + SearchLimit int + SearchTimeout time.Duration + DownloadTimeout time.Duration + AudioFormat string + AudioQualityPreview int + AudioQualityFinal int + PreviewTTL time.Duration + AllowedUsers []string + + // Proxy + ProxyEnabled bool + ProxyURL string + + // Paths + MusicDir string + MusicSubdir string + TmpDir string + YtdlpPath string + + // Logging + LogLevel string + + // HTTP + ListenAddr string +} + +func Load() *Config { + return &Config{ + MatrixHomeserver: getEnv("MATRIX_HOMESERVER", ""), + MatrixUserID: getEnv("MATRIX_USER_ID", ""), + MatrixAccessToken: getEnv("MATRIX_ACCESS_TOKEN", ""), + MatrixDeviceID: getEnv("MATRIX_DEVICE_ID", "YTDLBOT"), + MatrixEncryption: getEnvBool("MATRIX_ENCRYPTION", false), + + SearchLimit: getEnvInt("SEARCH_LIMIT", 10), + SearchTimeout: getEnvDuration("SEARCH_TIMEOUT", 10*time.Second), + DownloadTimeout: getEnvDuration("DOWNLOAD_TIMEOUT", 120*time.Second), + AudioFormat: getEnv("AUDIO_FORMAT", "opus"), + AudioQualityPreview: getEnvInt("AUDIO_QUALITY_PREVIEW", 5), + AudioQualityFinal: getEnvInt("AUDIO_QUALITY_FINAL", 0), + PreviewTTL: getEnvDuration("PREVIEW_TTL", 10*time.Minute), + AllowedUsers: getEnvSlice("ALLOWED_USERS", nil), + + ProxyEnabled: getEnvBool("PROXY_ENABLED", true), + ProxyURL: getEnv("PROXY_URL", "socks5://localhost:1080"), + + MusicDir: getEnv("MUSIC_DIR", "/music"), + MusicSubdir: getEnv("MUSIC_SUBDIR", "ytdlp"), + TmpDir: getEnv("TMP_DIR", "/tmp/ytdlp-previews"), + YtdlpPath: getEnv("YTDLP_PATH", "yt-dlp"), + + LogLevel: getEnv("LOG_LEVEL", "INFO"), + ListenAddr: getEnv("LISTEN_ADDR", ":8080"), + } +} + +func (c *Config) MusicCollectionDir() string { + return strings.TrimRight(c.MusicDir, "/") + "/" + c.MusicSubdir +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func getEnvBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + switch strings.ToLower(v) { + case "true", "1", "yes": + return true + default: + return false + } +} + +func getEnvDuration(key string, fallback time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return fallback + } + d, err := time.ParseDuration(v) + if err != nil { + return fallback + } + return d +} + +func getEnvSlice(key string, fallback []string) []string { + v := os.Getenv(key) + if v == "" { + return fallback + } + parts := strings.Split(v, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} diff --git a/internal/health/health.go b/internal/health/health.go new file mode 100644 index 0000000..7916519 --- /dev/null +++ b/internal/health/health.go @@ -0,0 +1,74 @@ +package health + +import ( + "net" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +type Checker struct { + YtdlpPath string + ProxyURL string +} + +type HealthResponse struct { + Status string `json:"status"` + Ytdlp string `json:"ytdlp"` + Byedpi string `json:"byedpi"` + Uptime string `json:"uptime"` + StartTime time.Time `json:"-"` +} + +func NewChecker(ytdlpPath, proxyURL string) *Checker { + return &Checker{ + YtdlpPath: ytdlpPath, + ProxyURL: proxyURL, + } +} + +func (c *Checker) Handler(startTime time.Time) echo.HandlerFunc { + return func(ctx echo.Context) error { + resp := HealthResponse{ + Status: "ok", + StartTime: startTime, + Uptime: time.Since(startTime).Round(time.Second).String(), + } + + // Check yt-dlp + if err := exec.Command(c.YtdlpPath, "--version").Run(); err != nil { + resp.Status = "degraded" + resp.Ytdlp = "unavailable" + } else { + resp.Ytdlp = "ok" + } + + // Check proxy (ByeByeDPI) — we check if we can resolve the host + // A real check would connect via SOCKS5, but for MVP TCP check is enough + if strings.Contains(c.ProxyURL, "localhost:1080") || strings.Contains(c.ProxyURL, "byedpi:1080") { + // Quick TCP dial check + proxyHost := "localhost:1080" + if strings.Contains(c.ProxyURL, "byedpi") { + proxyHost = "byedpi:1080" + } + conn, err := net.DialTimeout("tcp", proxyHost, 2*time.Second) + if err != nil { + resp.Status = "degraded" + resp.Byedpi = "unreachable" + } else { + conn.Close() + resp.Byedpi = "ok" + } + } else { + resp.Byedpi = "disabled" + } + + if resp.Status != "ok" { + return ctx.JSON(http.StatusServiceUnavailable, resp) + } + return ctx.JSON(http.StatusOK, resp) + } +} diff --git a/internal/preview/preview.go b/internal/preview/preview.go new file mode 100644 index 0000000..7a4033f --- /dev/null +++ b/internal/preview/preview.go @@ -0,0 +1,142 @@ +package preview + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/user/ytdlp-navidrome/internal/config" + "github.com/user/ytdlp-navidrome/internal/ytdlp" +) + +// State represents the per-user dialog state. +type State int + +const ( + StateIdle State = iota + StateSearchResults + StatePreviewSent +) + +// UserSession holds the current dialog state for a Matrix user. +type UserSession struct { + State State + Query string + Results []ytdlp.SearchResult + VideoID string + PreviewPath string + Timestamp time.Time +} + +// Manager handles preview lifecycle and user sessions. +type Manager struct { + mu sync.RWMutex + sessions map[string]*UserSession + cfg *config.Config +} + +func NewManager(cfg *config.Config) *Manager { + return &Manager{ + sessions: make(map[string]*UserSession), + cfg: cfg, + } +} + +// GetSession returns the current session for a user. +func (m *Manager) GetSession(userID string) *UserSession { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sessions[userID] +} + +// SetSearchResults stores search results for a user. +func (m *Manager) SetSearchResults(userID, query string, results []ytdlp.SearchResult) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[userID] = &UserSession{ + State: StateSearchResults, + Query: query, + Results: results, + Timestamp: time.Now(), + } +} + +// SetPreviewSent records that a preview was sent for a user. +func (m *Manager) SetPreviewSent(userID, videoID, previewPath string) { + m.mu.Lock() + defer m.mu.Unlock() + m.sessions[userID] = &UserSession{ + State: StatePreviewSent, + VideoID: videoID, + PreviewPath: previewPath, + Timestamp: time.Now(), + } +} + +// ResetSession clears a user session (e.g., on cancel or completion). +func (m *Manager) ResetSession(userID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, userID) +} + +// IsExpired checks if a session has exceeded the preview TTL. +func (s *UserSession) IsExpired(ttl time.Duration) bool { + return time.Since(s.Timestamp) > ttl +} + +// SanitizeFilename replaces forbidden characters with underscores. +func SanitizeFilename(name string) string { + forbidden := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} + result := name + for _, ch := range forbidden { + result = strings.ReplaceAll(result, ch, "_") + } + return result +} + +// BuildFinalFilename constructs the final filename: "Artist - Title.ext" +func BuildFinalFilename(result *ytdlp.SearchResult, ext string) string { + artist := result.Channel + if artist == "" { + artist = "unknown" + } + title := result.Title + if title == "" { + title = result.ID + } + return SanitizeFilename(fmt.Sprintf("%s - %s.%s", artist, title, ext)) +} + +// CleanupOldPreviews removes expired preview files from tmp dir. +func (m *Manager) CleanupOldPreviews(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.mu.Lock() + for userID, session := range m.sessions { + if session.State == StatePreviewSent && session.IsExpired(m.cfg.PreviewTTL) { + if session.PreviewPath != "" { + os.Remove(session.PreviewPath) + } + delete(m.sessions, userID) + } + } + m.mu.Unlock() + } + } +} + +// PreviewFilePath returns the expected preview file path for a video ID. +func PreviewFilePath(tmpDir, videoID, ext string) string { + return filepath.Join(tmpDir, fmt.Sprintf("%s.%s", videoID, ext)) +} diff --git a/internal/ytdlp/ytdlp.go b/internal/ytdlp/ytdlp.go new file mode 100644 index 0000000..1c8673e --- /dev/null +++ b/internal/ytdlp/ytdlp.go @@ -0,0 +1,171 @@ +package ytdlp + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// SearchResult is a single entry from yt-dlp flat playlist output. +type SearchResult struct { + ID string + Title string + Duration string + Channel string +} + +// RunSearch executes yt-dlp with a search query and returns parsed results. +func RunSearch(ctx context.Context, ytdlpPath, query string, limit int, proxyURL string, timeoutSeconds int) ([]SearchResult, error) { + searchQuery := fmt.Sprintf("ytsearch%d:%s", limit, query) + + args := []string{ + searchQuery, + "--flat-playlist", + "--print", "%(id)s\t%(title)s\t%(duration_string)s\t%(channel)s", + } + if proxyURL != "" { + args = append(args, "--proxy", proxyURL) + } + + cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds)) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if cmdCtx.Err() != nil { + return nil, fmt.Errorf("search timed out after %ds: %s", timeoutSeconds, stderr.String()) + } + return nil, fmt.Errorf("yt-dlp search failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String())) + } + + return parseSearchOutput(stdout.String()), nil +} + +func parseSearchOutput(output string) []SearchResult { + lines := strings.Split(strings.TrimSpace(output), "\n") + var results []SearchResult + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 4) + if len(parts) < 3 { + continue + } + r := SearchResult{ + ID: parts[0], + Title: parts[1], + Duration: parts[2], + } + if len(parts) >= 4 { + r.Channel = parts[3] + } + results = append(results, r) + } + return results +} + +// DownloadPreview downloads a single track as audio preview (lower quality) to tmpDir. +// Returns the path to the downloaded file. +func DownloadPreview(ctx context.Context, ytdlpPath, videoID, tmpDir, audioFormat string, quality, timeoutSeconds int, proxyURL string) (string, error) { + outputTmpl := fmt.Sprintf("%s/%s.%%%%(ext)s", tmpDir, videoID) + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + args := []string{ + url, + "-x", + "--audio-format", audioFormat, + "--audio-quality", fmt.Sprintf("%d", quality), + "--output", outputTmpl, + "--no-embed-thumbnail", + "--no-add-metadata", + } + if proxyURL != "" { + args = append(args, "--proxy", proxyURL) + } + + cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds)) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if cmdCtx.Err() != nil { + return "", fmt.Errorf("preview download timed out after %ds", timeoutSeconds) + } + return "", fmt.Errorf("yt-dlp preview failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String())) + } + + // yt-dlp outputs to {tmpDir}/{videoID}.{ext} + ext := audioFormat + if ext == "opus" { + ext = "opus" + } + // Try common extensions + for _, e := range []string{audioFormat, "opus", "m4a", "webm"} { + path := fmt.Sprintf("%s/%s.%s", tmpDir, videoID, e) + if fileExists(path) { + return path, nil + } + } + + return fmt.Sprintf("%s/%s.%s", tmpDir, videoID, audioFormat), nil +} + +// DownloadFinal downloads a track at best quality with metadata into musicDir. +// Returns the final file path. +func DownloadFinal(ctx context.Context, ytdlpPath, videoID, musicDir, audioFormat string, quality, timeoutSeconds int, proxyURL string) (string, error) { + outputTmpl := fmt.Sprintf("%s/%%%%(artist)s - %%%%(title)s.%%%%(ext)s", musicDir) + url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + + args := []string{ + url, + "-x", + "--audio-format", audioFormat, + "--audio-quality", fmt.Sprintf("%d", quality), + "--embed-thumbnail", + "--add-metadata", + "--output", outputTmpl, + "--no-playlist", + } + if proxyURL != "" { + args = append(args, "--proxy", proxyURL) + } + + cmdCtx, cancel := context.WithTimeout(ctx, toDuration(timeoutSeconds)) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, ytdlpPath, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if cmdCtx.Err() != nil { + return "", fmt.Errorf("final download timed out after %ds", timeoutSeconds) + } + return "", fmt.Errorf("yt-dlp final download failed: %w\nstderr: %s", err, strings.TrimSpace(stderr.String())) + } + + // Return the output template pattern — caller will need to find the actual file + return outputTmpl, nil +} + +func toDuration(seconds int) time.Duration { + return time.Duration(seconds) * time.Second +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..154e7d1 --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/labstack/echo/v4" + "github.com/user/ytdlp-navidrome/internal/bot" + "github.com/user/ytdlp-navidrome/internal/config" + "github.com/user/ytdlp-navidrome/internal/health" +) + +func main() { + cfg := config.Load() + + // Setup logging + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + log.Printf("Starting ytdlp-navidrome service...") + log.Printf("Log level: %s", cfg.LogLevel) + log.Printf("Proxy enabled: %v, URL: %s", cfg.ProxyEnabled, cfg.ProxyURL) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start health HTTP server + e := echo.New() + e.HideBanner = true + e.HidePort = true + + startTime := time.Now() + healthChecker := health.NewChecker(cfg.YtdlpPath, cfg.ProxyURL) + e.GET("/healthz", healthChecker.Handler(startTime)) + + go func() { + log.Printf("HTTP server listening on %s", cfg.ListenAddr) + if err := e.Start(cfg.ListenAddr); err != nil { + log.Printf("HTTP server stopped: %v", err) + } + }() + + // Start Matrix bot + b := bot.New(cfg) + go func() { + log.Printf("Matrix bot connecting...") + if err := b.Start(ctx); err != nil { + log.Fatalf("Matrix bot error: %v", err) + } + }() + + log.Printf("Service started. PID: %d", os.Getpid()) + + // Wait for shutdown signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("Received signal %v, shutting down...", sig) + + // Graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := e.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + cancel() + log.Printf("Service stopped.") +} diff --git a/secrets.sealed.yaml b/secrets.sealed.yaml new file mode 100644 index 0000000..ac7f3c9 --- /dev/null +++ b/secrets.sealed.yaml @@ -0,0 +1,20 @@ +{ + "kind": "SealedSecret", + "apiVersion": "bitnami.com/v1alpha1", + "metadata": { "name": "ytdlp-matrix-secret", "namespace": "navidrome" }, + "spec": + { + "template": + { + "metadata": + { "name": "ytdlp-matrix-secret", "namespace": "navidrome" }, + "type": "Opaque", + }, + "encryptedData": + { + "access-token": "AgA/sYGeXPFlg/QDUR2wvPw/nZ5ghTCmqkQ0LzOtcGuja2WL+/enpXe7hphn9ST9Q18n5YXCq6njpI7eGLKvFTXm7UlKQdEGvA3BjrlJsDuyxupn4M0Zv9I0PFUhCFP/akDeB9MmzFxCAZI/bmlZPVjrKw1xyZp1Mq3AAgRHIMGORM5N6NOwQ942cPL4C7rkpJWVKj2F10aaNWhhqYvC2AC/f7F6n8f1POlUl95tLQ4zXMsTQgSJ3Oe73uYU1yXXnu7lW2zv/9iWlG77Wysgf1+b+oEw+4L3R2F+E53LKIGMb7dU2nLnGpDw70fdzXfH00GIBypBByinjnDY/XLAEbcvdFg05RXpQLYJfulGqHGko6CfzkAs5XNZqBsp2DT2cTD/cDj5daB5pE1OAQXrxDW3U91/N8wlj9UTwYj3xWj0ZR723OLF7ddhC+Mqh+0ZFxXiGikPLjqbPF5XJx0QNgwZyvBKR3xZQTTzOWcTGUc/8EV0LEp03Q3kKl/zfu9vOj0ktbLU5/WoOwBVDe419GvHTvBjyWTt+pX65Edgc5Rkq6tUVAEkv9RTfKqJTeobQXj/fHvqUy2FHh+xg/Mz7ryyIMk9MxfKC6qyzMZc58NeM3Iivkuye2MhU4Q2W+U8D+ng0cxQGg/TObfABgL5YwEvE+PkcGEqtekIpUKvYiT8dus+3jlVuDozTvdLU333GmBzj7IrPRv6C4zwRvDU9jdENRFf0jmBfRDvoq09TT5MYQ==", + "homeserver": "AgAxk3NrUlaR9UljAd+LsfUxij860BNA1GG/QPnJuZucKVUlAQHWmkfq/Nq2nqy/hlKRCu9oEBSd8os3xe4kAR4j5hBLD97UuT4+m2UndVH3GwNsHuXnG47b06VL/aNo8Amf0P9MH+46Uc/1qJOPKJad5ttbjs51SfaCe5RpbAPvR+2hBK2yVZVFBQ6yfaxMwqX5KKsBnZedAlYq2kwcrjXdOVljDRGVliS5WOd4ub0hN+owQrUGcCBmdZ6Oi8ceXzqDxfMpVQzXljERhxWn50GhG5GdDbLB5pT4EX0BdKcLBv9oiPUjSx9Xj9iO7sSmlJbbF7yT3oeEF8wKt7WM/ursEyL4wA7xWSC3W4Tsf84YvDf7ekjLwyYPgtWvxQzy27lPiaQ7ZlcaSMAl83jQkVMe6Eg6BWd8ImIgzZLjy5M6LG6ujBlTPEss9iwLmvT+Ge3e7w3wBYLv9pi/ooJjxmrTIiNrz6CN7TTdXpbwwfcdihaDXTVf6Of+HQ4FwVXxsAn2h3Pm/ww+FCEl8fLwL2DcvCExAdX6+rTbxDVzUwnbCUpvI53/JDubu38JRA2c7lUS6JmBd2YI0ma1OKjbD4k8xDNMQqfsgqNN/RnKNnAREHqOjZ4fbuB4D4np822bB+5Z4/HgMCnAcrd/0AX5MTuV10TMxumYZwFzlV0HxCPbCV/ZqvVEBDeUusA792gJJtxpQLU22KIsAh1A8s9ao5QEsI2dikL84Z+cMyw1OlsaadqxN7w3Op0+ZdNo9w==", + "user-id": "AgBCSld+0kfhprw7frVD2V471s7PsLpdX09Nqyyals2YUhIxlfRbyMcWcVM0W7XtM8kWHdL5RbUbN0xLSySLNrzE1ZhZa68GoYeW7yT1LMpd2BLbaiMd3qTl4YJMeA5HakSNSWOykVZs3v1X2i0tF0zrAkIdfrBIurzRcZFLdjrLdV3PBprFQGrvwPLu1GEklBEyOgSR+WpHgtDD799YKOyEfMcVG18bF8NoH4wUlhRSBJtS6EiRNvU6cXNHwblPndHJVzSH/YUcUaSqg0lpAO8pdH2c/LSHc/cF0YtHQhutaWAnDLh4Fxb6waUhsM2BMtJGqiqQJj44JewvoZNSuJi+p3CCkrPMed3Q43EBIyTXN75qNLFBNT3xKTqCp6UT7xXu7xQHaCDe7vwxgIInAoty9kJuk/xxn+Of2AYfEPi6DnhVuOv9zumaeQqWZY5jvbypxfCXpodlUtGaQxaI8ymU1VklfqbA/FrGx8DPn6qJH94hImzVuJYCyjRfF09KIcreBJUBDjxWp4lUXKnWQ9XkuwL8omL7327izr7V9eeY4rxRAEQVUWtzpmwZSwsaYBxrKqHCxP8Gky0JdqywwZDSCSqoj5jKDoXEoOHj3FQ4KfsT44HAtQrydkaswGX1T7p1pXZmYovwiK5eDsA52UNwHn71nEZrWmhWgnRk1FC3XrJX/bfuHTWAk39uZ3VZ/6W4yl1Tc4yG4eVzMfY9jpUuZ4VqzQ8mY1KnmI0cIHeyM+qD366Cs1P5", + }, + }, +} diff --git a/service.yaml b/service.yaml new file mode 100644 index 0000000..fe72022 --- /dev/null +++ b/service.yaml @@ -0,0 +1,101 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: navidrome + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ytdlp-navidrome + namespace: navidrome +spec: + replicas: 1 + selector: + matchLabels: + app: ytdlp-navidrome + template: + metadata: + labels: + app: ytdlp-navidrome + spec: + containers: + - name: ytdlp-bot + image: ytdlp-navidrome:latest + imagePullPolicy: IfNotPresent + env: + - name: MATRIX_HOMESERVER + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: homeserver + - name: MATRIX_USER_ID + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: user-id + - name: MATRIX_ACCESS_TOKEN + valueFrom: + secretKeyRef: + name: ytdlp-matrix-secret + key: access-token + - name: PROXY_URL + value: "socks5://localhost:1080" + - name: MUSIC_DIR + value: "/music" + - name: TMP_DIR + value: "/tmp/ytdlp-previews" + volumeMounts: + - name: music + mountPath: /music/ytdlp + - name: tmp + mountPath: /tmp/ytdlp-previews + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "500m" + + - name: byedpi + image: ghcr.io/hufrea/byedpi:latest + command: ["ciadpi"] + args: + - "--ip=0.0.0.0" + - "--port=1080" + - "--auto" + - "--disorder=1-1" + - "--ttl=3" + ports: + - containerPort: 1080 + name: sock5 + resources: + requests: + memory: "16Mi" + cpu: "10m" + limits: + memory: "32Mi" + cpu: "100m" + + volumes: + - name: music + hostPath: + path: /mnt/ssd/k3s/services/navidrome/music + type: DirectoryOrCreate + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 512Mi