fix: archive yt-dlp videos when other assets fail #4

Merged
fmartingr merged 1 commit from yt-dlp-ignore-errors into master 2026-10-02 11:46:03 +02:00
2 changed files with 103 additions and 6 deletions

View file

@ -7,9 +7,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"time" "time"
@ -23,6 +25,9 @@ type YtDlpConfig struct {
WriteThumbnail bool `json:"write_thumbnail"` WriteThumbnail bool `json:"write_thumbnail"`
WriteSubs bool `json:"write_subs"` // Download subtitles as separate files WriteSubs bool `json:"write_subs"` // Download subtitles as separate files
EmbedSubs bool `json:"embed_subs"` // Embed subtitles into the video file EmbedSubs bool `json:"embed_subs"` // Embed subtitles into the video file
// IgnoreErrors archives the video even when other assets (subtitles, thumbnail)
// fail; those failures are logged as warnings instead of failing the archival.
IgnoreErrors bool `json:"ignore_errors"`
} }
// ToMap converts the YtDlpConfig to a map[string]any for use with ApplyConfig // ToMap converts the YtDlpConfig to a map[string]any for use with ApplyConfig
@ -52,6 +57,7 @@ func NewYtDlpArchiver() *YtDlpArchiver {
WriteThumbnail: true, WriteThumbnail: true,
WriteSubs: true, WriteSubs: true,
EmbedSubs: true, EmbedSubs: true,
IgnoreErrors: true,
}, },
} }
} }
@ -93,6 +99,7 @@ func (a *YtDlpArchiver) GetDefaultConfig() any {
WriteThumbnail: true, WriteThumbnail: true,
WriteSubs: true, WriteSubs: true,
EmbedSubs: true, EmbedSubs: true,
IgnoreErrors: true,
} }
} }
@ -113,6 +120,9 @@ func (a *YtDlpArchiver) ApplyConfig(config map[string]any) error {
if v, ok := config["embed_subs"].(bool); ok { if v, ok := config["embed_subs"].(bool); ok {
a.config.EmbedSubs = v a.config.EmbedSubs = v
} }
if v, ok := config["ignore_errors"].(bool); ok {
a.config.IgnoreErrors = v
}
return nil return nil
} }
@ -164,9 +174,11 @@ func (a *YtDlpArchiver) Archive(ctx context.Context, link *model.Link, stor stor
outputTemplate := filepath.Join(tempDir, "%(title)s.%(ext)s") outputTemplate := filepath.Join(tempDir, "%(title)s.%(ext)s")
args := []string{ args := []string{
"-o", outputTemplate, "-o", outputTemplate,
"--no-warnings",
"--no-playlist", "--no-playlist",
} }
if a.config.IgnoreErrors {
args = append(args, "--ignore-errors")
}
if a.config.WriteThumbnail { if a.config.WriteThumbnail {
args = append(args, "--write-thumbnail") args = append(args, "--write-thumbnail")
} }
@ -186,12 +198,17 @@ func (a *YtDlpArchiver) Archive(ctx context.Context, link *model.Link, stor stor
cmd.Stdout = &combinedOutput cmd.Stdout = &combinedOutput
cmd.Stderr = &combinedOutput cmd.Stderr = &combinedOutput
if err := cmd.Run(); err != nil { runErr := cmd.Run()
errMsg := err.Error() output := strings.TrimSpace(combinedOutput.String())
if exitErr, ok := err.(*exec.ExitError); ok { if runErr != nil && (!a.config.IgnoreErrors || !hasMediaFile(tempDir)) {
errMsg := runErr.Error()
if exitErr, ok := runErr.(*exec.ExitError); ok {
errMsg = fmt.Sprintf("exit code %d", exitErr.ExitCode()) errMsg = fmt.Sprintf("exit code %d", exitErr.ExitCode())
} }
return nil, fmt.Errorf("yt-dlp failed (%s): %s", errMsg, strings.TrimSpace(combinedOutput.String())) return nil, fmt.Errorf("yt-dlp failed (%s): %s", errMsg, output)
}
if problems := ytDlpProblems(output); len(problems) > 0 {
slog.Warn("yt-dlp archived the video with missing assets", "url", link.URL, "problems", problems)
} }
var files []FileInfo var files []FileInfo
@ -239,7 +256,7 @@ func (a *YtDlpArchiver) Archive(ctx context.Context, link *model.Link, stor stor
} }
if len(files) == 0 { if len(files) == 0 {
return nil, fmt.Errorf("yt-dlp produced no files. output: %s", strings.TrimSpace(combinedOutput.String())) return nil, fmt.Errorf("yt-dlp produced no files. output: %s", output)
} }
if title == "" { if title == "" {
@ -269,3 +286,36 @@ func mimeTypeFromExt(ext string) string {
func isImageMimeType(mimeType string) bool { func isImageMimeType(mimeType string) bool {
return strings.HasPrefix(mimeType, "image/") return strings.HasPrefix(mimeType, "image/")
} }
// ytDlpFormatPart matches the per-format streams (title.f400.mp4, title.f251-drc.webm)
// that yt-dlp keeps only when merging them into the final file failed.
// ponytail: numeric format ids only (YouTube), extend for extractors with named ids like fhls-720p.
var ytDlpFormatPart = regexp.MustCompile(`\.f\d+[^.]*\.[^.]+$`)
func hasMediaFile(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if ytDlpFormatPart.MatchString(entry.Name()) {
continue
}
mimeType := mimeTypeFromExt(filepath.Ext(entry.Name()))
if strings.HasPrefix(mimeType, "video/") || strings.HasPrefix(mimeType, "audio/") {
return true
}
}
return false
}
// ytDlpProblems returns the WARNING and ERROR lines of yt-dlp output.
func ytDlpProblems(output string) []string {
var problems []string
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "ERROR:") {
problems = append(problems, line)
}
}
return problems
}

View file

@ -1,6 +1,8 @@
package archiver package archiver
import ( import (
"os"
"path/filepath"
"testing" "testing"
) )
@ -127,3 +129,48 @@ func TestYtDlpConfig_ToMap(t *testing.T) {
t.Errorf("Expected embed_subs true, got %v", m["embed_subs"]) t.Errorf("Expected embed_subs true, got %v", m["embed_subs"])
} }
} }
func TestYtDlpArchiver_IgnoreErrorsDefaultAndConfig(t *testing.T) {
a := NewYtDlpArchiver()
if !a.config.IgnoreErrors || !a.GetDefaultConfig().(YtDlpConfig).IgnoreErrors {
t.Fatal("Expected IgnoreErrors to be true by default")
}
if err := a.ApplyConfig(map[string]any{"ignore_errors": false}); err != nil {
t.Fatalf("ApplyConfig failed: %v", err)
}
if a.config.IgnoreErrors {
t.Error("Expected IgnoreErrors to be false after config")
}
}
func TestHasMediaFile(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "video.en.vtt"), nil, 0o600); err != nil {
t.Fatal(err)
}
if hasMediaFile(dir) {
t.Error("Expected no media file with only subtitles")
}
for _, name := range []string{"video.f400.mp4", "video.f251-drc.webm", "video.mp4.part"} {
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil {
t.Fatal(err)
}
}
if hasMediaFile(dir) {
t.Error("Expected no media file with only unmerged streams and partial downloads")
}
if err := os.WriteFile(filepath.Join(dir, "video.webm"), nil, 0o600); err != nil {
t.Fatal(err)
}
if !hasMediaFile(dir) {
t.Error("Expected a media file after adding a video")
}
}
func TestYtDlpProblems(t *testing.T) {
output := "[youtube] abc: Downloading webpage\nWARNING: Unable to download video subtitles for 'en': HTTP Error 429\nERROR: Postprocessing: something\n[info] done"
problems := ytDlpProblems(output)
if len(problems) != 2 {
t.Fatalf("Expected 2 problems, got %d: %v", len(problems), problems)
}
}