add workflow to build
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user