add workflow to build

This commit is contained in:
roman.nikolsky
2026-07-04 01:56:10 +03:00
commit af098e6ec4
14 changed files with 1701 additions and 0 deletions
+379
View File
@@ -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)
}
}
+133
View File
@@ -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
}
+74
View File
@@ -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)
}
}
+142
View File
@@ -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))
}
+171
View File
@@ -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
}