Skip to content

Commit 8eb1e20

Browse files
committed
fix: resolve format errors and improve download reliability for YouTube Music
- fix info fetch to use stdout JSON even when yt-dlp exits non-zero (handles format warnings gracefully) - add --no-check-formats and --js-runtimes node to both fetch and download to fix "Requested format is not available" on music.youtube.com - improve format selector with explicit m4a/webm fallback chain - add --embed-metadata to write proper title/artist/album tags into downloaded files - remove video ID from playlist filenames for cleaner names (%(title)s.%(ext)s) - rename per-playlist archive file from .soundsnatch_archive.txt to .archive.txt - add global archive path to Config struct for single track deduplication
1 parent 9429973 commit 8eb1e20

4 files changed

Lines changed: 53 additions & 33 deletions

File tree

config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,25 @@ import (
1010
func loadConfig() Config {
1111
home, _ := os.UserHomeDir()
1212
configPath := filepath.Join(home, ".soundsnatch.yaml")
13+
archivePath := filepath.Join(home, ".soundsnatch_archive.txt")
1314

1415
config := Config{
1516
LastSaveDir: "",
1617
DefaultFormat: "mp3",
1718
Browser: "",
19+
ArchivePath: archivePath,
1820
}
1921

2022
data, err := os.ReadFile(configPath)
2123
if err == nil {
2224
yaml.Unmarshal(data, &config)
2325
}
2426

27+
// Always ensure ArchivePath is set to default if missing from config
28+
if config.ArchivePath == "" {
29+
config.ArchivePath = archivePath
30+
}
31+
2532
return config
2633
}
2734

downloader.go

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616

1717
func fetchInfoCmd(url string, browser string) tea.Cmd {
1818
return func() tea.Msg {
19-
args := []string{"-J", "--flat-playlist", "--no-warnings", "--quiet", "--ignore-errors"}
19+
args := []string{"-J", "--flat-playlist", "--no-warnings", "--quiet", "--ignore-errors", "--ignore-config", "--no-check-formats", "--js-runtimes", "node"}
2020
if browser != "" && browser != "none" {
2121
args = append(args, "--cookies-from-browser", browser)
2222
}
@@ -27,37 +27,44 @@ func fetchInfoCmd(url string, browser string) tea.Cmd {
2727
cmd = exec.Command("python3", append([]string{"-m", "yt_dlp"}, args...)...)
2828
}
2929

30-
var stderr strings.Builder
30+
var stdout, stderr strings.Builder
31+
cmd.Stdout = &stdout
3132
cmd.Stderr = &stderr
32-
out, err := cmd.Output()
33-
if err != nil {
34-
errMsgStr := stderr.String()
35-
if errMsgStr == "" {
36-
errMsgStr = err.Error()
33+
runErr := cmd.Run()
34+
35+
outStr := strings.TrimSpace(stdout.String())
36+
37+
// Prefer valid JSON output even if yt-dlp exited non-zero (e.g. format warnings)
38+
if outStr != "" {
39+
var info map[string]interface{}
40+
if err := json.Unmarshal([]byte(outStr), &info); err == nil {
41+
title, _ := info["title"].(string)
42+
durFloat, _ := info["duration"].(float64)
43+
return infoFetchedMsg{
44+
title: title,
45+
duration: durFloat,
46+
}
3747
}
38-
return errMsg{err: fmt.Errorf("could not fetch info: %s", errMsgStr)}
3948
}
4049

41-
var info map[string]interface{}
42-
if err := json.Unmarshal(out, &info); err != nil {
43-
return errMsg{err: fmt.Errorf("failed to parse info: %v", err)}
50+
// No usable JSON — surface the actual error
51+
if runErr != nil {
52+
errMsgStr := strings.TrimSpace(stderr.String())
53+
if errMsgStr == "" {
54+
errMsgStr = runErr.Error()
55+
}
56+
return errMsg{err: fmt.Errorf("could not fetch info: %s", errMsgStr)}
4457
}
4558

46-
title, _ := info["title"].(string)
47-
durFloat, _ := info["duration"].(float64)
48-
49-
return infoFetchedMsg{
50-
title: title,
51-
duration: durFloat,
52-
}
59+
return errMsg{err: fmt.Errorf("could not fetch info: no output from yt-dlp")}
5360
}
5461
}
5562

5663
func searchCmd(query string) tea.Cmd {
5764
return func() tea.Msg {
58-
cmd := exec.Command("yt-dlp", "ytsearch5:"+query, "-j", "--no-warnings", "--quiet", "--flat-playlist")
65+
cmd := exec.Command("yt-dlp", "ytsearch5:"+query, "-j", "--no-warnings", "--quiet", "--flat-playlist", "--ignore-config")
5966
if _, err := exec.LookPath("yt-dlp"); err != nil {
60-
cmd = exec.Command("python3", "-m", "yt_dlp", "ytsearch5:"+query, "-j", "--no-warnings", "--quiet", "--flat-playlist")
67+
cmd = exec.Command("python3", "-m", "yt_dlp", "ytsearch5:"+query, "-j", "--no-warnings", "--quiet", "--flat-playlist", "--ignore-config")
6168
}
6269

6370
out, err := cmd.Output()
@@ -96,36 +103,41 @@ func searchCmd(query string) tea.Cmd {
96103
}
97104
}
98105

99-
func startDownloadTask(c chan tea.Msg, url, saveDir, saveFilename, format, browser string) {
106+
func startDownloadTask(c chan tea.Msg, url, saveDir, saveFilename, format, browser, archivePath string) {
100107
outtmpl := filepath.Join(saveDir, saveFilename+"."+format)
101108
isPlaylist := strings.Contains(url, "list=") || strings.Contains(url, "playlist")
102-
109+
103110
args := []string{
104-
"-f", "bestaudio/best",
111+
"-f", "bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio/best",
105112
"--extract-audio",
106113
"--audio-format", format,
114+
"--audio-quality", "0",
115+
"--no-check-formats",
116+
"--js-runtimes", "node",
117+
"--embed-metadata",
107118
"--no-warnings",
108119
"--newline",
109120
"--progress",
110121
"--ignore-errors",
111122
"--no-cache-dir",
112123
"--lazy-playlist",
113124
"--no-overwrites",
125+
"--ignore-config",
114126
}
115127

116128
if isPlaylist {
117129
playlistDir := filepath.Join(saveDir, saveFilename)
118130
os.MkdirAll(playlistDir, 0755)
119-
120-
// LOCAL ARCHIVE: Keep track of downloads INSIDE the playlist folder
121-
// This prevents duplicates within the folder but allows downloading
122-
// the same song to a DIFFERENT folder.
123-
archivePath := filepath.Join(playlistDir, ".soundsnatch_archive.txt")
124-
args = append(args, "--download-archive", archivePath)
125-
126-
outtmpl = filepath.Join(playlistDir, "%(title)s [%(id)s].%(ext)s")
131+
outtmpl = filepath.Join(playlistDir, "%(title)s.%(ext)s")
132+
// Use a per-playlist archive so the same song can exist in different playlists
133+
// and playlists don't interfere with each other
134+
localArchive := filepath.Join(playlistDir, ".archive.txt")
135+
args = append(args, "--download-archive", localArchive)
127136
} else {
128137
args = append(args, "--no-playlist")
138+
if archivePath != "" {
139+
args = append(args, "--download-archive", archivePath)
140+
}
129141
}
130142

131143
args = append(args, "-o", outtmpl)
@@ -184,7 +196,7 @@ func startDownloadTask(c chan tea.Msg, url, saveDir, saveFilename, format, brows
184196
}
185197
}
186198

187-
c <- downloadDoneMsg{message: fmt.Sprintf("🎉 Sync Complete!\nFiles are in: %s", saveDir)}
199+
c <- downloadDoneMsg{message: fmt.Sprintf("🎉 Sync Complete!\nYour library at: %s is now up to date.", saveDir)}
188200
}
189201

190202
func waitForMsg(c chan tea.Msg) tea.Cmd {

types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type Config struct {
4646
LastSaveDir string `yaml:"last_save_dir"`
4747
DefaultFormat string `yaml:"default_format"`
4848
Browser string `yaml:"browser"`
49+
ArchivePath string `yaml:"archive_path"`
4950
}
5051

5152
type formatItem struct {

ui.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
348348
if ok {
349349
m.selectedFormat = i.ext
350350
m.state = stateDownloading
351-
go startDownloadTask(m.msgChan, m.url, m.saveDir, m.saveFilename, m.selectedFormat, m.config.Browser)
351+
go startDownloadTask(m.msgChan, m.url, m.saveDir, m.saveFilename, m.selectedFormat, m.config.Browser, m.config.ArchivePath)
352352
return m, tea.Batch(m.spinner.Tick, waitForMsg(m.msgChan))
353353
}
354354
}

0 commit comments

Comments
 (0)