add workflow to build
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tmp
|
||||||
|
secret.yaml
|
||||||
+45
@@ -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"]
|
||||||
@@ -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. Файл переименовывается в формат `<artist> - <title>.<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-эндпоинты для альтернативного доступа (опционально)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
+101
@@ -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
|
||||||
Reference in New Issue
Block a user