fix: archive yt-dlp videos when other assets fail #4
2 changed files with 103 additions and 6 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue