From c65309a648083a5aae19137d401ece90b01858ac Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:03:24 -0700 Subject: [PATCH 1/2] retry logic and metadata handling to song searches --- src/downloader/downloader.go | 73 ++++-- src/downloader/monitor.go | 21 +- src/downloader/slskd.go | 436 +++++++++++++++++++++++------------ src/downloader/youtube.go | 4 + src/util/metadata.go | 18 +- 5 files changed, 370 insertions(+), 182 deletions(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66..c151b48 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -246,20 +246,36 @@ func buildTrackPath(template string, track *models.Track) string { return filepath.Clean(result) } -func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { - trackDir := filepath.Join(srcDir, trackPath) - srcFile := filepath.Join(trackDir, track.File) +func (c *DownloadClient) FinalizeDownload(monCfg MonitorConfig, trackPath string, track *models.Track) error { + srcFile := filepath.Join(monCfg.FromDir, trackPath, track.File) - if c.Cfg.RenameTrack { // Rename file to {title}-{artist} format - track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) - } if c.Cfg.OverwriteMetadata { - metadata := util.BuildffmpegMetadata(*track) - if err := overwriteMetadata(metadata, srcFile); err != nil { + slog.Info(fmt.Sprintf("Writing clean metadata - %s", track.CleanTitle)) + if err := overwriteMetadata(util.BuildffmpegMetadata(*track), track.CoverPath, srcFile); err != nil { slog.Warn("problem overwriting metadata", "msg", err.Error()) } } + if c.Cfg.RenameTrack { + newFile := getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + if newFile != track.File { + if err := os.Rename(srcFile, filepath.Join(monCfg.FromDir, trackPath, newFile)); err != nil { + return fmt.Errorf("failed to rename track: %w", err) + } + track.File = newFile + } + } + + if monCfg.MigrateDownload { + return c.MoveDownload(monCfg.FromDir, monCfg.ToDir, trackPath, track) + } + return nil +} + +func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { + trackDir := filepath.Join(srcDir, trackPath) + srcFile := filepath.Join(trackDir, track.File) + in, err := os.Open(srcFile) if err != nil { return fmt.Errorf("couldn't open source file: %s", err.Error()) @@ -338,26 +354,35 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * return nil } -func overwriteMetadata(metadata []string, srcFile string) error { +func overwriteMetadata(metadata []string, coverPath, srcFile string) error { opts := ffmpeg.KwArgs{ - "c": "copy", - "metadata": metadata, - "loglevel": "error", - } - streams := []*ffmpeg.Stream{ - ffmpeg.Input(srcFile), + "c": "copy", + "map_metadata": "-1", + "metadata": metadata, + "loglevel": "error", + } + streams := []*ffmpeg.Stream{ffmpeg.Input(srcFile)} + + if coverPath != "" { + if _, err := os.Stat(coverPath); err == nil { + streams = append(streams, ffmpeg.Input(coverPath)) + opts["map"] = "-0:v?" + opts["disposition:v"] = "attached_pic" + if strings.EqualFold(filepath.Ext(srcFile), ".mp3") { + opts["id3v2_version"] = "3" + } } + } - tmpFile := tempAudioFile(srcFile) + tmpFile := tempAudioFile(srcFile) - if err := util.WriteMetadata(streams, "", tmpFile, opts); err != nil { - return fmt.Errorf("failed to overwrite metadata: %w", err) - } else { - if err := os.Rename(tmpFile, srcFile); err != nil { - return fmt.Errorf("failed to rename tmp file: %w", err) - } - } - return nil + if err := util.WriteMetadata(streams, "", tmpFile, opts); err != nil { + return fmt.Errorf("failed to overwrite metadata: %w", err) + } + if err := os.Rename(tmpFile, srcFile); err != nil { + return fmt.Errorf("failed to rename tmp file: %w", err) + } + return nil } func tempAudioFile(path string) string { diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 38a9d7d..c7c6a27 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -12,6 +12,7 @@ import ( type Monitor interface { GetDownloadStatus([]*models.Track) (map[string]FileStatus, error) GetConf() (MonitorConfig, error) + RetryDownload(*models.Track) (bool, error) Cleanup(models.Track, string) error } @@ -88,12 +89,8 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err slog.Info("[monitor] file downloaded successfully", "service", monCfg.Service, "file", track.File) var path string track.File, path = parsePath(track.File) - if monCfg.MigrateDownload { - if err = c.MoveDownload(monCfg.FromDir, monCfg.ToDir, path, track); err != nil { - slog.Error("error while moving file", "err", err.Error()) - } else { - slog.Info("track moved successfully", "service", monCfg.Service) - } + if err = c.FinalizeDownload(monCfg, path, track); err != nil { + slog.Error("error finalizing download", "service", monCfg.Service, "err", err.Error()) } delete(progressMap, key) successDownloads += 1 @@ -109,11 +106,19 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } else if monitoredTime > monCfg.MonitorDuration || fileStatus.State == "Errored" { - slog.Info("[monitor] no download progress for file, skipping", "service", monCfg.Service, "file", track.File, "state", fileStatus.State, "duration", monitoredTime,) - tracker.Skipped = true if err = m.Cleanup(*track, fileStatus.ID); err != nil { slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) } + if retried, _ := m.RetryDownload(track); retried { + slog.Info("[monitor] source failed, trying next", "service", monCfg.Service, "title", track.CleanTitle, "state", fileStatus.State) + // track.File changed, re-track under the new key + delete(progressMap, key) + newKey := fmt.Sprintf("%s|%s", track.ID, track.File) + progressMap[newKey] = &DownloadMonitor{LastUpdated: currentTime} + continue + } + slog.Info("[monitor] no more sources, skipping", "service", monCfg.Service, "file", track.File, "state", fileStatus.State, "duration", monitoredTime) + tracker.Skipped = true continue } } diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 0708786..f51bf6d 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -13,6 +13,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "time" ) @@ -99,6 +100,12 @@ type Slskd struct { HttpClient *util.HttpClient DownloadDir string Cfg config.Slskd + retry *retryState +} + +type retryState struct { + mu sync.Mutex + remaining map[string][]File // trackID -> untried ranked candidates } type SearchPayload struct { @@ -108,7 +115,8 @@ type SearchPayload struct { func NewSlskd(cfg config.Slskd, downloadDir string) *Slskd { return &Slskd{Cfg: cfg, HttpClient: util.NewHttp(util.HttpClientConfig{Timeout: cfg.Timeout}), - DownloadDir: downloadDir} + DownloadDir: downloadDir, + retry: &retryState{remaining: make(map[string][]File)}} } func (c *Slskd) AddHeader() { @@ -133,66 +141,111 @@ func (c *Slskd) GetConf() (MonitorConfig, error) { var errNoRes = errors.New("no results found for query") func (c *Slskd) QueryTrack(track *models.Track) error { + queries := c.searchQueries(track) - wildcardSearch := false - trackDetails := fmt.Sprintf("%s - %s", track.CleanTitle, track.Artist) - - retry: - ID, err := c.searchTrack(trackDetails) + var lastErr error + for _, q := range queries { + ID, err := c.searchTrack(q) if err != nil { return err } - slog.Info("initiating search", "track", trackDetails) - - cleanup := func() { - if err := c.deleteSearch(ID); err != nil { - slog.Warn("failed to delete search", "context", err.Error()) - } + slog.Info("initiating search", "track", q) + + completed, err := c.searchStatus(ID, q) + if err == nil && completed { + _, err = c.fetchCollectableFiles(track, ID) + if err == nil { + track.ID = ID + return nil + } } + lastErr = err - completed, err := c.searchStatus(ID, trackDetails, 0) - if errors.Is(err, errNoRes) && !wildcardSearch { - cleanup() - wildcardSearch = true - trackDetails = fmt.Sprintf("%s - %s", track.CleanTitle, wildcardArtist(track.Artist)) - slog.Debug("no result found with artist full name, trying with wildcard", "query", trackDetails) - goto retry + if delErr := c.deleteSearch(ID); delErr != nil { + slog.Warn("failed to delete search", "context", delErr.Error()) } + } - if err != nil { - cleanup() - return err - } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("no downloadable results for %s - %s", track.CleanTitle, track.Artist) +} - if !completed { - cleanup() - return fmt.Errorf("search not completed for %s, skipping track", trackDetails) +func (c *Slskd) searchQueries(track *models.Track) []string { + var queries []string + seen := make(map[string]bool) + add := func(q string) { + q = strings.TrimSpace(q) + if q == "" || seen[strings.ToLower(q)] { + return } + seen[strings.ToLower(q)] = true + queries = append(queries, q) + } - - track.ID = ID - return nil + add(fmt.Sprintf("%s - %s", track.CleanTitle, track.Artist)) + var names []string + for _, n := range splitArtists(track.MainArtist) { + if len([]rune(n)) >= 2 { + names = append(names, n) + } + } + if len(names) > 2 { + names = names[:2] + } + for _, name := range names { + add(fmt.Sprintf("%s %s", track.CleanTitle, name)) + } + add(fmt.Sprintf("%s - %s", track.CleanTitle, wildcardArtist(track.Artist))) + return queries } -func (c *Slskd) GetTrack(track *models.Track) error { - results, err := c.searchResults(track.ID) - if err != nil { - return err +func splitArtists(artist string) []string { + norm := artist + for _, sep := range []string{" featuring ", " feat.", " feat ", " ft.", " ft ", " with ", " & ", " x ", ",", "/", "+", "&"} { + norm = strings.ReplaceAll(norm, sep, "|") } - files, err := c.CollectFiles(*track, results) - if err != nil { - return err + var parts []string + for _, part := range strings.Split(norm, "|") { + if p := strings.TrimSpace(part); p != "" { + parts = append(parts, p) + } } - filterFiles, err := c.filterFiles(files) + return parts +} + +func (c *Slskd) GetTrack(track *models.Track) error { + files, err := c.fetchCollectableFiles(track, track.ID) if err != nil { return err } - if err := c.queueDownload(filterFiles, track); err != nil { + if err := c.queueDownload(files, track); err != nil { return err } return nil } +func (c *Slskd) fetchCollectableFiles(track *models.Track, searchID string) ([]File, error) { + var results SearchResults + for attempt := 0; attempt < 4; attempt++ { + r, err := c.searchResults(searchID) + if err != nil { + return nil, err + } + results = r + total := 0 + for _, res := range results { + total += len(res.Files) + } + if total > 0 { + break + } + time.Sleep(3 * time.Second) + } + return c.CollectFiles(*track, results) +} + func (c Slskd) searchTrack(trackDetails string) (string, error) { reqParams := "/api/v0/searches" @@ -215,31 +268,48 @@ func (c Slskd) searchTrack(trackDetails string) (string, error) { return queryResult.ID, nil } -func (c Slskd) searchStatus(ID, trackDetails string, count int) (bool, error) { // Recursive func to see if search for track is finished +func (c Slskd) searchStatus(ID, trackDetails string) (bool, error) { reqParams := fmt.Sprintf("/api/v0/searches/%s", ID) - body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, c.Headers) - if err != nil { - return false, err - } - var queryResult Search - if err := util.ParseResp(body, &queryResult); err != nil { - return false, err - } - if queryResult.IsComplete && queryResult.FileCount > 0 { - return true, nil - } else if queryResult.IsComplete && queryResult.FileCount == 0 { - return false, errNoRes - } else if queryResult.IsComplete && queryResult.FileCount == queryResult.LockedFileCount { - return false, fmt.Errorf("search complete, did not find any downloadable files for %s", trackDetails) - } else if count >= c.Cfg.Retry { - slog.Debug(fmt.Sprintf("failed to remove %s", ID), logging.RuntimeAttr("")) - return false, fmt.Errorf("search wasn't completed after %d retries, skipping %s", count, trackDetails) + const pollInterval = 3 * time.Second + + maxWait := time.Duration(c.Cfg.Retry) * 15 * time.Second + if maxWait < 90*time.Second { + maxWait = 90 * time.Second } + deadline := time.Now().Add(maxWait) + + for { + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParams, nil, c.Headers) + if err != nil { + return false, err + } + var queryResult Search + if err := util.ParseResp(body, &queryResult); err != nil { + return false, err + } + + downloadable := queryResult.FileCount - queryResult.LockedFileCount + + if queryResult.IsComplete { + if downloadable > 0 { + return true, nil + } + if queryResult.FileCount == 0 { + return false, errNoRes + } + return false, fmt.Errorf("search complete, did not find any downloadable files for %s", trackDetails) + } - slog.Debug(fmt.Sprintf("[%s] (%d/%d) Searching for %s", "slskd", count, c.Cfg.Retry, trackDetails)) - time.Sleep(15 * time.Second) - return c.searchStatus(ID, trackDetails, count+1) + if time.Now().After(deadline) { + if downloadable > 0 { + return true, nil + } + return false, fmt.Errorf("search wasn't completed within %s, skipping %s", maxWait, trackDetails) + } + + time.Sleep(pollInterval) + } } func (c Slskd) searchResults(ID string) (SearchResults, error) { @@ -267,108 +337,141 @@ func (c Slskd) deleteSearch(ID string) error { return nil } -// Collect all files in response that match criteria func (c Slskd) CollectFiles(track models.Track, searchResults SearchResults) ([]File, error) { - sanitizedArtist := util.AlnumOnly(track.MainArtist) - sanitizedAlbum := util.AlnumOnly(track.Album) sanitizedTitle := util.AlnumOnly(track.CleanTitle) + artistTokens := artistMatchTokens(track.MainArtist) - files := slices.Collect(func(yield func(File) bool) { - for _, result := range searchResults { - if result.FileCount == 0 || !result.HasFreeUploadSlot { - continue - } - for _, file := range result.Files { - nameExt := util.AlnumOnly(strings.TrimPrefix(strings.ToLower(filepath.Ext(string(file.Name))), ".")) - reportedExt := strings.TrimPrefix(strings.ToLower(file.Extension), ".") - if nameExt != "" { - file.Extension = nameExt - } else { - file.Extension = reportedExt - } + sanitizedAlbum := util.AlnumOnly(track.Album) + if sanitizedAlbum == sanitizedTitle || len([]rune(sanitizedAlbum)) < 4 { + sanitizedAlbum = "" + } - if !slices.Contains(c.Cfg.Filters.Extensions, file.Extension) || ContainsKeyword(track, file.Name, c.Cfg.Filters.FilterList) { - continue - } + extRank := make(map[string]int, len(c.Cfg.Filters.Extensions)) + for i, ext := range c.Cfg.Filters.Extensions { + extRank[ext] = i + } - if track.Duration > 0 && util.Abs(track.Duration/1000-file.Length) > 10 { // skip song if track lengths have a 10s+ difference - continue - } + type candidate struct { + file File + freeSlot bool + rank int + } + var candidates []candidate - sanitizedFilename := util.AlnumOnly(string(file.Name)) - matchesArtist := containsLower(sanitizedFilename, sanitizedArtist) - matchesAlbum := containsLower(sanitizedFilename, sanitizedAlbum) - matchesTitle := containsLower(sanitizedFilename, sanitizedTitle) - if (matchesArtist || matchesAlbum) && matchesTitle { - file.Username = result.Username - if !yield(file) { - return - } - } - } + for _, result := range searchResults { + if result.FileCount == 0 { + continue } - }) - if len(files) == 0 { - return nil, fmt.Errorf("no tracks passed collection for %s - %s", track.MainArtist, track.CleanTitle) - } - return files, nil -} + for _, file := range result.Files { + nameExt := util.AlnumOnly(strings.TrimPrefix(strings.ToLower(filepath.Ext(string(file.Name))), ".")) + reportedExt := strings.TrimPrefix(strings.ToLower(file.Extension), ".") + if nameExt != "" { + file.Extension = nameExt + } else { + file.Extension = reportedExt + } -func (c Slskd) filterFiles(files []File) ([]File, error) { - var filtered []File + rank, ok := extRank[file.Extension] + if !ok || ContainsKeyword(track, file.Name, c.Cfg.Filters.FilterList) { + continue + } - for _, ext := range c.Cfg.Filters.Extensions { - for _, file := range files { - if file.Extension != ext { + if track.Duration > 0 && util.Abs(track.Duration/1000-file.Length) > 10 { // skip song if track lengths have a 10s+ difference continue } if file.BitRate > 0 && file.BitRate < c.Cfg.Filters.MinBitRate { continue } - if file.BitDepth > 0 && file.BitDepth < c.Cfg.Filters.MinBitDepth { continue } - filtered = append(filtered, file) - if len(filtered) >= c.Cfg.DownloadAttempts { - return filtered, nil + if !matchesTrack(file, sanitizedTitle, sanitizedAlbum, artistTokens) { + continue } + + file.Username = result.Username + candidates = append(candidates, candidate{file: file, freeSlot: result.HasFreeUploadSlot, rank: rank}) } } - if len(filtered) == 0 { - return nil, fmt.Errorf("no files found that match filters") + if len(candidates) == 0 { + return nil, fmt.Errorf("no tracks passed collection for %s - %s", track.MainArtist, track.CleanTitle) } - return filtered, nil -} -func (c Slskd) queueDownload(files []File, track *models.Track) error { - for i, file := range files { - reqParams := fmt.Sprintf("/api/v0/transfers/downloads/%s", file.Username) - payload := []DownloadPayload{ - { - Filename: file.Name, - Size: file.Size, - }, + slices.SortStableFunc(candidates, func(a, b candidate) int { + if a.freeSlot != b.freeSlot { + if a.freeSlot { + return -1 + } + return 1 } + if a.rank != b.rank { + return a.rank - b.rank + } + return b.file.BitRate - a.file.BitRate + }) - DLpayload, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %s", err.Error()) + files := make([]File, 0, len(candidates)) + for _, cand := range candidates { + files = append(files, cand.file) + if len(files) >= c.Cfg.DownloadAttempts { + break + } + } + return files, nil +} + +func matchesTrack(file File, sanitizedTitle, sanitizedAlbum string, artistTokens []string) bool { + base := filepath.Base(strings.ReplaceAll(string(file.Name), `\`, `/`)) + if !containsLower(util.AlnumOnly(base), sanitizedTitle) { + return false + } + sanitizedFilename := util.AlnumOnly(string(file.Name)) + if sanitizedAlbum != "" && containsLower(sanitizedFilename, sanitizedAlbum) { + return true + } + for _, tok := range artistTokens { + if containsLower(sanitizedFilename, tok) { + return true } + } + return false +} - _, err = c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParams, bytes.NewBuffer(DLpayload), c.Headers) - if err == nil { - track.MainArtistID = file.Username - track.Size = file.Size - track.File = file.Name - return nil +func artistMatchTokens(artist string) []string { + seen := make(map[string]struct{}) + var tokens []string + for _, part := range splitArtists(strings.ToLower(artist)) { + tok := util.AlnumOnly(part) + if len(tok) < 3 { + continue + } + if _, dup := seen[tok]; dup { + continue + } + seen[tok] = struct{}{} + tokens = append(tokens, tok) + } + if len(tokens) == 0 { + if tok := util.AlnumOnly(artist); tok != "" { + tokens = append(tokens, tok) } + } + return tokens +} - slog.Warn(fmt.Sprintf("[%d/%d] failed to queue download for '%s - %s': %s", i+1, len(files), track.CleanTitle, track.Artist, err.Error())) - continue +func (c Slskd) queueDownload(files []File, track *models.Track) error { + for i, file := range files { + if err := c.queueFile(file, track); err != nil { + slog.Warn(fmt.Sprintf("[%d/%d] failed to queue download for '%s - %s': %s", i+1, len(files), track.CleanTitle, track.Artist, err.Error())) + continue + } + c.retry.mu.Lock() + c.retry.remaining[track.ID] = files[i+1:] + c.retry.mu.Unlock() + return nil } if err := c.deleteSearch(track.ID); err != nil { slog.Debug("failed to delete search", logging.RuntimeAttr(err.Error())) @@ -376,6 +479,43 @@ func (c Slskd) queueDownload(files []File, track *models.Track) error { return fmt.Errorf("couldn't download track: %s - %s", track.CleanTitle, track.Artist) } +// queueFile asks slskd to download one file and records it on the track. +func (c Slskd) queueFile(file File, track *models.Track) error { + payload, err := json.Marshal([]DownloadPayload{{Filename: file.Name, Size: file.Size}}) + if err != nil { + return fmt.Errorf("failed to marshal payload: %s", err.Error()) + } + reqParams := fmt.Sprintf("/api/v0/transfers/downloads/%s", file.Username) + if _, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParams, bytes.NewBuffer(payload), c.Headers); err != nil { + return err + } + track.MainArtistID = file.Username + track.Size = file.Size + track.File = file.Name + return nil +} + +// RetryDownload queues the next untried candidate, or returns false if none remain. +func (c *Slskd) RetryDownload(track *models.Track) (bool, error) { + c.retry.mu.Lock() + files := c.retry.remaining[track.ID] + c.retry.mu.Unlock() + + for i, file := range files { + if err := c.queueFile(file, track); err != nil { + continue + } + c.retry.mu.Lock() + c.retry.remaining[track.ID] = files[i+1:] + c.retry.mu.Unlock() + slog.Info("[slskd] retrying with next source", "track", track.CleanTitle, "file", file.Name) + return true, nil + } + c.retry.mu.Lock() + delete(c.retry.remaining, track.ID) + c.retry.mu.Unlock() + return false, nil +} func (c *Slskd) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { reqParams := "/api/v0/transfers/downloads" @@ -399,12 +539,12 @@ func (c *Slskd) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus for _, file := range dir.Files { if string(file.Name) == track.File { fileStatuses[track.File] = FileStatus{ - ID: file.ID, - Size: file.Size, - State: normalize(file.State), + ID: file.ID, + Size: file.Size, + State: normalize(file.State), BytesTransferred: file.BytesTransferred, - BytesRemaining: file.BytesRemaining, - PercentComplete: file.PercentComplete, + BytesRemaining: file.BytesRemaining, + PercentComplete: file.PercentComplete, } } } @@ -454,28 +594,28 @@ func wildcardArtist(artist string) string { if len(artist) >= 4 && strings.EqualFold(artist[:4], "the ") { prefix = artist[:4] artist = strings.TrimSpace(artist[4:]) -} - r := []rune(strings.TrimSpace(artist)) + } + r := []rune(strings.TrimSpace(artist)) - if len(r) < 3 { - return artist - } + if len(r) < 3 { + return artist + } - r[0] = '*' - return prefix + string(r) + r[0] = '*' + return prefix + string(r) } // different failure states slskd has (format is "Completed,Rejected", "Errored,Cancelled" etc..) -var failureStates = map[string]struct{} { - "Aborted": {}, - "TimedOut": {}, - "Rejected": {}, - "Errored": {}, +var failureStates = map[string]struct{}{ + "Aborted": {}, + "TimedOut": {}, + "Rejected": {}, + "Errored": {}, "Cancelled": {}, } // return a single error state for failed downloads -func normalize(state string) string{ +func normalize(state string) string { parts := strings.SplitSeq(state, ",") for p := range parts { diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index f1497ad..0f2bd1f 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -291,6 +291,10 @@ func (c *Youtube) GetDownloadStatus(tracks []*models.Track) (map[string]FileStat return nil, fmt.Errorf("no monitoring required") } +func (c *Youtube) RetryDownload(track *models.Track) (bool, error) { + return false, nil +} + func (c *Youtube) Cleanup(track models.Track, ID string) error { return nil } diff --git a/src/util/metadata.go b/src/util/metadata.go index 039ede8..8337bc7 100644 --- a/src/util/metadata.go +++ b/src/util/metadata.go @@ -3,6 +3,7 @@ package util import ( "explo/src/models" "fmt" + "log/slog" "strings" ffmpeg "github.com/u2takey/ffmpeg-go" @@ -41,7 +42,18 @@ func BuildffmpegMetadata(track models.Track) []string { metadata = addStringTag(metadata, "title", track.Title) metadata = addStringTag(metadata, "album", track.Album) - metadata = addStringTag(metadata, "albumartist", track.AlbumArtist) + + albumArtist := track.AlbumArtist + if albumArtist == "" && len(track.Artists) > 0 { + albumArtist = track.Artists[0] + } + if albumArtist == "" { + albumArtist = track.MainArtist + } + if albumArtist == "" { + albumArtist = track.Artist + } + metadata = addStringTag(metadata, "albumartist", albumArtist) metadata = addStringTag(metadata, "artistsort", track.ArtistSort) metadata = addStringTag(metadata, "date", track.OriginalDate) metadata = addStringTag(metadata, "genre", track.Genres) @@ -70,7 +82,9 @@ func BuildffmpegMetadata(track models.Track) []string { func WriteMetadata(streams []*ffmpeg.Stream, ffmpegPath, filePath string, opts ffmpeg.KwArgs) error { - cmd := ffmpeg.Output(streams, filePath, opts).OverWriteOutput().ErrorToStdOut() + cmd := ffmpeg.Output(streams, filePath, opts).OverWriteOutput().ErrorToStdOut().Silent(true) + + slog.Debug("ffmpeg command", "args", "ffmpeg "+strings.Join(cmd.GetArgs(), " ")) if ffmpegPath != "" { cmd.SetFfmpegPath(ffmpegPath) From 965ebe33b99c25a7a62cd042210557b98df5dbae Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:47:05 -0700 Subject: [PATCH 2/2] update metadata handling to overwrite clean tags on downloads --- src/config/config.go | 2 +- src/web/sample.env | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 874e56a..ece4785 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -100,7 +100,7 @@ type DownloadConfig struct { Slskd Slskd ExcludeLocal bool DownloadLimiter int `env:"DOWNLOAD_LIMITER" env-default:"1"` // rate limit download operations - OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"false"` // overwrite metadata when migrating downloads + OverwriteMetadata bool `env:"OVERWRITE_METADATA" env-default:"true"` // rewrite clean tags on every finished download KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"` diff --git a/src/web/sample.env b/src/web/sample.env index 68511ba..7082792 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -109,8 +109,8 @@ LIBRARY_NAME= # SINGLE_ARTIST=true # Playlist name format: week (Weekly-Exploration-2026-Week5) or date (Weekly-Exploration-2026-01-31) # PLAYLISTNAME_FORMAT=week -# Overwrite track metadata with metadata from ListenBrainz when moving downloaded tracks (slskd) (default: false) -# OVERWRITE_METADATA=false +# Rewrite clean tags on every finished download (strips the uploader's junk tags, writes proper artist/title/album). (default: true) +# OVERWRITE_METADATA=true # === Notifications ===