diff --git a/internal/dl/ytdlp.go b/internal/dl/ytdlp.go index 05a1129..3bbff6c 100644 --- a/internal/dl/ytdlp.go +++ b/internal/dl/ytdlp.go @@ -220,6 +220,9 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c // Use explicit format string if set (video+audio combination) if job.FormatString != "" { args = append(args, "-f", job.FormatString) + if job.Container != "" { + args = append(args, "--merge-output-format", job.Container) + } } else { // Build format string for merge switch job.MediaType { @@ -243,8 +246,8 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s") args = append(args, "--output", outputPath) - // Progress reporting - args = append(args, "--newline", "--no-progress") + // Progress reporting (--newline for line-based, no --no-progress so we get actual output) + args = append(args, "--newline") // Language selection if specified if job.Language != "" { @@ -337,6 +340,40 @@ func CancelDownload(job *domain.DownloadJob) { } } +// ytdlpSearchResult holds a single flat search result from yt-dlp. +type ytdlpSearchResult struct { + Title string `json:"title"` + URL string `json:"url"` +} + +// SearchYoutube searches YouTube via yt-dlp ytsearch and returns the first result URL and title. +func (c *Client) SearchYoutube(query string) (url, title string, err error) { + args := []string{ + "--flat-playlist", "-J", "--no-download", + "--no-warnings", + "ytsearch1:" + query, + } + if c.cookiesFile != "" { + args = append(args, "--cookies", c.cookiesFile) + } + + out, err := c.run(args) + if err != nil { + return "", "", fmt.Errorf("youtube search failed: %w", err) + } + + var info struct { + Entries []ytdlpSearchResult `json:"entries"` + } + if err := json.Unmarshal(out, &info); err != nil { + return "", "", fmt.Errorf("parse search result: %w", err) + } + if len(info.Entries) == 0 { + return "", "", fmt.Errorf("no results found") + } + return info.Entries[0].URL, info.Entries[0].Title, nil +} + func (c *Client) run(args []string) ([]byte, error) { cmd := exec.Command(c.binaryPath, args...) var stderr bytes.Buffer diff --git a/internal/handler/download.go b/internal/handler/download.go index eefbc56..d7305c3 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -65,17 +65,34 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string, info, err := h.YtDlp.GetMediaInfo(url) if err != nil { errMsg := err.Error() - switch { - case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"): - h.sendText(ctx, chatID, "This URL is not supported.") - case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"): - h.sendText(ctx, chatID, "This content requires authentication (cookies).") - case strings.Contains(errMsg, "live"): - h.sendText(ctx, chatID, "Cannot download live streams.") - default: - h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg)) + + // DRM content (Spotify, etc.): fall back to YouTube search + if strings.Contains(errMsg, "DRM") { + youtubeURL, ytTitle, searchErr := h.YtDlp.SearchYoutube(text) + if searchErr != nil { + h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected and no YouTube alternative was found: %s", errMsg)) + return + } + h.sendText(ctx, chatID, fmt.Sprintf("This content is DRM protected. Found on YouTube: %s", ytTitle)) + info, err = h.YtDlp.GetMediaInfo(youtubeURL) + if err != nil { + h.sendText(ctx, chatID, fmt.Sprintf("YouTube fallback failed: %s", err.Error())) + return + } + url = youtubeURL + } else { + switch { + case strings.Contains(errMsg, "Unsupported URL") || strings.Contains(errMsg, "is not a valid URL"): + h.sendText(ctx, chatID, "This URL is not supported.") + case strings.Contains(errMsg, "HTTP Error 403") || strings.Contains(errMsg, "cookies"): + h.sendText(ctx, chatID, "This content requires authentication (cookies).") + case strings.Contains(errMsg, "live"): + h.sendText(ctx, chatID, "Cannot download live streams.") + default: + h.sendText(ctx, chatID, fmt.Sprintf("Error: %s", errMsg)) + } + return } - return } if info.IsPlaylist { @@ -571,6 +588,17 @@ func formatLabelForDisplay(f domain.Format) string { } return label } + // Audio-only with no bitrate: show format ID + extension + if f.IsAudioOnly { + label := f.ID + if f.Extension != "" { + label += " (" + f.Extension + ")" + } + if f.Filesize > 0 { + label += fmt.Sprintf(" (%s)", formatBytes(f.Filesize)) + } + return label + } return f.ID }