From cc2f411987954840614ea254506dee2e7c343b6c Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Tue, 18 Aug 2026 22:05:00 -0700 Subject: [PATCH 01/57] Fix admin client sending no X-Plex-Token AddHeader() returned early whenever Creds.Headers was nil, before the branch that installs X-Plex-Token. The main plex setup path hides this by calling AddHeader() twice (client.go:157 and :167), and the admin user/password path hides it because GetAuth() sets the header itself. The admin API key path has neither. GetLibrary() builds the admin client with a fresh Credentials{APIKey: ...}, whose Headers map is nil, and calls AddHeader() exactly once -- so the client went out with only X-Plex-Client-Identifier and every request returned 401. This is the configuration the docs direct Plex MFA users to, since GetAuth() needs a literal password. It also covers accounts using SSO (Apple/Google), which likewise have no password to send. Keep the early return for the case it was written for -- no key to install yet -- and otherwise fall through to set the token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ZuEbqv1CGbPub9kB3g2s4 --- src/client/plex.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/client/plex.go b/src/client/plex.go index a4779659..003e8b2b 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -244,7 +244,15 @@ func (c *Plex) AddHeader() error { c.Cfg.Creds.Headers = make(map[string]string) c.Cfg.Creds.Headers["X-Plex-Client-Identifier"] = c.Cfg.ClientID - return nil + // Only bail out here when there is no key to install yet. The plex + // setup path calls AddHeader() again after GetAuth() fills in the + // token, but the admin client built in GetLibrary() gets a fresh + // Credentials{APIKey: ...} with a nil Headers map and is only called + // once -- returning early there left it with no X-Plex-Token, so + // every admin request 401'd. + if c.Cfg.Creds.APIKey == "" { + return nil + } } if c.Cfg.Creds.APIKey != "" { c.Cfg.Creds.Headers["X-Plex-Token"] = c.Cfg.Creds.APIKey From 56fc672d410ba881f9e94fd27cf429af4a52ba1d Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Tue, 18 Aug 2026 23:48:04 -0700 Subject: [PATCH 02/57] Fix "Save each run as new playlist" toggle never saving The toggle in PlaylistCard reverts immediately and the setting is never persisted. saveReplacePlaylist() posted {name, replace}, but HandleSaveReplacePlaylist reads body.ID to resolve the playlist: if def, ok := defs.PlaylistDefs[body.ID]; ok { } else if defs.CustomIDRe.MatchString(body.ID) { } else { http.Error(w, "unknown playlist name", http.StatusBadRequest) body.ID was always empty, so every request 400'd. Settings.jsx treats a failed save as a signal to roll back its optimistic update, which is why the toggle flips back on its own. Send {id, name, replace} instead, matching saveSchedule() -- whose handler resolves the playlist the same way and already receives both fields. The handler needs id to look up the playlist definition and name to derive the env prefix for custom playlists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013ZuEbqv1CGbPub9kB3g2s4 --- src/web/frontend/src/components/Settings.jsx | 2 +- src/web/frontend/src/lib/api.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index da645a4a..2d9730ab 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -320,7 +320,7 @@ function HomeSection() { onReplaceToggle: () => { const next = !(replacePlaylists[id] ?? true) setReplacePlaylists(prev => ({ ...prev, [id]: next })) - saveReplacePlaylist(id, next).catch(() => + saveReplacePlaylist(id, s.name, next).catch(() => setReplacePlaylists(prev => ({ ...prev, [id]: !next })) ) }, diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index 4fc349eb..0331c4fb 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -232,11 +232,11 @@ export async function saveEnrichMetadata(enabled) { if (!res.ok) throw new Error(await res.text()) } -export async function saveReplacePlaylist(name, replace) { +export async function saveReplacePlaylist(id, name, replace) { const res = await apiFetch('/api/ui/config/replace-playlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, replace }), + body: JSON.stringify({ id, name, replace }), }) if (!res.ok) throw new Error(await res.text()) } From b4f3b8ad802a0f6b42a1ec55ebb3f2bd2af23307 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 13:07:34 -0700 Subject: [PATCH 03/57] Add opt-in album downloads for slskd Set SLSKD_ALBUM_MODE=true and slskd fetches the whole release the recommended track belongs to, rather than that track alone. Off by default: it multiplies transfer volume per recommendation, and existing setups should not change behaviour on upgrade. How it works. QueryTrack searches " - " instead of " - ", falling back to the track when the recommendation carries no album. GetTrack then groups results by peer and directory, which is the closest thing Soulseek has to a release, and picks the best candidate: it must contain the recommended track, a directory naming the album beats one that merely contains the track, and among equals the fuller release wins. Every file goes to slskd in a single request, since the downloads endpoint already takes an array. Two filters are deliberately not applied to sibling tracks. The ten-second duration check is meaningful only for the recommended track, and FILTER_LIST is matched against the directory instead, so a live pressing is rejected as a release rather than one track at a time. Only the recommended track enters the playlist. Explo builds a discovery list, and a dozen album tracks per recommendation would bury it. The rest simply land in the library. track.File still points at the recommended track, so MonitorDownloads needs no changes. Siblings ride along in track.AlbumFiles and are migrated once the primary is moved, skipping any that slskd has not finished -- copying a file mid-transfer would put a truncated track in the library. Migration hangs off an optional albumMigrator interface rather than MoveDownload reaching for a concrete downloader. Youtube grabs a single file and simply does not implement it. The hook runs after the primary is removed and before the empty-directory check, so a directory still holding an unfinished album track survives to be cleaned up on a later pass. Co-Authored-By: Claude Opus 5 --- sample.env | 5 + src/config/config.go | 4 + src/downloader/downloader.go | 26 +++ src/downloader/slskd.go | 16 +- src/downloader/slskd_album.go | 364 +++++++++++++++++++++++++++++ src/downloader/slskd_album_test.go | 349 +++++++++++++++++++++++++++ src/models/types.go | 1 + src/web/sample.env | 5 + 8 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 src/downloader/slskd_album.go create mode 100644 src/downloader/slskd_album_test.go diff --git a/sample.env b/sample.env index 68511ba1..bee99758 100644 --- a/sample.env +++ b/sample.env @@ -84,6 +84,11 @@ LIBRARY_NAME= # MIGRATE_DOWNLOADS=false # Rename migrated track in {artist}-{title} format # RENAME_TRACK=false +# Download the whole release the matched file belongs to instead of just the +# recommended track. Only the recommended track is added to the playlist; the +# rest simply land in the library. Off by default -- it multiplies transfer +# volume per recommendation. (default: false) +# SLSKD_ALBUM_MODE=false # Directory where slskd downloads tracks (default: /slskd/) # PS! This is only needed on the binary version, in docker it's set through volume mapping # SLSKD_DIR=/slskd/ diff --git a/src/config/config.go b/src/config/config.go index 874e56a6..ce314d2f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -140,6 +140,10 @@ type Slskd struct { DownloadAttempts int `env:"SLSKD_DL_ATTEMPTS" env-default:"3"` // Max number of files to attempt downloading per track SlskdDir string `env:"SLSKD_DIR" env-default:"/slskd/"` MigrateDL bool `env:"MIGRATE_DOWNLOADS" env-default:"false"` // Move downloads from SlskdDir to DownloadDir + // Download every track in the release the matched file sits in, rather + // than the single recommended track. Off by default: it multiplies how + // much is transferred per recommendation. + AlbumMode bool `env:"SLSKD_ALBUM_MODE" env-default:"false"` Timeout int `env:"SLSKD_TIMEOUT" env-default:"20"` Filters Filters MonitorConfig SlskdMon diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 9451f66f..e9596375 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -33,6 +33,26 @@ type Downloader interface { Monitor } +// albumMigrator is implemented by downloaders that fetch a whole release and +// so have files to migrate beyond the one the monitor tracks. It is optional: +// a downloader that only ever grabs a single file simply does not implement it. +type albumMigrator interface { + MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) +} + +// albumMigrators returns the registered downloaders that migrate whole +// releases. Implementations are responsible for deciding whether they have +// anything to move for a given track. +func (c *DownloadClient) albumMigrators() []albumMigrator { + var migrators []albumMigrator + for _, d := range c.Downloaders { + if m, ok := d.(albumMigrator); ok { + migrators = append(migrators, m) + } + } + return migrators +} + // get download services from config and append them to DownloadClient func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterLocal bool) (*DownloadClient, error) { var downloader []Downloader @@ -326,6 +346,12 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * return fmt.Errorf("failed to delete original file: %s", err.Error()) } + // Siblings move before the emptiness check below, so a directory still + // holding an unfinished album track is not removed out from under it. + for _, m := range c.albumMigrators() { + m.MoveAlbumSiblings(trackDir, filepath.Dir(dstFile), track, c.Cfg.KeepPermissions) + } + isEmpty, err := isDirEmpty(trackDir) if err != nil { return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 0708786f..4aaa836d 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -136,6 +136,9 @@ func (c *Slskd) QueryTrack(track *models.Track) error { wildcardSearch := false trackDetails := fmt.Sprintf("%s - %s", track.CleanTitle, track.Artist) + if c.Cfg.AlbumMode { + trackDetails = albumSearchTerm(*track) + } retry: ID, err := c.searchTrack(trackDetails) @@ -154,7 +157,11 @@ func (c *Slskd) QueryTrack(track *models.Track) error { if errors.Is(err, errNoRes) && !wildcardSearch { cleanup() wildcardSearch = true - trackDetails = fmt.Sprintf("%s - %s", track.CleanTitle, wildcardArtist(track.Artist)) + if c.Cfg.AlbumMode && strings.TrimSpace(track.Album) != "" { + trackDetails = fmt.Sprintf("%s - %s", wildcardArtist(track.MainArtist), track.Album) + } else { + 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 } @@ -179,6 +186,13 @@ func (c *Slskd) GetTrack(track *models.Track) error { if err != nil { return err } + if c.Cfg.AlbumMode { + files, err := c.CollectAlbumFiles(*track, results) + if err != nil { + return err + } + return c.queueAlbumDownload(files, track) + } files, err := c.CollectFiles(*track, results) if err != nil { return err diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go new file mode 100644 index 00000000..f1ac3997 --- /dev/null +++ b/src/downloader/slskd_album.go @@ -0,0 +1,364 @@ +package downloader + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "explo/src/models" + "explo/src/util" +) + +// Soulseek peers report Windows-style paths. Normalising once means the rest of +// this file can treat them as ordinary slash-separated paths. +func normalizePeerPath(p string) string { + return strings.ReplaceAll(p, `\`, "/") +} + +// peerDir is one candidate release: the files a single peer shares from a +// single directory. +type peerDir struct { + username string + dir string + files []File + // primary is the file matching the recommended track. A directory without + // one is not a candidate, since the recommendation is what we came for. + primary *File +} + +// groupByDirectory buckets search results by peer and parent directory, keeping +// only files whose extension is allowed. Quality filtering happens later, per +// candidate, so that one bad file does not disqualify a whole release. +func (c Slskd) groupByDirectory(results SearchResults) []peerDir { + index := make(map[string]*peerDir) + var order []string + + for _, result := range results { + if result.FileCount == 0 || !result.HasFreeUploadSlot { + continue + } + for _, file := range result.Files { + file.Extension = resolveExtension(file) + if !slices.Contains(c.Cfg.Filters.Extensions, file.Extension) { + continue + } + + dir := path.Dir(normalizePeerPath(string(file.Name))) + key := result.Username + "\x00" + dir + + if _, ok := index[key]; !ok { + index[key] = &peerDir{username: result.Username, dir: dir} + order = append(order, key) + } + file.Username = result.Username + index[key].files = append(index[key].files, file) + } + } + + dirs := make([]peerDir, 0, len(order)) + for _, key := range order { + dirs = append(dirs, *index[key]) + } + return dirs +} + +// resolveExtension prefers the extension in the filename over the one the peer +// reports, matching what CollectFiles does for single tracks. +func resolveExtension(file File) string { + nameExt := util.AlnumOnly(strings.TrimPrefix(strings.ToLower(path.Ext(normalizePeerPath(string(file.Name)))), ".")) + if nameExt != "" { + return nameExt + } + return strings.TrimPrefix(strings.ToLower(file.Extension), ".") +} + +// findPrimary locates the recommended track inside a candidate directory. +// Without it the directory may well be the right album, but we would be +// downloading it without the track that prompted the recommendation. +func findPrimary(dir *peerDir, track models.Track) bool { + sanitizedTitle := util.AlnumOnly(track.CleanTitle) + if sanitizedTitle == "" { + return false + } + + for i := range dir.files { + name := util.AlnumOnly(path.Base(normalizePeerPath(string(dir.files[i].Name)))) + if !containsLower(name, sanitizedTitle) { + continue + } + // Duration is only meaningful for the recommended track; sibling tracks + // legitimately differ, which is why this check is not applied to them. + if track.Duration > 0 && dir.files[i].Length > 0 && + util.Abs(track.Duration/1000-dir.files[i].Length) > 10 { + continue + } + dir.primary = &dir.files[i] + return true + } + return false +} + +// scoreDir ranks a candidate release. A directory naming the album beats one +// that merely happens to contain the track, and among equals the fuller +// release wins. +func scoreDir(dir peerDir, track models.Track) int { + var score int + + sanitizedDir := util.AlnumOnly(dir.dir) + if album := util.AlnumOnly(track.Album); album != "" && containsLower(sanitizedDir, album) { + score += 100 + } + if artist := util.AlnumOnly(track.MainArtist); artist != "" && containsLower(sanitizedDir, artist) { + score += 50 + } + + // More tracks means a more complete release, but never let file count + // outweigh actually matching the album. + score += min(len(dir.files), 40) + + return score +} + +// qualityFiltered drops files failing the bitrate and bit-depth floors. Unlike +// filterFiles it does not cap the count -- the whole release is the point -- +// and it leaves the primary in place even if it fails, since a playlist entry +// with a mediocre file beats no entry at all. +func (c Slskd) qualityFiltered(dir peerDir) []File { + kept := make([]File, 0, len(dir.files)) + + for _, file := range dir.files { + isPrimary := dir.primary != nil && file.Name == dir.primary.Name + if !isPrimary { + if file.BitRate > 0 && file.BitRate < c.Cfg.Filters.MinBitRate { + continue + } + if file.BitDepth > 0 && file.BitDepth < c.Cfg.Filters.MinBitDepth { + continue + } + } + kept = append(kept, file) + } + return kept +} + +// CollectAlbumFiles picks the release to download. It returns the files to +// queue with the recommended track first, so callers can treat files[0] as the +// one that belongs in the playlist. +func (c Slskd) CollectAlbumFiles(track models.Track, results SearchResults) ([]File, error) { + candidates := c.groupByDirectory(results) + + var best *peerDir + var bestScore int + + for i := range candidates { + dir := &candidates[i] + + // A keyword hit on the directory rejects the whole release -- this is + // how "live" or "remix" pressings get excluded in album mode. + if ContainsKeyword(track, dir.dir, c.Cfg.Filters.FilterList) { + continue + } + if !findPrimary(dir, track) { + continue + } + if score := scoreDir(*dir, track); best == nil || score > bestScore { + best, bestScore = dir, score + } + } + + if best == nil { + return nil, fmt.Errorf("no release found containing %s - %s", track.MainArtist, track.CleanTitle) + } + + files := c.qualityFiltered(*best) + if len(files) == 0 { + return nil, fmt.Errorf("no files passed filters in %s", best.dir) + } + + // Primary first: queueAlbumDownload and the monitor both rely on it. + primaryName := best.primary.Name + slices.SortStableFunc(files, func(a, b File) int { + switch { + case a.Name == primaryName: + return -1 + case b.Name == primaryName: + return 1 + default: + return 0 + } + }) + + slog.Info("album release selected", + "dir", best.dir, "user", best.username, "tracks", len(files), "score", bestScore) + + return files, nil +} + +// queueAlbumDownload queues an entire release in a single request. slskd takes +// an array of files per peer, so the whole directory goes in one call. +func (c Slskd) queueAlbumDownload(files []File, track *models.Track) error { + if len(files) == 0 { + return fmt.Errorf("no files to queue for %s - %s", track.CleanTitle, track.Artist) + } + + primary := files[0] + + payload := make([]DownloadPayload, 0, len(files)) + for _, file := range files { + payload = append(payload, DownloadPayload{Filename: string(file.Name), Size: file.Size}) + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %s", err.Error()) + } + + reqParams := fmt.Sprintf("/api/v0/transfers/downloads/%s", primary.Username) + if _, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParams, bytes.NewBuffer(body), c.Headers); err != nil { + return fmt.Errorf("couldn't queue release for %s - %s: %s", track.CleanTitle, track.Artist, err.Error()) + } + + // The monitor and the playlist follow the primary; the rest ride along. + track.MainArtistID = primary.Username + track.Size = primary.Size + track.File = string(primary.Name) + + track.AlbumFiles = track.AlbumFiles[:0] + for _, file := range files[1:] { + track.AlbumFiles = append(track.AlbumFiles, string(file.Name)) + } + + slog.Info("queued release", + "track", track.CleanTitle, "artist", track.MainArtist, "files", len(files)) + + return nil +} + +// albumSearchTerm is what we ask slskd for in album mode. Falling back to the +// track keeps releases with no album metadata working. +func albumSearchTerm(track models.Track) string { + if strings.TrimSpace(track.Album) == "" { + return fmt.Sprintf("%s - %s", track.CleanTitle, track.Artist) + } + return fmt.Sprintf("%s - %s", track.MainArtist, track.Album) +} + +// siblingStates reports the transfer state slskd has for each file it is +// downloading from a peer, keyed by the peer's path for that file. +func (c Slskd) siblingStates(username string) (map[string]string, error) { + body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+"/api/v0/transfers/downloads", nil, c.Headers) + if err != nil { + return nil, err + } + + var statuses DownloadStatus + if err := util.ParseResp(body, &statuses); err != nil { + return nil, err + } + + states := make(map[string]string) + for _, status := range statuses { + if status.Username != username { + continue + } + for _, dir := range status.Directories { + for _, file := range dir.Files { + states[string(file.Name)] = normalize(file.State) + } + } + } + return states, nil +} + +// MoveAlbumSiblings migrates the rest of the release once the recommended +// track has been moved. A sibling still transferring is left where it is: the +// monitor finishes on the primary, and copying a partial file would put a +// truncated track in the library. +// +// keepPermissions is passed in rather than read from the slskd config because +// it is a download-wide setting owned by DownloadClient, which is also what +// calls this. +func (c Slskd) MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) { + if !c.Cfg.AlbumMode || len(track.AlbumFiles) == 0 { + return + } + + states, err := c.siblingStates(track.MainArtistID) + if err != nil { + slog.Warn("couldn't check release download states, leaving the rest of the album in place", + "context", err.Error()) + return + } + + var moved, pending int + for _, peerPath := range track.AlbumFiles { + if !strings.Contains(states[peerPath], "Succeeded") { + pending++ + continue + } + + name := path.Base(normalizePeerPath(peerPath)) + if err := copyFile(filepath.Join(trackDir, name), filepath.Join(destDir, name), keepPermissions); err != nil { + slog.Warn("failed to move album track", "file", name, "context", err.Error()) + continue + } + moved++ + } + + slog.Info("migrated release", "album", track.Album, "moved", moved, "still downloading", pending) +} + +// copyFile moves one finished album track into the library, mirroring how +// MoveDownload handles the recommended track: copy, sync, optionally preserve +// permissions, then drop the original. +func copyFile(src, dst string, keepPermissions bool) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("couldn't open source file: %s", err.Error()) + } + defer func() { + if cerr := in.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close source file: %s", cerr.Error())) + } + }() + + if err = os.MkdirAll(filepath.Dir(dst), os.ModePerm); err != nil { + return fmt.Errorf("couldn't make destination directory: %s", err.Error()) + } + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("couldn't create destination file: %s", err.Error()) + } + defer func() { + if cerr := out.Close(); cerr != nil { + slog.Error(fmt.Sprintf("failed to close destination file: %s", cerr.Error())) + } + }() + + if _, err = io.Copy(out, in); err != nil { + return fmt.Errorf("copy failed: %s", err.Error()) + } + if err = out.Sync(); err != nil { + return fmt.Errorf("sync failed: %s", err.Error()) + } + + if keepPermissions { + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("stat error: %s", err.Error()) + } + if err = os.Chmod(dst, info.Mode()); err != nil { + return fmt.Errorf("chmod failed: %s", err.Error()) + } + } + + return os.Remove(src) +} diff --git a/src/downloader/slskd_album_test.go b/src/downloader/slskd_album_test.go new file mode 100644 index 00000000..17bd4f7c --- /dev/null +++ b/src/downloader/slskd_album_test.go @@ -0,0 +1,349 @@ +package downloader + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + cfg "explo/src/config" + "explo/src/models" +) + +func albumClient(t *testing.T, handler http.HandlerFunc) *Slskd { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client := NewSlskd(cfg.Slskd{ + URL: server.URL, + APIKey: "test-key", + Timeout: 5, + AlbumMode: true, + Filters: cfg.Filters{ + Extensions: []string{"flac", "mp3"}, + MinBitRate: 256, + MinBitDepth: 8, + FilterList: []string{"live", "remix"}, + }, + }, "/data/") + client.AddHeader() + + return client +} + +// peer describes one peer sharing one directory. +type peer struct { + user string + dir string + names []string +} + +// albumResults builds search results without naming SearchResults' anonymous +// element type, so new fields on it cannot break these tests. +func albumResults(peers ...peer) SearchResults { + results := make(SearchResults, len(peers)) + for i, p := range peers { + results[i].Username = p.user + results[i].HasFreeUploadSlot = true + results[i].FileCount = len(p.names) + for _, name := range p.names { + results[i].Files = append(results[i].Files, File{ + Name: p.dir + `\` + name, + Extension: "flac", + BitRate: 900, + BitDepth: 16, + Size: 1000, + Length: 200, + }) + } + } + return results +} + +func albumTrack() models.Track { + return models.Track{ + CleanTitle: "Bad Guy", + Title: "Bad Guy", + Artist: "Billie Eilish", + MainArtist: "Billie Eilish", + Album: "When We All Fall Asleep", + Duration: 200_000, + } +} + +// The whole point of album mode: every track in the release is returned, not +// just the one that was recommended. +func TestCollectAlbumFiles_ReturnsWholeRelease(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, []string{"01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac"}}) + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 3 { + t.Fatalf("got %d files, want the whole 3-track release", len(files)) + } + if !strings.Contains(string(files[0].Name), "Bad Guy") { + t.Errorf("files[0] = %q, want the recommended track first", files[0].Name) + } +} + +// A directory that happens to contain the track but is not the album must lose +// to the one that actually names the album. +func TestCollectAlbumFiles_PrefersTheMatchingAlbum(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults( + peer{"peer1", `@@x\Various\Party Mix 2019`, + []string{"04 Bad Guy.flac", "05 Something Else.flac", "06 Another.flac", "07 More.flac"}}, + peer{"peer2", `@@y\Billie Eilish\When We All Fall Asleep`, + []string{"01 Bury A Friend.flac", "02 Bad Guy.flac"}}, + ) + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if files[0].Username != "peer2" { + t.Errorf("selected peer %q, want peer2 -- the compilation has more files but is the wrong release", files[0].Username) + } +} + +// A release with no copy of the recommended track is useless here, however +// well it otherwise matches. +func TestCollectAlbumFiles_RequiresTheRecommendedTrack(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, + []string{"01 Bury A Friend.flac", "03 Xanny.flac"}}) + + _, err := client.CollectAlbumFiles(albumTrack(), results) + if err == nil { + t.Fatal("CollectAlbumFiles() = nil, want an error when the recommended track is absent") + } + if !strings.Contains(err.Error(), "no release found") { + t.Errorf("error = %q, want it to say no release was found", err) + } +} + +// FILTER_LIST applies to the directory in album mode, so a live pressing is +// rejected as a whole rather than track by track. +func TestCollectAlbumFiles_RejectsFilteredRelease(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep (Live)`, []string{"01 Bury A Friend.flac", "02 Bad Guy.flac"}}) + + if _, err := client.CollectAlbumFiles(albumTrack(), results); err == nil { + t.Fatal("CollectAlbumFiles() = nil, want the live release to be filtered out") + } +} + +// Sibling tracks have their own durations, so the recommended track's runtime +// must not be used to exclude them. +func TestCollectAlbumFiles_DoesNotApplyTrackDurationToSiblings(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, []string{"01 Bury A Friend.flac", "02 Bad Guy.flac"}}) + + // A 6-minute sibling against a 200s recommended track. + results[0].Files[0].Length = 360 + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 2 { + t.Errorf("got %d files, want 2 -- the longer sibling was wrongly excluded", len(files)) + } +} + +// One request per release, carrying every file, and the track left pointing at +// the recommended one so the monitor and the playlist still work. +func TestQueueAlbumDownload_QueuesEveryFileInOneRequest(t *testing.T) { + var gotPayload []DownloadPayload + var gotPath string + var requests int + + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + gotPath = r.URL.Path + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request body: %v", err) + } + if err := json.Unmarshal(body, &gotPayload); err != nil { + t.Errorf("unmarshaling request body: %v", err) + } + if _, err := w.Write([]byte(`{}`)); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, []string{"01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac"}}) + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + + track := albumTrack() + if err := client.queueAlbumDownload(files, &track); err != nil { + t.Fatalf("queueAlbumDownload() = %v, want nil", err) + } + + if requests != 1 { + t.Errorf("made %d requests, want 1 for the whole release", requests) + } + if gotPath != "/api/v0/transfers/downloads/peer1" { + t.Errorf("posted to %q, want the peer's download endpoint", gotPath) + } + if len(gotPayload) != 3 { + t.Errorf("queued %d files, want 3", len(gotPayload)) + } + if !strings.Contains(track.File, "Bad Guy") { + t.Errorf("track.File = %q, want the recommended track -- the monitor follows this", track.File) + } + if len(track.AlbumFiles) != 2 { + t.Errorf("AlbumFiles has %d entries, want the 2 siblings", len(track.AlbumFiles)) + } + for _, sibling := range track.AlbumFiles { + if sibling == track.File { + t.Error("the recommended track is also listed as a sibling; it would be migrated twice") + } + } +} + +// Album mode searches for the release. Without an album name there is nothing +// to search for, so it must fall back to the track. +func TestAlbumSearchTerm(t *testing.T) { + track := albumTrack() + if got, want := albumSearchTerm(track), "Billie Eilish - When We All Fall Asleep"; got != want { + t.Errorf("albumSearchTerm() = %q, want %q", got, want) + } + + track.Album = " " + if got, want := albumSearchTerm(track), "Bad Guy - Billie Eilish"; got != want { + t.Errorf("with no album, albumSearchTerm() = %q, want the track fallback %q", got, want) + } +} + +// Peers report Windows paths; grouping by directory has to survive that or +// every file looks like it lives in its own release. +func TestNormalizePeerPathGrouping(t *testing.T) { + client := albumClient(t, nil) + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, []string{"01 Bury A Friend.flac", "02 Bad Guy.flac"}}) + + dirs := client.groupByDirectory(results) + if len(dirs) != 1 { + t.Fatalf("grouped into %d directories, want 1 -- backslash paths were not normalised", len(dirs)) + } + if len(dirs[0].files) != 2 { + t.Errorf("directory holds %d files, want 2", len(dirs[0].files)) + } +} + +// downloadsResponse fakes GET /api/v0/transfers/downloads: one peer, one +// directory, and whatever per-file transfer states the test needs. +func downloadsResponse(t *testing.T, user string, states map[string]string) string { + t.Helper() + + files := make([]map[string]any, 0, len(states)) + for name, state := range states { + files = append(files, map[string]any{"filename": name, "state": state}) + } + + body, err := json.Marshal([]map[string]any{{ + "username": user, + "directories": []map[string]any{{"files": files}}, + }}) + if err != nil { + t.Fatalf("building fake downloads response: %v", err) + } + return string(body) +} + +// A sibling slskd has finished belongs in the library; one still transferring +// must be left alone, because copying it would put a truncated track there. +func TestMoveAlbumSiblings_SkipsUnfinishedTransfers(t *testing.T) { + const ( + peerDirectory = `@@x\Billie Eilish\When We All Fall Asleep` + done = peerDirectory + `\01 Bury A Friend.flac` + pending = peerDirectory + `\03 Xanny.flac` + ) + + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(downloadsResponse(t, "peer1", map[string]string{ + done: "Completed, Succeeded", + pending: "InProgress", + }))); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + srcDir := t.TempDir() + destDir := t.TempDir() + for _, name := range []string{"01 Bury A Friend.flac", "03 Xanny.flac"} { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte(name), 0o644); err != nil { + t.Fatalf("seeding %s: %v", name, err) + } + } + + track := albumTrack() + track.MainArtistID = "peer1" // queueAlbumDownload stashes the peer here + track.AlbumFiles = []string{done, pending} + + client.MoveAlbumSiblings(srcDir, destDir, &track, false) + + if _, err := os.Stat(filepath.Join(destDir, "01 Bury A Friend.flac")); err != nil { + t.Errorf("finished sibling was not migrated: %v", err) + } + if _, err := os.Stat(filepath.Join(srcDir, "01 Bury A Friend.flac")); !os.IsNotExist(err) { + t.Error("finished sibling was copied but the original was left behind") + } + if _, err := os.Stat(filepath.Join(destDir, "03 Xanny.flac")); !os.IsNotExist(err) { + t.Error("a still-transferring sibling was migrated; the library would get a truncated file") + } + if _, err := os.Stat(filepath.Join(srcDir, "03 Xanny.flac")); err != nil { + t.Errorf("still-transferring sibling should be left in place: %v", err) + } +} + +// Album mode is opt-in, so the migration must be inert when it is off even if +// a track somehow carries siblings. +func TestMoveAlbumSiblings_NoopWhenAlbumModeIsOff(t *testing.T) { + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("slskd was queried with album mode off") + }) + client.Cfg.AlbumMode = false + + srcDir := t.TempDir() + destDir := t.TempDir() + if err := os.WriteFile(filepath.Join(srcDir, "01 Bury A Friend.flac"), []byte("x"), 0o644); err != nil { + t.Fatalf("seeding sibling: %v", err) + } + + track := albumTrack() + track.MainArtistID = "peer1" + track.AlbumFiles = []string{`@@x\dir\01 Bury A Friend.flac`} + + client.MoveAlbumSiblings(srcDir, destDir, &track, false) + + if _, err := os.Stat(filepath.Join(srcDir, "01 Bury A Friend.flac")); err != nil { + t.Errorf("sibling was migrated with album mode off: %v", err) + } +} + +// *Slskd must satisfy the optional interface DownloadClient looks for, or the +// hook in MoveDownload silently never fires. +func TestSlskdImplementsAlbumMigrator(t *testing.T) { + var _ albumMigrator = &Slskd{} +} diff --git a/src/models/types.go b/src/models/types.go index f3656baf..1728d858 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -17,6 +17,7 @@ type Track struct { CleanTitle string // Title as returned by LB Title string // Title as built in listenbrainz.go File string // File name + AlbumFiles []string // Sibling files queued with File when album mode is on Size int // File size Present bool // is track present in the system or not Duration int // Track duration in milliseconds (not available for every track) diff --git a/src/web/sample.env b/src/web/sample.env index 68511ba1..bee99758 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -84,6 +84,11 @@ LIBRARY_NAME= # MIGRATE_DOWNLOADS=false # Rename migrated track in {artist}-{title} format # RENAME_TRACK=false +# Download the whole release the matched file belongs to instead of just the +# recommended track. Only the recommended track is added to the playlist; the +# rest simply land in the library. Off by default -- it multiplies transfer +# volume per recommendation. (default: false) +# SLSKD_ALBUM_MODE=false # Directory where slskd downloads tracks (default: /slskd/) # PS! This is only needed on the binary version, in docker it's set through volume mapping # SLSKD_DIR=/slskd/ From ac4a6f7271ec47804466b170318c13fe05a6dae4 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 16:38:49 -0700 Subject: [PATCH 04/57] Scope the session cookie to the instance Running several Explo instances on one host -- the usual unraid setup, one container per port -- logged the user out of whichever instance they had visited first. Loading the second instance was enough to break the first; no login was needed. Cookies are scoped by host, not by port (RFC 6265 section 8.5). Every instance wrote its session ID to a cookie literally named "session" for that host, so the browser kept one entry and each instance overwrote the last. Sessions live in each process's own in-memory store, so an ID minted by one instance means nothing to the next: start() failed to read it, minted a fresh unauthenticated session, and the user was bounced to the login screen. The same collision fails CSRF checks, since the token belongs to whichever session was written last. The cookie name is now derived from WEB_ADDR -- explo_session_7288 -- which is exactly what distinguishes instances on one host. UI_COOKIE_NAME overrides it for deployments that cannot be told apart this way, such as several instances behind one reverse proxy on the same host and port. An address with no readable port falls back to the shared name rather than emitting a malformed cookie, and the derived suffix is sanitized because WEB_ADDR may name a service ("http") rather than a number. Existing sessions do not survive the rename: everyone logs in once more after upgrading, and the stale "session" cookie is left in the jar until the browser expires it. Nothing reads it any more. The regression test drives two instances through one cookie jar, which is what a browser does, and asserts that visiting the second leaves the first logged in. Co-Authored-By: Claude Opus 5 --- docker-compose.yaml | 4 + src/config/config.go | 4 + src/web/backend/auth/cookiename.go | 65 +++++++++++++ src/web/backend/auth/session_test.go | 135 +++++++++++++++++++++++++++ src/web/backend/server.go | 10 +- 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/web/backend/auth/cookiename.go create mode 100644 src/web/backend/auth/session_test.go diff --git a/docker-compose.yaml b/docker-compose.yaml index df73c8bb..7d47417f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,6 +25,10 @@ services: # Web UI credentials (required for login) - UI_USERNAME= - UI_PASSWORD= + # Session cookie name. Defaults to explo_session_ from WEB_ADDR, so + # that instances on one host do not overwrite each other's login. Only set + # this if several instances share a host AND port behind a reverse proxy. + #- UI_COOKIE_NAME= # [Legacy] Schedules are managed through the web UI. # These can still be set here for backwards compatibility — they will take precedence over the UI. diff --git a/src/config/config.go b/src/config/config.go index ce314d2f..57c58574 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -48,6 +48,10 @@ type ServerConfig struct { Port string `env:"WEB_ADDR" env-default:":7288"` Username string `env:"UI_USERNAME"` Password string `env:"UI_PASSWORD"` + // Name of the session cookie. Empty means derive it from Port, so that + // instances on one host do not overwrite each other's session. Set it + // explicitly when several instances share a host and port behind a proxy. + CookieName string `env:"UI_COOKIE_NAME"` WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"` WebEnvPath string `env:"WEB_ENV_PATH" env-default:"/opt/explo/.env"` CacheSizeMB int64 `env:"WEB_CACHE_MB" env-default:"500"` diff --git a/src/web/backend/auth/cookiename.go b/src/web/backend/auth/cookiename.go new file mode 100644 index 00000000..17ecabd8 --- /dev/null +++ b/src/web/backend/auth/cookiename.go @@ -0,0 +1,65 @@ +package auth + +import ( + "net" + "strings" +) + +// cookieNamePrefix is shared by every instance; what follows distinguishes them. +const cookieNamePrefix = "explo_session" + +// DefaultCookieName derives a session cookie name from the address the server +// listens on. +// +// Cookies are scoped by host, not by port (RFC 6265 section 8.5), so several +// Explo instances on one machine -- the usual unraid setup, one container per +// port -- would otherwise all write a cookie called "session" for that host and +// overwrite each other. Sessions live in each process's own memory, so an ID +// minted by one instance is meaningless to the next, and the user is silently +// logged out of whichever instance they visited first. +// +// Including the port gives each instance its own cookie. Deployments that +// cannot be told apart this way (several instances behind one reverse proxy on +// the same host and port) should set UI_COOKIE_NAME explicitly. +func DefaultCookieName(addr string) string { + port := portFromAddr(addr) + if port == "" { + return cookieNamePrefix + } + + return cookieNamePrefix + "_" + port +} + +// portFromAddr pulls the port out of a listen address. It accepts the forms +// Go's net package does -- ":7288", "0.0.0.0:7288", "[::]:7288" -- and returns +// "" for anything it cannot read, so a malformed WEB_ADDR degrades to the +// shared name rather than producing a nonsense cookie. +func portFromAddr(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return "" + } + + _, port, err := net.SplitHostPort(addr) + if err != nil { + return "" + } + + return sanitizeCookieName(port) +} + +// sanitizeCookieName keeps the name a valid cookie token. A port is normally +// digits, but WEB_ADDR may name a service instead ("http"), so anything outside +// the safe set is dropped rather than trusted into a Set-Cookie header. +func sanitizeCookieName(s string) string { + var b strings.Builder + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + b.WriteRune(r) + } + } + + return b.String() +} diff --git a/src/web/backend/auth/session_test.go b/src/web/backend/auth/session_test.go new file mode 100644 index 00000000..32f87699 --- /dev/null +++ b/src/web/backend/auth/session_test.go @@ -0,0 +1,135 @@ +package auth + +import ( + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "testing" + "time" +) + +// instance stands in for one Explo container: its own session manager, its own +// in-memory store, and its own listening port. +type instance struct { + manager *SessionManager + server *httptest.Server +} + +func newInstance(t *testing.T, cookieName string) *instance { + t.Helper() + + manager := NewSessionManager(NewInMemorySessionStore(), time.Hour, 7*24*time.Hour, cookieName) + + handler := manager.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session := manager.GetSession(r) + if r.URL.Path == "/login" { + session.Put("authenticated", true) + w.WriteHeader(http.StatusOK) + return + } + if authenticated, _ := session.Get("authenticated").(bool); !authenticated { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + return &instance{manager: manager, server: server} +} + +// get drives one request from the shared browser jar. +func get(t *testing.T, client *http.Client, url string) int { + t.Helper() + + resp, err := client.Get(url) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + defer resp.Body.Close() + + return resp.StatusCode +} + +// Two Explo instances on one unraid host are two ports on the same hostname. +// Cookies are not scoped by port (RFC 6265 section 8.5), so a fixed cookie +// name means the second instance overwrites the first's session ID, and +// neither store recognises the other's, logging the user out. +func TestSessionCookie_InstancesDoNotClobberEachOther(t *testing.T) { + first := newInstance(t, DefaultCookieName(":7288")) + second := newInstance(t, DefaultCookieName(":7289")) + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("creating cookie jar: %v", err) + } + client := &http.Client{Jar: jar} + + if code := get(t, client, first.server.URL+"/login"); code != http.StatusOK { + t.Fatalf("logging into the first instance = %d, want 200", code) + } + + // Merely visiting the second instance must not disturb the first. + if code := get(t, client, second.server.URL+"/"); code != http.StatusUnauthorized { + t.Fatalf("second instance = %d, want 401 -- it shares no session with the first", code) + } + + if code := get(t, client, first.server.URL+"/"); code != http.StatusOK { + t.Errorf("first instance = %d, want 200 -- the second instance clobbered its session cookie", code) + } +} + +// The whole point of the default: two addresses must not produce one name. +func TestDefaultCookieName_DiffersPerAddress(t *testing.T) { + if a, b := DefaultCookieName(":7288"), DefaultCookieName(":7289"); a == b { + t.Errorf("both addresses produced %q; instances on one host would share a cookie", a) + } +} + +// WEB_ADDR is free-form, so the derivation has to cope with every listen +// address Go accepts, and degrade rather than emit a broken cookie name. +func TestDefaultCookieName_AddressForms(t *testing.T) { + tests := []struct { + name string + addr string + want string + }{ + {"port only", ":7288", "explo_session_7288"}, + {"host and port", "0.0.0.0:7288", "explo_session_7288"}, + {"ipv6", "[::]:7288", "explo_session_7288"}, + {"named service", ":http", "explo_session_http"}, + {"no port falls back to the shared name", "0.0.0.0", "explo_session"}, + {"empty falls back to the shared name", "", "explo_session"}, + {"unsafe characters are dropped", ":72 88;x", "explo_session_7288x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DefaultCookieName(tt.addr); got != tt.want { + t.Errorf("DefaultCookieName(%q) = %q, want %q", tt.addr, got, tt.want) + } + }) + } +} + +// A derived name is only useful if the session actually round-trips under it. +func TestSessionCookie_UsesTheConfiguredName(t *testing.T) { + const name = "explo_session_7288" + + instance := newInstance(t, name) + + resp, err := http.Get(instance.server.URL + "/login") + if err != nil { + t.Fatalf("GET /login: %v", err) + } + defer resp.Body.Close() + + for _, cookie := range resp.Cookies() { + if cookie.Name == name { + return + } + } + t.Errorf("no %q cookie in %v", name, resp.Cookies()) +} diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 9da7747e..92c667fb 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -39,11 +39,19 @@ type Server struct { } func NewServer(cfg config.ServerConfig) *Server { + // Cookies are not scoped by port, so several instances on one host would + // share -- and overwrite -- a fixed cookie name. Default to a name derived + // from the listen address; UI_COOKIE_NAME overrides it. + cookieName := cfg.CookieName + if cookieName == "" { + cookieName = auth.DefaultCookieName(cfg.Port) + } + sessionManager := auth.NewSessionManager( auth.NewInMemorySessionStore(), 1*time.Hour, 7*(24*time.Hour), - "session", + cookieName, ) authStore := auth.NewAuthStore( From 5aeff08b58fb1e0ad5e3d0703edd8b9dd7db2668 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 17:03:05 -0700 Subject: [PATCH 05/57] Check response body close in session tests golangci-lint's errcheck rejected the bare deferred Close. The helper reports the failure instead of discarding it, matching how the rest of the suite treats errors it does not expect. Co-Authored-By: Claude Opus 5 --- src/web/backend/auth/session_test.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/web/backend/auth/session_test.go b/src/web/backend/auth/session_test.go index 32f87699..5f463941 100644 --- a/src/web/backend/auth/session_test.go +++ b/src/web/backend/auth/session_test.go @@ -40,6 +40,16 @@ func newInstance(t *testing.T, cookieName string) *instance { return &instance{manager: manager, server: server} } +// closeBody releases a response body, reporting rather than swallowing a +// failure to do so. +func closeBody(t *testing.T, resp *http.Response) { + t.Helper() + + if err := resp.Body.Close(); err != nil { + t.Errorf("closing response body: %v", err) + } +} + // get drives one request from the shared browser jar. func get(t *testing.T, client *http.Client, url string) int { t.Helper() @@ -48,7 +58,7 @@ func get(t *testing.T, client *http.Client, url string) int { if err != nil { t.Fatalf("GET %s: %v", url, err) } - defer resp.Body.Close() + defer closeBody(t, resp) return resp.StatusCode } @@ -124,7 +134,7 @@ func TestSessionCookie_UsesTheConfiguredName(t *testing.T) { if err != nil { t.Fatalf("GET /login: %v", err) } - defer resp.Body.Close() + defer closeBody(t, resp) for _, cookie := range resp.Cookies() { if cookie.Name == name { From 6007c2e61507287e26d428da2cf2df6398638773 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 17:15:31 -0700 Subject: [PATCH 06/57] Expose slskd album mode in the setup wizard SLSKD_ALBUM_MODE could only be set by hand, in the container's environment or the .env file. It is a normal downloader preference, so it belongs beside the other slskd options. Adds a "Download full albums" toggle to the wizard's downloader step, between the file extensions field and keyword exclusion, where the rest of the download-shape settings live. The copy says what the toggle actually costs -- much more downloaded per run -- since that is the reason it defaults to off. The key is added to AllConfigKeys as well as the step 3 handler. Writing it without reading it back is a silent failure: the wizard would save the toggle and then show it as off next time it opened, because /api/config only returns keys on that list. A test covers it, since nothing about the symptom points at the list. Setting SLSKD_ALBUM_MODE in the container's environment still wins and disables the toggle, which is how every other field in the wizard already behaves. Co-Authored-By: Claude Opus 5 --- src/web/backend/defs/defs.go | 2 +- src/web/backend/settings/handlers.go | 6 ++ src/web/backend/settings/wizard_test.go | 83 ++++++++++++++++++++++ src/web/frontend/src/components/Wizard.jsx | 10 +++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/web/backend/settings/wizard_test.go diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 5b4756dd..1fffd63e 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -180,6 +180,6 @@ var AllConfigKeys = []string{ "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", - "SLSKD_URL", "SLSKD_API_KEY", + "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_ALBUM_MODE", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", } \ No newline at end of file diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 513baf27..2519458a 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -438,6 +438,7 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { FilterList string `json:"filter_list"` SlskdURL string `json:"slskd_url"` SlskdAPIKey string `json:"slskd_api_key"` + SlskdAlbumMode bool `json:"slskd_album_mode"` Extensions string `json:"extensions"` // slskd } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -458,6 +459,10 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { if body.MigrateDownloads { migrateDL = "true" } + albumMode := "false" + if body.SlskdAlbumMode { + albumMode = "true" + } updates := map[string]string{ "DOWNLOAD_DIR": body.DownloadDir, "USE_SUBDIRECTORY": useSubdir, @@ -468,6 +473,7 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, + "SLSKD_ALBUM_MODE": albumMode, "EXTENSIONS": body.Extensions, // slskd "WIZARD_COMPLETE": "true", } diff --git a/src/web/backend/settings/wizard_test.go b/src/web/backend/settings/wizard_test.go new file mode 100644 index 00000000..feae2454 --- /dev/null +++ b/src/web/backend/settings/wizard_test.go @@ -0,0 +1,83 @@ +package settings + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + + "explo/src/web/backend/app" + "explo/src/web/backend/defs" +) + +// newSettings points a Settings at a throwaway .env. +func newSettings(t *testing.T) (*Settings, string) { + t.Helper() + + envPath := filepath.Join(t.TempDir(), ".env") + + return NewSettings(app.Config{WebEnvPath: envPath}), envPath +} + +// postStep3 drives the wizard's downloader step with the given body. +func postStep3(t *testing.T, s *Settings, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshaling body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/ui/wizard/step3", bytes.NewReader(payload)) + rec := httptest.NewRecorder() + s.HandleWizardStep3(rec, req) + + return rec +} + +// The toggle is only useful if it reaches the .env the downloader reads. +func TestWizardStep3_PersistsAlbumMode(t *testing.T) { + tests := []struct { + name string + on bool + want string + }{ + {"enabled", true, "SLSKD_ALBUM_MODE=true"}, + {"disabled", false, "SLSKD_ALBUM_MODE=false"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, envPath := newSettings(t) + + rec := postStep3(t, s, map[string]any{ + "download_services": []string{"slskd"}, + "slskd_album_mode": tt.on, + }) + if rec.Code != http.StatusOK { + t.Fatalf("step3 = %d (%s), want 200", rec.Code, rec.Body.String()) + } + + written, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading written env: %v", err) + } + if !bytes.Contains(written, []byte(tt.want)) { + t.Errorf("written env does not contain %q:\n%s", tt.want, written) + } + }) + } +} + +// AllConfigKeys is what /api/config reads back. Leaving the key out of it is +// silent: the wizard writes the toggle, then shows it as off next time it is +// opened, because the value never reaches the frontend. +func TestAlbumModeIsReadableByTheUI(t *testing.T) { + if !slices.Contains(defs.AllConfigKeys, "SLSKD_ALBUM_MODE") { + t.Error("SLSKD_ALBUM_MODE missing from AllConfigKeys; the wizard toggle would always read back as off") + } +} diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 2744d8ca..fbb8ca98 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -479,6 +479,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { filterList, slskdUrl, slskdApiKey, + slskdAlbumMode, extensions, } = fields; const isLocked = (key) => envSources[key] === "env"; @@ -564,6 +565,13 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('extensions', e.target.value)} placeholder="flac,mp3" autoComplete="off" spellCheck={false} disabled={isLocked('EXTENSIONS')} /> + setField("slskdAlbumMode", v)} + disabled={isLocked("SLSKD_ALBUM_MODE")} + name="Download full albums" + desc="Fetch the whole release each recommendation belongs to. Only the recommended track is added to the playlist; the rest land in your library. Downloads much more per run." + /> {/* Show keyword exclusion when YouTube isn't enabled — otherwise it lives in the YouTube section */} Date: Sat, 29 Aug 2026 17:52:15 -0700 Subject: [PATCH 07/57] Keep migrating a release until its files arrive Album mode stranded most of each release. The recommended track is queued first and so normally finishes first, and migration ran once, at that moment: every sibling still transferring was skipped and never looked at again. The monitor then marked the track present and dropped it from its progress map, so nothing came back. A real run migrated 2 of 14 tracks and abandoned the other 11 in slskd's directory. MoveAlbumSiblings now reports how many files are still in flight, and the monitor retries each release every tick until that reaches zero. It drops migrated files from track.AlbumFiles so repeated calls are idempotent, and drops ones slskd has given up on so a failed transfer cannot hold a release open. Files still transferring are left alone, as before -- copying a partial file would put a truncated track in the library. The monitor's exit condition now also waits on outstanding releases, which is what makes the retry reachable. A release that stops making progress is abandoned on the same deadline a stalled track is, so a peer that goes away cannot keep the monitor running; its files are left in place rather than deleted. Progress is measured against the count the migrator last returned rather than against track.AlbumFiles, so the deadline depends only on the interface, not on how an implementation happens to keep score -- an earlier version read the field directly and a test with a different migrator abandoned a release that was actively downloading. Sibling migration moved out of MoveDownload, which now returns the directory the track landed in. Doing it there was wrong in principle: the work belongs where the retrying happens, not in a function that runs once. Directory cleanup moved with it, so a release directory is removed after the last sibling leaves rather than while siblings are still in it. errorState replaces the "Errored" literal, so code reacting to a failed transfer cannot drift from the code that labels one. Co-Authored-By: Claude Opus 5 --- src/downloader/downloader.go | 56 ++++--- src/downloader/monitor.go | 87 +++++++++- src/downloader/monitor_album_test.go | 240 +++++++++++++++++++++++++++ src/downloader/slskd.go | 7 +- src/downloader/slskd_album.go | 69 +++++--- src/downloader/slskd_album_test.go | 103 ++++++++++++ 6 files changed, 516 insertions(+), 46 deletions(-) create mode 100644 src/downloader/monitor_album_test.go diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index e9596375..d38f1703 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -36,8 +36,12 @@ type Downloader interface { // albumMigrator is implemented by downloaders that fetch a whole release and // so have files to migrate beyond the one the monitor tracks. It is optional: // a downloader that only ever grabs a single file simply does not implement it. +// +// MoveAlbumSiblings returns how many files are still in flight. The monitor +// calls it again while that is above zero, because the recommended track +// normally finishes ahead of the rest of its release. type albumMigrator interface { - MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) + MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) int } // albumMigrators returns the registered downloaders that migrate whole @@ -266,7 +270,11 @@ func buildTrackPath(template string, track *models.Track) string { return filepath.Clean(result) } -func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) error { +// MoveDownload moves the recommended track into the library and returns the +// directory it landed in. Album siblings are migrated by the monitor rather +// than here: they usually have not finished downloading yet, so they need +// revisiting long after this returns. +func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *models.Track) (string, error) { trackDir := filepath.Join(srcDir, trackPath) srcFile := filepath.Join(trackDir, track.File) @@ -282,7 +290,7 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * in, err := os.Open(srcFile) if err != nil { - return fmt.Errorf("couldn't open source file: %s", err.Error()) + return "", fmt.Errorf("couldn't open source file: %s", err.Error()) } defer func() { @@ -304,18 +312,18 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * dstFile = filepath.Join(destDir, relativePath) } else { if err = os.MkdirAll(destDir, os.ModePerm); err != nil { - return fmt.Errorf("couldn't make download directory: %s", err.Error()) + return "", fmt.Errorf("couldn't make download directory: %s", err.Error()) } dstFile = filepath.Join(destDir, track.File) } if err = os.MkdirAll(filepath.Dir(dstFile), os.ModePerm); err != nil { - return fmt.Errorf("couldn't make destination directory: %s", err.Error()) + return "", fmt.Errorf("couldn't make destination directory: %s", err.Error()) } out, err := os.Create(dstFile) if err != nil { - return fmt.Errorf("couldn't create destination file: %s", err.Error()) + return "", fmt.Errorf("couldn't create destination file: %s", err.Error()) } defer func() { @@ -325,42 +333,48 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * }() if _, err = io.Copy(out, in); err != nil { - return fmt.Errorf("copy failed: %s", err.Error()) + return "", fmt.Errorf("copy failed: %s", err.Error()) } if err = out.Sync(); err != nil { - return fmt.Errorf("sync failed: %s", err.Error()) + return "", fmt.Errorf("sync failed: %s", err.Error()) } if c.Cfg.KeepPermissions { info, err := os.Stat(srcFile) if err != nil { - return fmt.Errorf("stat error: %s", err.Error()) + return "", fmt.Errorf("stat error: %s", err.Error()) } if err = os.Chmod(dstFile, info.Mode()); err != nil { - return fmt.Errorf("chmod failed: %s", err.Error()) + return "", fmt.Errorf("chmod failed: %s", err.Error()) } } if err = os.Remove(srcFile); err != nil { - return fmt.Errorf("failed to delete original file: %s", err.Error()) + return "", fmt.Errorf("failed to delete original file: %s", err.Error()) } - // Siblings move before the emptiness check below, so a directory still - // holding an unfinished album track is not removed out from under it. - for _, m := range c.albumMigrators() { - m.MoveAlbumSiblings(trackDir, filepath.Dir(dstFile), track, c.Cfg.KeepPermissions) + // A directory still holding album siblings is left alone; the monitor + // removes it once it has migrated the last of them. + if err = removeDirIfEmpty(trackDir); err != nil { + return "", err } - isEmpty, err := isDirEmpty(trackDir) + return filepath.Dir(dstFile), nil +} + +// removeDirIfEmpty cleans up a download directory once nothing is left in it. +func removeDirIfEmpty(dir string) error { + isEmpty, err := isDirEmpty(dir) if err != nil { return fmt.Errorf("couldn't check if directory is empty: %s", err.Error()) - } else if isEmpty { - if err = os.Remove(trackDir); err != nil { - return fmt.Errorf("failed to remove empty directory: %s", err.Error()) - } } - + if !isEmpty { + return nil + } + if err = os.Remove(dir); err != nil { + return fmt.Errorf("failed to remove empty directory: %s", err.Error()) + } return nil } diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 38a9d7d5..792a1a7b 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -5,6 +5,7 @@ import ( "explo/src/models" "fmt" "log/slog" + "path/filepath" "strings" "time" ) @@ -34,10 +35,26 @@ type FileStatus struct { PercentComplete float64 `json:"percentComplete"` } +// pendingAlbum is a release whose recommended track has already been migrated +// but whose remaining files are still downloading. The monitor revisits these +// each tick, because the recommended track is queued first and so normally +// finishes well ahead of its siblings. +type pendingAlbum struct { + track *models.Track + trackDir string + destDir string + // remaining is the count the migrator last reported. Progress is measured + // against this rather than against the migrator's own bookkeeping, so the + // deadline depends only on what the interface returns. + remaining int + lastChange time.Time +} + func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) error { var successDownloads int progressMap := make(map[string]*DownloadMonitor) + pendingAlbums := make(map[string]*pendingAlbum) monCfg, err := m.GetConf() if err != nil { return err @@ -89,10 +106,17 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err var path string track.File, path = parsePath(track.File) if monCfg.MigrateDownload { - if err = c.MoveDownload(monCfg.FromDir, monCfg.ToDir, path, track); err != nil { + destDir, err := c.MoveDownload(monCfg.FromDir, monCfg.ToDir, path, track) + if err != nil { slog.Error("error while moving file", "err", err.Error()) } else { slog.Info("track moved successfully", "service", monCfg.Service) + c.trackAlbum(pendingAlbums, key, &pendingAlbum{ + track: track, + trackDir: filepath.Join(monCfg.FromDir, path), + destDir: destDir, + lastChange: currentTime, + }, monCfg) } } delete(progressMap, key) @@ -117,8 +141,11 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err continue } } - // Exit condition: all tracks have been processed or skipped - if tracksProcessed(tracks, progressMap) { + c.migratePendingAlbums(pendingAlbums, monCfg, time.Now().Local()) + + // Exit condition: all tracks have been processed or skipped, and + // every release has finished being migrated + if tracksProcessed(tracks, progressMap) && len(pendingAlbums) == 0 { slog.Info("[monitor] Finished", "service", monCfg.Service, "downloaded files", successDownloads, "total tracks", len(tracks)) return nil } @@ -137,4 +164,56 @@ func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMon } } return true -} \ No newline at end of file +} +// trackAlbum records a release for later migration, migrating whatever has +// already finished. Releases with nothing left over are never recorded, so a +// single-track download costs nothing. +func (c *DownloadClient) trackAlbum(pending map[string]*pendingAlbum, key string, album *pendingAlbum, monCfg MonitorConfig) { + if remaining := c.migrateAlbum(album); remaining > 0 { + album.remaining = remaining + pending[key] = album + slog.Info("[monitor] waiting for the rest of the release", "service", monCfg.Service, + "album", album.track.Album, "files", remaining) + } +} + +// migrateAlbum moves whatever of one release has finished, returning how many +// of its files are still in flight. +func (c *DownloadClient) migrateAlbum(album *pendingAlbum) int { + var remaining int + for _, m := range c.albumMigrators() { + remaining += m.MoveAlbumSiblings(album.trackDir, album.destDir, album.track, c.Cfg.KeepPermissions) + } + return remaining +} + +// migratePendingAlbums revisits every release still waiting on files. A release +// that stops making progress is abandoned on the same deadline a stalled track +// is, so the monitor cannot be held open by a peer that has gone away. +func (c *DownloadClient) migratePendingAlbums(pending map[string]*pendingAlbum, monCfg MonitorConfig, now time.Time) { + for key, album := range pending { + before := album.remaining + remaining := c.migrateAlbum(album) + album.remaining = remaining + + if remaining == 0 { + delete(pending, key) + if err := removeDirIfEmpty(album.trackDir); err != nil { + slog.Debug("couldn't clean up release directory", "context", err.Error()) + } + slog.Info("[monitor] release fully migrated", "service", monCfg.Service, "album", album.track.Album) + continue + } + + if remaining < before { + album.lastChange = now + continue + } + + if now.Sub(album.lastChange) > monCfg.MonitorDuration { + delete(pending, key) + slog.Warn("[monitor] giving up on the rest of the release", "service", monCfg.Service, + "album", album.track.Album, "files left in place", remaining) + } + } +} diff --git a/src/downloader/monitor_album_test.go b/src/downloader/monitor_album_test.go new file mode 100644 index 00000000..23fc2c65 --- /dev/null +++ b/src/downloader/monitor_album_test.go @@ -0,0 +1,240 @@ +package downloader + +import ( + "os" + "path/filepath" + "testing" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// fakeMigrator stands in for a downloader that fetches whole releases, draining +// a fixed number of files per pass so a test can control how progress unfolds. +type fakeMigrator struct { + remaining int + perPass int + calls int + lastDest string + lastSource string +} + +func (f *fakeMigrator) QueryTrack(*models.Track) error { return nil } +func (f *fakeMigrator) GetTrack(*models.Track) error { return nil } + +func (f *fakeMigrator) GetDownloadStatus([]*models.Track) (map[string]FileStatus, error) { + return nil, nil +} +func (f *fakeMigrator) GetConf() (MonitorConfig, error) { return MonitorConfig{}, nil } +func (f *fakeMigrator) Cleanup(models.Track, string) error { return nil } + +func (f *fakeMigrator) MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) int { + f.calls++ + f.lastSource, f.lastDest = trackDir, destDir + + f.remaining -= f.perPass + if f.remaining < 0 { + f.remaining = 0 + } + return f.remaining +} + +func newClient(t *testing.T, m *fakeMigrator) *DownloadClient { + t.Helper() + + return &DownloadClient{ + Cfg: &cfg.DownloadConfig{}, + Downloaders: []Downloader{m}, + } +} + +// A release whose files keep arriving must be revisited until none are left, +// and its download directory cleaned up once it is done. +func TestMigratePendingAlbums_RetriesUntilComplete(t *testing.T) { + migrator := &fakeMigrator{remaining: 3, perPass: 1} + client := newClient(t, migrator) + + trackDir := t.TempDir() + monCfg := MonitorConfig{MonitorDuration: time.Hour} + + pending := map[string]*pendingAlbum{} + client.trackAlbum(pending, "key", &pendingAlbum{ + track: &models.Track{Album: "When We All Fall Asleep"}, + trackDir: trackDir, + destDir: t.TempDir(), + lastChange: time.Now(), + }, monCfg) + + if len(pending) != 1 { + t.Fatal("release with outstanding files was not recorded for retry") + } + + for i := 0; i < 3; i++ { + client.migratePendingAlbums(pending, monCfg, time.Now()) + } + + if len(pending) != 0 { + t.Errorf("release still pending after every file arrived (%d passes)", migrator.calls) + } + if _, err := os.Stat(trackDir); !os.IsNotExist(err) { + t.Errorf("emptied release directory was not cleaned up: %v", err) + } +} + +// Progress must reset the clock, or a slow release is abandoned mid-transfer. +func TestMigratePendingAlbums_ProgressExtendsTheDeadline(t *testing.T) { + migrator := &fakeMigrator{remaining: 10, perPass: 1} + client := newClient(t, migrator) + + start := time.Now() + monCfg := MonitorConfig{MonitorDuration: time.Minute} + + album := &pendingAlbum{ + track: &models.Track{Album: "When We All Fall Asleep"}, + trackDir: t.TempDir(), + destDir: t.TempDir(), + lastChange: start, + } + pending := map[string]*pendingAlbum{} + client.trackAlbum(pending, "key", album, monCfg) + + // Well past the deadline, but a file lands on every pass. + client.migratePendingAlbums(pending, monCfg, start.Add(10*time.Minute)) + + if len(pending) != 1 { + t.Fatal("a release still receiving files was abandoned") + } + if !album.lastChange.After(start) { + t.Error("progress did not reset the deadline") + } +} + +// A peer that goes away must not hold the monitor open forever. +func TestMigratePendingAlbums_GivesUpWhenStalled(t *testing.T) { + migrator := &fakeMigrator{remaining: 5, perPass: 0} + client := newClient(t, migrator) + + start := time.Now() + trackDir := t.TempDir() + if err := os.WriteFile(filepath.Join(trackDir, "half.flac"), []byte("x"), 0o644); err != nil { + t.Fatalf("seeding stalled file: %v", err) + } + + monCfg := MonitorConfig{MonitorDuration: time.Minute} + + pending := map[string]*pendingAlbum{} + client.trackAlbum(pending, "key", &pendingAlbum{ + track: &models.Track{Album: "When We All Fall Asleep"}, + trackDir: trackDir, + destDir: t.TempDir(), + lastChange: start, + }, monCfg) + + client.migratePendingAlbums(pending, monCfg, start.Add(30*time.Second)) + if len(pending) != 1 { + t.Fatal("release abandoned before the deadline") + } + + client.migratePendingAlbums(pending, monCfg, start.Add(2*time.Minute)) + if len(pending) != 0 { + t.Error("a stalled release was never abandoned; the monitor would never finish") + } + if _, err := os.Stat(filepath.Join(trackDir, "half.flac")); err != nil { + t.Errorf("abandoned files should be left in place, not deleted: %v", err) + } +} + +// A single-track download has no siblings, so it must never be recorded. +func TestTrackAlbum_IgnoresReleasesWithNothingLeft(t *testing.T) { + client := newClient(t, &fakeMigrator{remaining: 0, perPass: 0}) + + pending := map[string]*pendingAlbum{} + client.trackAlbum(pending, "key", &pendingAlbum{ + track: &models.Track{Album: "Single"}, + trackDir: t.TempDir(), + destDir: t.TempDir(), + }, MonitorConfig{}) + + if len(pending) != 0 { + t.Error("a release with no outstanding files was recorded for retry") + } +} + +// monitorStub drives MonitorDownloads: the tracked file is reported complete +// immediately, while the release behind it still has files in flight. +type monitorStub struct { + fakeMigrator + cfg MonitorConfig +} + +func (m *monitorStub) GetConf() (MonitorConfig, error) { return m.cfg, nil } + +func (m *monitorStub) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + statuses := make(map[string]FileStatus, len(tracks)) + for _, track := range tracks { + statuses[track.File] = FileStatus{ID: "1", State: "Completed, Succeeded", PercentComplete: 100} + } + return statuses, nil +} + +// The regression in full: MonitorDownloads must not finish while a release +// still has files coming. Before this, it migrated once at the moment the +// recommended track landed and then returned, stranding the rest of the album. +func TestMonitorDownloads_WaitsForTheRestOfTheRelease(t *testing.T) { + fromDir := t.TempDir() + toDir := t.TempDir() + + releaseDir := filepath.Join(fromDir, "release") + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("creating release dir: %v", err) + } + if err := os.WriteFile(filepath.Join(releaseDir, "primary.flac"), []byte("primary"), 0o644); err != nil { + t.Fatalf("seeding primary: %v", err) + } + + stub := &monitorStub{ + fakeMigrator: fakeMigrator{remaining: 3, perPass: 1}, + cfg: MonitorConfig{ + CheckInterval: time.Millisecond, + MonitorDuration: time.Hour, + MigrateDownload: true, + FromDir: fromDir, + ToDir: toDir, + Service: "slskd", + }, + } + + client := &DownloadClient{ + Cfg: &cfg.DownloadConfig{}, + Downloaders: []Downloader{stub}, + } + + track := &models.Track{ + ID: "track-1", + Album: "When We All Fall Asleep", + CleanTitle: "Bad Guy", + File: `release\primary.flac`, + } + + done := make(chan error, 1) + go func() { done <- client.MonitorDownloads([]*models.Track{track}, stub) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil", err) + } + case <-time.After(5 * time.Second): + t.Fatal("MonitorDownloads never returned") + } + + // One pass drains one file, so it must have come back for the other two + // rather than migrating once and returning. + if stub.calls < 3 { + t.Errorf("migrator called %d times; the monitor stopped before the release finished", stub.calls) + } + if stub.remaining != 0 { + t.Errorf("%d files were left behind", stub.remaining) + } +} diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 4aaa836d..2a0d56a9 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -480,6 +480,11 @@ func wildcardArtist(artist string) string { } // different failure states slskd has (format is "Completed,Rejected", "Errored,Cancelled" etc..) +// errorState is the single state normalize collapses every slskd failure into. +// Shared so that code reacting to a failed transfer cannot drift from the code +// that labels one. +const errorState = "Errored" + var failureStates = map[string]struct{} { "Aborted": {}, "TimedOut": {}, @@ -496,7 +501,7 @@ func normalize(state string) string{ p = strings.TrimSpace(p) if _, ok := failureStates[p]; ok { slog.Debug("[slskd] download failed", "status", state) - return "Errored" + return errorState } } return state diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go index f1ac3997..ebf73318 100644 --- a/src/downloader/slskd_album.go +++ b/src/downloader/slskd_album.go @@ -277,42 +277,71 @@ func (c Slskd) siblingStates(username string) (map[string]string, error) { return states, nil } -// MoveAlbumSiblings migrates the rest of the release once the recommended -// track has been moved. A sibling still transferring is left where it is: the -// monitor finishes on the primary, and copying a partial file would put a -// truncated track in the library. +// MoveAlbumSiblings migrates whatever of the release has finished downloading +// and reports how many files are still in flight, so callers can come back for +// the rest. Doing this once was not enough: the recommended track is queued +// first and so usually finishes first, which left most of the album skipped and +// never looked at again. +// +// A sibling still transferring is left where it is -- copying a partial file +// would put a truncated track in the library. Migrated files are dropped from +// track.AlbumFiles, and so are ones slskd has given up on, which makes repeated +// calls idempotent and lets the count reach zero. // // keepPermissions is passed in rather than read from the slskd config because // it is a download-wide setting owned by DownloadClient, which is also what // calls this. -func (c Slskd) MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) { +func (c Slskd) MoveAlbumSiblings(trackDir, destDir string, track *models.Track, keepPermissions bool) int { if !c.Cfg.AlbumMode || len(track.AlbumFiles) == 0 { - return + return 0 } states, err := c.siblingStates(track.MainArtistID) if err != nil { - slog.Warn("couldn't check release download states, leaving the rest of the album in place", - "context", err.Error()) - return + // Nothing is dropped on a failed lookup: the files are still there and + // the next attempt can still find them. + slog.Warn("couldn't check release download states, will retry", + "album", track.Album, "context", err.Error()) + return len(track.AlbumFiles) } - var moved, pending int - for _, peerPath := range track.AlbumFiles { - if !strings.Contains(states[peerPath], "Succeeded") { - pending++ - continue - } + var moved, failed int + pending := make([]string, 0, len(track.AlbumFiles)) + for _, peerPath := range track.AlbumFiles { + state := states[peerPath] name := path.Base(normalizePeerPath(peerPath)) - if err := copyFile(filepath.Join(trackDir, name), filepath.Join(destDir, name), keepPermissions); err != nil { - slog.Warn("failed to move album track", "file", name, "context", err.Error()) - continue + + switch { + case strings.Contains(state, "Succeeded"): + if err := copyFile(filepath.Join(trackDir, name), filepath.Join(destDir, name), keepPermissions); err != nil { + // Keep it pending: slskd can report a transfer complete a moment + // before the file is readable. + slog.Debug("album track not ready to move yet", "file", name, "context", err.Error()) + pending = append(pending, peerPath) + continue + } + moved++ + + case state == errorState: + // slskd has given up on this one, so waiting for it would only hold + // the release open until the deadline. + slog.Debug("album track failed to download, giving up on it", "file", name) + failed++ + + default: + pending = append(pending, peerPath) } - moved++ } - slog.Info("migrated release", "album", track.Album, "moved", moved, "still downloading", pending) + track.AlbumFiles = pending + + if moved > 0 || failed > 0 { + slog.Info("migrated release", "album", track.Album, + "moved", moved, "failed", failed, "still downloading", len(pending)) + } + + return len(pending) } // copyFile moves one finished album track into the library, mirroring how diff --git a/src/downloader/slskd_album_test.go b/src/downloader/slskd_album_test.go index 17bd4f7c..5a3424b2 100644 --- a/src/downloader/slskd_album_test.go +++ b/src/downloader/slskd_album_test.go @@ -347,3 +347,106 @@ func TestMoveAlbumSiblings_NoopWhenAlbumModeIsOff(t *testing.T) { func TestSlskdImplementsAlbumMigrator(t *testing.T) { var _ albumMigrator = &Slskd{} } + +// The bug this guards: the recommended track is queued first and normally +// finishes first, so the siblings are still transferring at the moment the +// release is first migrated. They have to be picked up on a later pass instead +// of being skipped once and abandoned. +func TestMoveAlbumSiblings_PicksUpSiblingsThatFinishLater(t *testing.T) { + const ( + peerDirectory = `@@x\Billie Eilish\When We All Fall Asleep` + early = peerDirectory + `\01 Bury A Friend.flac` + late = peerDirectory + `\03 Xanny.flac` + ) + + // The state the slow sibling reports, flipped between attempts. + lateState := "InProgress" + + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(downloadsResponse(t, "peer1", map[string]string{ + early: "Completed, Succeeded", + late: lateState, + }))); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + srcDir := t.TempDir() + destDir := t.TempDir() + for _, name := range []string{"01 Bury A Friend.flac", "03 Xanny.flac"} { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte(name), 0o644); err != nil { + t.Fatalf("seeding %s: %v", name, err) + } + } + + track := albumTrack() + track.MainArtistID = "peer1" + track.AlbumFiles = []string{early, late} + + if remaining := client.MoveAlbumSiblings(srcDir, destDir, &track, false); remaining != 1 { + t.Fatalf("first pass left %d files pending, want 1", remaining) + } + if _, err := os.Stat(filepath.Join(destDir, "03 Xanny.flac")); !os.IsNotExist(err) { + t.Fatal("the unfinished sibling was migrated on the first pass") + } + + lateState = "Completed, Succeeded" + + if remaining := client.MoveAlbumSiblings(srcDir, destDir, &track, false); remaining != 0 { + t.Errorf("second pass left %d files pending, want 0", remaining) + } + if _, err := os.Stat(filepath.Join(destDir, "03 Xanny.flac")); err != nil { + t.Errorf("the sibling that finished later was never migrated: %v", err) + } +} + +// Already-migrated files must not be retried: the source is gone, so a second +// attempt would fail forever and hold the release open until the deadline. +func TestMoveAlbumSiblings_ForgetsMigratedFiles(t *testing.T) { + const done = `@@x\dir\01 Bury A Friend.flac` + + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(downloadsResponse(t, "peer1", map[string]string{ + done: "Completed, Succeeded", + }))); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + srcDir := t.TempDir() + if err := os.WriteFile(filepath.Join(srcDir, "01 Bury A Friend.flac"), []byte("x"), 0o644); err != nil { + t.Fatalf("seeding sibling: %v", err) + } + + track := albumTrack() + track.MainArtistID = "peer1" + track.AlbumFiles = []string{done} + + client.MoveAlbumSiblings(srcDir, t.TempDir(), &track, false) + + if len(track.AlbumFiles) != 0 { + t.Errorf("AlbumFiles still holds %v after migration", track.AlbumFiles) + } +} + +// A sibling slskd has given up on must not keep the release open, or the +// monitor waits out the full deadline for a file that is never coming. +func TestMoveAlbumSiblings_DropsFailedSiblings(t *testing.T) { + const failed = `@@x\dir\03 Xanny.flac` + + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(downloadsResponse(t, "peer1", map[string]string{ + failed: "Completed, Errored", + }))); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + track := albumTrack() + track.MainArtistID = "peer1" + track.AlbumFiles = []string{failed} + + if remaining := client.MoveAlbumSiblings(t.TempDir(), t.TempDir(), &track, false); remaining != 0 { + t.Errorf("a failed sibling left %d files pending, want 0", remaining) + } +} From 71e43c514b0919407f6490acc17a096ad3779b78 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 18:05:39 -0700 Subject: [PATCH 08/57] Don't lose a playlist to one failed status poll Monitoring ended for every track at once whenever a status poll failed, and tracks that never reach Present are dropped before the playlist is built. One blip -- a restarted slskd, a dropped connection -- therefore cost every download still in flight. Two things caused it. The monitor treated any error from GetDownloadStatus as fatal, and slskd's implementation returned an error when it found nothing to report, which is the normal state a minute after queueing rather than a failure. The first poll happens one CheckInterval after the downloads are queued, so a slskd that had not yet surfaced the transfers ended the run before it had begun. An empty result is now just an empty result; the monitor already decides for itself how long to wait for a file to appear. Genuine request failures are still reported, but the monitor rides out up to three in a row before giving up, and a success resets the budget so unrelated blips spread across a long run do not accumulate into one. Co-Authored-By: Claude Opus 5 --- src/downloader/monitor.go | 21 +++- src/downloader/monitor_poll_test.go | 185 ++++++++++++++++++++++++++++ src/downloader/slskd.go | 9 +- 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 src/downloader/monitor_poll_test.go diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 38a9d7d5..e52fb5c8 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -9,6 +9,11 @@ import ( "time" ) +// maxPollFailures is how many consecutive status polls may fail before the +// monitor gives up. Anything less than the whole run is worth surviving: the +// downloads themselves keep going, and only the monitor loses sight of them. +const maxPollFailures = 3 + type Monitor interface { GetDownloadStatus([]*models.Track) (map[string]FileStatus, error) GetConf() (MonitorConfig, error) @@ -46,11 +51,25 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err ticker := time.NewTicker(monCfg.CheckInterval) defer ticker.Stop() + var pollFailures int + for range ticker.C { statuses, err := m.GetDownloadStatus(tracks) if err != nil { - return fmt.Errorf("[%s/monitor] error fetching download status: %s", monCfg.Service, err.Error()) + // A failed poll used to end monitoring for every track at once, so + // one blip -- a restarted service, a dropped connection -- cost the + // whole playlist, since tracks that never reach Present are dropped + // before it is built. Ride out a few and try again on the next tick. + pollFailures++ + if pollFailures >= maxPollFailures { + return fmt.Errorf("[%s/monitor] error fetching download status %d times in a row, giving up: %s", + monCfg.Service, pollFailures, err.Error()) + } + slog.Warn("[monitor] couldn't fetch download status, retrying", + "service", monCfg.Service, "attempt", pollFailures, "context", err.Error()) + continue } + pollFailures = 0 currentTime := time.Now().Local() diff --git a/src/downloader/monitor_poll_test.go b/src/downloader/monitor_poll_test.go new file mode 100644 index 00000000..7e0a5022 --- /dev/null +++ b/src/downloader/monitor_poll_test.go @@ -0,0 +1,185 @@ +package downloader + +import ( + "fmt" + "net/http" + "testing" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// pollStub drives MonitorDownloads with a scripted sequence of poll outcomes, +// so a test can interleave failures with successes. +type pollStub struct { + cfg MonitorConfig + // results is consumed one entry per poll; the last entry repeats. + results []pollResult + polls int +} + +type pollResult struct { + err error + complete bool +} + +func (p *pollStub) QueryTrack(*models.Track) error { return nil } +func (p *pollStub) GetTrack(*models.Track) error { return nil } +func (p *pollStub) GetConf() (MonitorConfig, error) { return p.cfg, nil } +func (p *pollStub) Cleanup(models.Track, string) error { return nil } + +func (p *pollStub) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + result := p.results[min(p.polls, len(p.results)-1)] + p.polls++ + + if result.err != nil { + return nil, result.err + } + + statuses := make(map[string]FileStatus, len(tracks)) + for _, track := range tracks { + if result.complete { + statuses[track.File] = FileStatus{ID: "1", State: "Completed, Succeeded", PercentComplete: 100} + continue + } + statuses[track.File] = FileStatus{ID: "1", State: "InProgress", BytesTransferred: p.polls, BytesRemaining: 100} + } + return statuses, nil +} + +func runMonitor(t *testing.T, stub *pollStub, track *models.Track) error { + t.Helper() + + client := &DownloadClient{Cfg: &cfg.DownloadConfig{}} + + done := make(chan error, 1) + go func() { done <- client.MonitorDownloads([]*models.Track{track}, stub) }() + + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + t.Fatal("MonitorDownloads never returned") + return nil + } +} + +func monitorCfg() MonitorConfig { + return MonitorConfig{ + CheckInterval: time.Millisecond, + MonitorDuration: time.Hour, + Service: "slskd", + } +} + +// A blip must not cost the playlist. Tracks that never reach Present are +// dropped before the playlist is built, so aborting the monitor on one failed +// poll used to throw away every download still in flight. +func TestMonitorDownloads_SurvivesATransientPollFailure(t *testing.T) { + stub := &pollStub{ + cfg: monitorCfg(), + results: []pollResult{ + {err: fmt.Errorf("connection refused")}, + {complete: true}, + }, + } + + track := &models.Track{ID: "track-1", File: "song.flac"} + + if err := runMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil after recovering", err) + } + if !track.Present { + t.Error("track was abandoned after a single failed poll; it would be dropped from the playlist") + } +} + +// Recovery must reset the budget, or a long run accumulates unrelated blips +// until it trips the limit. +func TestMonitorDownloads_ResetsTheFailureBudgetOnSuccess(t *testing.T) { + stub := &pollStub{ + cfg: monitorCfg(), + results: []pollResult{ + {err: fmt.Errorf("blip")}, + {err: fmt.Errorf("blip")}, + {}, // recovered, still downloading + {err: fmt.Errorf("blip")}, + {err: fmt.Errorf("blip")}, + {complete: true}, + }, + } + + track := &models.Track{ID: "track-1", File: "song.flac"} + + if err := runMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil -- four failures spread around a success is not four in a row", err) + } + if !track.Present { + t.Error("track never completed") + } +} + +// A service that is genuinely gone must still end the run rather than spin. +func TestMonitorDownloads_GivesUpOnRepeatedPollFailures(t *testing.T) { + stub := &pollStub{ + cfg: monitorCfg(), + results: []pollResult{{err: fmt.Errorf("connection refused")}}, + } + + err := runMonitor(t, stub, &models.Track{ID: "track-1", File: "song.flac"}) + if err == nil { + t.Fatal("MonitorDownloads() = nil, want an error once the service stays unreachable") + } + if stub.polls != maxPollFailures { + t.Errorf("polled %d times, want %d before giving up", stub.polls, maxPollFailures) + } +} + +// An empty status map means slskd has not surfaced the transfers yet. Treating +// that as an error aborted the whole run one minute after queueing. +func TestMonitorDownloads_EmptyStatusIsNotAFailure(t *testing.T) { + stub := &pollStub{ + cfg: monitorCfg(), + results: []pollResult{{}, {complete: true}}, + } + + // No tracks: GetDownloadStatus returns an empty map, exactly as slskd does + // before any transfer appears. + track := &models.Track{ID: "track-1", File: "song.flac"} + if err := runMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil", err) + } + if !track.Present { + t.Error("track never completed") + } +} + +// The slskd side of the same bug: an empty transfer list is a normal state a +// minute after queueing, not a failure to report upward. +func TestSlskdGetDownloadStatus_EmptyListIsNotAnError(t *testing.T) { + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + if _, err := w.Write([]byte(`[]`)); err != nil { + t.Errorf("fake slskd failed to write response: %v", err) + } + }) + + statuses, err := client.GetDownloadStatus([]*models.Track{{File: "song.flac", MainArtistID: "peer1"}}) + if err != nil { + t.Fatalf("GetDownloadStatus() = %v, want nil -- nothing queued yet is not a failure", err) + } + if len(statuses) != 0 { + t.Errorf("got %d statuses, want none", len(statuses)) + } +} + +// A genuinely broken request must still be reported. +func TestSlskdGetDownloadStatus_ReportsRealFailures(t *testing.T) { + client := albumClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + if _, err := client.GetDownloadStatus([]*models.Track{{File: "song.flac"}}); err == nil { + t.Error("GetDownloadStatus() = nil, want an error when slskd fails") + } +} diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index 4aaa836d..9b97c512 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -425,10 +425,11 @@ func (c *Slskd) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus } } } - if len(fileStatuses) != 0 { - return fileStatuses, nil - } - return nil, fmt.Errorf("no files found to monitor") + // An empty result is not a failure. slskd may not have surfaced the + // transfers yet, and reporting that as an error used to abort monitoring + // for every track at once. The monitor decides on its own how long to wait + // for a file to appear. + return fileStatuses, nil } func (c Slskd) deleteDownload(user, ID string) error { From 4510b4982e6d65419dace7ea5ad228865731122d Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 18:17:05 -0700 Subject: [PATCH 09/57] Time out a run that never finishes A run started from the web UI got a context with no deadline, so a wedged child process was never killed. The "already running" guard then refused every later run, and the only way back was the stop button or a restart -- neither of which helps a run that hangs while nobody is watching. RUN_TIMEOUT kills the child after four hours by default, comfortably past a normal run. Killing it lets cmd.Wait return, which clears the guard by the path that already exists, so nothing else had to change. 0 disables the timeout. Co-Authored-By: Claude Opus 5 --- sample.env | 4 + src/config/config.go | 4 + src/web/backend/app/app.go | 11 ++- src/web/backend/run/manual_run.go | 15 +++- src/web/backend/run/manual_run_test.go | 116 +++++++++++++++++++++++++ src/web/backend/server.go | 1 + src/web/sample.env | 4 + 7 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/web/backend/run/manual_run_test.go diff --git a/sample.env b/sample.env index bee99758..a5c1f682 100644 --- a/sample.env +++ b/sample.env @@ -1,6 +1,10 @@ # === Web UI === # Web cache limit in MB (cover art, backgrounds) (default: 500) # WEB_CACHE_MB=500 +# Kill a run started from the web UI after this many minutes. Without it a +# wedged run keeps the UI refusing new runs until it is stopped by hand. +# 0 disables the timeout. (default: 240) +# RUN_TIMEOUT=240 # === Discovery Config === diff --git a/src/config/config.go b/src/config/config.go index 57c58574..88b40d01 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -55,6 +55,10 @@ type ServerConfig struct { WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"` WebEnvPath string `env:"WEB_ENV_PATH" env-default:"/opt/explo/.env"` CacheSizeMB int64 `env:"WEB_CACHE_MB" env-default:"500"` + // Kill a run started from the web UI after this many minutes. Without it a + // wedged run holds the "already running" guard until someone stops it by + // hand. 0 disables the timeout. + RunTimeout int `env:"RUN_TIMEOUT" env-default:"240"` ExploPath string } diff --git a/src/web/backend/app/app.go b/src/web/backend/app/app.go index a302987e..add8f818 100644 --- a/src/web/backend/app/app.go +++ b/src/web/backend/app/app.go @@ -1,8 +1,11 @@ package app +import "time" type Config struct { - WebEnvPath string - WebDataDir string - ExploPath string -} \ No newline at end of file + WebEnvPath string + WebDataDir string + ExploPath string + // RunTimeout kills a run that has outlasted it. Zero means no timeout. + RunTimeout time.Duration +} diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go index 7dacdc30..21a5e941 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -1,6 +1,7 @@ package run import ( + "time" "fmt" "errors" "log/slog" @@ -53,7 +54,10 @@ func newManualRunState() manualRunState { } func (mr *ManualRun) startRun(args []string) error { - ctx, cancel := context.WithCancel(context.Background()) + // A run that wedges holds the "already running" guard below, so every later + // run is refused until someone stops it by hand. The timeout kills the + // child, which lets cmd.Wait return and clears the guard on its own. + ctx, cancel := runContext(mr.cfg.RunTimeout) cmd := exec.CommandContext(ctx, mr.cfg.ExploPath, args...) // Strip WEB_UI from env so the child process runs normally, not as web server. env := make([]string, 0, len(os.Environ())) @@ -176,4 +180,11 @@ func buildArgs(playlist, downloadMode, WebEnvPath string, replacePlaylist bool) args = append(args, "--replace-playlist=false") } return args -} \ No newline at end of file +} +// runContext bounds a run, or leaves it unbounded when the timeout is zero. +func runContext(timeout time.Duration) (context.Context, context.CancelFunc) { + if timeout <= 0 { + return context.WithCancel(context.Background()) + } + return context.WithTimeout(context.Background(), timeout) +} diff --git a/src/web/backend/run/manual_run_test.go b/src/web/backend/run/manual_run_test.go new file mode 100644 index 00000000..9c24b747 --- /dev/null +++ b/src/web/backend/run/manual_run_test.go @@ -0,0 +1,116 @@ +package run + +import ( + "context" + "testing" + "time" + + "explo/src/web/backend/app" +) + +// Zero means a run is never killed on a timer, only by the stop endpoint. +func TestRunContext_ZeroLeavesTheRunUnbounded(t *testing.T) { + ctx, cancel := runContext(0) + defer cancel() + + if _, ok := ctx.Deadline(); ok { + t.Error("a zero timeout produced a deadline; runs would be killed unexpectedly") + } + + select { + case <-ctx.Done(): + t.Error("context was already done") + default: + } +} + +// A configured timeout has to actually bound the run. +func TestRunContext_AppliesTheTimeout(t *testing.T) { + ctx, cancel := runContext(30 * time.Millisecond) + defer cancel() + + if _, ok := ctx.Deadline(); !ok { + t.Fatal("no deadline was set, so a wedged run would never be killed") + } + + select { + case <-ctx.Done(): + if ctx.Err() != context.DeadlineExceeded { + t.Errorf("ctx.Err() = %v, want DeadlineExceeded", ctx.Err()) + } + case <-time.After(2 * time.Second): + t.Error("the deadline never fired") + } +} + +// The guard that refuses a second run must clear itself when a wedged run is +// killed, or the timeout buys nothing: every later run is still refused. +func TestStartRun_TimeoutReleasesTheAlreadyRunningGuard(t *testing.T) { + mr := NewManualRun(app.Config{ + // A command that would otherwise run far longer than the test. + ExploPath: "/bin/sleep", + RunTimeout: 50 * time.Millisecond, + }) + + if err := mr.startRun([]string{"30"}); err != nil { + t.Fatalf("startRun() = %v, want nil", err) + } + if !mr.currentRunStatus().Running { + t.Fatal("run did not register as running") + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if !mr.currentRunStatus().Running { + return + } + time.Sleep(5 * time.Millisecond) + } + + t.Error("the run was still marked running long after its timeout; later runs would keep being refused") +} + +// Back-to-back runs must be possible once the first has been cut short. +func TestStartRun_SecondRunIsAcceptedAfterATimeout(t *testing.T) { + mr := NewManualRun(app.Config{ + ExploPath: "/bin/sleep", + RunTimeout: 50 * time.Millisecond, + }) + + if err := mr.startRun([]string{"30"}); err != nil { + t.Fatalf("first startRun() = %v, want nil", err) + } + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) && mr.currentRunStatus().Running { + time.Sleep(5 * time.Millisecond) + } + + if err := mr.startRun([]string{"0"}); err != nil { + t.Errorf("second startRun() = %v, want nil once the first was killed", err) + } +} + +// While a run really is in flight, a second must still be refused. +func TestStartRun_RefusesAConcurrentRun(t *testing.T) { + mr := NewManualRun(app.Config{ + ExploPath: "/bin/sleep", + RunTimeout: 5 * time.Second, + }) + + if err := mr.startRun([]string{"30"}); err != nil { + t.Fatalf("startRun() = %v, want nil", err) + } + t.Cleanup(func() { + mr.state.mu.Lock() + cancel := mr.state.cancel + mr.state.mu.Unlock() + if cancel != nil { + cancel() + } + }) + + if err := mr.startRun([]string{"30"}); err != errRunAlreadyStarted { + t.Errorf("second startRun() = %v, want errRunAlreadyStarted", err) + } +} diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 92c667fb..9e28a41e 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -63,6 +63,7 @@ func NewServer(cfg config.ServerConfig) *Server { WebEnvPath: cfg.WebEnvPath, WebDataDir: cfg.WebDataDir, ExploPath: cfg.ExploPath, + RunTimeout: time.Duration(cfg.RunTimeout) * time.Minute, } settings := settings.NewSettings(webCfg) diff --git a/src/web/sample.env b/src/web/sample.env index bee99758..a5c1f682 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -1,6 +1,10 @@ # === Web UI === # Web cache limit in MB (cover art, backgrounds) (default: 500) # WEB_CACHE_MB=500 +# Kill a run started from the web UI after this many minutes. Without it a +# wedged run keeps the UI refusing new runs until it is stopped by hand. +# 0 disables the timeout. (default: 240) +# RUN_TIMEOUT=240 # === Discovery Config === From 2ab627314aa6a9ed6593f91b51ae6cd4ed584de4 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 18:32:01 -0700 Subject: [PATCH 10/57] Stop a scheduled run from overlapping itself crond starts a job on schedule whether or not the previous one finished, and a run can easily outlast its own interval -- downloads are slow. Two copies of the same job then race on the same playlist. Scheduled runs go through a wrapper that holds a lock for the duration, so a second start stands down and says so. It exits 0, since a skipped schedule is not an error and cron would otherwise complain every time. The lock is per job rather than global. A long weekly-exploration must not cancel weekly-jams: skipping a different playlist entirely is worse than letting the two overlap, and the runs the guard exists to prevent are a job racing itself. Where flock is missing the wrapper runs the job unlocked and says so, because an overlapping run is a lesser problem than a playlist that never gets built. The web UI's own guard is unaffected; it never applied to cron, which execs the binary directly. Co-Authored-By: Claude Opus 5 --- Dockerfile | 3 +- docker/explo-run.sh | 46 +++++++++++++++++++++ docker/explo-run_test.sh | 86 ++++++++++++++++++++++++++++++++++++++++ docker/start.sh | 6 ++- 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100755 docker/explo-run.sh create mode 100755 docker/explo-run_test.sh diff --git a/Dockerfile b/Dockerfile index 87d110ec..a1c2e964 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,11 +44,12 @@ WORKDIR /opt/explo/ # Copy entrypoint, binary, python helper COPY ./docker/start.sh /start.sh +COPY ./docker/explo-run.sh /explo-run.sh COPY --from=builder /app/explo . COPY src/downloader/youtube_music/search_ytmusic.py . -RUN chmod +x /start.sh ./explo +RUN chmod +x /start.sh /explo-run.sh ./explo ENV WEB_ADDR=":7288" diff --git a/docker/explo-run.sh b/docker/explo-run.sh new file mode 100755 index 00000000..d03424a7 --- /dev/null +++ b/docker/explo-run.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Run one scheduled Explo job under a lock of its own. +# +# crond starts a job on schedule whether or not the last one finished, and a run +# can outlast its own interval -- downloads are slow. Two copies of the same job +# then race on the same playlist. The lock makes the second one stand down. +# +# The lock is per job, not global: a long weekly-exploration must not cancel +# weekly-jams, since skipping a different playlist entirely is worse than +# running the two at once. +# +# Usage: explo-run.sh [args...] + +set -u + +if [ "$#" -lt 2 ]; then + echo "[explo] usage: explo-run.sh [args...]" >&2 + exit 2 +fi + +job="$1" +shift + +lock="${EXPLO_LOCK_DIR:-/tmp}/explo-${job}.lock" + +# Without flock, run unlocked rather than not at all: an overlapping run is a +# lesser problem than a playlist that never gets built. +if ! command -v flock >/dev/null 2>&1; then + echo "[explo] $job: flock unavailable, running without an overlap guard" + exec "$@" +fi + +# Hold the lock on a file descriptor so it is released when this shell exits, +# however it exits. Taking it this way also keeps the exit status unambiguous: +# flock fails here only because the lock is held, never because the job did. +exec 9>"$lock" || { + echo "[explo] $job: couldn't open $lock, running without an overlap guard" + exec "$@" +} + +if ! flock -n 9; then + echo "[explo] $job: previous run still in progress, skipping this schedule" + exit 0 +fi + +exec "$@" diff --git a/docker/explo-run_test.sh b/docker/explo-run_test.sh new file mode 100755 index 00000000..abe844b9 --- /dev/null +++ b/docker/explo-run_test.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Tests for explo-run.sh. Run it directly: sh docker/explo-run_test.sh +# +# There is no shell test harness in CI (the pipeline runs golangci-lint only), +# so this is here to be run by hand and to document what the lock guarantees. + +set -u + +script="$(dirname "$0")/explo-run.sh" +lockdir="$(mktemp -d)" +export EXPLO_LOCK_DIR="$lockdir" + +failures=0 + +fail() { + echo "FAIL: $1" + failures=$((failures + 1)) +} + +pass() { + echo "ok: $1" +} + +# A job runs normally when nothing holds its lock. +out="$("$script" solo /bin/echo ran)" +if [ "$out" = "ran" ]; then + pass "runs the command when the lock is free" +else + fail "expected 'ran', got '$out'" +fi + +# A second copy of the SAME job stands down instead of racing the first. +"$script" overlap /bin/sleep 2 & +first=$! +sleep 0.3 + +out="$("$script" overlap /bin/echo ran)" +case "$out" in + *"skipping this schedule"*) + pass "a second copy of the same job skips" ;; + *) + fail "expected the second run to skip, got '$out'" ;; +esac + +# Skipping must look like success, or cron mails an error every schedule. +"$script" overlap /bin/echo ran >/dev/null +if [ $? -eq 0 ]; then + pass "a skipped run exits 0" +else + fail "a skipped run exited non-zero" +fi + +# A DIFFERENT job must not be blocked: skipping another playlist entirely is +# worse than two runs overlapping. +out="$("$script" other /bin/echo ran)" +if [ "$out" = "ran" ]; then + pass "a different job is not blocked" +else + fail "a different job was blocked, got '$out'" +fi + +wait "$first" + +# Once the first finishes, the job can run again. +out="$("$script" overlap /bin/echo ran)" +if [ "$out" = "ran" ]; then + pass "the lock is released when the run ends" +else + fail "lock was not released, got '$out'" +fi + +# The command's own exit status must reach cron unchanged. +"$script" status /bin/sh -c 'exit 3' +if [ $? -eq 3 ]; then + pass "the command's exit status is passed through" +else + fail "exit status was not passed through" +fi + +rm -rf "$lockdir" + +if [ "$failures" -ne 0 ]; then + echo "$failures failure(s)" + exit 1 +fi +echo "all passed" diff --git a/docker/start.sh b/docker/start.sh index 205f1bb5..af06a26e 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -52,7 +52,7 @@ fi # $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility if [ -n "$CRON_SCHEDULE" ]; then - echo "$CRON_SCHEDULE apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" > /etc/crontabs/root + echo "$CRON_SCHEDULE apk add --no-cache --upgrade yt-dlp && cd /opt/explo && /explo-run.sh legacy $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" > /etc/crontabs/root chmod 600 /etc/crontabs/root echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE" crond -f -l 2 @@ -71,7 +71,9 @@ for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do fi # Default: just run explo if flags are empty - cmd="apk add --no-cache --upgrade yt-dlp && cd /opt/explo && $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1" + # Wrapped so a run that outlasts its own schedule does not get a second copy + # started on top of it. The lock is per job, so other playlists still run. + cmd="apk add --no-cache --upgrade yt-dlp && cd /opt/explo && /explo-run.sh \"$job\" $RUNNER ./explo --config \"$_cfg\" $flags >> /proc/1/fd/1 2>&1" echo "$schedule $cmd" >> /etc/crontabs/root echo "[setup] Registered job: $job" From 5a9f071260821f29decc624d5acaf4d38deb38d4 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 19:36:43 -0700 Subject: [PATCH 11/57] Let a playlist build from local files only Some playlists are worth downloading for and some are not -- a daily list is better assembled from what you already own than fetched fresh every morning. Until now that choice was global. Adds a "Use local files only" toggle to the playlist menu, beside "Save each run as new playlist". It writes --download-mode=skip into that playlist's FLAGS, which is the flag the runner already honours and the one the sample compose file suggests setting by hand; the UI just writes it for you. Nothing about how a run behaves is new. The toggle covers scheduled runs. The Run button keeps its own download mode, and the menu says so, since that is the part someone would otherwise assume. Saving a schedule rewrites FLAGS from a default and re-adds only the flags it knows about, so the new one had to join that list. Leaving it off would have turned local-only back off the next time the schedule was edited, with nothing to suggest why -- there is a test for exactly that. The two toggles now share one helper for adding and removing a flag, rather than the second handler being a copy of the first. Co-Authored-By: Claude Opus 5 --- src/web/backend/routes.go | 1 + src/web/backend/settings/handlers.go | 115 +++++++--- .../backend/settings/playlist_flags_test.go | 199 ++++++++++++++++++ src/web/frontend/src/components/Settings.jsx | 18 +- .../src/components/ui/PlaylistCard.jsx | 39 +++- src/web/frontend/src/lib/api.js | 9 + 6 files changed, 343 insertions(+), 38 deletions(-) create mode 100644 src/web/backend/settings/playlist_flags_test.go diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index ec75b4bf..50be14b3 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -54,6 +54,7 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.settings.HandleSavePathTemplate)) s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata)) s.mux.Handle("POST /api/ui/config/replace-playlist", s.auth(s.settings.HandleSaveReplacePlaylist)) + s.mux.Handle("POST /api/ui/config/local-only", s.auth(s.settings.HandleSaveLocalOnly)) s.mux.Handle("POST /api/ui/config/clean-downloads", s.auth(s.settings.HandleSaveCleanDownloads)) // Path template presets: GET list, POST add; DELETE per name under prefix diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 2519458a..81a722cc 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -128,7 +128,7 @@ func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { currentFlags = s.ParseEnvText(string(data))[envPrefix+"_FLAGS"] } mergeFlags := func(base string) string { - injected := []string{"--replace-playlist=false", "--clean-downloads"} + injected := []string{"--replace-playlist=false", "--clean-downloads", localOnlyFlag} var extras []string for _, f := range injected { if strings.Contains(currentFlags, f) { @@ -213,63 +213,108 @@ func (s *Settings) HandleSaveEnrichMetadata(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusOK) } -// HandleSaveReplacePlaylist injects or removes --replace-playlist=false from a playlist's FLAGS env var. -func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { - var body struct { - ID string `json:"id"` - Name string `json:"name"` - Replace bool `json:"replace"` +// localOnlyFlag builds the playlist from tracks already in the library instead +// of downloading. It is the same flag the docs suggest putting in *_FLAGS by +// hand; the UI just writes it for you. +const localOnlyFlag = "--download-mode=skip" + +// playlistFlagsKey resolves which env var holds a playlist's flags, and what +// those flags are when the file says nothing, for built-in and custom +// playlists alike. +func playlistFlagsKey(id, name string) (flagsKey, defaultFlags string, err error) { + if def, ok := defs.PlaylistDefs[id]; ok { + return def.EnvPrefix + "_FLAGS", def.DefaultFlags, nil } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return + if defs.CustomIDRe.MatchString(id) { + return util.CustomEnvPrefix(name) + "_FLAGS", "--playlist " + id, nil } + return "", "", fmt.Errorf("unknown playlist name") +} - var envPrefix string - var defaultFlags string - if def, ok := defs.PlaylistDefs[body.ID]; ok { - envPrefix = def.EnvPrefix - defaultFlags = def.DefaultFlags - } else if defs.CustomIDRe.MatchString(body.ID) { - envPrefix = util.CustomEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.ID - } else { - http.Error(w, "unknown playlist name", http.StatusBadRequest) - return +// setPlaylistFlag adds or removes one flag in a playlist's FLAGS env var, +// leaving every other flag as it found it. Adding a flag that is already there +// and removing one that is not are both no-ops, so a repeated request cannot +// duplicate or mangle anything. +func (s *Settings) setPlaylistFlag(id, name, flag string, enabled bool) error { + flagsKey, defaultFlags, err := playlistFlagsKey(id, name) + if err != nil { + return err } - flagsKey := envPrefix + "_FLAGS" - const replaceFlag = "--replace-playlist=false" - data, err := os.ReadFile(s.cfg.WebEnvPath) if err != nil && !os.IsNotExist(err) { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return err } - current := s.ParseEnvText(string(data)) - currentFlags := current[flagsKey] + + currentFlags := s.ParseEnvText(string(data))[flagsKey] if currentFlags == "" { currentFlags = defaultFlags } - hasFlag := strings.Contains(currentFlags, replaceFlag) newFlags := currentFlags - if !body.Replace && !hasFlag { - newFlags = strings.TrimSpace(currentFlags + " " + replaceFlag) - } else if body.Replace && hasFlag { - newFlags = strings.TrimSpace(strings.ReplaceAll(currentFlags, replaceFlag, "")) + switch hasFlag := strings.Contains(currentFlags, flag); { + case enabled && !hasFlag: + newFlags = strings.TrimSpace(currentFlags + " " + flag) + case !enabled && hasFlag: + newFlags = strings.TrimSpace(strings.ReplaceAll(currentFlags, flag, "")) for strings.Contains(newFlags, " ") { newFlags = strings.ReplaceAll(newFlags, " ", " ") } } - if err := s.UpdateEnvKeys(map[string]string{flagsKey: newFlags}, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + return s.UpdateEnvKeys(map[string]string{flagsKey: newFlags}, web.SampleEnv) +} + +// HandleSaveReplacePlaylist injects or removes --replace-playlist=false from a playlist's FLAGS env var. +func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { + var body struct { + ID string `json:"id"` + Name string `json:"name"` + Replace bool `json:"replace"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // The flag is the negative: present means "do not replace". + if err := s.setPlaylistFlag(body.ID, body.Name, "--replace-playlist=false", !body.Replace); err != nil { + http.Error(w, err.Error(), statusFor(err)) return } w.WriteHeader(http.StatusOK) } +// HandleSaveLocalOnly injects or removes --download-mode=skip from one +// playlist's FLAGS env var, so a playlist can be built from the library alone +// while the others keep downloading. +func (s *Settings) HandleSaveLocalOnly(w http.ResponseWriter, r *http.Request) { + var body struct { + ID string `json:"id"` + Name string `json:"name"` + LocalOnly bool `json:"local_only"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + if err := s.setPlaylistFlag(body.ID, body.Name, localOnlyFlag, body.LocalOnly); err != nil { + http.Error(w, err.Error(), statusFor(err)) + return + } + w.WriteHeader(http.StatusOK) +} + +// statusFor keeps an unrecognised playlist a client error while anything else +// stays a server error. +func statusFor(err error) int { + if err != nil && err.Error() == "unknown playlist name" { + return http.StatusBadRequest + } + return http.StatusInternalServerError +} + // HandleSaveCleanDownloads injects or removes --clean-downloads from every playlist's FLAGS env var. func (s *Settings) HandleSaveCleanDownloads(w http.ResponseWriter, r *http.Request) { var body struct { diff --git a/src/web/backend/settings/playlist_flags_test.go b/src/web/backend/settings/playlist_flags_test.go new file mode 100644 index 00000000..dda86e4d --- /dev/null +++ b/src/web/backend/settings/playlist_flags_test.go @@ -0,0 +1,199 @@ +package settings + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "explo/src/web/backend/app" +) + +// flagsSettings points a Settings at a throwaway .env seeded with the given +// contents. +func flagsSettings(t *testing.T, env string) (*Settings, string) { + t.Helper() + + envPath := filepath.Join(t.TempDir(), ".env") + if env != "" { + if err := os.WriteFile(envPath, []byte(env), 0o644); err != nil { + t.Fatalf("seeding env: %v", err) + } + } + + return NewSettings(app.Config{WebEnvPath: envPath}), envPath +} + +// flagsFor reads back one playlist's FLAGS value. +func flagsFor(t *testing.T, s *Settings, envPath, key string) string { + t.Helper() + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading env: %v", err) + } + return s.ParseEnvText(string(data))[key] +} + +func postJSON(t *testing.T, handler http.HandlerFunc, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshaling body: %v", err) + } + + rec := httptest.NewRecorder() + handler(rec, httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))) + return rec +} + +// Turning the toggle on has to write the flag the runner actually reads. +func TestSaveLocalOnly_AddsAndRemovesTheFlag(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams\n") + + if rec := postJSON(t, s.HandleSaveLocalOnly, map[string]any{ + "id": "daily-jams", "local_only": true, + }); rec.Code != http.StatusOK { + t.Fatalf("enabling = %d (%s), want 200", rec.Code, rec.Body.String()) + } + + flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS") + if !strings.Contains(flags, "--download-mode=skip") { + t.Errorf("flags = %q, want the local-only flag", flags) + } + if !strings.Contains(flags, "--playlist daily-jams") { + t.Errorf("flags = %q, lost the existing flags", flags) + } + + if rec := postJSON(t, s.HandleSaveLocalOnly, map[string]any{ + "id": "daily-jams", "local_only": false, + }); rec.Code != http.StatusOK { + t.Fatalf("disabling = %d, want 200", rec.Code) + } + + flags = flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS") + if strings.Contains(flags, "--download-mode=skip") { + t.Errorf("flags = %q, want the local-only flag gone", flags) + } + if !strings.Contains(flags, "--playlist daily-jams") { + t.Errorf("flags = %q, lost the existing flags on removal", flags) + } + if strings.Contains(flags, " ") { + t.Errorf("flags = %q, removal left a double space", flags) + } +} + +// The UI can fire the same request twice; neither direction may compound. +func TestSaveLocalOnly_IsIdempotent(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams\n") + + for i := 0; i < 3; i++ { + postJSON(t, s.HandleSaveLocalOnly, map[string]any{"id": "daily-jams", "local_only": true}) + } + if got := strings.Count(flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS"), "--download-mode=skip"); got != 1 { + t.Errorf("flag appears %d times after repeated enables, want 1", got) + } + + for i := 0; i < 3; i++ { + postJSON(t, s.HandleSaveLocalOnly, map[string]any{"id": "daily-jams", "local_only": false}) + } + if flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS"); strings.Contains(flags, "--download-mode=skip") { + t.Errorf("flags = %q, want the flag gone after repeated disables", flags) + } +} + +// Setting one playlist to local-only must leave the others downloading -- the +// whole point is that it is per playlist. +func TestSaveLocalOnly_LeavesOtherPlaylistsAlone(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams\nWEEKLY_JAMS_FLAGS=--playlist weekly-jams\n") + + postJSON(t, s.HandleSaveLocalOnly, map[string]any{"id": "daily-jams", "local_only": true}) + + if flags := flagsFor(t, s, envPath, "WEEKLY_JAMS_FLAGS"); strings.Contains(flags, "--download-mode=skip") { + t.Errorf("weekly-jams = %q, want it untouched", flags) + } +} + +// It must not disturb the toggle that sits next to it in the same menu. +func TestSaveLocalOnly_PreservesTheReplaceFlag(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams --replace-playlist=false\n") + + postJSON(t, s.HandleSaveLocalOnly, map[string]any{"id": "daily-jams", "local_only": true}) + + flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS") + if !strings.Contains(flags, "--replace-playlist=false") { + t.Errorf("flags = %q, lost the replace setting", flags) + } + if !strings.Contains(flags, "--download-mode=skip") { + t.Errorf("flags = %q, want the local-only flag", flags) + } +} + +// Saving a schedule rewrites FLAGS wholesale from a default and re-adds only +// the flags it knows about. Leaving the new one off that list would silently +// turn local-only back off the next time the schedule was edited. +func TestSaveSchedule_PreservesLocalOnly(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams --download-mode=skip\n") + + rec := postJSON(t, s.HandleSaveSchedule, map[string]any{ + "id": "daily-jams", "enabled": true, "day": -1, "hour": 3, "minute": 30, + }) + if rec.Code != http.StatusOK { + t.Fatalf("saving schedule = %d (%s), want 200", rec.Code, rec.Body.String()) + } + + if flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS"); !strings.Contains(flags, "--download-mode=skip") { + t.Errorf("flags = %q, editing the schedule dropped local-only", flags) + } +} + +// Custom playlists get their env prefix from their name, not their id. +func TestSaveLocalOnly_SupportsCustomPlaylists(t *testing.T) { + s, envPath := flagsSettings(t, "") + + rec := postJSON(t, s.HandleSaveLocalOnly, map[string]any{ + "id": "custom-abc123", "name": "Road Trip", "local_only": true, + }) + if rec.Code != http.StatusOK { + t.Fatalf("custom playlist = %d (%s), want 200", rec.Code, rec.Body.String()) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading env: %v", err) + } + if !bytes.Contains(data, []byte("--download-mode=skip")) { + t.Errorf("custom playlist flags were not written:\n%s", data) + } +} + +// An unrecognised playlist is the caller's mistake, not a server fault. +func TestSaveLocalOnly_RejectsAnUnknownPlaylist(t *testing.T) { + s, _ := flagsSettings(t, "") + + rec := postJSON(t, s.HandleSaveLocalOnly, map[string]any{"id": "nonsense", "local_only": true}) + if rec.Code != http.StatusBadRequest { + t.Errorf("unknown playlist = %d, want 400", rec.Code) + } +} + +// The refactor that introduced the shared helper must not have changed how the +// neighbouring toggle behaves. +func TestSaveReplacePlaylist_StillWorks(t *testing.T) { + s, envPath := flagsSettings(t, "DAILY_JAMS_FLAGS=--playlist daily-jams\n") + + postJSON(t, s.HandleSaveReplacePlaylist, map[string]any{"id": "daily-jams", "replace": false}) + if flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS"); !strings.Contains(flags, "--replace-playlist=false") { + t.Errorf("flags = %q, want the replace flag added when replace is off", flags) + } + + postJSON(t, s.HandleSaveReplacePlaylist, map[string]any{"id": "daily-jams", "replace": true}) + if flags := flagsFor(t, s, envPath, "DAILY_JAMS_FLAGS"); strings.Contains(flags, "--replace-playlist=false") { + t.Errorf("flags = %q, want the replace flag gone when replace is on", flags) + } +} diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 2d9730ab..87b84302 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -15,7 +15,8 @@ import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata, - saveReplacePlaylist, saveCleanDownloads, + saveReplacePlaylist, + saveLocalOnly, saveCleanDownloads, fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' @@ -203,6 +204,7 @@ function CustomPlaylistsSection({ function HomeSection() { const [schedules, setSchedules] = useState(null) const [replacePlaylists, setReplacePlaylists] = useState({}) + const [localOnlyPlaylists, setLocalOnlyPlaylists] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) const [lbUser, setLbUser] = useState('') const [openTracklist, setOpenTracklist] = useState(null) @@ -229,10 +231,14 @@ function HomeSection() { setCustomPlaylists(customList) const rp = {} + const lo = {} for (const p of PLAYLISTS) { - rp[p.value] = !(values[p.flagsKey] || '').includes('--replace-playlist=false') + const flags = values[p.flagsKey] || '' + rp[p.value] = !flags.includes('--replace-playlist=false') + lo[p.value] = flags.includes('--download-mode=skip') } setReplacePlaylists(rp) + setLocalOnlyPlaylists(lo) const s = {} for (const p of PLAYLISTS) { @@ -324,6 +330,14 @@ function HomeSection() { setReplacePlaylists(prev => ({ ...prev, [id]: !next })) ) }, + localOnly: localOnlyPlaylists[id] ?? false, + onLocalOnlyToggle: () => { + const next = !(localOnlyPlaylists[id] ?? false) + setLocalOnlyPlaylists(prev => ({ ...prev, [id]: next })) + saveLocalOnly(id, s.name, next).catch(() => + setLocalOnlyPlaylists(prev => ({ ...prev, [id]: !next })) + ) + }, onDayChange: day => setSchedules(prev => ({ ...prev, [id]: { ...prev[id], day } })), diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index a3401179..f9c21f6d 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -374,6 +374,8 @@ export function PlaylistCard({ onDelete, replacePlaylist = true, onReplaceToggle, + localOnly = false, + onLocalOnlyToggle, trackId, artworkUrl, sourceUrl, @@ -447,9 +449,10 @@ export function PlaylistCard({ const [copyLabel, setCopyLabel] = useState('Copy URL') const [cardHovered, setCardHovered] = useState(false) const [replaceInfoOpen, setReplaceInfoOpen] = useState(false) + const [localOnlyInfoOpen, setLocalOnlyInfoOpen] = useState(false) const menuBtnRef = useRef(null) const canEdit = !fixedSchedule && !!onToggleEdit - const hasMenu = canEdit || !!onDelete || !!sourceUrl || !!onReplaceToggle + const hasMenu = canEdit || !!onDelete || !!sourceUrl || !!onReplaceToggle || !!onLocalOnlyToggle useEffect(() => { if (!menuOpen) { setConfirmDelete(false); setDeleteTracksChecked(false); return } @@ -707,6 +710,40 @@ export function PlaylistCard({ )} )} + {onLocalOnlyToggle && ( +
+ + {localOnlyInfoOpen && ( + + Builds this playlist from tracks already in your library instead of + downloading, so it may come out shorter. Applies to scheduled runs; + the Run button follows its own download mode. + + )} +
+ )} {onDelete && !confirmDelete && ( + ) + })} + +

+ {PREFERENCE_HINTS[sizePreference || 'none']} +

+ ) } diff --git a/src/web/sample.env b/src/web/sample.env index c3f24fa9..a37b9a42 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -114,6 +114,12 @@ LIBRARY_NAME= # to files that describe themselves and files that do not. 0 disables the # ceiling. (default: 0) # MAX_BITRATE=0 +# Which end of the accepted range to reach for first. "none" leaves the order of +# EXTENSIONS deciding, as it always has. "smaller" takes the best lossy file and +# falls back to lossless only when there is none -- note it still prefers a 320 +# over a 256, since wanting a small file does not mean wanting a bad one. +# "larger" is the reverse. Ranking only; nothing is rejected. (default: none) +# SIZE_PREFERENCE=none # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella From 4fcd5187b74626ab9c59d128da0327029b3d7774 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sat, 29 Aug 2026 21:22:42 -0700 Subject: [PATCH 15/57] Restore the release preference onto dev RELEASE_PREFERENCE and its scoring -- preferring the release closest to the album's real track count rather than the fullest one -- were reviewed and merged as #16, and are not in dev. The same failure as #15, hours apart. #16 was based on feat/size-preference, which never reached dev: its content arrived through the cherry-pick in #17 instead, so merging into it put the work on a branch nothing builds from. GitHub reports #16 as merged, and by its own accounting it is. This is the same commit, cherry-picked onto current dev. It applied cleanly. Where dev has moved on -- the sibling retry from #7, the run timeout from #10, the local-only flag from #12 -- dev's newer versions are kept, since only the release-preference change is being replayed. Co-Authored-By: Claude Opus 5 --- sample.env | 7 + src/config/config.go | 4 + src/downloader/preference.go | 76 ++++++++++ src/downloader/preference_test.go | 166 +++++++++++++++++++++ src/downloader/slskd_album.go | 20 ++- src/web/backend/defs/defs.go | 2 +- src/web/backend/settings/handlers.go | 63 ++++---- src/web/backend/settings/wizard_test.go | 38 +++++ src/web/frontend/src/components/Wizard.jsx | 37 +++++ src/web/sample.env | 7 + 10 files changed, 386 insertions(+), 34 deletions(-) diff --git a/sample.env b/sample.env index a37b9a42..d2aebeb2 100644 --- a/sample.env +++ b/sample.env @@ -120,6 +120,13 @@ LIBRARY_NAME= # over a 256, since wanting a small file does not mean wanting a bad one. # "larger" is the reverse. Ranking only; nothing is rejected. (default: none) # SIZE_PREFERENCE=none +# Which release album mode takes when several match. "fuller" takes the most +# complete directory, as it always has. "smaller" takes the one closest to the +# album's real track count, so a 21-track deluxe edition does not arrive in +# place of the 11-track album -- note this needs ENRICH_TRACK_METADATA, since +# that is where the real track count comes from, and falls back to "fuller" +# without it. (default: fuller) +# RELEASE_PREFERENCE=fuller # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella diff --git a/src/config/config.go b/src/config/config.go index 734f8923..8dffb901 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -157,6 +157,10 @@ type Slskd struct { // lossy file and falls back to lossless only when there is none; "larger" // is the reverse. SizePreference string `env:"SIZE_PREFERENCE" env-default:"none"` + // Which release to reach for in album mode: "fuller" takes the most + // complete one, as it always has; "smaller" takes the one closest to the + // album's real track count, which needs ENRICH_TRACK_METADATA to know it. + ReleasePreference string `env:"RELEASE_PREFERENCE" env-default:"fuller"` // Download every track in the release the matched file sits in, rather // than the single recommended track. Off by default: it multiplies how // much is transferred per recommendation. diff --git a/src/downloader/preference.go b/src/downloader/preference.go index 629d7cac..ff5a3f82 100644 --- a/src/downloader/preference.go +++ b/src/downloader/preference.go @@ -2,6 +2,8 @@ package downloader import ( "cmp" + + "explo/src/models" "slices" "strings" ) @@ -162,3 +164,77 @@ func scorePreference(files []File, pref string) int { } return 0 } + +// Release preferences. Where SizePreference is about how good each file is, +// this is about how much of the release to take. +const ( + // PreferFullerRelease is how Explo has always chosen: the most complete + // directory wins. + PreferFullerRelease = "fuller" + // PreferSmallerRelease reaches for the release closest to the album's real + // track count, which is what keeps a 21-track deluxe edition from being + // downloaded in place of the 11-track album. + // + // Deliberately "closest" and not "fewest": a directory holding only the + // recommended track would win every time under fewest, and album mode + // would quietly stop fetching albums at all. + PreferSmallerRelease = "smaller" +) + +// maxReleaseSizeScore caps every size term. Below the album (100) and artist +// (50) terms in scoreDir, so being the right size can decide between releases +// that match equally well but can never win against a better match. +const maxReleaseSizeScore = 40 + +// releaseSizeMissPenalty is how much each track of difference from the +// expected count costs, so a release ten tracks out scores nothing. +const releaseSizeMissPenalty = 4 + +func normaliseReleasePreference(pref string) string { + if strings.EqualFold(strings.TrimSpace(pref), PreferSmallerRelease) { + return PreferSmallerRelease + } + return PreferFullerRelease +} + +// expectedTrackCount reports how many files a correct release should hold, and +// whether that is knowable at all. +// +// TrackTotal comes from MusicBrainz during enrichment and counts the first +// medium only. A multi-disc release therefore legitimately holds more files +// than TrackTotal, so a closeness score would punish the complete release for +// being complete -- better to admit we cannot tell. +func expectedTrackCount(track models.Track) (int, bool) { + if track.TrackTotal <= 0 { + return 0, false + } + if track.DiscTotal > 1 { + return 0, false + } + return track.TrackTotal, true +} + +// releaseSizeScore rewards a release for being the size it ought to be. +// +// Without a usable expected count -- enrichment off, the lookup failed, or a +// multi-disc release -- it falls back to preferring the fuller release rather +// than guessing. Guessing is what would reopen the single-track hole: only +// when the real track count is known can a lone file be recognised as either a +// genuine single or a fragment of an album. +func releaseSizeScore(dir peerDir, track models.Track, pref string) int { + if pref != PreferSmallerRelease { + return fullerReleaseScore(dir) + } + + expected, ok := expectedTrackCount(track) + if !ok { + return fullerReleaseScore(dir) + } + + diff := len(dir.files) - expected + if diff < 0 { + diff = -diff + } + + return max(0, maxReleaseSizeScore-diff*releaseSizeMissPenalty) +} diff --git a/src/downloader/preference_test.go b/src/downloader/preference_test.go index 8ae40662..d80c8541 100644 --- a/src/downloader/preference_test.go +++ b/src/downloader/preference_test.go @@ -247,3 +247,169 @@ func TestNormalisePreference(t *testing.T) { } } } + +// releaseDir builds a candidate release of n files whose directory names both +// the artist and the album, so only the size term varies between cases. +func releaseDir(n int) peerDir { + dir := peerDir{dir: "Billie Eilish - When We All Fall Asleep"} + for i := 0; i < n; i++ { + dir.files = append(dir.files, File{ + Name: "track.mp3", + Extension: "mp3", + BitRate: 320, + }) + } + return dir +} + +func albumTrackOf(total, discs int) models.Track { + return models.Track{ + CleanTitle: "Bad Guy", + MainArtist: "Billie Eilish", + Album: "When We All Fall Asleep", + TrackTotal: total, + DiscTotal: discs, + } +} + +func TestReleaseSizeScore_PrefersTheRealAlbumLength(t *testing.T) { + track := albumTrackOf(11, 1) + + exact := releaseSizeScore(releaseDir(11), track, PreferSmallerRelease) + deluxe := releaseSizeScore(releaseDir(21), track, PreferSmallerRelease) + + if exact <= deluxe { + t.Errorf("11-track release scored %d against a 21-track deluxe %d; the deluxe would win", + exact, deluxe) + } + if exact != maxReleaseSizeScore { + t.Errorf("an exact match scored %d, want the full %d", exact, maxReleaseSizeScore) + } +} + +// The hole this design exists to close: under a naive "fewest tracks wins", +// a directory holding only the recommended track beats every real album and +// album mode stops fetching albums. +func TestReleaseSizeScore_ASingleFileLosesToTheRealAlbum(t *testing.T) { + track := albumTrackOf(12, 1) + + lone := releaseSizeScore(releaseDir(1), track, PreferSmallerRelease) + album := releaseSizeScore(releaseDir(12), track, PreferSmallerRelease) + + if lone >= album { + t.Errorf("a 1-file directory scored %d against the 12-track album %d; album mode would "+ + "quietly stop downloading albums", lone, album) + } +} + +// A real single is not a degenerate case: when the release genuinely has one +// track, one file is the right answer. +func TestReleaseSizeScore_ASingleFileWinsForARealSingle(t *testing.T) { + track := albumTrackOf(1, 1) + + lone := releaseSizeScore(releaseDir(1), track, PreferSmallerRelease) + padded := releaseSizeScore(releaseDir(9), track, PreferSmallerRelease) + + if lone <= padded { + t.Errorf("one file scored %d for a one-track single, against %d for nine", lone, padded) + } +} + +func TestReleaseSizeScore_FallsBackWithoutACanonicalCount(t *testing.T) { + cases := map[string]models.Track{ + // Enrichment is off or the MusicBrainz lookup failed. + "no track total": albumTrackOf(0, 1), + // TrackTotal counts the first medium only, so a two-disc release + // legitimately holds more files than it. Scoring closeness would + // punish the complete release for being complete. + "multi disc": albumTrackOf(11, 2), + } + + for name, track := range cases { + t.Run(name, func(t *testing.T) { + dir := releaseDir(21) + got := releaseSizeScore(dir, track, PreferSmallerRelease) + want := fullerReleaseScore(dir) + + if got != want { + t.Errorf("score = %d, want the fuller-release fallback %d", got, want) + } + }) + } +} + +// The default must leave release scoring exactly as it was. +func TestReleaseSizeScore_FullerIsUnchanged(t *testing.T) { + track := albumTrackOf(11, 1) + + for _, n := range []int{1, 11, 21, 60} { + dir := releaseDir(n) + if got, want := releaseSizeScore(dir, track, PreferFullerRelease), min(n, 40); got != want { + t.Errorf("fuller score for %d files = %d, want %d", n, got, want) + } + } +} + +// The cap is what stops a right-sized wrong album beating a wrong-sized right +// one. Without it the size term could outrank the album name. +func TestReleaseSizeScore_NeverExceedsTheCap(t *testing.T) { + track := albumTrackOf(11, 1) + + for _, n := range []int{0, 1, 11, 21, 200} { + if got := releaseSizeScore(releaseDir(n), track, PreferSmallerRelease); got > maxReleaseSizeScore { + t.Errorf("score for %d files = %d, above the cap of %d", n, got, maxReleaseSizeScore) + } + } +} + +// End to end through the scorer the album flow actually calls. +func TestScoreDirWithPreference_RightSizedWrongAlbumStillLoses(t *testing.T) { + track := albumTrackOf(11, 1) + + client := prefClient(PreferNone) + client.Cfg.ReleasePreference = PreferSmallerRelease + + right := releaseDir(21) // the deluxe, but the correct album + + wrong := releaseDir(11) // exactly the right length, wrong album entirely + wrong.dir = "Some Other Compilation" + + if client.scoreDirWithPreference(wrong, track) >= client.scoreDirWithPreference(right, track) { + t.Error("a right-sized wrong album beat a wrong-sized right album") + } +} + +func TestScoreDirWithPreference_SmallerPicksTheStandardEdition(t *testing.T) { + track := albumTrackOf(11, 1) + + client := prefClient(PreferNone) + client.Cfg.ReleasePreference = PreferSmallerRelease + + standard := releaseDir(11) + deluxe := releaseDir(21) + + if client.scoreDirWithPreference(standard, track) <= client.scoreDirWithPreference(deluxe, track) { + t.Error("the deluxe edition beat the standard album") + } + + // And the default still does the opposite. + client.Cfg.ReleasePreference = PreferFullerRelease + if client.scoreDirWithPreference(deluxe, track) <= client.scoreDirWithPreference(standard, track) { + t.Error("with the default preference the fuller release did not win") + } +} + +func TestNormaliseReleasePreference(t *testing.T) { + cases := map[string]string{ + "smaller": PreferSmallerRelease, + "SMALLER": PreferSmallerRelease, + " fuller ": PreferFullerRelease, + "": PreferFullerRelease, + "nonsense": PreferFullerRelease, + } + for in, want := range cases { + if got := normaliseReleasePreference(in); got != want { + t.Errorf("normaliseReleasePreference(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go index 32d8ff0d..c31324e3 100644 --- a/src/downloader/slskd_album.go +++ b/src/downloader/slskd_album.go @@ -119,18 +119,30 @@ func scoreDir(dir peerDir, track models.Track) int { score += 50 } - // More tracks means a more complete release, but never let file count - // outweigh actually matching the album. - score += min(len(dir.files), 40) + score += fullerReleaseScore(dir) return score } +// fullerReleaseScore is the historic size term: more tracks means a more +// complete release. Capped so that file count can never outweigh actually +// matching the album. +func fullerReleaseScore(dir peerDir) int { + return min(len(dir.files), maxReleaseSizeScore) +} + // scoreDirWithPreference adds the size preference to a release's score. Kept // separate so the matching terms above stay readable, and weighted well below // them: quality decides between equally good matches and never overrides one. func (c Slskd) scoreDirWithPreference(dir peerDir, track models.Track) int { - return scoreDir(dir, track) + scorePreference(dir.files, normalisePreference(c.Cfg.SizePreference)) + score := scoreDir(dir, track) + scorePreference(dir.files, normalisePreference(c.Cfg.SizePreference)) + + // Swap the historic size term for the configured one. Both are capped the + // same way, so the balance against the name terms is unchanged. + score -= fullerReleaseScore(dir) + score += releaseSizeScore(dir, track, normaliseReleasePreference(c.Cfg.ReleasePreference)) + + return score } // qualityFiltered drops files failing the bitrate and bit-depth floors. Unlike diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 43f43f7d..11f0e64c 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -180,7 +180,7 @@ var AllConfigKeys = []string{ "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", - "MIN_BITRATE", "MAX_BITRATE", "SIZE_PREFERENCE", + "MIN_BITRATE", "MAX_BITRATE", "SIZE_PREFERENCE", "RELEASE_PREFERENCE", "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_ALBUM_MODE", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", } \ No newline at end of file diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 70fcd992..b5dd51cd 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -475,20 +475,21 @@ func (s *Settings) HandleWizardStep2(w http.ResponseWriter, r *http.Request) { // handleWizardStep3 saves downloader configuration. func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { var body struct { - DownloadDir string `json:"download_dir"` - UseSubdirectory bool `json:"use_subdirectory"` - MigrateDownloads bool `json:"migrate_downloads"` - DownloadServices []string `json:"download_services"` - YoutubeAPIKey string `json:"youtube_api_key"` - TrackExtension string `json:"track_extension"` // yt-dlp - FilterList string `json:"filter_list"` - SlskdURL string `json:"slskd_url"` - SlskdAPIKey string `json:"slskd_api_key"` - SlskdAlbumMode bool `json:"slskd_album_mode"` - Extensions string `json:"extensions"` // slskd - MinBitRate int `json:"min_bitrate"` - MaxBitRate int `json:"max_bitrate"` - SizePreference string `json:"size_preference"` + DownloadDir string `json:"download_dir"` + UseSubdirectory bool `json:"use_subdirectory"` + MigrateDownloads bool `json:"migrate_downloads"` + DownloadServices []string `json:"download_services"` + YoutubeAPIKey string `json:"youtube_api_key"` + TrackExtension string `json:"track_extension"` // yt-dlp + FilterList string `json:"filter_list"` + SlskdURL string `json:"slskd_url"` + SlskdAPIKey string `json:"slskd_api_key"` + SlskdAlbumMode bool `json:"slskd_album_mode"` + Extensions string `json:"extensions"` // slskd + MinBitRate int `json:"min_bitrate"` + MaxBitRate int `json:"max_bitrate"` + SizePreference string `json:"size_preference"` + ReleasePreference string `json:"release_preference"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -516,22 +517,26 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { if body.SizePreference == "" { body.SizePreference = "none" } + if body.ReleasePreference == "" { + body.ReleasePreference = "fuller" + } updates := map[string]string{ - "DOWNLOAD_DIR": body.DownloadDir, - "USE_SUBDIRECTORY": useSubdir, - "MIGRATE_DOWNLOADS": migrateDL, - "DOWNLOAD_SERVICES": joined, - "YOUTUBE_API_KEY": body.YoutubeAPIKey, - "TRACK_EXTENSION": body.TrackExtension, // yt-dlp - "FILTER_LIST": body.FilterList, - "SLSKD_URL": body.SlskdURL, - "SLSKD_API_KEY": body.SlskdAPIKey, - "SLSKD_ALBUM_MODE": albumMode, - "EXTENSIONS": body.Extensions, // slskd - "MIN_BITRATE": strconv.Itoa(body.MinBitRate), - "MAX_BITRATE": strconv.Itoa(body.MaxBitRate), - "SIZE_PREFERENCE": body.SizePreference, - "WIZARD_COMPLETE": "true", + "DOWNLOAD_DIR": body.DownloadDir, + "USE_SUBDIRECTORY": useSubdir, + "MIGRATE_DOWNLOADS": migrateDL, + "DOWNLOAD_SERVICES": joined, + "YOUTUBE_API_KEY": body.YoutubeAPIKey, + "TRACK_EXTENSION": body.TrackExtension, // yt-dlp + "FILTER_LIST": body.FilterList, + "SLSKD_URL": body.SlskdURL, + "SLSKD_API_KEY": body.SlskdAPIKey, + "SLSKD_ALBUM_MODE": albumMode, + "EXTENSIONS": body.Extensions, // slskd + "MIN_BITRATE": strconv.Itoa(body.MinBitRate), + "MAX_BITRATE": strconv.Itoa(body.MaxBitRate), + "SIZE_PREFERENCE": body.SizePreference, + "RELEASE_PREFERENCE": body.ReleasePreference, + "WIZARD_COMPLETE": "true", } if err := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { diff --git a/src/web/backend/settings/wizard_test.go b/src/web/backend/settings/wizard_test.go index 56223673..b6f4ba1c 100644 --- a/src/web/backend/settings/wizard_test.go +++ b/src/web/backend/settings/wizard_test.go @@ -178,3 +178,41 @@ func TestSizePreferenceIsReadableByTheUI(t *testing.T) { t.Error("SIZE_PREFERENCE missing from AllConfigKeys; the control would always read back as its default") } } + +func TestWizardStep3_PersistsTheReleasePreference(t *testing.T) { + s, envPath := newSettings(t) + + postStep3(t, s, map[string]any{ + "download_services": []string{"slskd"}, + "release_preference": "smaller", + }) + + written, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading written env: %v", err) + } + if !bytes.Contains(written, []byte("RELEASE_PREFERENCE=smaller")) { + t.Errorf("written env does not record the release preference:\n%s", written) + } +} + +// An absent value must record the default rather than blanking the key. +func TestWizardStep3_DefaultsTheReleasePreference(t *testing.T) { + s, envPath := newSettings(t) + + postStep3(t, s, map[string]any{"download_services": []string{"slskd"}}) + + written, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading written env: %v", err) + } + if !bytes.Contains(written, []byte("RELEASE_PREFERENCE=fuller")) { + t.Errorf("an absent release preference was not defaulted:\n%s", written) + } +} + +func TestReleasePreferenceIsReadableByTheUI(t *testing.T) { + if !slices.Contains(defs.AllConfigKeys, "RELEASE_PREFERENCE") { + t.Error("RELEASE_PREFERENCE missing from AllConfigKeys; the control would always read back as its default") + } +} diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 5c18f8fc..c99009c7 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -485,6 +485,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { minBitrate, maxBitrate, sizePreference, + releasePreference, } = fields; const isLocked = (key) => envSources[key] === "env"; @@ -584,6 +585,40 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { name="Download full albums" desc="Fetch the whole release each recommendation belongs to. Only the recommended track is added to the playlist; the rest land in your library. Downloads much more per run." /> + +
+

Which release to take

+

+ Smaller picks the edition closest to the album's real length, so a 21-track + deluxe does not arrive in place of the 11-track album. Needs Enrich track + metadata, since that is where the real length comes from; without it this + falls back to taking the fuller release. +

+
+ {[ + { value: 'fuller', label: 'Fuller release' }, + { value: 'smaller', label: 'Smaller release' }, + ].map(opt => { + const active = (releasePreference || 'fuller') === opt.value + return ( + + ) + })} +
+
+
{/* Show keyword exclusion when YouTube isn't enabled — otherwise it lives in the YouTube section */} Date: Sun, 30 Aug 2026 11:07:28 -0700 Subject: [PATCH 16/57] Prefer the original release in album mode Album mode picked releases partly on file count, so a peer sharing a flattened six-disc anthology outscored every honest copy of the album and the whole box set was downloaded to obtain one recommended track. PREFER_ORIGINAL_RELEASE trims each candidate back to the original album before it is scored, rather than rejecting the peer: extra discs a peer has flattened into one directory are dropped, then bonus tracks appended past the album's real length. Trimming before scoring is what makes the comparison fair, since an untrimmed box set would otherwise win on bulk. MusicBrainz decides what counts as extra: a genuine double album keeps both discs when DiscTotal confirms it, and with no answer the assumption is one disc, because a peer flattening discs together is far more common than an unenriched double album. TrackTotal describes the first medium only, so the bonus-track pass is skipped whenever more than one disc survives. The recommended track is always kept, even when it is itself a bonus track: losing it would cost the playlist entry, which is worse than downloading one file more than intended. Directories whose numbering cannot be read are left alone rather than cut at a guess -- peers sharing an unexpanded '{track-number:02d}' template are not hypothetical. Co-Authored-By: Claude Opus 5 --- sample.env | 7 + src/config/config.go | 5 + src/downloader/original_release.go | 281 +++++++++++++++++++ src/downloader/original_release_test.go | 303 +++++++++++++++++++++ src/downloader/slskd_album.go | 12 + src/web/backend/defs/defs.go | 1 + src/web/backend/settings/handlers.go | 13 +- src/web/backend/settings/wizard_test.go | 40 +++ src/web/frontend/src/components/Wizard.jsx | 11 + src/web/sample.env | 7 + 10 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 src/downloader/original_release.go create mode 100644 src/downloader/original_release_test.go diff --git a/sample.env b/sample.env index d2aebeb2..d4cef0af 100644 --- a/sample.env +++ b/sample.env @@ -127,6 +127,13 @@ LIBRARY_NAME= # that is where the real track count comes from, and falls back to "fuller" # without it. (default: fuller) # RELEASE_PREFERENCE=fuller +# Trim a release down to the original album before downloading it. Extra discs a +# peer has flattened into one directory are dropped (a real double album is kept +# whole when ENRICH_TRACK_METADATA confirms it), and bonus tracks appended past +# the album's real length are dropped after them. The recommended track is always +# kept, even when it is itself a bonus track. Set to false to take releases +# exactly as the peer shares them. (default: true) +# PREFER_ORIGINAL_RELEASE=true # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella diff --git a/src/config/config.go b/src/config/config.go index 8dffb901..62f30132 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -161,6 +161,11 @@ type Slskd struct { // complete one, as it always has; "smaller" takes the one closest to the // album's real track count, which needs ENRICH_TRACK_METADATA to know it. ReleasePreference string `env:"RELEASE_PREFERENCE" env-default:"fuller"` + // Trim a candidate release down to the original album before downloading it: + // drop extra discs a peer has flattened into one directory, and drop bonus + // tracks appended past the album's real length. Needs ENRICH_TRACK_METADATA + // for the bonus-track half; disc trimming works without it. + PreferOriginalRelease bool `env:"PREFER_ORIGINAL_RELEASE" env-default:"true"` // Download every track in the release the matched file sits in, rather // than the single recommended track. Off by default: it multiplies how // much is transferred per recommendation. diff --git a/src/downloader/original_release.go b/src/downloader/original_release.go new file mode 100644 index 00000000..811875cd --- /dev/null +++ b/src/downloader/original_release.go @@ -0,0 +1,281 @@ +package downloader + +import ( + "log/slog" + "path" + "regexp" + "slices" + "strconv" + "strings" + + "explo/src/models" +) + +// Preferring the original release is a trim, not a rejection. A peer sharing a +// six-disc anthology usually has the album we want sitting inside it, so +// reducing the candidate to that album beats skipping the peer and beats +// downloading all six discs. +// +// The trim runs before scoring, which is the point: a 39-file box set and a +// 16-file pressing of the same album are not comparable until both have been +// reduced to the album, and fullerReleaseScore would otherwise hand the box set +// the win on bulk alone. + +// discTrackPattern matches a flattened "disc-track" prefix such as 01-04 or +// 1-09. The leading boundary rejects a digit, so a year like 1975-04 cannot be +// read as disc 19 or disc 5. +var discTrackPattern = regexp.MustCompile(`(?:^|[^0-9])([0-9]{1,2})-([0-9]{2})(?:[^0-9]|$)`) + +// packedDiscTrackPattern matches the four-digit DDTT form, as in "0101 - Colors". +// The disc half is capped at 09 so that a leading year (1999, 2015) is not read +// as disc 19 or disc 20. +var packedDiscTrackPattern = regexp.MustCompile(`^(0[1-9])([0-9]{2})(?:[^0-9]|$)`) + +// leadingTrackPattern matches a plain track number: "02 - Sign of the Times". +var leadingTrackPattern = regexp.MustCompile(`^([0-9]{1,2})(?:[^0-9]|$)`) + +// underscoreTrackPattern matches the track number in an underscore-joined name, +// as in "Hayley Williams_Ego Death_18_Parachute". Limited to one or two digits +// so an embedded year cannot match, and tried after the disc-aware patterns so +// that "..._01-04_Chocolate" is read as disc 1 rather than as track 1. +var underscoreTrackPattern = regexp.MustCompile(`_([0-9]{1,2})_`) + +// yearPattern finds a release year in a directory name. +var yearPattern = regexp.MustCompile(`(?:^|[^0-9])((?:19|20)[0-9]{2})(?:[^0-9]|$)`) + +// originalYearPenalty is how much a directory loses for naming a year later +// than the album's original release. Deliberately small: it only separates +// releases that already survived the trim as equals, which is the "then +// earliest" half of preferring the original. +const originalYearPenalty = 8 + +// fileNumbering is what a peer's filename says about where the file sits in the +// release. disc is 0 when the name carries no disc marker at all, which is the +// normal case for a single-disc directory. +type fileNumbering struct { + disc int + track int + ok bool +} + +// parseNumbering reads the disc and track position out of a filename. Peers +// name files every way imaginable, so this recognises the three forms that +// actually show up and gives up on anything else rather than guessing. +func parseNumbering(name string) fileNumbering { + base := path.Base(normalizePeerPath(name)) + base = strings.TrimSuffix(base, path.Ext(base)) + + if m := packedDiscTrackPattern.FindStringSubmatch(base); m != nil { + disc, _ := strconv.Atoi(m[1]) + trackNo, _ := strconv.Atoi(m[2]) + if trackNo > 0 { + return fileNumbering{disc: disc, track: trackNo, ok: true} + } + } + + if m := discTrackPattern.FindStringSubmatch(base); m != nil { + disc, _ := strconv.Atoi(m[1]) + trackNo, _ := strconv.Atoi(m[2]) + if disc > 0 && trackNo > 0 { + return fileNumbering{disc: disc, track: trackNo, ok: true} + } + } + + if m := leadingTrackPattern.FindStringSubmatch(base); m != nil { + trackNo, _ := strconv.Atoi(m[1]) + if trackNo > 0 { + return fileNumbering{track: trackNo, ok: true} + } + } + + if m := underscoreTrackPattern.FindStringSubmatch(base); m != nil { + trackNo, _ := strconv.Atoi(m[1]) + if trackNo > 0 { + return fileNumbering{track: trackNo, ok: true} + } + } + + return fileNumbering{} +} + +// minParsedShare is how much of a directory must yield a usable track number +// before the trim will act on it. Below this the directory is named in some way +// we do not understand, and trimming would be cutting at random -- a peer +// sharing a literal "{track-number:02d} - Cowgirl.mp3" is not hypothetical. +const minParsedShare = 2.0 / 3.0 + +// allowedDiscs is how many discs the release may legitimately span. +// +// MusicBrainz is trusted when it has an answer: a real double album keeps both +// discs. With no answer the assumption is one disc, because the case this +// exists for -- a peer flattening several discs into a single folder -- is far +// more common than an unenriched genuine double album. +func allowedDiscs(track models.Track) int { + if track.DiscTotal > 1 { + return track.DiscTotal + } + return 1 +} + +// trimToOriginal reduces a candidate release to the original album: extra discs +// first, then bonus tracks appended past the album's real length. +// +// The primary is retained unconditionally. Trimming away the recommended track +// would lose the playlist entry entirely, which is a worse outcome than +// downloading one file more than intended -- and a recommendation that is +// itself a bonus track is exactly the case that would otherwise do it. +func trimToOriginal(dir *peerDir, track models.Track) { + if dir.primary == nil || len(dir.files) < 2 { + return + } + + numbering := make([]fileNumbering, len(dir.files)) + var parsed int + for i, file := range dir.files { + numbering[i] = parseNumbering(string(file.Name)) + if numbering[i].ok { + parsed++ + } + } + if float64(parsed) < float64(len(dir.files))*minParsedShare { + return + } + + primaryName := dir.primary.Name + keep := make([]bool, len(dir.files)) + for i := range keep { + keep[i] = true + } + + discs := trimExtraDiscs(dir, numbering, keep, track) + + // The bonus-track pass is skipped whenever more than one disc survives: + // TrackTotal counts the first medium only, so measuring a kept two-disc + // release against it would amputate the second disc for being there. + if discs <= 1 { + trimBonusTracks(dir, numbering, keep, track) + } + + kept := make([]File, 0, len(dir.files)) + for i, file := range dir.files { + if keep[i] || file.Name == primaryName { + kept = append(kept, file) + } + } + if len(kept) == len(dir.files) { + return + } + + slog.Debug("trimmed release to the original album", + "dir", dir.dir, "from", len(dir.files), "to", len(kept)) + + dir.files = kept + // dir.primary points into the old backing array, so it has to be re-seated + // or every later use of it reads a file that is no longer in the release. + dir.primary = nil + for i := range dir.files { + if dir.files[i].Name == primaryName { + dir.primary = &dir.files[i] + break + } + } +} + +// trimExtraDiscs drops discs beyond what the release may legitimately span, +// keeping the lowest-numbered ones. It reports how many discs survive. +func trimExtraDiscs(dir *peerDir, numbering []fileNumbering, keep []bool, track models.Track) int { + present := make([]int, 0, 4) + for _, n := range numbering { + if n.ok && n.disc > 0 && !slices.Contains(present, n.disc) { + present = append(present, n.disc) + } + } + if len(present) <= 1 { + return len(present) + } + + slices.Sort(present) + allowed := allowedDiscs(track) + if len(present) <= allowed { + return len(present) + } + + wanted := present[:allowed] + for i, n := range numbering { + if n.ok && n.disc > 0 && !slices.Contains(wanted, n.disc) { + keep[i] = false + } + } + return allowed +} + +// trimBonusTracks drops whatever sits past the album's real track count. +// +// Bonus material is appended after the album by near-universal convention, so +// the lowest TrackTotal positions are the album. Files with no readable track +// number sort last and are therefore the first to go. +func trimBonusTracks(dir *peerDir, numbering []fileNumbering, keep []bool, track models.Track) { + expected := track.TrackTotal + if expected <= 0 { + return + } + + order := make([]int, 0, len(dir.files)) + for i := range dir.files { + if keep[i] { + order = append(order, i) + } + } + if len(order) <= expected { + return + } + + slices.SortStableFunc(order, func(a, b int) int { + na, nb := numbering[a], numbering[b] + switch { + case na.ok != nb.ok: + if na.ok { + return -1 + } + return 1 + case na.track != nb.track: + return na.track - nb.track + default: + return 0 + } + }) + + for _, i := range order[expected:] { + keep[i] = false + } +} + +// laterThanOriginal reports whether a directory names a release year after the +// album's original one -- a remaster or reissue carrying the same tracklist, +// which the trim cannot tell apart from the original by file count alone. +// +// Only the final path segment is read: the parent folders belong to the peer's +// library layout, and a year in one of those says nothing about this release. +func laterThanOriginal(dir string, track models.Track) bool { + if track.OriginalYear <= 0 { + return false + } + + matches := yearPattern.FindAllStringSubmatch(path.Base(normalizePeerPath(dir)), -1) + if len(matches) == 0 { + return false + } + + earliest := 0 + for _, m := range matches { + year, err := strconv.Atoi(m[1]) + if err != nil { + continue + } + if earliest == 0 || year < earliest { + earliest = year + } + } + + return earliest > track.OriginalYear +} diff --git a/src/downloader/original_release_test.go b/src/downloader/original_release_test.go new file mode 100644 index 00000000..57ccda35 --- /dev/null +++ b/src/downloader/original_release_test.go @@ -0,0 +1,303 @@ +package downloader + +import ( + "strings" + "testing" + + cfg "explo/src/config" + "explo/src/models" +) + +// numberedDir builds a candidate release from filenames, with the primary +// pointed at whichever file names the recommended track. +func numberedDir(primaryTitle string, names ...string) peerDir { + dir := peerDir{dir: "Billie Eilish - When We All Fall Asleep"} + for _, name := range names { + dir.files = append(dir.files, File{ + Name: name, + Extension: "flac", + BitRate: 900, + }) + } + for i := range dir.files { + if strings.Contains(string(dir.files[i].Name), primaryTitle) { + dir.primary = &dir.files[i] + break + } + } + return dir +} + +func fileNames(dir peerDir) []string { + names := make([]string, 0, len(dir.files)) + for _, f := range dir.files { + names = append(names, string(f.Name)) + } + return names +} + +func TestParseNumbering(t *testing.T) { + cases := []struct { + name string + disc int + track int + ok bool + }{ + // The four shapes that actually turned up in production logs. + {"The 1975_The 1975_01-04_Chocolate.mp3", 1, 4, true}, + {"1-09 In My Room.flac", 1, 9, true}, + {"0101 - Colors.flac", 1, 1, true}, + {"02 - Sign of the Times.mp3", 0, 2, true}, + + {"Hayley Williams_Ego Death_18_Parachute.flac", 0, 18, true}, + {"05-gracie_abrams-look_at_my_life.mp3", 0, 5, true}, + + // A year must never be read as a disc marker. + {"1975-04 Chocolate.mp3", 0, 0, false}, + {"2015 - Badlands.flac", 0, 0, false}, + + // Nothing parseable: a peer whose naming template never expanded. + {"{track-number:02d} - Cowgirl.mp3", 0, 0, false}, + {"Sundown-HUNTR.flac", 0, 0, false}, + } + + for _, tc := range cases { + got := parseNumbering(tc.name) + if got.ok != tc.ok || got.disc != tc.disc || got.track != tc.track { + t.Errorf("parseNumbering(%q) = disc %d track %d ok %v, want disc %d track %d ok %v", + tc.name, got.disc, got.track, got.ok, tc.disc, tc.track, tc.ok) + } + } +} + +// The case that prompted this: a peer flattening several discs into one folder, +// which then outscored every honest copy of the album on file count alone. +func TestTrimToOriginal_DropsFlattenedExtraDiscs(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", "01-03 Xanny.flac", + "02-01 Demo Take.flac", "02-02 Alternate Mix.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 3, DiscTotal: 1}) + + if len(dir.files) != 3 { + t.Fatalf("kept %d files %v, want the 3 on disc one", len(dir.files), fileNames(dir)) + } + for _, name := range fileNames(dir) { + if strings.HasPrefix(name, "02-") { + t.Errorf("kept %q from the second disc", name) + } + } +} + +// A real double album is not bloat. MusicBrainz is the arbiter, and when it +// says two discs, both stay. +func TestTrimToOriginal_KeepsARealDoubleAlbum(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", + "02-01 Xanny.flac", "02-02 Ilomilo.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 2, DiscTotal: 2}) + + if len(dir.files) != 4 { + t.Fatalf("kept %d files %v, want all 4 of a genuine 2-disc release", + len(dir.files), fileNames(dir)) + } +} + +// With no MusicBrainz answer the assumption is one disc: a peer flattening +// discs together is far more common than an unenriched double album. +func TestTrimToOriginal_TrimsToDiscOneWhenMusicBrainzIsSilent(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", + "02-01 Xanny.flac", "02-02 Ilomilo.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy"}) + + if len(dir.files) != 2 { + t.Fatalf("kept %d files %v, want disc one only", len(dir.files), fileNames(dir)) + } +} + +// TrackTotal counts the first medium only, so measuring a legitimately kept +// two-disc release against it would amputate the second disc for existing. +func TestTrimToOriginal_SkipsTheBonusPassOnAKeptDoubleAlbum(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", "01-03 Xanny.flac", + "02-01 Ilomilo.flac", "02-02 8.flac", "02-03 My Strange Addiction.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 3, DiscTotal: 2}) + + if len(dir.files) != 6 { + t.Fatalf("kept %d files %v, want all 6: TrackTotal describes disc one, not the release", + len(dir.files), fileNames(dir)) + } +} + +// The Halsey case: a single-disc anthology padded past the album's real length. +func TestTrimToOriginal_DropsBonusTracks(t *testing.T) { + dir := numberedDir("Bad Guy", + "01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac", + "04 Bonus Demo.flac", "05 Bonus Live Take.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 3, DiscTotal: 1}) + + if len(dir.files) != 3 { + t.Fatalf("kept %d files %v, want the 3-track album", len(dir.files), fileNames(dir)) + } + for _, name := range fileNames(dir) { + if strings.Contains(name, "Bonus") { + t.Errorf("kept bonus material %q", name) + } + } +} + +// Trimming away the recommended track would lose the playlist entry, which is +// worse than downloading one file more than intended. +func TestTrimToOriginal_AlwaysKeepsThePrimary(t *testing.T) { + dir := numberedDir("Bad Guy", + "01 Bury A Friend.flac", "02 Xanny.flac", "03 Ilomilo.flac", + "04 Bonus Demo.flac", "05 Bad Guy.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 3, DiscTotal: 1}) + + var found bool + for _, name := range fileNames(dir) { + if strings.Contains(name, "Bad Guy") { + found = true + } + } + if !found { + t.Fatalf("trimmed away the recommended track; kept %v", fileNames(dir)) + } + if dir.primary == nil || !strings.Contains(string(dir.primary.Name), "Bad Guy") { + t.Errorf("primary = %v, want it still pointing at the recommended track", dir.primary) + } +} + +// dir.primary points into the files slice. Trimming reallocates it, so a +// pointer left behind would read a file no longer in the release. +func TestTrimToOriginal_ReseatsThePrimaryPointer(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", + "02-01 Demo Take.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 2, DiscTotal: 1}) + + if dir.primary == nil { + t.Fatal("primary = nil after trimming") + } + var inSlice bool + for i := range dir.files { + if &dir.files[i] == dir.primary { + inSlice = true + } + } + if !inSlice { + t.Error("primary points outside the trimmed release; it was not re-seated") + } +} + +// Cutting a directory we cannot read the numbering of would be cutting at +// random. A peer sharing an unexpanded naming template is not hypothetical. +func TestTrimToOriginal_SkipsUnparseableDirectories(t *testing.T) { + dir := numberedDir("Bad Guy", + "Bury A Friend.flac", "Bad Guy.flac", "Xanny.flac", + "{track-number:02d} - Ilomilo.flac", "Goodbye.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 3, DiscTotal: 1}) + + if len(dir.files) != 5 { + t.Fatalf("kept %d files %v, want all 5 left alone", len(dir.files), fileNames(dir)) + } +} + +func TestLaterThanOriginal(t *testing.T) { + cases := []struct { + dir string + year int + want bool + }{ + {"Halsey/2023 - Badlands (Remastered)", 2015, true}, + {"Halsey/2015 - Badlands", 2015, false}, + {"Halsey/Badlands", 2015, false}, + {"Halsey/2015 - Badlands (Decade Edition Anthology)", 2015, false}, + {"Halsey/2023 - Badlands", 0, false}, + // A year in a parent folder describes the peer's library, not this + // release, so only the final segment is read. + {"@@x/2023 Rips/Halsey/2015 - Badlands", 2015, false}, + } + + for _, tc := range cases { + got := laterThanOriginal(tc.dir, models.Track{OriginalYear: tc.year}) + if got != tc.want { + t.Errorf("laterThanOriginal(%q, %d) = %v, want %v", tc.dir, tc.year, got, tc.want) + } + } +} + +// originalClient mirrors albumClient with the trim switched on. +func originalClient(t *testing.T, prefer bool) *Slskd { + t.Helper() + + client := NewSlskd(cfg.Slskd{ + URL: "http://localhost", + APIKey: "test-key", + Timeout: 5, + AlbumMode: true, + PreferOriginalRelease: prefer, + Filters: cfg.Filters{ + Extensions: []string{"flac", "mp3"}, + MinBitRate: 256, + MinBitDepth: 8, + FilterList: []string{"live", "remix"}, + }, + }, "/data/") + client.AddHeader() + + return client +} + +func flattenedTrack() models.Track { + track := albumTrack() + track.TrackTotal = 3 + track.DiscTotal = 1 + return track +} + +func flattenedResults() SearchResults { + return albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, []string{ + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", "01-03 Xanny.flac", + "02-01 Demo Take.flac", "02-02 Alternate Mix.flac", + }}) +} + +func TestCollectAlbumFiles_TrimsAFlattenedReleaseToTheAlbum(t *testing.T) { + files, err := originalClient(t, true).CollectAlbumFiles(flattenedTrack(), flattenedResults()) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 3 { + t.Fatalf("queued %d files, want the 3 on disc one", len(files)) + } + if !strings.Contains(string(files[0].Name), "Bad Guy") { + t.Errorf("files[0] = %q, want the recommended track first", files[0].Name) + } +} + +func TestCollectAlbumFiles_LeavesReleasesAloneWhenDisabled(t *testing.T) { + files, err := originalClient(t, false).CollectAlbumFiles(flattenedTrack(), flattenedResults()) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 5 { + t.Fatalf("queued %d files with the setting off, want all 5 untouched", len(files)) + } +} diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go index c31324e3..3ac39255 100644 --- a/src/downloader/slskd_album.go +++ b/src/downloader/slskd_album.go @@ -142,6 +142,12 @@ func (c Slskd) scoreDirWithPreference(dir peerDir, track models.Track) int { score -= fullerReleaseScore(dir) score += releaseSizeScore(dir, track, normaliseReleasePreference(c.Cfg.ReleasePreference)) + // Trimming equalises tracklists, so a reissue and the original become + // indistinguishable by file count. The year is what still separates them. + if c.Cfg.PreferOriginalRelease && laterThanOriginal(dir.dir, track) { + score -= originalYearPenalty + } + return score } @@ -192,6 +198,12 @@ func (c Slskd) CollectAlbumFiles(track models.Track, results SearchResults) ([]F if !findPrimary(dir, track) { continue } + // Before scoring, not after: a release still carrying its bonus discs + // would otherwise outscore the plain album on file count and win the + // comparison it should have lost. + if c.Cfg.PreferOriginalRelease { + trimToOriginal(dir, track) + } if score := c.scoreDirWithPreference(*dir, track); best == nil || score > bestScore { best, bestScore = dir, score } diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 11f0e64c..1b27d556 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -181,6 +181,7 @@ var AllConfigKeys = []string{ "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "MIN_BITRATE", "MAX_BITRATE", "SIZE_PREFERENCE", "RELEASE_PREFERENCE", + "PREFER_ORIGINAL_RELEASE", "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_ALBUM_MODE", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", } \ No newline at end of file diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index b5dd51cd..af1bdc48 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -490,6 +490,10 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { MaxBitRate int `json:"max_bitrate"` SizePreference string `json:"size_preference"` ReleasePreference string `json:"release_preference"` + // A pointer because this one defaults to on: a plain bool cannot tell + // "the client cleared it" from "the client did not send it", and would + // quietly disable the feature on every payload that omits the key. + PreferOriginalRelease *bool `json:"prefer_original_release"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) @@ -520,6 +524,10 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { if body.ReleasePreference == "" { body.ReleasePreference = "fuller" } + preferOriginal := "true" + if body.PreferOriginalRelease != nil && !*body.PreferOriginalRelease { + preferOriginal = "false" + } updates := map[string]string{ "DOWNLOAD_DIR": body.DownloadDir, "USE_SUBDIRECTORY": useSubdir, @@ -536,7 +544,10 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { "MAX_BITRATE": strconv.Itoa(body.MaxBitRate), "SIZE_PREFERENCE": body.SizePreference, "RELEASE_PREFERENCE": body.ReleasePreference, - "WIZARD_COMPLETE": "true", + + "PREFER_ORIGINAL_RELEASE": preferOriginal, + + "WIZARD_COMPLETE": "true", } if err := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { diff --git a/src/web/backend/settings/wizard_test.go b/src/web/backend/settings/wizard_test.go index b6f4ba1c..6e29f970 100644 --- a/src/web/backend/settings/wizard_test.go +++ b/src/web/backend/settings/wizard_test.go @@ -216,3 +216,43 @@ func TestReleasePreferenceIsReadableByTheUI(t *testing.T) { t.Error("RELEASE_PREFERENCE missing from AllConfigKeys; the control would always read back as its default") } } + +func TestWizardStep3_PersistsPreferOriginalRelease(t *testing.T) { + s, envPath := newSettings(t) + + postStep3(t, s, map[string]any{ + "download_services": []string{"slskd"}, + "prefer_original_release": false, + }) + + written, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading written env: %v", err) + } + if !bytes.Contains(written, []byte("PREFER_ORIGINAL_RELEASE=false")) { + t.Errorf("written env does not record the original-release preference:\n%s", written) + } +} + +// This one defaults to on, so an omitted key must not read as "off". A plain +// bool on the request body cannot tell the two apart, which is why the field is +// a pointer. +func TestWizardStep3_DefaultsPreferOriginalReleaseToOn(t *testing.T) { + s, envPath := newSettings(t) + + postStep3(t, s, map[string]any{"download_services": []string{"slskd"}}) + + written, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading written env: %v", err) + } + if !bytes.Contains(written, []byte("PREFER_ORIGINAL_RELEASE=true")) { + t.Errorf("an absent original-release preference was not defaulted to on:\n%s", written) + } +} + +func TestPreferOriginalReleaseIsReadableByTheUI(t *testing.T) { + if !slices.Contains(defs.AllConfigKeys, "PREFER_ORIGINAL_RELEASE") { + t.Error("PREFER_ORIGINAL_RELEASE missing from AllConfigKeys; the toggle would always read back as its default") + } +} diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index c99009c7..0388cb89 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -486,6 +486,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { maxBitrate, sizePreference, releasePreference, + preferOriginalRelease, } = fields; const isLocked = (key) => envSources[key] === "env"; @@ -618,6 +619,13 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { })} + setField("preferOriginalRelease", v)} + disabled={isLocked("PREFER_ORIGINAL_RELEASE")} + name="Prefer the original album" + desc="Trim a release back to the original album: drop extra discs a peer has flattened into one folder, then drop bonus tracks past the album's real length. A genuine double album is kept whole, and the recommended track is never trimmed away." + /> {/* Show keyword exclusion when YouTube isn't enabled — otherwise it lives in the YouTube section */} @@ -725,6 +733,8 @@ export default function Wizard({ maxBitrate: parseInt(config.MAX_BITRATE) || 0, sizePreference: config.SIZE_PREFERENCE || "none", releasePreference: config.RELEASE_PREFERENCE || "fuller", + // Defaults to on, so only an explicit "false" turns it off. + preferOriginalRelease: config.PREFER_ORIGINAL_RELEASE !== "false", adminAuthMethod: config.ADMIN_AUTH_METHOD || "password", adminApiKey: config.ADMIN_API_KEY || "", adminSystemUsername: config.ADMIN_SYSTEM_USERNAME || "", @@ -815,6 +825,7 @@ export default function Wizard({ max_bitrate: fields.maxBitrate, size_preference: fields.sizePreference, release_preference: fields.releasePreference, + prefer_original_release: fields.preferOriginalRelease, }); onComplete(); } catch (e) { diff --git a/src/web/sample.env b/src/web/sample.env index d2aebeb2..d4cef0af 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -127,6 +127,13 @@ LIBRARY_NAME= # that is where the real track count comes from, and falls back to "fuller" # without it. (default: fuller) # RELEASE_PREFERENCE=fuller +# Trim a release down to the original album before downloading it. Extra discs a +# peer has flattened into one directory are dropped (a real double album is kept +# whole when ENRICH_TRACK_METADATA confirms it), and bonus tracks appended past +# the album's real length are dropped after them. The recommended track is always +# kept, even when it is itself a bonus track. Set to false to take releases +# exactly as the peer shares them. (default: true) +# PREFER_ORIGINAL_RELEASE=true # Comma-separated (without spaces) keywords to avoid, when filtering slskd results (default: live,remix,instrumental,extended,clean,acapella) # FILTER_LIST=live,remix,instrumental,extended,clean,acapella From 9889df5d9b07e73f89887e8ab26c4e9113d3a1b7 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sun, 30 Aug 2026 11:09:31 -0700 Subject: [PATCH 17/57] Assert wizard defaults against the written value, not the sample comment sample.env documents every key as a comment carrying its default, so bytes.Contains(written, "SIZE_PREFERENCE=none") matched that comment and passed whether or not the wizard wrote the key at all. Replacing the handler's default with a wrong value left the test green. Reading the value off an uncommented line makes the three default assertions fail when the default does. Co-Authored-By: Claude Opus 5 --- src/web/backend/settings/wizard_test.go | 42 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/web/backend/settings/wizard_test.go b/src/web/backend/settings/wizard_test.go index 6e29f970..5bc98e87 100644 --- a/src/web/backend/settings/wizard_test.go +++ b/src/web/backend/settings/wizard_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "explo/src/web/backend/app" @@ -168,9 +169,7 @@ func TestWizardStep3_DefaultsTheSizePreference(t *testing.T) { if err != nil { t.Fatalf("reading written env: %v", err) } - if !bytes.Contains(written, []byte("SIZE_PREFERENCE=none")) { - t.Errorf("an absent preference was not defaulted:\n%s", written) - } + assertEnvValue(t, written, "SIZE_PREFERENCE", "none") } func TestSizePreferenceIsReadableByTheUI(t *testing.T) { @@ -179,6 +178,31 @@ func TestSizePreferenceIsReadableByTheUI(t *testing.T) { } } +// envValue reads a key's written value, ignoring commented lines. sample.env +// documents every key as a comment carrying its default, so a plain substring +// search for "KEY=default" matches that comment and passes whether or not the +// wizard wrote anything at all. +func envValue(written []byte, key string) string { + for _, line := range strings.Split(string(written), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + continue + } + if name, value, ok := strings.Cut(line, "="); ok && strings.TrimSpace(name) == key { + return strings.TrimSpace(value) + } + } + return "" +} + +func assertEnvValue(t *testing.T, written []byte, key, want string) { + t.Helper() + + if got := envValue(written, key); got != want { + t.Errorf("%s = %q, want %q\n%s", key, got, want, written) + } +} + func TestWizardStep3_PersistsTheReleasePreference(t *testing.T) { s, envPath := newSettings(t) @@ -206,9 +230,7 @@ func TestWizardStep3_DefaultsTheReleasePreference(t *testing.T) { if err != nil { t.Fatalf("reading written env: %v", err) } - if !bytes.Contains(written, []byte("RELEASE_PREFERENCE=fuller")) { - t.Errorf("an absent release preference was not defaulted:\n%s", written) - } + assertEnvValue(t, written, "RELEASE_PREFERENCE", "fuller") } func TestReleasePreferenceIsReadableByTheUI(t *testing.T) { @@ -229,9 +251,7 @@ func TestWizardStep3_PersistsPreferOriginalRelease(t *testing.T) { if err != nil { t.Fatalf("reading written env: %v", err) } - if !bytes.Contains(written, []byte("PREFER_ORIGINAL_RELEASE=false")) { - t.Errorf("written env does not record the original-release preference:\n%s", written) - } + assertEnvValue(t, written, "PREFER_ORIGINAL_RELEASE", "false") } // This one defaults to on, so an omitted key must not read as "off". A plain @@ -246,9 +266,7 @@ func TestWizardStep3_DefaultsPreferOriginalReleaseToOn(t *testing.T) { if err != nil { t.Fatalf("reading written env: %v", err) } - if !bytes.Contains(written, []byte("PREFER_ORIGINAL_RELEASE=true")) { - t.Errorf("an absent original-release preference was not defaulted to on:\n%s", written) - } + assertEnvValue(t, written, "PREFER_ORIGINAL_RELEASE", "true") } func TestPreferOriginalReleaseIsReadableByTheUI(t *testing.T) { From cf3d1444a35e37b1f2f3185756a5f1f2b96747c1 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Sun, 30 Aug 2026 11:30:02 -0700 Subject: [PATCH 18/57] Take the album's shape from its release group, not one matched release PREFER_ORIGINAL_RELEASE trimmed a candidate release using the numbers on whichever release MusicBrainz matched. When a recommendation resolved to a deluxe edition those numbers described the deluxe: DiscTotal reported two discs, so a flattened multi-disc directory was kept whole and the box set won anyway. The feature failed on exactly the case it exists for. Ask the release group instead. The modal first-medium track count and modal medium count across the album's other editions describe the album as everyone agrees it is, and outrank the matched release wherever the two disagree -- a genuine double album has a group that agrees on two, so it is still kept whole. Releases from other release groups are excluded, as are bootlegs whenever any official release exists, and vinyl pressings, whose extra media would otherwise inflate the disc count. This costs nothing: enrichTracks already requests inc=media+releases and parses every release, then used one and discarded the rest. No additional MusicBrainz calls, no additional time in the enrichment step. Also refuses to trim a directory whose parsed numbering repeats itself. Duplicate positions mean either two editions sitting side by side or a misparse, and neither can say which files are the album -- leaving the release whole is the safe failure. Trimming is the one place where a wrong answer is worse than no answer, since unlike every comparable tool we edit the release rather than accepting or rejecting it whole. The trim now logs at Info. It changes what gets downloaded, so it belongs in the run log beside 'album release selected' rather than being invisible. MBRelease and MBMedium were extracted from anonymous structs so the consensus could be computed by a testable helper. Co-Authored-By: Claude Opus 5 --- src/discovery/canonical.go | 98 +++++++++++++ src/discovery/canonical_test.go | 184 ++++++++++++++++++++++++ src/discovery/listenbrainz.go | 75 ++++++---- src/downloader/original_release.go | 64 ++++++++- src/downloader/original_release_test.go | 83 +++++++++++ src/downloader/preference.go | 24 +++- src/downloader/preference_test.go | 27 ++++ src/models/types.go | 6 + 8 files changed, 519 insertions(+), 42 deletions(-) create mode 100644 src/discovery/canonical.go create mode 100644 src/discovery/canonical_test.go diff --git a/src/discovery/canonical.go b/src/discovery/canonical.go new file mode 100644 index 00000000..fb2fce4f --- /dev/null +++ b/src/discovery/canonical.go @@ -0,0 +1,98 @@ +package discovery + +import "strings" + +// The album's shape as its release group agrees it is. +// +// A recommendation carries whichever release MusicBrainz matched, which is not +// necessarily the album anyone thinks of as the album: a track resolving to a +// two-disc deluxe edition reports two discs and the deluxe's length, and album +// mode then treats a padded directory as correct. Asking the rest of the group +// what shape the album usually has answers that far more reliably than trusting +// the single matched release. +// +// This costs nothing extra. The enrichment request already asks for +// inc=media+releases and parses every release; until now all but one were +// discarded. + +// inflatedFormats report more media than the album has discs. A 2LP pressing of +// a single-CD album lists two media, which would drag the disc consensus up. +var inflatedFormats = []string{"vinyl", "cassette"} + +func inflatesDiscCount(rel MBRelease) bool { + for _, medium := range rel.Media { + for _, format := range inflatedFormats { + if strings.EqualFold(medium.Format, format) { + return true + } + } + } + return false +} + +// CanonicalReleaseShape reports the modal first-medium track count and the modal +// medium count across the releases sharing groupID, or zeroes when there is no +// usable consensus. Zero leaves every caller on the matched release's own +// numbers, which is what happens with enrichment switched off. +func CanonicalReleaseShape(releases []MBRelease, groupID string) (trackTotal, discTotal int) { + if groupID == "" { + return 0, 0 + } + + group := make([]MBRelease, 0, len(releases)) + for _, rel := range releases { + // Other release groups carry this recording too -- greatest hits, + // soundtracks -- and their lengths say nothing about this album. + if rel.ReleaseGroup.ID != groupID || len(rel.Media) == 0 || inflatesDiscCount(rel) { + continue + } + group = append(group, rel) + } + if len(group) == 0 { + return 0, 0 + } + + // Bootlegs and promos are poor evidence of the album's real shape, but they + // are better than nothing: a group with no official release at all still + // gets a consensus rather than falling back to the matched release. + official := make([]MBRelease, 0, len(group)) + for _, rel := range group { + if strings.EqualFold(rel.Status, "Official") { + official = append(official, rel) + } + } + if len(official) > 0 { + group = official + } + + tracks := make([]int, 0, len(group)) + discs := make([]int, 0, len(group)) + for _, rel := range group { + if count := rel.Media[0].TrackCount; count > 0 { + tracks = append(tracks, count) + } + discs = append(discs, len(rel.Media)) + } + + return modal(tracks), modal(discs) +} + +// modal returns the most common value, breaking ties toward the smaller one. +// +// The tie-break is the point of the whole exercise: when an album is shared +// equally as a 12-track original and a 20-track expanded edition, the original +// is the one being asked for. +func modal(values []int) int { + counts := make(map[int]int, len(values)) + for _, value := range values { + counts[value]++ + } + + var best, bestCount int + for value, count := range counts { + if count > bestCount || (count == bestCount && value < best) { + best, bestCount = value, count + } + } + return best +} diff --git a/src/discovery/canonical_test.go b/src/discovery/canonical_test.go new file mode 100644 index 00000000..799db2c3 --- /dev/null +++ b/src/discovery/canonical_test.go @@ -0,0 +1,184 @@ +package discovery + +import ( + "encoding/json" + "testing" +) + +const groupID = "group-1" + +// release builds one release in groupID. Each entry in mediaTracks is one +// medium's track count, so len(mediaTracks) is the disc count. +func release(status string, format string, mediaTracks ...int) MBRelease { + rel := MBRelease{Status: status} + rel.ReleaseGroup.ID = groupID + for _, count := range mediaTracks { + rel.Media = append(rel.Media, MBMedium{Format: format, TrackCount: count}) + } + return rel +} + +// The point of the whole exercise: the matched release may be a deluxe, and the +// group knows better. +func TestCanonicalReleaseShape_PrefersTheConsensusOverAnOutlier(t *testing.T) { + releases := []MBRelease{ + release("Official", "CD", 16), + release("Official", "CD", 16), + release("Official", "Digital Media", 16), + release("Official", "CD", 16, 23), // the two-disc deluxe + } + + tracks, discs := CanonicalReleaseShape(releases, groupID) + + if tracks != 16 || discs != 1 { + t.Errorf("CanonicalReleaseShape() = %d tracks, %d discs; want 16 and 1", tracks, discs) + } +} + +// A real double album has a group that agrees it is one. +func TestCanonicalReleaseShape_KeepsAGenuineDoubleAlbum(t *testing.T) { + releases := []MBRelease{ + release("Official", "CD", 13, 13), + release("Official", "CD", 13, 13), + release("Official", "CD", 26), + } + + if _, discs := CanonicalReleaseShape(releases, groupID); discs != 2 { + t.Errorf("disc consensus = %d, want 2", discs) + } +} + +// A recording appears on greatest hits and soundtracks too, and their lengths +// say nothing about this album. +func TestCanonicalReleaseShape_IgnoresOtherReleaseGroups(t *testing.T) { + other := release("Official", "CD", 40) + other.ReleaseGroup.ID = "group-2" + + releases := []MBRelease{ + release("Official", "CD", 11), + other, other, other, + } + + if tracks, _ := CanonicalReleaseShape(releases, groupID); tracks != 11 { + t.Errorf("track consensus = %d, want 11 from this group only", tracks) + } +} + +func TestCanonicalReleaseShape_IgnoresBootlegsWhenOfficialsExist(t *testing.T) { + releases := []MBRelease{ + release("Official", "CD", 12), + release("Bootleg", "CD", 30), + release("Bootleg", "CD", 30), + release("Bootleg", "CD", 30), + } + + if tracks, _ := CanonicalReleaseShape(releases, groupID); tracks != 12 { + t.Errorf("track consensus = %d, want the official 12 despite more bootlegs", tracks) + } +} + +// Poor evidence beats none: a group with nothing official still yields numbers +// rather than falling back to the matched release. +func TestCanonicalReleaseShape_UsesBootlegsWhenThatIsAllThereIs(t *testing.T) { + releases := []MBRelease{ + release("Bootleg", "CD", 9), + release("Bootleg", "CD", 9), + } + + if tracks, _ := CanonicalReleaseShape(releases, groupID); tracks != 9 { + t.Errorf("track consensus = %d, want 9", tracks) + } +} + +// A 2LP pressing of a single-CD album lists two media and would drag the disc +// consensus up to two. +func TestCanonicalReleaseShape_ExcludesVinylFromTheDiscCount(t *testing.T) { + releases := []MBRelease{ + release("Official", "CD", 14), + release("Official", "Vinyl", 7, 7), + release("Official", "Vinyl", 7, 7), + } + + tracks, discs := CanonicalReleaseShape(releases, groupID) + + if discs != 1 { + t.Errorf("disc consensus = %d, want 1; the vinyl pressings inflated it", discs) + } + if tracks != 14 { + t.Errorf("track consensus = %d, want the CD's 14", tracks) + } +} + +// The tie-break is the point: asked for an album shared equally as original and +// expanded, the original is the one wanted. +func TestCanonicalReleaseShape_BreaksTiesTowardTheSmaller(t *testing.T) { + releases := []MBRelease{ + release("Official", "CD", 12), + release("Official", "CD", 20), + } + + if tracks, _ := CanonicalReleaseShape(releases, groupID); tracks != 12 { + t.Errorf("track consensus = %d, want the smaller 12 on a tie", tracks) + } +} + +func TestCanonicalReleaseShape_ZeroWithoutUsableReleases(t *testing.T) { + cases := map[string]struct { + releases []MBRelease + group string + }{ + "no releases": {nil, groupID}, + "no group id": {[]MBRelease{release("Official", "CD", 12)}, ""}, + "no media": {[]MBRelease{release("Official", "CD")}, groupID}, + "all other group": {[]MBRelease{{Status: "Official"}}, groupID}, + } + + for name, tc := range cases { + tracks, discs := CanonicalReleaseShape(tc.releases, tc.group) + if tracks != 0 || discs != 0 { + t.Errorf("%s: got %d tracks, %d discs; want zeroes so callers fall back", + name, tracks, discs) + } + } +} + +// The consensus is read off a real MusicBrainz payload, so the struct tags have +// to survive. MBRelease and MBMedium were extracted from anonymous structs to +// make this helper testable, and a mistyped tag there would silently yield no +// consensus rather than an error. +func TestCanonicalReleaseShape_FromAMusicBrainzPayload(t *testing.T) { + const body = `{ + "id": "rec-1", + "releases": [ + {"id": "r1", "title": "When We All Fall Asleep", "status": "Official", + "release-group": {"id": "group-1", "primary-type": "Album"}, + "media": [{"position": 1, "format": "CD", "track-count": 14}]}, + {"id": "r2", "title": "When We All Fall Asleep", "status": "Official", + "release-group": {"id": "group-1", "primary-type": "Album"}, + "media": [{"position": 1, "format": "Digital Media", "track-count": 14}]}, + {"id": "r3", "title": "When We All Fall Asleep (Deluxe)", "status": "Official", + "release-group": {"id": "group-1", "primary-type": "Album"}, + "media": [{"position": 1, "format": "CD", "track-count": 14}, + {"position": 2, "format": "CD", "track-count": 8}]}, + {"id": "r4", "title": "Now That's What I Call Music", "status": "Official", + "release-group": {"id": "group-2", "primary-type": "Album"}, + "media": [{"position": 1, "format": "CD", "track-count": 42}]} + ] + }` + + var recording MBRecording + if err := json.Unmarshal([]byte(body), &recording); err != nil { + t.Fatalf("decoding the payload: %v", err) + } + if len(recording.Releases) != 4 { + t.Fatalf("decoded %d releases, want 4; the struct tags no longer match", + len(recording.Releases)) + } + + tracks, discs := CanonicalReleaseShape(recording.Releases, "group-1") + + if tracks != 14 || discs != 1 { + t.Errorf("CanonicalReleaseShape() = %d tracks, %d discs; want 14 and 1 "+ + "(the deluxe outvoted, the compilation ignored)", tracks, discs) + } +} diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index ddb25df8..3ec060fd 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -149,36 +149,43 @@ type TopRecordings struct { } type MBRecording struct { - ID string `json:"id"` - Releases []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Country string `json:"country"` - Date string `json:"date"` - Year int `json:"year,omitempty"` - ArtistCredit []struct { - Name string `json:"name"` - Artist struct { - ID string `json:"id"` - SortName string `json:"sort-name"` - } `json:"artist"` - } `json:"artist-credit"` - ReleaseGroup struct { - ID string `json:"id"` - PrimaryType string `json:"primary-type"` - } `json:"release-group"` - Media []struct { - Position int `json:"position"` - Format string `json:"format"` - TrackCount int `json:"track-count"` - Tracks []struct { - ID string `json:"id"` - Position int `json:"position"` - Number string `json:"number"` - } `json:"tracks"` - } `json:"media"` - } `json:"releases"` + ID string `json:"id"` + Releases []MBRelease `json:"releases"` +} + +// MBRelease is one release carrying the recording. Named rather than anonymous +// so the release-group consensus can be computed by a helper that takes it. +type MBRelease struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Country string `json:"country"` + Date string `json:"date"` + Year int `json:"year,omitempty"` + ArtistCredit []struct { + Name string `json:"name"` + Artist struct { + ID string `json:"id"` + SortName string `json:"sort-name"` + } `json:"artist"` + } `json:"artist-credit"` + ReleaseGroup struct { + ID string `json:"id"` + PrimaryType string `json:"primary-type"` + } `json:"release-group"` + Media []MBMedium `json:"media"` +} + +// MBMedium is one disc of a release. +type MBMedium struct { + Position int `json:"position"` + Format string `json:"format"` + TrackCount int `json:"track-count"` + Tracks []struct { + ID string `json:"id"` + Position int `json:"position"` + Number string `json:"number"` + } `json:"tracks"` } type TitleArgs struct { @@ -482,6 +489,8 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( discTotal := 0 mbReleaseTrackID := "" releaseType := "" + canonicalTrackTotal := 0 + canonicalDiscTotal := 0 var mbData *MBRecording var mbErr error @@ -526,6 +535,10 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( artistSort = bestRelease.ArtistCredit[0].Artist.SortName } + // Consensus over the whole group, not just the release the + // lookup landed on. Free: these releases are already parsed. + canonicalTrackTotal, canonicalDiscTotal = CanonicalReleaseShape(mbData.Releases, mbReleaseGroupID) + discTotal = len(bestRelease.Media) if len(bestRelease.Media) > 0 { firstMedia := bestRelease.Media[0] @@ -572,6 +585,8 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( TrackTotal: trackTotal, DiscNumber: discNumber, DiscTotal: discTotal, + CanonicalTrackTotal: canonicalTrackTotal, + CanonicalDiscTotal: canonicalDiscTotal, MusicBrainzTrackID: track.MusicBrainzTrackID, MusicBrainzAlbumID: recording.Release.CaaReleaseMbid, MusicBrainzReleaseGroupID: mbReleaseGroupID, diff --git a/src/downloader/original_release.go b/src/downloader/original_release.go index 811875cd..c50f96ba 100644 --- a/src/downloader/original_release.go +++ b/src/downloader/original_release.go @@ -106,17 +106,34 @@ const minParsedShare = 2.0 / 3.0 // allowedDiscs is how many discs the release may legitimately span. // -// MusicBrainz is trusted when it has an answer: a real double album keeps both -// discs. With no answer the assumption is one disc, because the case this -// exists for -- a peer flattening several discs into a single folder -- is far -// more common than an unenriched genuine double album. +// The release group's consensus outranks the matched release. A recommendation +// that resolved to a two-disc deluxe reports DiscTotal 2, and trusting that +// would keep the padded directory whole -- the exact failure this feature +// exists to prevent. What the album's other editions agree on is the better +// answer, and a genuine double album has a group that agrees on two. +// +// With no consensus the matched release is used, and with neither the +// assumption is one disc: a peer flattening several discs into a single folder +// is far more common than an unenriched genuine double album. func allowedDiscs(track models.Track) int { + if track.CanonicalDiscTotal > 0 { + return track.CanonicalDiscTotal + } if track.DiscTotal > 1 { return track.DiscTotal } return 1 } +// expectedAlbumLength is how many tracks the original album holds, preferring +// the group's consensus over the matched release for the same reason. +func expectedAlbumLength(track models.Track) int { + if track.CanonicalTrackTotal > 0 { + return track.CanonicalTrackTotal + } + return track.TrackTotal +} + // trimToOriginal reduces a candidate release to the original album: extra discs // first, then bonus tracks appended past the album's real length. // @@ -138,6 +155,18 @@ func trimToOriginal(dir *peerDir, track models.Track) { } } if float64(parsed) < float64(len(dir.files))*minParsedShare { + slog.Debug("not trimming release, numbering unreadable", "dir", dir.dir, + "parsed", parsed, "files", len(dir.files)) + return + } + // Numbering that reads cleanly but repeats itself is describing something + // other than one release -- two editions side by side, or a misparse. Either + // way the positions cannot say which files are the album, and cutting on + // them would drop the wrong ones. Leaving the release whole is the safe + // failure; this is the only place where a wrong answer is worse than none. + if dup, ok := duplicatePosition(numbering); ok { + slog.Debug("not trimming release, numbering repeats", "dir", dir.dir, + "disc", dup.disc, "track", dup.track) return } @@ -166,8 +195,10 @@ func trimToOriginal(dir *peerDir, track models.Track) { return } - slog.Debug("trimmed release to the original album", - "dir", dir.dir, "from", len(dir.files), "to", len(kept)) + // Info, not Debug: this changes what gets downloaded, so it belongs in the + // run log beside "album release selected" rather than being invisible. + slog.Info("trimmed release to the original album", + "dir", dir.dir, "from", len(dir.files), "to", len(kept), "discs kept", discs) dir.files = kept // dir.primary points into the old backing array, so it has to be re-seated @@ -181,6 +212,25 @@ func trimToOriginal(dir *peerDir, track models.Track) { } } +// duplicatePosition reports the first (disc, track) position claimed by more +// than one file, if any. +func duplicatePosition(numbering []fileNumbering) (fileNumbering, bool) { + type position struct{ disc, track int } + + seen := make(map[position]bool, len(numbering)) + for _, n := range numbering { + if !n.ok { + continue + } + at := position{n.disc, n.track} + if seen[at] { + return n, true + } + seen[at] = true + } + return fileNumbering{}, false +} + // trimExtraDiscs drops discs beyond what the release may legitimately span, // keeping the lowest-numbered ones. It reports how many discs survive. func trimExtraDiscs(dir *peerDir, numbering []fileNumbering, keep []bool, track models.Track) int { @@ -215,7 +265,7 @@ func trimExtraDiscs(dir *peerDir, numbering []fileNumbering, keep []bool, track // the lowest TrackTotal positions are the album. Files with no readable track // number sort last and are therefore the first to go. func trimBonusTracks(dir *peerDir, numbering []fileNumbering, keep []bool, track models.Track) { - expected := track.TrackTotal + expected := expectedAlbumLength(track) if expected <= 0 { return } diff --git a/src/downloader/original_release_test.go b/src/downloader/original_release_test.go index 57ccda35..3e42d280 100644 --- a/src/downloader/original_release_test.go +++ b/src/downloader/original_release_test.go @@ -301,3 +301,86 @@ func TestCollectAlbumFiles_LeavesReleasesAloneWhenDisabled(t *testing.T) { t.Fatalf("queued %d files with the setting off, want all 5 untouched", len(files)) } } + +// The hole PR #19 left open. ListenBrainz resolved the recording to a two-disc +// deluxe, so DiscTotal says 2 and the flattened directory was kept whole -- the +// exact failure the feature exists to prevent. The group's consensus knows the +// album is single-disc. +func TestTrimToOriginal_ConsensusBeatsADeluxeMatch(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", "01-03 Xanny.flac", + "02-01 Demo Take.flac", "02-02 Alternate Mix.flac", + ) + + trimToOriginal(&dir, models.Track{ + CleanTitle: "Bad Guy", + TrackTotal: 23, // the deluxe's length + DiscTotal: 2, // the deluxe's disc count + CanonicalTrackTotal: 3, + CanonicalDiscTotal: 1, + }) + + if len(dir.files) != 3 { + t.Fatalf("kept %d files %v, want the 3 on disc one", len(dir.files), fileNames(dir)) + } +} + +// A genuine double album has a group that agrees it is one, so consensus +// winning must not amputate it. +func TestTrimToOriginal_ConsensusKeepsAGenuineDoubleAlbum(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", + "02-01 Xanny.flac", "02-02 Ilomilo.flac", + ) + + trimToOriginal(&dir, models.Track{ + CleanTitle: "Bad Guy", + CanonicalTrackTotal: 2, + CanonicalDiscTotal: 2, + }) + + if len(dir.files) != 4 { + t.Fatalf("kept %d files %v, want all 4", len(dir.files), fileNames(dir)) + } +} + +// Numbering that reads cleanly but repeats is describing something other than +// one release. Cutting on it would drop the wrong files. +func TestTrimToOriginal_SkipsWhenNumberingRepeats(t *testing.T) { + dir := numberedDir("Bad Guy", + "01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac", + "01 Bury A Friend (Remaster).flac", "02 Bad Guy (Remaster).flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", CanonicalTrackTotal: 3, CanonicalDiscTotal: 1}) + + if len(dir.files) != 5 { + t.Fatalf("kept %d files %v, want all 5 left alone", len(dir.files), fileNames(dir)) + } +} + +func TestExpectedAlbumLength_PrefersTheConsensus(t *testing.T) { + got := expectedAlbumLength(models.Track{TrackTotal: 23, CanonicalTrackTotal: 16}) + if got != 16 { + t.Errorf("expectedAlbumLength() = %d, want the consensus 16", got) + } + if got := expectedAlbumLength(models.Track{TrackTotal: 23}); got != 23 { + t.Errorf("without a consensus = %d, want the matched release's 23", got) + } +} + +// Everything above must be inert with enrichment off, leaving PR #19's +// behaviour byte for byte. +func TestTrimToOriginal_UnchangedWithoutAConsensus(t *testing.T) { + dir := numberedDir("Bad Guy", + "01-01 Bury A Friend.flac", "01-02 Bad Guy.flac", + "02-01 Xanny.flac", "02-02 Ilomilo.flac", + ) + + trimToOriginal(&dir, models.Track{CleanTitle: "Bad Guy", TrackTotal: 2, DiscTotal: 2}) + + if len(dir.files) != 4 { + t.Fatalf("kept %d files %v, want DiscTotal still honoured with no consensus", + len(dir.files), fileNames(dir)) + } +} diff --git a/src/downloader/preference.go b/src/downloader/preference.go index ff5a3f82..7e066cb5 100644 --- a/src/downloader/preference.go +++ b/src/downloader/preference.go @@ -200,18 +200,32 @@ func normaliseReleasePreference(pref string) string { // expectedTrackCount reports how many files a correct release should hold, and // whether that is knowable at all. // -// TrackTotal comes from MusicBrainz during enrichment and counts the first +// The count comes from MusicBrainz during enrichment and covers the first // medium only. A multi-disc release therefore legitimately holds more files -// than TrackTotal, so a closeness score would punish the complete release for -// being complete -- better to admit we cannot tell. +// than that, so a closeness score would punish the complete release for being +// complete -- better to admit we cannot tell. +// +// Which disc count decides that is the subtle part. The matched release may be +// a two-disc deluxe of a single-disc album, and bailing on its DiscTotal would +// hand the padded directory the fuller-release score it should have lost. The +// release group's consensus is asked first and the matched release only when +// there is none, so a genuine double album still bails while a deluxe-edition +// mismatch does not. func expectedTrackCount(track models.Track) (int, bool) { - if track.TrackTotal <= 0 { + expected := expectedAlbumLength(track) + if expected <= 0 { return 0, false } + if discs := track.CanonicalDiscTotal; discs > 0 { + if discs > 1 { + return 0, false + } + return expected, true + } if track.DiscTotal > 1 { return 0, false } - return track.TrackTotal, true + return expected, true } // releaseSizeScore rewards a release for being the size it ought to be. diff --git a/src/downloader/preference_test.go b/src/downloader/preference_test.go index d80c8541..7c1e38be 100644 --- a/src/downloader/preference_test.go +++ b/src/downloader/preference_test.go @@ -413,3 +413,30 @@ func TestNormaliseReleasePreference(t *testing.T) { } } } + +// The scorer has the same deluxe-match problem as the trim: bailing on the +// matched release's DiscTotal hands a padded directory the fuller-release score. +func TestExpectedTrackCount_ConsensusBeatsADeluxeMatch(t *testing.T) { + expected, ok := expectedTrackCount(models.Track{ + TrackTotal: 23, DiscTotal: 2, + CanonicalTrackTotal: 16, CanonicalDiscTotal: 1, + }) + + if !ok { + t.Fatal("expectedTrackCount() gave up despite a single-disc consensus") + } + if expected != 16 { + t.Errorf("expected = %d, want the consensus 16", expected) + } +} + +// A genuine double album must still bail: its first-medium count cannot measure +// a complete two-disc directory. +func TestExpectedTrackCount_StillBailsOnARealDoubleAlbum(t *testing.T) { + if _, ok := expectedTrackCount(models.Track{ + TrackTotal: 13, DiscTotal: 2, + CanonicalTrackTotal: 13, CanonicalDiscTotal: 2, + }); ok { + t.Error("expectedTrackCount() answered for a 2-disc consensus; the complete release would be punished") + } +} diff --git a/src/models/types.go b/src/models/types.go index 1728d858..235c2022 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -32,6 +32,12 @@ type Track struct { TrackTotal int // Total tracks in media DiscNumber int // Disc/media position DiscTotal int // Total discs/media + // Canonical* describe the album as its release group agrees it is, rather + // than as the one matched release happens to be. A recommendation resolving + // to a deluxe edition still reports the standard album's shape here, which + // is what album mode needs to recognise a padded release. + CanonicalTrackTotal int // Modal first-medium track count across the release group + CanonicalDiscTotal int // Modal medium count across the release group MusicBrainzReleaseGroupID string MusicBrainzAlbumArtistID string MusicBrainzTrackID string From 46d8f005b605c6ee95e2633012123c7d432c6b0f Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Wed, 23 Sep 2026 20:45:19 -0700 Subject: [PATCH 19/57] Download each album once per run in album mode Every recommended track used to fetch its own copy of its album, so a playlist with three songs from Rio downloaded Rio three times, often from different peers, and every copy landed in the library. The first track of an album now leads: it searches and queues the release. Other tracks from the same album (same release group, or same artist and album name) wait for it and take their own file out of what was queued, becoming ordinary monitored tracks so they are tagged and placed with their own metadata. A track the release lacks falls back to a single-track download, and if the leader finds nothing the next track tries the album instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SAtyMwMpAfVKGGcthm5px7 --- src/downloader/slskd.go | 46 +++- src/downloader/slskd_shared_release.go | 258 ++++++++++++++++++ src/downloader/slskd_shared_release_test.go | 281 ++++++++++++++++++++ 3 files changed, 573 insertions(+), 12 deletions(-) create mode 100644 src/downloader/slskd_shared_release.go create mode 100644 src/downloader/slskd_shared_release_test.go diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index aca20f15..d5e0ce6c 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -100,6 +100,8 @@ type Slskd struct { HttpClient *util.HttpClient DownloadDir string Cfg config.Slskd + // releases stops tracks from one album each downloading it in album mode. + releases *releaseRegistry } type SearchPayload struct { @@ -109,7 +111,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, + releases: newReleaseRegistry()} } func (c *Slskd) AddHeader() { @@ -135,10 +138,19 @@ func (c *Slskd) GetConf() (MonitorConfig, error) { var errNoRes = errors.New("no results found for query") func (c *Slskd) QueryTrack(track *models.Track) error { + if c.Cfg.AlbumMode { + return c.queryAlbum(track) + } + return c.search(track, false) +} + +// search looks for a release when albumMode is set and for the lone track +// otherwise, recording the search on the track for GetTrack to read. +func (c *Slskd) search(track *models.Track, albumMode bool) error { wildcardSearch := false trackDetails := fmt.Sprintf("%s - %s", track.CleanTitle, track.Artist) - if c.Cfg.AlbumMode { + if albumMode { trackDetails = albumSearchTerm(*track) } @@ -159,7 +171,7 @@ func (c *Slskd) QueryTrack(track *models.Track) error { if errors.Is(err, errNoRes) && !wildcardSearch { cleanup() wildcardSearch = true - if c.Cfg.AlbumMode && strings.TrimSpace(track.Album) != "" { + if albumMode && strings.TrimSpace(track.Album) != "" { trackDetails = fmt.Sprintf("%s - %s", wildcardArtist(track.MainArtist), track.Album) } else { trackDetails = fmt.Sprintf("%s - %s", track.CleanTitle, wildcardArtist(track.Artist)) @@ -184,17 +196,24 @@ func (c *Slskd) QueryTrack(track *models.Track) error { } func (c *Slskd) GetTrack(track *models.Track) error { + if c.Cfg.AlbumMode { + switch state := c.releases.state(track); { + case state != nil && state.role == roleShared: + // Already queued as part of another track's release. + return nil + case state != nil && state.role == roleSingle: + return c.getSingle(track) + } + return c.getAlbum(track) + } + return c.getSingle(track) +} + +func (c *Slskd) getSingle(track *models.Track) error { results, err := c.searchResults(track.ID) if err != nil { return err } - if c.Cfg.AlbumMode { - files, err := c.CollectAlbumFiles(*track, results) - if err != nil { - return err - } - return c.queueAlbumDownload(files, track) - } files, err := c.CollectFiles(*track, results) if err != nil { return err @@ -451,8 +470,11 @@ func (c Slskd) deleteDownload(user, ID string) error { } func (c *Slskd) Cleanup(track models.Track, fileID string) error { - if err := c.deleteSearch(track.ID); err != nil { - slog.Debug("failed to delete search request", logging.RuntimeAttr(err.Error())) + // A track shared from another's release has no search of its own. + if !c.releases.isSharedID(track.ID) { + if err := c.deleteSearch(track.ID); err != nil { + slog.Debug("failed to delete search request", logging.RuntimeAttr(err.Error())) + } } if err := c.deleteDownload(track.MainArtistID, fileID); err != nil { slog.Debug("failed to delete download", logging.RuntimeAttr(err.Error())) diff --git a/src/downloader/slskd_shared_release.go b/src/downloader/slskd_shared_release.go new file mode 100644 index 00000000..f6167351 --- /dev/null +++ b/src/downloader/slskd_shared_release.go @@ -0,0 +1,258 @@ +package downloader + +import ( + "fmt" + "log/slog" + "path" + "slices" + "strings" + "sync" + + "explo/src/models" + "explo/src/util" +) + +// In album mode every recommended track used to fetch its own copy of its +// album. A playlist carrying three songs from one record therefore downloaded +// that record three times, usually from three different peers, and the copies +// all landed in the library. releaseRegistry makes the first track of an album +// the one that downloads it; the others wait for that release and take their +// own file out of it. + +// trackRole is how a track is being fetched within a run. +type trackRole int + +const ( + // roleAlbum is the default: the track searches for and queues its own release. + roleAlbum trackRole = iota + // roleLeader is roleAlbum for a track whose release others are waiting on. + roleLeader + // roleShared tracks were found in a release another track queued, so + // there is nothing left to download for them. + roleShared + // roleSingle tracks share an album with a queued release that lacks them, + // so they fall back to a single-track download. + roleSingle +) + +type trackState struct { + role trackRole + key string + release *sharedRelease +} + +// sharedRelease is one release queued on behalf of every track from its album. +type sharedRelease struct { + done chan struct{} + leader *models.Track + // files is what the leader queued, primary first. It stays nil if the + // leader failed, which tells waiting tracks to try the album themselves. + files []File + // claimed holds the files already spoken for, so two tracks can never be + // handed the same file. + claimed map[string]bool +} + +type releaseRegistry struct { + mu sync.Mutex + releases map[string]*sharedRelease + tracks map[*models.Track]*trackState + // sharedIDs are the IDs handed to shared tracks. They name no slskd + // search, so Cleanup must not try to delete one. + sharedIDs map[string]bool +} + +func newReleaseRegistry() *releaseRegistry { + return &releaseRegistry{ + releases: make(map[string]*sharedRelease), + tracks: make(map[*models.Track]*trackState), + sharedIDs: make(map[string]bool), + } +} + +// albumKey identifies the album a track belongs to. The release group is +// preferred because it is the same for every edition of an album, which the +// album name is not. Tracks with no album cannot share a release. +func albumKey(track models.Track) string { + if track.MusicBrainzReleaseGroupID != "" { + return "rg:" + track.MusicBrainzReleaseGroupID + } + artist := strings.ToLower(util.AlnumOnly(track.MainArtist)) + album := strings.ToLower(util.AlnumOnly(track.Album)) + if artist == "" || album == "" { + return "" + } + return "name:" + artist + "\x00" + album +} + +// claim returns the release for key, creating it if this is the first track +// to ask. The creator is the leader and must resolve it with publish or fail. +func (r *releaseRegistry) claim(key string, track *models.Track) (*sharedRelease, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if rel, ok := r.releases[key]; ok { + return rel, false + } + + rel := &sharedRelease{ + done: make(chan struct{}), + leader: track, + claimed: make(map[string]bool), + } + r.releases[key] = rel + r.tracks[track] = &trackState{role: roleLeader, key: key, release: rel} + return rel, true +} + +// publish hands the queued release to every track waiting on it. +func (r *releaseRegistry) publish(state *trackState, files []File) { + r.mu.Lock() + defer r.mu.Unlock() + + state.release.files = files + state.release.claimed[files[0].Name] = true + close(state.release.done) +} + +// fail releases the claim so that a waiting track can lead the album instead. +// Failing is specific to the leader -- its track may be missing from every +// release found -- so another track from the album may well succeed. +func (r *releaseRegistry) fail(state *trackState) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.releases[state.key] == state.release { + delete(r.releases, state.key) + } + state.role = roleAlbum + close(state.release.done) +} + +func (r *releaseRegistry) state(track *models.Track) *trackState { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + return r.tracks[track] +} + +func (r *releaseRegistry) setRole(track *models.Track, role trackRole) { + r.mu.Lock() + defer r.mu.Unlock() + r.tracks[track] = &trackState{role: role} +} + +func (r *releaseRegistry) isSharedID(id string) bool { + if r == nil { + return false + } + r.mu.Lock() + defer r.mu.Unlock() + return r.sharedIDs[id] +} + +// share looks for track inside a published release. If it is there, the track +// follows that file instead of downloading anything: it is taken out of the +// leader's siblings so the monitor migrates it as a track in its own right, +// with its own metadata and path. +func (r *releaseRegistry) share(rel *sharedRelease, track *models.Track) bool { + r.mu.Lock() + defer r.mu.Unlock() + + candidates := peerDir{username: rel.files[0].Username} + for _, file := range rel.files { + if !rel.claimed[file.Name] { + candidates.files = append(candidates.files, file) + } + } + if !findPrimary(&candidates, *track) { + return false + } + file := *candidates.primary + rel.claimed[file.Name] = true + + rel.leader.AlbumFiles = slices.DeleteFunc(rel.leader.AlbumFiles, func(name string) bool { + return name == string(file.Name) + }) + + // The monitor looks statuses up by ID, so each shared track needs one of + // its own even though it has no search behind it. + track.ID = fmt.Sprintf("%s:shared:%d", rel.leader.ID, len(rel.claimed)) + r.sharedIDs[track.ID] = true + r.tracks[track] = &trackState{role: roleShared} + + track.MainArtistID = file.Username + track.Size = file.Size + track.File = string(file.Name) + + slog.Info("track found in a release already queued, not downloading it again", + "track", track.CleanTitle, "album", track.Album, "file", path.Base(normalizePeerPath(track.File))) + return true +} + +// queryAlbum is QueryTrack in album mode. The first track of an album searches +// for it; the rest wait to see what it queued. +func (c *Slskd) queryAlbum(track *models.Track) error { + key := albumKey(*track) + if c.releases == nil || key == "" { + return c.search(track, true) + } + + for { + rel, leader := c.releases.claim(key, track) + if leader { + if err := c.search(track, true); err != nil { + c.releases.fail(c.releases.state(track)) + return err + } + return nil + } + + <-rel.done + if rel.files == nil { + // The leader found nothing. Try to lead the album ourselves. + continue + } + if c.releases.share(rel, track) { + return nil + } + + slog.Info("track missing from the release queued for its album, downloading it alone", + "track", track.CleanTitle, "album", track.Album) + c.releases.setRole(track, roleSingle) + return c.search(track, false) + } +} + +// getAlbum is GetTrack in album mode. A leader resolves its release whatever +// happens, since other tracks are blocked until it does. +func (c *Slskd) getAlbum(track *models.Track) error { + state := c.releases.state(track) + + files, err := c.queueAlbum(track) + if state != nil && state.role == roleLeader { + if err != nil { + c.releases.fail(state) + } else { + c.releases.publish(state, files) + } + } + return err +} + +func (c *Slskd) queueAlbum(track *models.Track) ([]File, error) { + results, err := c.searchResults(track.ID) + if err != nil { + return nil, err + } + files, err := c.CollectAlbumFiles(*track, results) + if err != nil { + return nil, err + } + if err := c.queueAlbumDownload(files, track); err != nil { + return nil, err + } + return files, nil +} diff --git a/src/downloader/slskd_shared_release_test.go b/src/downloader/slskd_shared_release_test.go new file mode 100644 index 00000000..8b25cfe8 --- /dev/null +++ b/src/downloader/slskd_shared_release_test.go @@ -0,0 +1,281 @@ +package downloader + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "sync" + "testing" + + "explo/src/models" + + "golang.org/x/sync/errgroup" +) + +// fakeSlskd answers searches from a fixed catalogue and records what was +// searched for and queued. +type fakeSlskd struct { + t *testing.T + catalog func(query string) SearchResults + + mu sync.Mutex + searches []string + byID map[string]string + queued [][]DownloadPayload + deleted []string +} + +func (f *fakeSlskd) handler(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + var resp any = struct{}{} + p := r.URL.Path + + switch { + case r.Method == http.MethodPost && p == "/api/v0/searches": + var body SearchPayload + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.t.Errorf("decoding search: %v", err) + } + f.searches = append(f.searches, body.SearchText) + id := fmt.Sprintf("search-%d", len(f.searches)) + f.byID[id] = body.SearchText + resp = Search{ID: id} + + case r.Method == http.MethodGet && strings.HasSuffix(p, "/responses"): + id := strings.TrimSuffix(strings.TrimPrefix(p, "/api/v0/searches/"), "/responses") + resp = f.catalog(f.byID[id]) + + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v0/searches/"): + id := strings.TrimPrefix(p, "/api/v0/searches/") + count := 0 + for _, result := range f.catalog(f.byID[id]) { + count += len(result.Files) + } + resp = Search{ID: id, IsComplete: true, FileCount: count} + + case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v0/searches/"): + f.deleted = append(f.deleted, strings.TrimPrefix(p, "/api/v0/searches/")) + + case r.Method == http.MethodPost && strings.HasPrefix(p, "/api/v0/transfers/downloads/"): + body, _ := io.ReadAll(r.Body) + var payload []DownloadPayload + if err := json.Unmarshal(body, &payload); err != nil { + f.t.Errorf("decoding queue request: %v", err) + } + f.queued = append(f.queued, payload) + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + f.t.Errorf("fake slskd failed to write response: %v", err) + } +} + +func newFakeSlskd(t *testing.T, catalog func(string) SearchResults) (*fakeSlskd, *Slskd) { + t.Helper() + fake := &fakeSlskd{t: t, catalog: catalog, byID: make(map[string]string)} + client := albumClient(t, fake.handler) + return fake, client +} + +var rioFiles = []string{ + "01 Rio.flac", "02 My Own Way.flac", "03 Lonely in Your Nightboat.flac", + "04 Hungry Like the Wolf.flac", "05 Hold Back the Rain.flac", "08 Save a Prayer.flac", +} + +// rioCatalog shares Rio from two peers, the way the same album usually turns +// up more than once on Soulseek. +func rioCatalog(query string) SearchResults { + if strings.Contains(query, "Rio") { + return albumResults( + peer{"peer1", `@@a\Duran Duran\Rio`, rioFiles}, + peer{"peer2", `@@b\Music\Duran Duran - Rio (1982)`, rioFiles}, + ) + } + return nil +} + +func rioTrack(title string) *models.Track { + return &models.Track{ + CleanTitle: title, + Title: title, + Artist: "Duran Duran", + MainArtist: "Duran Duran", + Album: "Rio", + MusicBrainzReleaseGroupID: "rio-rg", + Duration: 200_000, + } +} + +// runTracks drives tracks the way StartDownload does, concurrently. +func runTracks(client *Slskd, tracks ...*models.Track) { + var g errgroup.Group + g.SetLimit(3) + for _, track := range tracks { + g.Go(func() error { + if err := client.QueryTrack(track); err != nil { + return nil + } + _ = client.GetTrack(track) + return nil + }) + } + _ = g.Wait() +} + +// The reported bug: three songs from Rio in one playlist downloaded Rio three +// times. One release must be queued, and each song must follow its own file +// in it. +func TestAlbumMode_DownloadsEachAlbumOnce(t *testing.T) { + fake, client := newFakeSlskd(t, rioCatalog) + + tracks := []*models.Track{rioTrack("Rio"), rioTrack("Hungry Like the Wolf"), rioTrack("Save a Prayer")} + runTracks(client, tracks...) + + if len(fake.searches) != 1 { + t.Errorf("searched %d times (%q), want once for the album", len(fake.searches), fake.searches) + } + if len(fake.queued) != 1 { + t.Fatalf("queued %d releases, want 1", len(fake.queued)) + } + if len(fake.queued[0]) != len(rioFiles) { + t.Errorf("queued %d files, want the whole %d-track release", len(fake.queued[0]), len(rioFiles)) + } + + ids := make(map[string]bool) + var leader *models.Track + for _, track := range tracks { + if track.ID == "" { + t.Errorf("%s has no ID; the monitor would never look at it", track.CleanTitle) + } + if ids[track.ID] { + t.Errorf("%s shares ID %q with another track; the monitor would confuse them", track.CleanTitle, track.ID) + } + ids[track.ID] = true + + if !strings.Contains(track.File, track.CleanTitle) { + t.Errorf("%s follows %q, want its own file", track.CleanTitle, track.File) + } + if len(track.AlbumFiles) > 0 { + leader = track + } + } + + if leader == nil { + t.Fatal("no track kept the release's siblings") + } + for _, track := range tracks { + if slices.Contains(leader.AlbumFiles, track.File) { + t.Errorf("%s is still a sibling of the release; it would be migrated twice", track.CleanTitle) + } + } + if want := len(rioFiles) - len(tracks); len(leader.AlbumFiles) != want { + t.Errorf("release has %d siblings left, want %d", len(leader.AlbumFiles), want) + } +} + +// A song the queued release lacks still has to be downloaded, on its own +// rather than as yet another copy of the album. +func TestAlbumMode_TrackMissingFromReleaseDownloadsAlone(t *testing.T) { + bonus := "Rio (Night Version)" + fake, client := newFakeSlskd(t, func(query string) SearchResults { + if strings.Contains(query, bonus) { + return albumResults(peer{"peer3", `@@c\Duran Duran\Singles`, []string{"Rio (Night Version).flac"}}) + } + return rioCatalog(query) + }) + client.Cfg.DownloadAttempts = 1 + + runTracks(client, rioTrack("Hungry Like the Wolf")) + single := rioTrack(bonus) + runTracks(client, single) + + if got := fake.searches[len(fake.searches)-1]; got != bonus+" - Duran Duran" { + t.Errorf("last search = %q, want a single-track search", got) + } + if len(fake.queued) != 2 || len(fake.queued[1]) != 1 { + t.Fatalf("queued %v, want the release then one file", fake.queued) + } + if !strings.Contains(single.File, "Night Version") { + t.Errorf("%s follows %q, want the single file", single.CleanTitle, single.File) + } +} + +// If the first track of an album finds no release with itself in it, the +// album is not written off: the next track tries it. +func TestAlbumMode_AnotherTrackLeadsWhenTheFirstFails(t *testing.T) { + fake, client := newFakeSlskd(t, rioCatalog) + + unfindable := rioTrack("Not On The Album") + runTracks(client, unfindable) + found := rioTrack("Save a Prayer") + runTracks(client, found) + + if len(fake.queued) != 1 { + t.Fatalf("queued %d releases, want 1 from the second track", len(fake.queued)) + } + if !strings.Contains(found.File, "Save a Prayer") { + t.Errorf("track follows %q, want its own file", found.File) + } +} + +func TestAlbumMode_DifferentAlbumsAreNotShared(t *testing.T) { + fake, client := newFakeSlskd(t, func(query string) SearchResults { + if strings.Contains(query, "Seven and the Ragged Tiger") { + return albumResults(peer{"peer4", `@@d\Duran Duran\Seven and the Ragged Tiger`, []string{"01 The Reflex.flac", "02 New Moon on Monday.flac"}}) + } + return rioCatalog(query) + }) + + other := rioTrack("The Reflex") + other.Album, other.MusicBrainzReleaseGroupID = "Seven and the Ragged Tiger", "seven-rg" + runTracks(client, rioTrack("Rio"), other) + + if len(fake.queued) != 2 { + t.Errorf("queued %d releases, want one per album", len(fake.queued)) + } +} + +// Shared tracks carry an ID that names no search, so cleaning up after one +// must not ask slskd to delete it. +func TestCleanup_SkipsSearchForSharedTracks(t *testing.T) { + fake, client := newFakeSlskd(t, rioCatalog) + + leader, shared := rioTrack("Rio"), rioTrack("Save a Prayer") + runTracks(client, leader) + runTracks(client, shared) + + if !client.releases.isSharedID(shared.ID) { + t.Fatalf("%s was not shared from the release", shared.CleanTitle) + } + fake.deleted = nil + if err := client.Cleanup(*shared, "file-id"); err != nil { + t.Fatalf("Cleanup() = %v", err) + } + if len(fake.deleted) != 0 { + t.Errorf("deleted searches %q for a shared track, want none", fake.deleted) + } +} + +func TestAlbumKey(t *testing.T) { + a, b := rioTrack("Rio"), rioTrack("Rio") + b.Album = "Rio (2009 Collector's Edition)" + if albumKey(*a) != albumKey(*b) { + t.Error("editions of one release group got different keys") + } + + a.MusicBrainzReleaseGroupID, b.MusicBrainzReleaseGroupID = "", "" + b.Album = "RIO" + if albumKey(*a) != albumKey(*b) { + t.Error("album names differing only in case got different keys") + } + + a.Album = "" + if albumKey(*a) != "" { + t.Error("a track with no album got a key; it would be grouped with others") + } +} From 85a8802105e9892a86cd50262d6526bbbddd1cc1 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 13:56:27 -0700 Subject: [PATCH 20/57] Add bug tracking TODO list Track the 31 verified findings from the codebase review (BUG-01 to BUG-31) by category, with locations, user impact, fix direction, status tracking, and a cross-session handoff process. --- TODO.md | 309 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..ec94376b --- /dev/null +++ b/TODO.md @@ -0,0 +1,309 @@ +# Explo — Bug Tracking TODO + +> Persistent tracker for the code-review bug list. Keep this file updated across sessions — it is the single source of truth for what is open, in progress, and done. +> YouTube downloader items (`src/downloader/youtube.go` issues) were removed from this list at the owner's request and are intentionally not tracked here. + +**Created:** 2026-10-19 · **Source:** Full-codebase bug review (6 subsystem reviewers + adversarial verification pass, all findings re-checked against source) · **Branch at review:** `fix/album-dedupe` (`46d8f00`) + +## How to use this file across sessions + +1. **Start of session:** Read this file. Pick the highest-severity `open` item, change its Status to `in-progress`, and note the session/date. +2. **Before fixing:** Re-check the cited `file:line` — line numbers drift as code changes. Confirm the bug still exists before editing. +3. **Fix:** Make the smallest fix at the root cause. Add or update a test where the area has committed tests nearby. +4. **Verify:** Run `go vet ./...` and the relevant package tests, e.g. `go test ./src/client/...`. Note: some packages need localhost sockets (`httptest`), which may be blocked in sandboxed environments — record that instead of claiming a pass you did not see. +5. **End of session:** Update the checkbox, Status, PR/commit, and Verification fields for anything you touched — even if unfinished. Leave a one-line Handoff note so the next session can resume without re-investigating. +6. **Do not delete issues.** Move fixed items to `done` with the commit/PR that fixed them. If an issue turns out to be invalid, mark it `wontfix` with a one-line reason. + +Status values: `open` · `in-progress` · `blocked` · `done` · `wontfix` + +## Progress + +| Category | Total | Done | Open | +|---|---|---|---| +| App crashes | 5 | 0 | 5 | +| Data loss and corruption | 3 | 0 | 3 | +| Security | 2 | 0 | 2 | +| Playlists broken or wrong | 7 | 0 | 7 | +| Hangs and resource leaks | 4 | 0 | 4 | +| Wrong songs and metadata | 4 | 0 | 4 | +| Web UI and settings screens | 4 | 0 | 4 | +| Scheduling | 2 | 0 | 2 | +| **Total** | **31** | **0** | **31** | + +Update this table whenever an issue changes status. + +--- + +## App crashes + +- [ ] **BUG-01 — Session race crashes the app** · Severity: High + - Location: `src/web/backend/auth/session.go:141` + - User impact: Using the UI in multiple tabs or clicking fast can crash the whole app. + - Detail: `Session.Get/Put/Delete` access `s.data`/`s.id` with no lock (only `Migrate` locks). Concurrent requests sharing one session cause a fatal `concurrent map writes` panic. + - Fix direction: Lock all session access (guard `Get`/`Put`/`Delete` and `writeCookieIfNecessary`'s read of `session.id`). + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-02 — Lidarr album cache race crashes the app** · Severity: High + - Location: `src/downloader/lidarr.go:353` (also `:519`, panic at `:527`) + - User impact: Fetching several albums at once can crash the app instead of downloading them. + - Detail: `AlbumCache` map is read without `CacheMu` while `cacheAlbum` writes it under lock from concurrent goroutines. At `:519` a nil cache entry is then dereferenced at `:527`. + - Fix direction: `RLock` the reads and add a nil guard before use. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-03 — Empty artist list panics the run** · Severity: High + - Location: `src/discovery/listenbrainz.go:393` + - User impact: One unusual track from ListenBrainz can crash the whole run so no playlist gets made. + - Detail: `recArtists[0]` is indexed unconditionally; ListenBrainz metadata with an empty/missing artists array panics. + - Fix direction: Guard `len(recArtists)` and degrade gracefully (skip or fall back to a joined/empty artist). + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-04 — Config typo panics instead of erroring** · Severity: High + - Location: `src/discovery/discovery.go:21`, dereferenced at `src/main/main.go:198` + - User impact: One typo in your config crashes the app with no hint about what you typed wrong. + - Detail: `NewDiscoverer` returns nil for any `DISCOVERY_SERVICE` other than `listenbrainz`, and no config validation catches it. + - Fix direction: Validate the value in config loading, or return an error from `NewDiscoverer` and fail fast with the bad value in the message. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-05 — Plex library panic** · Severity: Medium + - Location: `src/client/plex.go:381` + - User impact: Plex hiccups while creating a library can crash the run instead of showing an error. + - Detail: `AddLibrary()` indexes `MediaContainer.Library[0]` with no length check when Plex returns an empty container. + - Fix direction: Length-check and return a descriptive error. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Data loss and corruption + +- [ ] **BUG-06 — Custom playlists can be wiped by a race** · Severity: High + - Location: `src/web/backend/playlist/custom_playlists.go:107` + - User impact: Adding or removing playlists at the wrong moment can lose or wipe all your custom playlists. + - Detail: `saveCustomPlaylists` does an unlocked, non-atomic `os.WriteFile` while import/delete/refresh/cron all do unsynchronized load-modify-save. A reader mid-truncate gets a parse error, the loader returns nil, and the next save wipes `custom-playlists.json`. + - Fix direction: Add a mutex around load-modify-save and write via temp file + rename. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-07 — File move can erase an existing song** · Severity: Medium + - Location: `src/downloader/downloader.go:400` (see also `src/downloader/slskd_album.go:347`) + - User impact: Moving files can silently erase an existing song in your library. + - Detail: `moveFile` truncates the destination with `os.Create` before copying, with no same-file or existing-file check. If `src == dst` (or a sibling name collides with a library file), the source/existing file is zeroed while the track is still reported present. + - Fix direction: Refuse or skip when source and destination are the same file or the destination exists. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-08 — Settings saves can be lost or corrupt the file** · Severity: Medium + - Location: `src/web/backend/settings/settings.go:124` + - User impact: Saving settings in quick succession can silently undo some of your changes, and a crash mid-save can corrupt the file. + - Detail: `UpdateEnvKeys` does an unsynchronized read-modify-write with a truncating `os.WriteFile`; concurrent handlers lose each other's updates, and a crash mid-write leaves a truncated `.env`. + - Fix direction: Lock the read-modify-write and write via temp file + rename. Related: BUG-06 (same pattern), BUG-10 (same file). + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Security + +- [ ] **BUG-09 — Settings value can inject extra config keys** · Severity: High + - Location: `src/web/backend/settings/settings.go:134` + - User impact: A value containing a line break can silently overwrite your other settings on the next start. + - Detail: `formatEnvValue` quotes only on `"$#?'`, never newline/CR, and `UpdateEnvKeys` writes `key=value` raw — a value like `x\nAPI_KEY=pwned` injects arbitrary extra `.env` lines. + - Fix direction: Reject or escape CR/LF in keys and values before writing. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-10 — Admin API key shown in plain text** · Severity: Low + - Location: `src/web/frontend/src/lib/utils.js:5` (rendered at `src/web/frontend/src/components/Settings.jsx:888`) + - User impact: An admin API key shows in plain text on the settings screen where it should be hidden. + - Detail: `SENSITIVE_KEY_RE` matches `api_key` but not `APIKEY` (no underscore), so keys such as `ADMIN_SYSTEM_APIKEY` are not masked. + - Fix direction: Add `apikey` to the sensitive-key pattern and audit for other unmasked key spellings. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Playlists broken or wrong + +- [ ] **BUG-11 — Playlists gain songs that never arrived** · Severity: High + - Location: `src/downloader/monitor.go:135` (error only logged at `:140`) + - User impact: Playlists can include songs that never actually landed in your library, and they never get retried. + - Detail: The track is marked `Present`, counted as success, and cleaned up *before* `MoveDownload` runs; if the move fails, the playlist still keeps the track. + - Fix direction: Set `Present` and count success only after `MoveDownload` succeeds. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-12 — One missing song can block a whole Subsonic playlist** · Severity: High + - Location: `src/client/subsonic.go:238` + - User impact: One song that could not be found can stop the entire playlist from being created in Navidrome/Subsonic. + - Detail: `CreatePlaylist` appends `&songId=` for every track without checking `track.Present` or an empty ID (unlike the Jellyfin/Emby formatters), so an absent track sends `&songId=` and the server can reject the whole create. + - Fix direction: Skip tracks that are not present or have an empty ID. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-13 — Jellyfin playlist creation fails without a username** · Severity: Medium + - Location: `src/client/jellyfin.go:236` + - User impact: Without a username set, playlist creation in Jellyfin always fails. + - Detail: With `SYSTEM_USERNAME` unset, `CreatePlaylist` sends the API key (`resolveAPIKey()`) as the Jellyfin `UserId`, which the server rejects with 400/404. + - Fix direction: Resolve the real user ID from the API, or omit the user where the API allows it; fail with a clear message otherwise. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-14 — “Replace playlist” duplicates playlists on Emby** · Severity: Medium + - Location: `src/client/emby.go:241` + - User impact: Choosing “replace playlist” on Emby quietly piles up duplicate playlists instead of replacing. + - Detail: `DeletePlaylist()` is a commented-out no-op returning nil, so callers believe the old playlist was deleted before creating the new one. + - Fix direction: Implement the Emby delete call, or make replace explicitly delete-then-create. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-15 — Playlist names with quotes break Jellyfin/Emby** · Severity: Medium + - Location: `src/client/jellyfin.go:243`, `src/client/emby.go:227` + - User impact: Playlist names containing quotes break creation with a server error. + - Detail: Playlist name/description are interpolated unescaped into JSON built with `fmt`, so a quote, backslash, or newline produces invalid JSON and the server returns 400. + - Fix direction: Build the bodies with `json.Marshal` instead of string formatting. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-16 — MPD playlists double on every re-run** · Severity: Medium + - Location: `src/client/mpd.go:67` + - User impact: Re-running the same playlist in MPD adds every song again, doubling it each time. + - Detail: The `.m3u` is opened with `O_APPEND`, never truncated, and the handle is never closed. + - Fix direction: Open with `O_TRUNC` (create fresh like the other clients) and close the file. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-17 — Playlist card Run overwrites the wrong playlist** · Severity: Medium + - Location: `src/web/frontend/src/components/Settings.jsx:401` + - User impact: Running a playlist from its card can overwrite a different playlist than the one you clicked. + - Detail: The tracklist Run handler passes `replacePlaylists[playlist]` (the manual-run selector's state) instead of the opened tracklist's own flag. + - Fix direction: Use `openTracklist` when looking up the replace flag. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Hangs and resource leaks + +- [ ] **BUG-18 — A trickling download hangs the run forever** · Severity: Medium + - Location: `src/downloader/monitor.go:161` (deadline check at `:167`) + - User impact: One very slow download can hang the whole run forever instead of timing out. + - Detail: The progress branch `continue`s before the `MaxDuration` check, so any download that moves at least a byte per poll never times out. + - Fix direction: Check the deadline before the progress `continue`, on every poll. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-19 — One long log line wedges all future runs** · Severity: Medium + - Location: `src/web/backend/run/events.go:48` (wait at `:65`) + - User impact: One very long log line can wedge a run so every later run is refused as “already running”. + - Detail: `collectRunOutput` uses `bufio.Scanner` with the default 64KB line limit; an over-long line stops draining the pipe, the child blocks on write, and `cmd.Wait` never returns. + - Fix direction: Enlarge the scanner buffer and keep draining even after a scan error. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-20 — Stalled or huge remote downloads can hang or OOM the app** · Severity: Medium + - Location: `src/util/http.go:94` (also `:106`, cover path at `:137`) + - User impact: One stalled or oversized download can hang the app or eat all its memory. + - Detail: `http.Get`/default client with no timeout, plus unbounded `io.ReadAll`, on remote responses (same pattern in playlist/apple-music fetching). + - Fix direction: Use a timeout client and cap reads with `io.LimitReader`. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-21 — Rejected runs leak file handles** · Severity: Low + - Location: `src/web/backend/run/manual_run.go:95` + - User impact: Repeatedly trying to start a run while one is running slowly leaks system resources until the app can fail. + - Detail: The already-running branch closes `pw` a second time instead of closing the log file `lf` opened earlier, leaking one descriptor per rejected attempt. + - Fix direction: Close `lf` on that branch. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Wrong songs and metadata + +- [ ] **BUG-22 — Every song tagged as track 1, disc 1** · Severity: Medium + - Location: `src/discovery/listenbrainz.go:542` + - User impact: Every song gets tagged as “track 1” of disc 1, scrambling album ordering in your player. + - Detail: Enrichment always takes `Media[0].Tracks[0]`, so track number, disc number, and release-track ID come from the first track of the first medium for every song. + - Fix direction: Select the track matching the recording/release track being enriched. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-23 — Playlist order is shuffled between runs** · Severity: Medium + - Location: `src/discovery/listenbrainz.go:357` + - User impact: Your playlist order changes randomly between runs instead of following the recommendation ranking. + - Detail: `getTracks` iterates the `Recordings` map, losing the recommendation score order from the API response. + - Fix direction: Iterate the ordered API slice, not the map. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-24 — Bad metadata entries for some file types** · Severity: Medium + - Location: `src/util/metadata.go:45` (tag sets at `:99`, `:162`) + - User impact: Some songs fail tagging or get garbled tags depending on file type. + - Detail: Tag helpers check the value but not an empty key, and several tag sets leave fields empty — producing `=value` ffmpeg metadata entries for formats like m4a/wav and ape-family files. + - Fix direction: Skip empty keys (and empty values) when building ffmpeg metadata args. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-25 — Failed cover art can fail the song** · Severity: Medium + - Location: `src/util/http.go:136` (caller at `src/downloader/youtube.go:259`) + - User impact: When cover art fails to download the song can fail too, instead of just skipping the artwork. + - Detail: `DownloadCover` returns a destination path even when the fetch or write failed, so callers embed a cover from a file that does not exist and the track save fails. + - Fix direction: Return an error (or empty path) on failure and let callers proceed without cover art. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Web UI and settings screens + +- [ ] **BUG-26 — Buttons break after a restart or a week left open (CSRF)** · Severity: Medium + - Location: `src/web/frontend/src/lib/api.js:1` (backend check: `src/web/backend/auth/middleware.go:119`) + - User impact: After a restart or a week left open, every button that changes something fails until you reload the page. + - Detail: The frontend caches the CSRF token forever in a module variable, but the backend mints a new token per session. Sessions are in-memory with a 7-day expiry, so any restart or expiry strands the open page with a stale token and every POST/DELETE returns `403 CSRF token mismatch`. Login/logout do not rotate the token, so they are not a trigger. + - Fix direction: In `apiFetch`, on 403 clear the cached token, re-fetch `/api/ui/csrf`, and retry the request once. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-27 — Server hiccup leaves a permanent blank page** · Severity: Medium + - Location: `src/web/frontend/src/App.jsx:17`, `src/web/frontend/src/lib/api.js:54` + - User impact: If the server hiccups while the page loads you get a permanent blank page instead of an error or login screen. + - Detail: Startup fetches have no `catch` and `fetchConfig` does not check `res.ok`, so a 500/network failure throws and the view never leaves its initial state. + - Fix direction: Check `res.ok`, catch startup errors, and render an error/retry state. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-28 — Path-template settings page can never load** · Severity: Medium + - Location: `src/web/backend/routes.go:61` + - User impact: The path-template settings screen simply returns “not found” and can never be used. + - Detail: The pattern `api/ui/path-templates` lacks a leading `/`, so `ServeMux` parses `api` as a host pattern and normal requests 404. + - Fix direction: Register `/api/ui/path-templates`. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-29 — Wizard fields look editable but edits are ignored** · Severity: Medium + - Location: `src/web/frontend/src/components/Wizard.jsx:171` (backend values: `src/web/backend/settings/handlers.go:40`) + - User impact: Fields that should be locked by your Docker config look editable, but your edits are silently ignored. + - Detail: Step 2 checks `envSources[key] === '.env'`, but the backend only emits `'env'` or `'file'`, so the lock never engages. + - Fix direction: Compare against `'env'` (as Steps 1 and 3 already do). + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +## Scheduling + +- [ ] **BUG-30 — Legacy schedule setting disables all scheduling** · Severity: High + - Location: `docker/start.sh:66` + - User impact: If you set the old schedule option, none of your schedules run and music just never arrives. + - Detail: The legacy `CRON_SCHEDULE` branch runs `crond -f` in the foreground, blocking the later per-playlist schedules, `EXECUTE_ON_START`, and the final `crond` invocation. + - Fix direction: Don't run `crond` in the foreground in that branch; register the cron entry and let the normal startup path start the daemon. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +- [ ] **BUG-31 — Weekly playlists start refreshing daily** · Severity: Medium + - Location: `src/web/backend/playlist/jobs.go:45` + - User impact: A playlist set to refresh weekly starts refreshing every day once it is old enough. + - Detail: The cron closure gates on the `LastFetched` value captured at registration and never reloads it before the check (the fresh load happens only after the gate), so once a playlist is past its refresh interval it refreshes on every daily tick. + - Fix direction: Reload the playlist state before evaluating the refresh gate. + - Status: open · PR/commit: — · Verification: — + - Handoff: — + +--- + +## Session log + +Record one line per working session so progress survives across sessions. + +| Date | Session / author | Issues touched | Outcome / handoff | +|---|---|---|---| +| 2026-10-19 | Bug review | BUG-01 – BUG-31 created | Tracker created; all items open and unverified for fixes. | From 91946818fd28f549fecb4339110e0bb292430ee5 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 14:25:39 -0700 Subject: [PATCH 21/57] Add per-provider fallback threshold for download services Port b7122e3 from the stale feat/provider-fallback branch onto the current monitor: SLSKD_FALLBACK_AFTER / LIDARR_FALLBACK_AFTER (minutes, default 0 = disabled) bound the whole monitor phase for one provider. On expiry, unresolved tracks are abandoned with Cleanup so the next service in DOWNLOAD_SERVICES gets a turn instead of a slow provider holding the run forever. Also fix BUG-18: the progress branch no longer bypasses the per-track MaxDuration check, so a trickling download times out instead of resetting its clock on every byte. This covers the intent of the unlanded SLSKD_MONITOR_MAX_RUNTIME cap (dfaaedb) without adding a second, near-identical knob. --- TODO.md | 11 +-- sample.env | 9 ++ src/config/config.go | 6 ++ src/downloader/lidarr.go | 1 + src/downloader/monitor.go | 47 +++++++++- src/downloader/monitor_fallback_test.go | 115 ++++++++++++++++++++++++ src/downloader/slskd.go | 1 + src/web/backend/defs/defs.go | 1 + src/web/sample.env | 9 ++ 9 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 src/downloader/monitor_fallback_test.go diff --git a/TODO.md b/TODO.md index ec94376b..01badce0 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,11 @@ Status values: `open` · `in-progress` · `blocked` · `done` · `wontfix` | Data loss and corruption | 3 | 0 | 3 | | Security | 2 | 0 | 2 | | Playlists broken or wrong | 7 | 0 | 7 | -| Hangs and resource leaks | 4 | 0 | 4 | +| Hangs and resource leaks | 4 | 1 | 3 | | Wrong songs and metadata | 4 | 0 | 4 | | Web UI and settings screens | 4 | 0 | 4 | | Scheduling | 2 | 0 | 2 | -| **Total** | **31** | **0** | **31** | +| **Total** | **31** | **1** | **30** | Update this table whenever an issue changes status. @@ -180,13 +180,13 @@ Update this table whenever an issue changes status. ## Hangs and resource leaks -- [ ] **BUG-18 — A trickling download hangs the run forever** · Severity: Medium +- [x] **BUG-18 — A trickling download hangs the run forever** · Severity: Medium - Location: `src/downloader/monitor.go:161` (deadline check at `:167`) - User impact: One very slow download can hang the whole run forever instead of timing out. - Detail: The progress branch `continue`s before the `MaxDuration` check, so any download that moves at least a byte per poll never times out. - Fix direction: Check the deadline before the progress `continue`, on every poll. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: branch `feat/download-provider-fallback` (this commit) · Verification: `go vet ./...` clean; `go test ./src/downloader/ -run TestMonitorDownloads` passes including `-race`; new regression tests `TestMonitorDownloads_TricklingTrackHitsMaxDuration`, `TestMonitorDownloads_FallbackAfterAbandonsUnresolvedTracks`, `TestMonitorDownloads_FallbackAfterZeroWaitsForCompletion`. Full downloader package still fails only on pre-existing sandbox `httptest: bind: operation not permitted` (confirmed identical on untouched `dev`). + - Handoff: Fixed together with the provider-fallback port: the progress branch no longer bypasses `MaxDuration`, and the new `SLSKD_FALLBACK_AFTER`/`LIDARR_FALLBACK_AFTER` whole-provider budget (default 0 = disabled) abandons unresolved tracks (with `Cleanup`) so the next `DOWNLOAD_SERVICES` provider gets a turn. `dfaaedb`'s separate `SLSKD_MONITOR_MAX_RUNTIME` knob was not ported — its intent is now covered by the fixed `MaxDuration` plus `FallbackAfter`, avoiding two near-identical caps. - [ ] **BUG-19 — One long log line wedges all future runs** · Severity: Medium - Location: `src/web/backend/run/events.go:48` (wait at `:65`) @@ -307,3 +307,4 @@ Record one line per working session so progress survives across sessions. | Date | Session / author | Issues touched | Outcome / handoff | |---|---|---|---| | 2026-10-19 | Bug review | BUG-01 – BUG-31 created | Tracker created; all items open and unverified for fixes. | +| 2026-10-19 | Provider fallback port | BUG-18 done | Ported `FallbackAfter` from stale `b7122e3` onto `dev` (branch `feat/download-provider-fallback`) and fixed BUG-18 deadline bypass. `feat/lidarr`/`feat/provider-fallback` remote branches still pending deletion. | diff --git a/sample.env b/sample.env index 6e70ebce..39fc9d6b 100644 --- a/sample.env +++ b/sample.env @@ -56,6 +56,15 @@ LIBRARY_NAME= # KEEP_PERMISSIONS=true # Comma-separated list (no spaces) of download services, in priority order (supported: lidarr,slskd,youtube) (default: youtube) # DOWNLOAD_SERVICES=youtube +# Time budget (minutes) for a single download service before Explo gives up on it +# and lets the next service in DOWNLOAD_SERVICES try the remaining tracks. +# 0 (default) waits indefinitely. Example chain -- prefer full albums from Lidarr, +# fall back to slskd for anything it hasn't delivered in 15 minutes: +# DOWNLOAD_SERVICES=lidarr,slskd +# LIDARR_FALLBACK_AFTER=15 +# SLSKD_FALLBACK_AFTER=10 +# LIDARR_FALLBACK_AFTER=0 +# SLSKD_FALLBACK_AFTER=0 # Path templating, Options are Artist, Album, TrackName, TrackNumber, File, Ext (eg. "{{Artist}}/{{Album}}/{{File}}") # PATH_TEMPLATE="" diff --git a/src/config/config.go b/src/config/config.go index 56e25fef..600adc2b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -187,6 +187,9 @@ type SlskdMon struct { Interval int `env:"SLSKD_MONITOR_INTERVAL" env-default:"1"` // in minutes StallDuration int `env:"SLSKD_STALL_DURATION" env-default:"15"` // in minutes MaxDuration int `env:"SLSKD_MONITOR_MAX_DURATION" env-default:"120"` // in minutes + // Give up on this provider entirely after this long and let the next + // service in DOWNLOAD_SERVICES try the remaining tracks. 0 disables. + FallbackAfter int `env:"SLSKD_FALLBACK_AFTER" env-default:"0"` // in minutes } type Lidarr struct { @@ -207,6 +210,9 @@ type LidarrMon struct { Interval int `env:"LIDARR_MONITOR_INTERVAL" env-default:"1"` // in minutes StallDuration int `env:"LIDARR_STALL_DURATION" env-default:"20"` // in minutes MaxDuration int `env:"LIDARR_MONITOR_MAX_DURATION" env-default:"120"` // in minutes + // Give up on this provider entirely after this long and let the next + // service in DOWNLOAD_SERVICES try the remaining tracks. 0 disables. + FallbackAfter int `env:"LIDARR_FALLBACK_AFTER" env-default:"0"` // in minutes } type DiscoveryConfig struct { diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 8bc7db36..4c832ca3 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -221,6 +221,7 @@ func (c *Lidarr) GetConf() (MonitorConfig, error) { CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, StallDuration: time.Duration(c.Cfg.MonitorConfig.StallDuration) * time.Minute, MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, + FallbackAfter: time.Duration(c.Cfg.MonitorConfig.FallbackAfter) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, FromDir: c.Cfg.LidarrDir, diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index faea6780..aee8082e 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -28,6 +28,11 @@ type MonitorConfig struct { CheckInterval time.Duration StallDuration time.Duration MaxDuration time.Duration + // FallbackAfter bounds the whole monitor phase for one provider. Once it + // elapses, any track still unresolved is abandoned so the next provider in + // DOWNLOAD_SERVICES can attempt it. Zero means wait indefinitely, which is + // the historical behaviour. + FallbackAfter time.Duration MigrateDownload bool FromDir string ToDir string @@ -77,6 +82,8 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err var pollFailures int + phaseStart := time.Now() + for range ticker.C { statuses, err := m.GetDownloadStatus(tracks) if err != nil { @@ -158,7 +165,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } continue - } else if fileStatus.BytesTransferred > tracker.LastBytesTransferred { + } else if fileStatus.BytesTransferred > tracker.LastBytesTransferred && !(monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration) { tracker.LastBytesTransferred = fileStatus.BytesTransferred tracker.LastUpdated = currentTime slog.Info("[monitor] progress updated", "service", monCfg.Service, "title", track.CleanTitle, "bytes transferred", fileStatus.BytesTransferred) @@ -166,7 +173,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if fileStatus.State == "Errored" || stallTime > monCfg.StallDuration || - monitoredTime > monCfg.MaxDuration { + (monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration) { switch { case fileStatus.State == "Errored": @@ -203,10 +210,46 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err slog.Info("[monitor] Finished", "service", monCfg.Service, "downloaded files", successDownloads, "total tracks", len(tracks)) return nil } + + // Budget for this provider exhausted: abandon whatever is still in + // flight so the next service in DOWNLOAD_SERVICES gets a turn. + if monCfg.FallbackAfter > 0 && time.Since(phaseStart) > monCfg.FallbackAfter { + abandoned := abandonUnresolved(tracks, statuses, progressMap, m) + slog.Info("[monitor] fallback threshold reached, moving on to the next download service", + "service", monCfg.Service, "threshold", monCfg.FallbackAfter, + "downloaded files", successDownloads, "unresolved tracks", abandoned) + return nil + } } return nil } +// abandonUnresolved cancels any still-running download for tracks this provider +// did not finish, so it does not keep downloading in the background after we +// have moved on. Returns how many tracks were left unresolved. +func abandonUnresolved(tracks []*models.Track, statuses map[string]FileStatus, progressMap map[string]*DownloadMonitor, m Monitor) int { + var unresolved int + for _, track := range tracks { + if track.Present { + continue + } + key := fmt.Sprintf("%s|%s", track.ID, track.CleanTitle) + if tracker, exists := progressMap[key]; exists && tracker.Skipped { + continue + } + unresolved++ + + fileStatus, exists := statuses[track.ID] + if !exists { + continue + } + if err := m.Cleanup(*track, fileStatus.QueueID); err != nil { + slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) + } + } + return unresolved +} + // Checks if all tracks are processed (either downloaded or skipped) func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMonitor) bool { for _, track := range tracks { diff --git a/src/downloader/monitor_fallback_test.go b/src/downloader/monitor_fallback_test.go new file mode 100644 index 00000000..71d7402d --- /dev/null +++ b/src/downloader/monitor_fallback_test.go @@ -0,0 +1,115 @@ +package downloader + +import ( + "testing" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// fallbackStub always reports progress but never completes, unless +// completeAfter is set, in which case it completes after that many polls. +type fallbackStub struct { + conf MonitorConfig + completeAfter int + polls int + cleanups int +} + +func (s *fallbackStub) GetConf() (MonitorConfig, error) { return s.conf, nil } +func (s *fallbackStub) Cleanup(models.Track, string) error { + s.cleanups++ + return nil +} +func (s *fallbackStub) MoveDownload(string, string, string, *models.Track) (string, error) { + return "", nil +} + +func (s *fallbackStub) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + s.polls++ + statuses := make(map[string]FileStatus, len(tracks)) + for _, track := range tracks { + if s.completeAfter > 0 && s.polls > s.completeAfter { + statuses[track.ID] = FileStatus{ID: "1", Filename: track.File, State: "Completed, Succeeded", PercentComplete: 100, QueueID: "q1"} + continue + } + statuses[track.ID] = FileStatus{ID: "1", Filename: track.File, State: "InProgress", BytesTransferred: s.polls, BytesRemaining: 100, QueueID: "q1"} + } + return statuses, nil +} + +func runFallbackMonitor(t *testing.T, stub *fallbackStub, track *models.Track) error { + t.Helper() + client := &DownloadClient{Cfg: &cfg.DownloadConfig{}} + done := make(chan error, 1) + go func() { done <- client.MonitorDownloads([]*models.Track{track}, stub) }() + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + t.Fatal("MonitorDownloads never returned") + return nil + } +} + +func TestMonitorDownloads_FallbackAfterAbandonsUnresolvedTracks(t *testing.T) { + stub := &fallbackStub{conf: MonitorConfig{ + CheckInterval: time.Millisecond, + StallDuration: time.Hour, + MaxDuration: 24 * time.Hour, + FallbackAfter: 20 * time.Millisecond, + Service: "lidarr", + }} + track := &models.Track{ID: "track-1", File: "song.flac"} + + if err := runFallbackMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil so the next provider can try", err) + } + if track.Present { + t.Error("unfinished track was marked present") + } + if stub.cleanups == 0 { + t.Error("abandoned track was never cleaned up; the provider would keep downloading in the background") + } +} + +func TestMonitorDownloads_FallbackAfterZeroWaitsForCompletion(t *testing.T) { + stub := &fallbackStub{completeAfter: 3, conf: MonitorConfig{ + CheckInterval: time.Millisecond, + StallDuration: time.Hour, + MaxDuration: 24 * time.Hour, + FallbackAfter: 0, + Service: "lidarr", + }} + track := &models.Track{ID: "track-1", File: "song.flac"} + + if err := runFallbackMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil", err) + } + if !track.Present { + t.Error("completed track was not marked present with fallback disabled") + } +} + +// BUG-18: a trickling download moves bytes every poll, so the progress +// branch must not bypass the MaxDuration deadline. +func TestMonitorDownloads_TricklingTrackHitsMaxDuration(t *testing.T) { + stub := &fallbackStub{conf: MonitorConfig{ + CheckInterval: time.Millisecond, + StallDuration: time.Hour, + MaxDuration: 20 * time.Millisecond, + Service: "slskd", + }} + track := &models.Track{ID: "track-1", File: "song.flac"} + + if err := runFallbackMonitor(t, stub, track); err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil", err) + } + if track.Present { + t.Error("trickling track was marked present after MaxDuration") + } + if stub.cleanups == 0 { + t.Error("timed-out track was never cleaned up") + } +} diff --git a/src/downloader/slskd.go b/src/downloader/slskd.go index d5e0ce6c..82286100 100644 --- a/src/downloader/slskd.go +++ b/src/downloader/slskd.go @@ -128,6 +128,7 @@ func (c *Slskd) GetConf() (MonitorConfig, error) { CheckInterval: time.Duration(c.Cfg.MonitorConfig.Interval) * time.Minute, StallDuration: time.Duration(c.Cfg.MonitorConfig.StallDuration) * time.Minute, MaxDuration: time.Duration(c.Cfg.MonitorConfig.MaxDuration) * time.Minute, + FallbackAfter: time.Duration(c.Cfg.MonitorConfig.FallbackAfter) * time.Minute, MigrateDownload: c.Cfg.MigrateDL, ToDir: c.DownloadDir, FromDir: c.Cfg.SlskdDir, diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index e9104cc1..c72a44a7 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -196,6 +196,7 @@ var AllConfigKeys = []string{ "MIN_BITRATE", "MAX_BITRATE", "SIZE_PREFERENCE", "RELEASE_PREFERENCE", "PREFER_ORIGINAL_RELEASE", "SLSKD_URL", "SLSKD_API_KEY", "SLSKD_MIGRATE_DOWNLOADS", "SLSKD_ALBUM_MODE", + "SLSKD_FALLBACK_AFTER", "LIDARR_FALLBACK_AFTER", "LIDARR_URL", "LIDARR_API_KEY", "LIDARR_MIGRATE_DOWNLOADS", "LIDARR_ROOT_FOLDER", "WIZARD_COMPLETE", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", } diff --git a/src/web/sample.env b/src/web/sample.env index 6e70ebce..39fc9d6b 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -56,6 +56,15 @@ LIBRARY_NAME= # KEEP_PERMISSIONS=true # Comma-separated list (no spaces) of download services, in priority order (supported: lidarr,slskd,youtube) (default: youtube) # DOWNLOAD_SERVICES=youtube +# Time budget (minutes) for a single download service before Explo gives up on it +# and lets the next service in DOWNLOAD_SERVICES try the remaining tracks. +# 0 (default) waits indefinitely. Example chain -- prefer full albums from Lidarr, +# fall back to slskd for anything it hasn't delivered in 15 minutes: +# DOWNLOAD_SERVICES=lidarr,slskd +# LIDARR_FALLBACK_AFTER=15 +# SLSKD_FALLBACK_AFTER=10 +# LIDARR_FALLBACK_AFTER=0 +# SLSKD_FALLBACK_AFTER=0 # Path templating, Options are Artist, Album, TrackName, TrackNumber, File, Ext (eg. "{{Artist}}/{{Album}}/{{File}}") # PATH_TEMPLATE="" From 92a561e341762dfa876b2a5e4fa515a1c20b8f00 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 14:30:41 -0700 Subject: [PATCH 22/57] Note PR #25 and stale-branch cleanup in tracker --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 01badce0..28059266 100644 --- a/TODO.md +++ b/TODO.md @@ -307,4 +307,4 @@ Record one line per working session so progress survives across sessions. | Date | Session / author | Issues touched | Outcome / handoff | |---|---|---|---| | 2026-10-19 | Bug review | BUG-01 – BUG-31 created | Tracker created; all items open and unverified for fixes. | -| 2026-10-19 | Provider fallback port | BUG-18 done | Ported `FallbackAfter` from stale `b7122e3` onto `dev` (branch `feat/download-provider-fallback`) and fixed BUG-18 deadline bypass. `feat/lidarr`/`feat/provider-fallback` remote branches still pending deletion. | +| 2026-10-19 | Provider fallback port | BUG-18 done | Ported `FallbackAfter` from stale `b7122e3` onto `dev` (branch `feat/download-provider-fallback`, PR #25) and fixed BUG-18 deadline bypass. `feat/lidarr`/`feat/provider-fallback` remote branches deleted after the port landed on the new branch. | From 95b2228fa9ad5ad507b596108cd25280fdb06ce2 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 14:37:25 -0700 Subject: [PATCH 23/57] Fix staticcheck QF1001 in monitor deadline check CI's golangci-lint flagged !(a && b) on the BUG-18 progress branch. Extract an expired variable and reuse it in both branches instead. --- src/downloader/monitor.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index aee8082e..3e258b98 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -136,6 +136,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err stallTime := currentTime.Sub(tracker.LastUpdated) monitoredTime := currentTime.Sub(tracker.StartedAt) + expired := monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { track.File = fileStatus.Filename @@ -165,7 +166,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } continue - } else if fileStatus.BytesTransferred > tracker.LastBytesTransferred && !(monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration) { + } else if fileStatus.BytesTransferred > tracker.LastBytesTransferred && !expired { tracker.LastBytesTransferred = fileStatus.BytesTransferred tracker.LastUpdated = currentTime slog.Info("[monitor] progress updated", "service", monCfg.Service, "title", track.CleanTitle, "bytes transferred", fileStatus.BytesTransferred) @@ -173,7 +174,7 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err } else if fileStatus.State == "Errored" || stallTime > monCfg.StallDuration || - (monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration) { + expired { switch { case fileStatus.State == "Errored": From c85aaaf993237169a9bcdb384c75d6f2d48ae9d5 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 16:02:27 -0700 Subject: [PATCH 24/57] Fix session race crashing the app under concurrent requests Session.Get/Put/Delete touched s.data and s.id with no lock, so two requests sharing one session (multiple tabs, fast clicking) could hit a fatal concurrent map writes panic. Guard all session access with an RWMutex: Get/ID take the read lock, Put/Delete the write lock, Migrate swaps the ID under the write lock and re-registers via writeWithID, and cookie writing goes through ID(). Adds a -race regression test driving one session from 8 goroutines. --- src/web/backend/auth/middleware.go | 8 +++-- src/web/backend/auth/session.go | 34 +++++++++++++----- src/web/backend/auth/session_race_test.go | 42 +++++++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 src/web/backend/auth/session_race_test.go diff --git a/src/web/backend/auth/middleware.go b/src/web/backend/auth/middleware.go index 7f719a14..5c64bac9 100644 --- a/src/web/backend/auth/middleware.go +++ b/src/web/backend/auth/middleware.go @@ -68,10 +68,14 @@ func (s *InMemorySessionStore) read(id string) (*Session, error) { } func (s *InMemorySessionStore) write(session *Session) error { + return s.writeWithID(session, session.ID()) +} + +func (s *InMemorySessionStore) writeWithID(session *Session, id string) error { s.mu.Lock() defer s.mu.Unlock() - s.sessions[session.id] = session + s.sessions[id] = session return nil } @@ -146,7 +150,7 @@ func writeCookieIfNecessary(w *sessionResponseWriter) { cookie := &http.Cookie{ Name: w.sessionManager.cookieName, - Value: session.id, + Value: session.ID(), HttpOnly: true, Path: "/", SameSite: http.SameSiteLaxMode, diff --git a/src/web/backend/auth/session.go b/src/web/backend/auth/session.go index dc6c5ec4..3cfa6795 100644 --- a/src/web/backend/auth/session.go +++ b/src/web/backend/auth/session.go @@ -13,7 +13,7 @@ import ( type Session struct { createdAt time.Time - mu sync.Mutex + mu sync.RWMutex id string data map[string]any } @@ -71,9 +71,9 @@ func (m *SessionManager) gc(d time.Duration) { func (m *SessionManager) validate(session *Session) bool { if time.Since(session.createdAt) > m.absoluteExpiration { - + // Delete the session from the store - err := m.store.destroy(session.id) + err := m.store.destroy(session.ID()) if err != nil { panic(err) } @@ -113,16 +113,16 @@ func (m *SessionManager) start(r *http.Request) (*Session, *http.Request) { func (m *SessionManager) Migrate(session *Session) error { session.mu.Lock() - defer session.mu.Unlock() + oldID := session.id + session.id = generateToken() + newID := session.id + session.mu.Unlock() - err := m.store.destroy(session.id) - if err != nil { + if err := m.store.destroy(oldID); err != nil { return err } - session.id = generateToken() - - return m.store.write(session) + return m.store.writeWithID(session, newID) } @@ -138,15 +138,31 @@ func generateToken() string { return base64.RawURLEncoding.EncodeToString(id) } +func (s *Session) ID() string { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.id +} + func (s *Session) Get(key string) any { + s.mu.RLock() + defer s.mu.RUnlock() + return s.data[key] } func (s *Session) Put(key string, value any) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[key] = value } func (s *Session) Delete(key string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, key) } diff --git a/src/web/backend/auth/session_race_test.go b/src/web/backend/auth/session_race_test.go new file mode 100644 index 00000000..cdb9437f --- /dev/null +++ b/src/web/backend/auth/session_race_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "sync" + "testing" + "time" +) + +// BUG-01: concurrent requests sharing one session (multiple tabs, fast +// clicking) raced on Session.data and Session.id, causing a fatal +// "concurrent map writes" panic. Run with -race. +func TestSession_ConcurrentAccess(t *testing.T) { + manager := NewSessionManager(NewInMemorySessionStore(), time.Hour, 7*24*time.Hour, "explo_session_test") + session := newSession() + if err := manager.store.write(session); err != nil { + t.Fatalf("writing session: %v", err) + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + for j := 0; j < 500; j++ { + session.Put("key", n) + _ = session.Get("key") + session.Delete("key") + _ = session.Get("csrf_token") + } + }(i) + } + wg.Wait() + + // Session must still be usable after concurrent access. + session.Put("authenticated", true) + if got, _ := session.Get("authenticated").(bool); !got { + t.Error("session data lost after concurrent access") + } + if session.ID() == "" { + t.Error("session ID empty after concurrent access") + } +} From 88bac9b798c7ca6ab6573cb504071da88f788034 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 16:03:39 -0700 Subject: [PATCH 25/57] Fix Lidarr album cache race and nil dereference Two AlbumCache reads (the 409-already-added check and the history import in GetDownloadStatus) read the map without CacheMu while cacheAlbum writes it from concurrent goroutines, and the history import dereferenced a possibly-nil cache entry, panicking the run. Route both reads through a locked cachedAlbum accessor and skip the history import when the album is not cached. Adds a -race regression test with per-goroutine tracks sharing only the cache map. --- src/downloader/lidarr.go | 19 ++++++++-- src/downloader/lidarr_cache_race_test.go | 45 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 src/downloader/lidarr_cache_race_test.go diff --git a/src/downloader/lidarr.go b/src/downloader/lidarr.go index 4c832ca3..f3a2b332 100644 --- a/src/downloader/lidarr.go +++ b/src/downloader/lidarr.go @@ -351,7 +351,7 @@ func (c *Lidarr) addNewAlbum(track *models.Track) error { body, err := c.HttpClient.MakeRequest("POST", queryURL, bytes.NewReader(payloadBody), c.Headers) if err != nil { if strings.Contains(err.Error(), "got 409") || strings.Contains(string(body), "has already been added") { - if _, ok := c.AlbumCache[c.cacheKey(track)]; ok { + if _, ok := c.cachedAlbum(c.cacheKey(track)); ok { slog.Debug("album already in Lidarr", "album", track.MusicBrainzReleaseGroupID) return nil } @@ -517,7 +517,11 @@ func (c *Lidarr) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatu } - cache := c.AlbumCache[c.cacheKey(albumTracks[0])] + cache, ok := c.cachedAlbum(c.cacheKey(albumTracks[0])) + if !ok || cache == nil { + slog.Debug("[lidarr] album not cached, skipping history import", "album", albumTracks[0].Album) + continue + } for _, r := range history.Records { if r.Data.ImportedPath == "" { continue @@ -756,6 +760,17 @@ func (c *Lidarr) cacheKey(track *models.Track) string { return track.MainArtist + "|" + track.Album } +// cachedAlbum reads the album cache under its lock. All AlbumCache reads +// must go through here (or hold CacheMu) — cacheAlbum writes the map from +// concurrent goroutines. +func (c *Lidarr) cachedAlbum(key string) (*AlbumMetadata, bool) { + c.CacheMu.RLock() + defer c.CacheMu.RUnlock() + + a, ok := c.AlbumCache[key] + return a, ok +} + func (c *Lidarr) cacheAlbum(track *models.Track) { key := c.cacheKey(track) c.CacheMu.Lock() diff --git a/src/downloader/lidarr_cache_race_test.go b/src/downloader/lidarr_cache_race_test.go new file mode 100644 index 00000000..2cc629a2 --- /dev/null +++ b/src/downloader/lidarr_cache_race_test.go @@ -0,0 +1,45 @@ +package downloader + +import ( + "sync" + "testing" + + "explo/src/models" +) + +// BUG-02: AlbumCache was read without CacheMu while cacheAlbum wrote it +// from concurrent goroutines, and a missing entry was dereferenced. Run +// with -race. +func TestLidarrAlbumCache_ConcurrentAccess(t *testing.T) { + c := &Lidarr{AlbumCache: make(map[string]*AlbumMetadata)} + newTrack := func() *models.Track { + return &models.Track{MainArtist: "Artist", Album: "Album", AlbumID: "1", MainArtistID: "2"} + } + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // Each goroutine works on its own track, as concurrent album + // fetches do; they share only the cache map under test. + track := newTrack() + for j := 0; j < 200; j++ { + c.cacheAlbum(track) + if _, ok := c.cachedAlbum(c.cacheKey(track)); !ok { + t.Error("cached album disappeared") + return + } + c.populateFromCache(track) + } + }() + } + wg.Wait() + + if _, ok := c.cachedAlbum(c.cacheKey(newTrack())); !ok { + t.Fatal("album not cached after concurrent writes") + } + if _, ok := c.cachedAlbum("missing|key"); ok { + t.Error("missing key reported as cached") + } +} From 51474f7c29f8a3fcad6c0f7510bd7ad5f11f3667 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:38:57 -0700 Subject: [PATCH 26/57] Fix custom playlists being wiped by concurrent saves load-modify-save of custom-playlists.json was unsynchronized across import/delete/refresh/cron, and saveCustomPlaylists wrote the file non-atomically: a reader mid-truncate parsed the file as empty and the next save wiped every custom playlist. Serialize all access with a package mutex held across the whole load-modify-save via updateCustomPlaylists, rewire import/refresh/ delete/cron/artwork call sites to it, and write via temp file + rename so readers never see a torn file. Adds concurrency regression tests. --- src/web/backend/playlist/custom_playlists.go | 72 ++++++++++++++++--- .../backend/playlist/custom_playlists_test.go | 63 ++++++++++++++++ src/web/backend/playlist/handlers.go | 45 +++++++----- src/web/backend/playlist/jobs.go | 15 ++-- 4 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 src/web/backend/playlist/custom_playlists_test.go diff --git a/src/web/backend/playlist/custom_playlists.go b/src/web/backend/playlist/custom_playlists.go index da85ca06..4a6b3dc0 100644 --- a/src/web/backend/playlist/custom_playlists.go +++ b/src/web/backend/playlist/custom_playlists.go @@ -2,12 +2,14 @@ package playlist import ( "encoding/json" + "errors" "fmt" "log/slog" "os" "path/filepath" "regexp" "strings" + "sync" "time" "explo/src/discovery" @@ -48,17 +50,15 @@ func GetCustomPlaylist(cfgDir, id string) *CustomPlaylist { // MarkCustomPlaylistArtworkUploaded sets ArtworkUploaded=true and persists. func MarkCustomPlaylistArtworkUploaded(cfgDir, id string) error { - playlists := loadCustomPlaylists(cfgDir) - for i := range playlists { - if playlists[i].ID == id { - if playlists[i].ArtworkUploaded { - return nil + return updateCustomPlaylists(cfgDir, func(playlists []CustomPlaylist) ([]CustomPlaylist, error) { + for i := range playlists { + if playlists[i].ID == id { + playlists[i].ArtworkUploaded = true + break } - playlists[i].ArtworkUploaded = true - return saveCustomPlaylists(cfgDir, playlists) } - } - return nil + return playlists, nil + }) } var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) @@ -91,7 +91,23 @@ func customPlaylistsPath(cfgDir string) string { return filepath.Join(cfgDir, "custom-playlists.json") } +// customPlaylistsMu serializes every load-modify-save of +// custom-playlists.json. Without it, two writers (import, delete, refresh, +// cron) could each load the same list and the later save silently dropped +// the other's change; a reader mid-write could also parse a truncated file +// as empty and the next save then wiped every playlist. +var customPlaylistsMu sync.Mutex + +var errPlaylistNotFound = errors.New("playlist not found") + func loadCustomPlaylists(cfgDir string) []CustomPlaylist { + customPlaylistsMu.Lock() + defer customPlaylistsMu.Unlock() + + return loadCustomPlaylistsUnlocked(cfgDir) +} + +func loadCustomPlaylistsUnlocked(cfgDir string) []CustomPlaylist { data, err := os.ReadFile(customPlaylistsPath(cfgDir)) if err != nil { return nil @@ -105,11 +121,47 @@ func loadCustomPlaylists(cfgDir string) []CustomPlaylist { } func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error { + customPlaylistsMu.Lock() + defer customPlaylistsMu.Unlock() + + return saveCustomPlaylistsUnlocked(cfgDir, playlists) +} + +// saveCustomPlaylistsUnlocked writes via a temp file in the same directory +// plus rename, so a reader never observes a half-written file. +func saveCustomPlaylistsUnlocked(cfgDir string, playlists []CustomPlaylist) error { raw, err := json.MarshalIndent(playlists, "", " ") if err != nil { return err } - return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644) + tmp, err := os.CreateTemp(cfgDir, "custom-playlists-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, customPlaylistsPath(cfgDir)) +} + +// updateCustomPlaylists runs fn against the freshly loaded list and saves +// the result, holding the lock across the whole load-modify-save. If fn +// returns an error, nothing is saved and the error is returned. +func updateCustomPlaylists(cfgDir string, fn func([]CustomPlaylist) ([]CustomPlaylist, error)) error { + customPlaylistsMu.Lock() + defer customPlaylistsMu.Unlock() + + updated, err := fn(loadCustomPlaylistsUnlocked(cfgDir)) + if err != nil { + return err + } + return saveCustomPlaylistsUnlocked(cfgDir, updated) } // FetchResult is the uniform return type for fetching playlist data from any source. diff --git a/src/web/backend/playlist/custom_playlists_test.go b/src/web/backend/playlist/custom_playlists_test.go new file mode 100644 index 00000000..e3763ed1 --- /dev/null +++ b/src/web/backend/playlist/custom_playlists_test.go @@ -0,0 +1,63 @@ +package playlist + +import ( + "fmt" + "sync" + "testing" +) + +// BUG-06: unsynchronized load-modify-save of custom-playlists.json let two +// writers drop each other's playlists, and a non-atomic write could be +// parsed as empty mid-truncate, wiping the file on the next save. +func TestUpdateCustomPlaylists_ConcurrentAppendsAllSurvive(t *testing.T) { + dir := t.TempDir() + const writers = 16 + + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + id := fmt.Sprintf("custom-%d", n) + err := updateCustomPlaylists(dir, func(cur []CustomPlaylist) ([]CustomPlaylist, error) { + return append(cur, CustomPlaylist{ID: id, Name: id}), nil + }) + if err != nil { + t.Errorf("update %s: %v", id, err) + } + // Readers must never observe a torn/empty file mid-write. + _ = loadCustomPlaylists(dir) + }(i) + } + wg.Wait() + + got := loadCustomPlaylists(dir) + if len(got) != writers { + t.Fatalf("saved %d playlists, want %d — a concurrent writer's append was lost", len(got), writers) + } + seen := make(map[string]bool, writers) + for _, p := range got { + seen[p.ID] = true + } + for i := 0; i < writers; i++ { + if !seen[fmt.Sprintf("custom-%d", i)] { + t.Errorf("playlist custom-%d missing after concurrent saves", i) + } + } +} + +func TestUpdateCustomPlaylists_ErrorSkipsSave(t *testing.T) { + dir := t.TempDir() + if err := saveCustomPlaylists(dir, []CustomPlaylist{{ID: "keep", Name: "keep"}}); err != nil { + t.Fatalf("seed save: %v", err) + } + if err := updateCustomPlaylists(dir, func(cur []CustomPlaylist) ([]CustomPlaylist, error) { + return nil, errPlaylistNotFound + }); err != errPlaylistNotFound { + t.Fatalf("update error = %v, want errPlaylistNotFound", err) + } + got := loadCustomPlaylists(dir) + if len(got) != 1 || got[0].ID != "keep" { + t.Errorf("list = %v, want the seeded playlist untouched after a failed update", got) + } +} diff --git a/src/web/backend/playlist/handlers.go b/src/web/backend/playlist/handlers.go index 49ff8faa..91f0208a 100644 --- a/src/web/backend/playlist/handlers.go +++ b/src/web/backend/playlist/handlers.go @@ -148,8 +148,10 @@ func (p *Playlist) HandleImportCustomPlaylist(w http.ResponseWriter, r *http.Req ColorIndex: len(existing), LastFetched: time.Now().UTC(), } - existing = append(existing, cp) - if err := saveCustomPlaylists(p.cfg.WebDataDir, existing); err != nil { + if err := updateCustomPlaylists(p.cfg.WebDataDir, func(current []CustomPlaylist) ([]CustomPlaylist, error) { + cp.ColorIndex = len(current) + return append(current, cp), nil + }); err != nil { slog.Error("custom-playlists: failed to save metadata", "err", err) http.Error(w, "failed to save playlist metadata: "+err.Error(), http.StatusInternalServerError) return @@ -235,8 +237,15 @@ func (p *Playlist) HandleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Re } go downloadAndCacheCovers(p.cfg.WebDataDir, id, tracks) - playlists[idx].LastFetched = time.Now().UTC() - if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { + if err := updateCustomPlaylists(p.cfg.WebDataDir, func(current []CustomPlaylist) ([]CustomPlaylist, error) { + for i := range current { + if current[i].ID == id { + current[i].LastFetched = time.Now().UTC() + break + } + } + return current, nil + }); err != nil { slog.Warn("custom-playlists: failed to update last_fetched after refresh", "err", err) } @@ -260,24 +269,28 @@ func (p *Playlist) HandleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Req deleteTracks := r.URL.Query().Get("delete_tracks") == "true" slog.Info("custom-playlists: delete request", "id", id, "delete_tracks", deleteTracks) - existing := loadCustomPlaylists(p.cfg.WebDataDir) - filtered := existing[:0] found := false var deletedName string - for _, p := range existing { - if p.ID == id { - found = true - deletedName = p.Name - } else { - filtered = append(filtered, p) + err := updateCustomPlaylists(p.cfg.WebDataDir, func(existing []CustomPlaylist) ([]CustomPlaylist, error) { + filtered := make([]CustomPlaylist, 0, len(existing)) + for _, p := range existing { + if p.ID == id { + found = true + deletedName = p.Name + } else { + filtered = append(filtered, p) + } } - } - if !found { + if !found { + return nil, errPlaylistNotFound + } + return filtered, nil + }) + if err == errPlaylistNotFound { http.Error(w, "playlist not found", http.StatusNotFound) return } - - if err := saveCustomPlaylists(p.cfg.WebDataDir, filtered); err != nil { + if err != nil { http.Error(w, "failed to save: "+err.Error(), http.StatusInternalServerError) return } diff --git a/src/web/backend/playlist/jobs.go b/src/web/backend/playlist/jobs.go index abf444a3..883fe463 100644 --- a/src/web/backend/playlist/jobs.go +++ b/src/web/backend/playlist/jobs.go @@ -52,14 +52,15 @@ func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { return } writePrefetchCache(p.cfg.WebDataDir, plist.ID, result.Tracks) - playlists := loadCustomPlaylists(p.cfg.WebDataDir) - for i, pl := range playlists { - if pl.ID == plist.ID { - playlists[i].LastFetched = time.Now().UTC() - break + if err := updateCustomPlaylists(p.cfg.WebDataDir, func(playlists []CustomPlaylist) ([]CustomPlaylist, error) { + for i, pl := range playlists { + if pl.ID == plist.ID { + playlists[i].LastFetched = time.Now().UTC() + break + } } - } - if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { + return playlists, nil + }); err != nil { slog.Error("custom-playlists: failed to save after refresh", "err", err) } }), From 1b1685549aa15a241322699bbf9a04a9af7eba64 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:40:31 -0700 Subject: [PATCH 27/57] Fix panic on ListenBrainz recordings with no artists getTracks indexed recArtists[0] unconditionally, so one recording whose metadata had an empty artists array crashed the whole run. Extract trackFromRecording (so the conversion is testable without the network) and guard the artist MBID lookup, degrading to the artist credit name with an empty artist ID, matching the guard already used in enrichTracks. Adds regression tests for empty and populated artist lists. --- src/discovery/listenbrainz.go | 75 +++++++++++-------- .../listenbrainz_empty_artist_test.go | 44 +++++++++++ 2 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 src/discovery/listenbrainz_empty_artist_test.go diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 9890129a..4b77f53a 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -356,46 +356,57 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T tracks := make([]*models.Track, 0, len(recordings)) for mbTrackID, recording := range recordings { - rec := recording.Recording - rel := recording.Release + tracks = append(tracks, c.trackFromRecording(mbTrackID, recording, singleArtist)) + } - title := rec.Name - artist := recording.Artist.Name - mainArtist := recording.Artist.Name + return tracks, nil - recArtists := recording.Artist.Artists +} - if len(recArtists) > 1 { - mainArtist = recArtists[0].Name - if singleArtist { - var titleArgs TitleArgs - titleArgs.CleanTitle = rec.Name - for _, artist := range recArtists[1:] { +// trackFromRecording builds one track from ListenBrainz recording metadata. +// The artists list may be empty for unusual metadata; degrade to the credit +// name with no artist MBID instead of panicking on recArtists[0]. +func (c *ListenBrainz) trackFromRecording(mbTrackID string, recording Metadata, singleArtist bool) *models.Track { + rec := recording.Recording + rel := recording.Release - titleArgs.FeatArtists = append(titleArgs.FeatArtists, artist.Name) - } - title = c.buildTrackTitle(titleArgs) - artist = mainArtist - } - } + title := rec.Name + artist := recording.Artist.Name + mainArtist := recording.Artist.Name - tracks = append(tracks, &models.Track{ - Album: recording.Release.Name, - Artist: artist, - AlbumArtist: rel.AlbumArtistName, - MainArtist: mainArtist, - CleanTitle: rec.Name, - Title: title, - Duration: rec.Length, - MusicBrainzTrackID: mbTrackID, - MusicBrainzAlbumID: rel.CaaReleaseMbid, - MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, - MusicBrainzArtistID: recArtists[0].MusicBrainzArtistID, - }) + recArtists := recording.Artist.Artists + + artistID := "" + if len(recArtists) > 0 { + artistID = recArtists[0].MusicBrainzArtistID } - return tracks, nil + if len(recArtists) > 1 { + mainArtist = recArtists[0].Name + if singleArtist { + var titleArgs TitleArgs + titleArgs.CleanTitle = rec.Name + for _, artist := range recArtists[1:] { + titleArgs.FeatArtists = append(titleArgs.FeatArtists, artist.Name) + } + title = c.buildTrackTitle(titleArgs) + artist = mainArtist + } + } + return &models.Track{ + Album: recording.Release.Name, + Artist: artist, + AlbumArtist: rel.AlbumArtistName, + MainArtist: mainArtist, + CleanTitle: rec.Name, + Title: title, + Duration: rec.Length, + MusicBrainzTrackID: mbTrackID, + MusicBrainzAlbumID: rel.CaaReleaseMbid, + MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, + MusicBrainzArtistID: artistID, + } } func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ([]*models.Track, error) { diff --git a/src/discovery/listenbrainz_empty_artist_test.go b/src/discovery/listenbrainz_empty_artist_test.go new file mode 100644 index 00000000..a4e1b5fc --- /dev/null +++ b/src/discovery/listenbrainz_empty_artist_test.go @@ -0,0 +1,44 @@ +package discovery + +import ( + "encoding/json" + "testing" +) + +// BUG-03: ListenBrainz metadata with an empty artists array panicked on +// recArtists[0], crashing the whole run. +func TestTrackFromRecording_EmptyArtistsDoesNotPanic(t *testing.T) { + var md Metadata + raw := `{"artist":{"name":"Solo Credit","artists":[]},"recording":{"name":"Song","length":123},"release":{"name":"Album"}}` + if err := json.Unmarshal([]byte(raw), &md); err != nil { + t.Fatal(err) + } + + c := &ListenBrainz{} + track := c.trackFromRecording("mbid-1", md, false) + + if track.MusicBrainzArtistID != "" { + t.Errorf("MusicBrainzArtistID = %q, want empty for missing artists", track.MusicBrainzArtistID) + } + if track.MainArtist != "Solo Credit" || track.Title != "Song" { + t.Errorf("track = %+v, want credit-name fallback and title preserved", track) + } +} + +func TestTrackFromRecording_WithArtistsKeepsArtistID(t *testing.T) { + var md Metadata + raw := `{"artist":{"name":"A feat. B","artists":[{"artist_mbid":"a1","name":"A"},{"artist_mbid":"b2","name":"B"}]},"recording":{"name":"Song","length":1},"release":{"name":"Album"}}` + if err := json.Unmarshal([]byte(raw), &md); err != nil { + t.Fatal(err) + } + + c := &ListenBrainz{} + track := c.trackFromRecording("mbid-2", md, false) + + if track.MusicBrainzArtistID != "a1" { + t.Errorf("MusicBrainzArtistID = %q, want a1", track.MusicBrainzArtistID) + } + if track.MainArtist != "A" { + t.Errorf("MainArtist = %q, want A", track.MainArtist) + } +} From 8c7fbf1ae71a6959db886ab48f9bc16efa905e78 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:41:06 -0700 Subject: [PATCH 28/57] Error clearly on an unknown DISCOVERY_SERVICE instead of panicking NewDiscoverer returned nil for any value other than listenbrainz and main dereferenced it, so one config typo crashed the app with no hint of what was typed wrong. Return an error naming the bad value and the supported values, and fail fast through main's existing error path. Adds tests for the typo case and the valid service. --- src/discovery/discoverer_test.go | 33 ++++++++++++++++++++++++++++++++ src/discovery/discovery.go | 7 ++++--- src/main/main.go | 8 ++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 src/discovery/discoverer_test.go diff --git a/src/discovery/discoverer_test.go b/src/discovery/discoverer_test.go new file mode 100644 index 00000000..04919eea --- /dev/null +++ b/src/discovery/discoverer_test.go @@ -0,0 +1,33 @@ +package discovery + +import ( + "strings" + "testing" + + cfg "explo/src/config" +) + +// BUG-04: a typo in DISCOVERY_SERVICE made NewDiscoverer return nil and +// the caller panicked dereferencing it, with no hint of the bad value. +func TestNewDiscoverer_UnknownServiceErrorsWithValue(t *testing.T) { + c, err := NewDiscoverer(cfg.DiscoveryConfig{Discovery: "listenbranz"}, nil) + if err == nil { + t.Fatal("NewDiscoverer with a typo = nil error, want a clear failure") + } + if c != nil { + t.Errorf("client = %v, want nil on error", c) + } + if !strings.Contains(err.Error(), "listenbranz") { + t.Errorf("error %q does not name the bad value", err) + } +} + +func TestNewDiscoverer_ListenBrainzOK(t *testing.T) { + c, err := NewDiscoverer(cfg.DiscoveryConfig{Discovery: "listenbrainz"}, nil) + if err != nil { + t.Fatalf("NewDiscoverer(listenbrainz): %v", err) + } + if c == nil || c.Discovery == nil { + t.Error("NewDiscoverer(listenbrainz) returned an incomplete client") + } +} diff --git a/src/discovery/discovery.go b/src/discovery/discovery.go index 1a1b2e99..ad8967ae 100644 --- a/src/discovery/discovery.go +++ b/src/discovery/discovery.go @@ -4,6 +4,7 @@ import ( cfg "explo/src/config" "explo/src/models" "explo/src/util" + "fmt" "log/slog" ) @@ -15,16 +16,16 @@ type Discovery interface { QueryTracks() ([]*models.Track, error) } -func NewDiscoverer(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *DiscoverClient { +func NewDiscoverer(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) (*DiscoverClient, error) { c := &DiscoverClient{cfg: &cfg} switch cfg.Discovery { case "listenbrainz": c.Discovery = NewListenBrainz(cfg, httpClient) default: - return nil + return nil, fmt.Errorf("unknown DISCOVERY_SERVICE %q: supported values are: listenbrainz", cfg.Discovery) } - return c + return c, nil } func (c *DiscoverClient) Discover() ([]*models.Track, error) { diff --git a/src/main/main.go b/src/main/main.go index 19f9823f..462045f2 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -194,8 +194,12 @@ func main() { } } } else { - disc := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) - tracks, err = disc.Discover() + disc, discErr := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) + if discErr != nil { + err = discErr + } else { + tracks, err = disc.Discover() + } } if err != nil { From 1d957aa445a1800d509c9419765007f585037c03 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:41:43 -0700 Subject: [PATCH 29/57] Return an error instead of panicking on an empty Plex library response AddLibrary indexed MediaContainer.Library[0] unchecked, so a Plex hiccup that answered library creation with an empty container crashed the run. Route the lookup through firstLibraryKey, which returns a descriptive error naming what to check. Adds unit tests for the empty and populated container cases. --- src/client/plex.go | 16 +++++++++++++++- src/client/plex_library_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/client/plex_library_test.go diff --git a/src/client/plex.go b/src/client/plex.go index 90515478..c77738fe 100644 --- a/src/client/plex.go +++ b/src/client/plex.go @@ -378,9 +378,23 @@ func (c *Plex) AddLibrary() error { if err = util.ParseResp(body, &libraries); err != nil { return err } - c.LibraryID = libraries.MediaContainer.Library[0].Key + key, err := firstLibraryKey(libraries) + if err != nil { + return err + } + c.LibraryID = key return nil } + +// firstLibraryKey returns the created library's key. Plex can answer a +// library creation with an empty container (a hiccup, a race with another +// client); indexing [0] without a check panicked the run. +func firstLibraryKey(libraries Libraries) (string, error) { + if len(libraries.MediaContainer.Library) == 0 { + return "", fmt.Errorf("plex returned no library after creating one; check the Plex server and the configured library name and download directory") + } + return libraries.MediaContainer.Library[0].Key, nil +} func (c *Plex) RefreshLibrary() error { if c.AdminClient != nil { return c.AdminClient.refreshLibraryRequest() diff --git a/src/client/plex_library_test.go b/src/client/plex_library_test.go new file mode 100644 index 00000000..d5d6d1de --- /dev/null +++ b/src/client/plex_library_test.go @@ -0,0 +1,32 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// BUG-05: Plex returning an empty MediaContainer after library creation +// panicked on Library[0]. +func TestFirstLibraryKey_EmptyContainerErrors(t *testing.T) { + var libs Libraries + if err := json.Unmarshal([]byte(`{"MediaContainer":{"size":0}}`), &libs); err != nil { + t.Fatal(err) + } + if _, err := firstLibraryKey(libs); err == nil { + t.Fatal("firstLibraryKey(empty) = nil error, want a descriptive failure instead of a panic") + } +} + +func TestFirstLibraryKey_ReturnsKey(t *testing.T) { + var libs Libraries + if err := json.Unmarshal([]byte(`{"MediaContainer":{"Directory":[{"title":"Music","key":"7"}]}}`), &libs); err != nil { + t.Fatal(err) + } + key, err := firstLibraryKey(libs) + if err != nil { + t.Fatalf("firstLibraryKey: %v", err) + } + if key != "7" { + t.Errorf("key = %q, want 7", key) + } +} From 8b4d1c35f1889dd70248afe127ad9f589cd895d5 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:42:06 -0700 Subject: [PATCH 30/57] Stop file moves from erasing the destination song moveFile truncated the destination with os.Create before copying: when source and destination were the same file the only copy was zeroed, and when a sibling name collided with a library file the existing song was silently overwritten while the track still reported present. Refuse the move when the destination already exists (keeping both files), and treat src == dst as already in place. Adds tests for same-file, existing-destination, and normal moves. --- src/downloader/downloader.go | 18 ++++++++ src/downloader/movefile_test.go | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/downloader/movefile_test.go diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index fad7fc67..5c65fd8d 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -385,6 +385,24 @@ func removeDirIfEmpty(dir string) error { } func moveFile(srcFile, dstFile string, keepPermissions bool) error { + // Never truncate a file that is already where it belongs, and never + // erase an existing destination: os.Create below would zero the + // source itself when src == dst, or silently overwrite a library + // song whose name collides with the incoming file. + srcAbs, srcErr := filepath.Abs(srcFile) + dstAbs, dstErr := filepath.Abs(dstFile) + if srcErr == nil && dstErr == nil && srcAbs == dstAbs { + return nil + } + if srcInfo, err := os.Stat(srcFile); err == nil { + if dstInfo, err := os.Stat(dstFile); err == nil { + if os.SameFile(srcInfo, dstInfo) { + return nil + } + return fmt.Errorf("destination already exists, refusing to overwrite: %s", dstFile) + } + } + in, err := os.Open(srcFile) if err != nil { return fmt.Errorf("couldn't open source file: %w", err) diff --git a/src/downloader/movefile_test.go b/src/downloader/movefile_test.go new file mode 100644 index 00000000..4baadb4b --- /dev/null +++ b/src/downloader/movefile_test.go @@ -0,0 +1,77 @@ +package downloader + +import ( + "os" + "path/filepath" + "testing" +) + +// BUG-07: moveFile truncated the destination with os.Create before +// copying, so src == dst zeroed the only copy and a name collision +// silently erased an existing library song. + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestMoveFile_SameSourceAndDestinationKeepsFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "song.flac") + writeTestFile(t, path, "audio") + + if err := moveFile(path, path, false); err != nil { + t.Fatalf("moveFile(same, same) = %v, want nil (already in place)", err) + } + if got := readTestFile(t, path); got != "audio" { + t.Errorf("file content = %q, want %q — the file was truncated", got, "audio") + } +} + +func TestMoveFile_ExistingDestinationIsNotErased(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "downloads", "song.flac") + dst := filepath.Join(dir, "library", "song.flac") + writeTestFile(t, src, "new download") + writeTestFile(t, dst, "existing library song") + + if err := moveFile(src, dst, false); err == nil { + t.Fatal("moveFile onto an existing destination = nil, want refusal") + } + if got := readTestFile(t, dst); got != "existing library song" { + t.Errorf("destination = %q, want the existing song untouched", got) + } + if got := readTestFile(t, src); got != "new download" { + t.Errorf("source = %q, want the download kept after refusal", got) + } +} + +func TestMoveFile_NormalMoveStillWorks(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "downloads", "song.flac") + dst := filepath.Join(dir, "library", "song.flac") + writeTestFile(t, src, "audio") + + if err := moveFile(src, dst, false); err != nil { + t.Fatalf("moveFile normal: %v", err) + } + if got := readTestFile(t, dst); got != "audio" { + t.Errorf("destination = %q, want audio", got) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Errorf("source still present after move: %v", err) + } +} From 95d84553e58afa74e15fa4e4754eb122dc83622c Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:43:19 -0700 Subject: [PATCH 31/57] Serialize settings saves and write .env atomically UpdateEnvKeys did an unsynchronized read-modify-write with a truncating os.WriteFile, so two saves in quick succession lost each other's changes and a crash mid-save corrupted the file. Hold a package mutex across the whole read-modify-write, write via temp file + rename (keeping 0600 permissions), and route the raw save/reset handlers through the same lock and atomic write. Adds a -race concurrency test asserting all parallel updates survive. --- .../backend/settings/env_save_race_test.go | 63 +++++++++++++++++++ src/web/backend/settings/handlers.go | 10 ++- src/web/backend/settings/settings.go | 32 +++++++++- 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 src/web/backend/settings/env_save_race_test.go diff --git a/src/web/backend/settings/env_save_race_test.go b/src/web/backend/settings/env_save_race_test.go new file mode 100644 index 00000000..44962915 --- /dev/null +++ b/src/web/backend/settings/env_save_race_test.go @@ -0,0 +1,63 @@ +package settings + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "explo/src/web/backend/app" +) + +// BUG-08: concurrent UpdateEnvKeys calls each read the same starting +// .env and the later write silently undid the earlier update; a crash +// mid-write also left a truncated file. +func TestUpdateEnvKeys_ConcurrentUpdatesAllSurvive(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("BASE=1\n"), 0600); err != nil { + t.Fatal(err) + } + s := &Settings{cfg: app.Config{WebEnvPath: path}} + + const writers = 12 + var wg sync.WaitGroup + for i := 0; i < writers; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := fmt.Sprintf("KEY_%d", n) + if err := s.UpdateEnvKeys(map[string]string{key: fmt.Sprint(n)}, nil); err != nil { + t.Errorf("UpdateEnvKeys(%s): %v", key, err) + } + }(i) + } + wg.Wait() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := s.ParseEnvText(string(data)) + if got["BASE"] != "1" { + t.Errorf("BASE = %q, want 1", got["BASE"]) + } + for i := 0; i < writers; i++ { + key := fmt.Sprintf("KEY_%d", i) + if got[key] != fmt.Sprint(i) { + t.Errorf("%s = %q, want %d — a concurrent update was lost", key, got[key], i) + } + } + // Atomic rename must not litter temp files. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != ".env" { + t.Errorf("dir entries = %v, want only .env", entries) + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0600 { + t.Errorf(".env mode = %v, err = %v; want 0600", info.Mode().Perm(), err) + } +} diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 4de1f412..3a7775bb 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -73,7 +73,10 @@ func (s *Settings) HandleSaveConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid .env content: "+err.Error(), http.StatusBadRequest) return } - if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil { + envFileMu.Lock() + err = writeEnvFileAtomic(s.cfg.WebEnvPath, data) + envFileMu.Unlock() + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -82,7 +85,10 @@ func (s *Settings) HandleSaveConfig(w http.ResponseWriter, r *http.Request) { // handleResetConfig resets all settings and restarts the container. func (s *Settings) HandleResetConfig(w http.ResponseWriter, r *http.Request) { - if err := os.WriteFile(s.cfg.WebEnvPath, web.SampleEnv, 0600); err != nil { + envFileMu.Lock() + err := writeEnvFileAtomic(s.cfg.WebEnvPath, web.SampleEnv) + envFileMu.Unlock() + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/src/web/backend/settings/settings.go b/src/web/backend/settings/settings.go index 4159d758..abf00e7e 100644 --- a/src/web/backend/settings/settings.go +++ b/src/web/backend/settings/settings.go @@ -3,7 +3,9 @@ package settings import ( "fmt" "os" + "path/filepath" "strings" + "sync" "explo/src/web/backend/app" ) @@ -68,9 +70,37 @@ func validateEnvText(text string) error { return nil } +// envFileMu serializes every read-modify-write of the .env file. Without +// it, two settings saves in quick succession each read the same starting +// content and the later write silently undid the earlier one's changes. +var envFileMu sync.Mutex + +// writeEnvFileAtomic writes via a temp file in the same directory plus +// rename, so a crash mid-save (or a concurrent reader) never sees a +// truncated .env. +func writeEnvFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".env-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + // updateEnvKeys reads the env file (falling back to fallback if missing), updates the // given key=value pairs in-place preserving comments, and writes the result back. func (s *Settings) UpdateEnvKeys(updates map[string]string, fallback []byte) error { + envFileMu.Lock() + defer envFileMu.Unlock() + path := s.cfg.WebEnvPath data, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -121,7 +151,7 @@ func (s *Settings) UpdateEnvKeys(updates map[string]string, fallback []byte) err prevBlank = blank } - return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0600) + return writeEnvFileAtomic(path, []byte(strings.Join(out, "\n")+"\n")) } // Check for special chars in env vars that might need quoting From 65d43d246aab9f32381ee7eebdc55fb29415b0f8 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:43:50 -0700 Subject: [PATCH 32/57] Mask API keys however they are spelled in the settings view SENSITIVE_KEY_RE only matched api_key with an underscore, so keys like ADMIN_SYSTEM_APIKEY were rendered in plain text on the raw config screen. Classify by meaning with separator-optional forms: api[-_]?key, access/auth/private key variants, token, secret, credential and password/passwd spellings all mask now, while ordinary settings stay visible. Verified with a Node probe over 15 sensitive and 5 plain key spellings (no JS test runner exists in the repo). --- src/web/frontend/src/lib/utils.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web/frontend/src/lib/utils.js b/src/web/frontend/src/lib/utils.js index 56bbd2f1..6d97d2c0 100644 --- a/src/web/frontend/src/lib/utils.js +++ b/src/web/frontend/src/lib/utils.js @@ -2,7 +2,11 @@ function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>') } -const SENSITIVE_KEY_RE = /password|token|api_key|secret|accesstoken/i +// Mask by meaning, not exact spelling: API keys arrive as API_KEY, +// APIKEY, ADMIN_API_KEY or X-API-KEY depending on the service, and the +// same goes for tokens and passwords. An unrecognized secret-looking key +// must be masked, never shown, so match the separator-optional forms. +const SENSITIVE_KEY_RE = /pass(word|wd)?|token|secret|credential|api[-_]?key|access[-_]?key|auth[-_]?key|private[-_]?key/i export function highlightEnv(text, masked = false) { return text.split('\n').map(line => { From 526fb4bdd3619d4c28380d21f289745559323d6e Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:44:12 -0700 Subject: [PATCH 33/57] Stop legacy CRON_SCHEDULE from disabling all scheduling The backwards-compat block ran crond -f in the foreground, which never returns: the per-playlist schedule loop, EXECUTE_ON_START and the final crond invocation were all unreachable, so anyone with the old CRON_SCHEDULE set got no scheduled runs at all. Register the legacy job only and let the normal startup path start the daemon, and skip CRON_SCHEDULE in the *_SCHEDULE loop so the legacy job is not registered twice. Verified with sh -n and a registration simulation. --- docker/start.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docker/start.sh b/docker/start.sh index 8bfca51a..0febf346 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -61,16 +61,23 @@ if [ -f "$_cfg" ]; then fi -# $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility +# $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility. +# Only register the job here: running `crond -f` in the foreground would block +# everything below (per-playlist schedules, EXECUTE_ON_START, the final crond), +# so legacy users silently lost all scheduling. if [ -n "$CRON_SCHEDULE" ]; then echo "$CRON_SCHEDULE cd /opt/explo && /explo-run.sh legacy $RUNNER ./explo --config \"$_cfg\" >> /proc/1/fd/1 2>&1" >> /etc/crontabs/root chmod 600 /etc/crontabs/root echo "[setup] Registered single CRON_SCHEDULE job: $CRON_SCHEDULE" - crond -f -l 2 fi # Loop over all *_SCHEDULE environment variables for var in $(env | grep "_SCHEDULE=" | cut -d= -f1); do + # CRON_SCHEDULE is registered by the legacy block above; registering it + # again here would run the same job twice. + if [ "$var" = "CRON_SCHEDULE" ]; then + continue + fi job="${var%_SCHEDULE}" # Job name (e.g WEEKLY_EXPLORATION) schedule="$(printenv "$var")" # Cron schedule flags_var="${job}_FLAGS" From 63d5cf3b55c3b8c7fdfdc99562269b829c692e0f Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:44:48 -0700 Subject: [PATCH 34/57] Keep draining run output past very long lines collectRunOutput used bufio.Scanner with the default 64KB line limit: one over-long line stopped draining, the child blocked on a full pipe, cmd.Wait never returned, and every later run was refused as already running. Replace the scanner with scanRunLines, which reads with ReadSlice, keeps draining no matter the line length, and truncates lines past 1MB for the log. Adds tests for long, huge, empty and partial lines. --- src/web/backend/run/events.go | 53 ++++++++++++++++++++++++++--- src/web/backend/run/events_test.go | 54 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/web/backend/run/events_test.go diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index 3f958bbd..d0acfd09 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -8,9 +8,55 @@ import ( "path/filepath" "bufio" "os/exec" + "strings" ) +// maxRunLineBytes caps how much of one output line is kept. Lines beyond +// it are drained and truncated rather than stopping the reader. +const maxRunLineBytes = 1 << 20 + +// scanRunLines emits each line from r until EOF. Unlike bufio.Scanner +// with its 64KB limit, a single very long line cannot stop draining: +// before this, an over-long line wedged the child on a full pipe, +// cmd.Wait never returned, and every later run was refused as already +// running. +func scanRunLines(r io.Reader, emit func(string)) error { + reader := bufio.NewReaderSize(r, 64*1024) + for { + var buf []byte + truncated := false + for { + frag, err := reader.ReadSlice('\n') + if len(buf)+len(frag) <= maxRunLineBytes { + buf = append(buf, frag...) + } else { + if len(buf) < maxRunLineBytes { + buf = append(buf, frag[:maxRunLineBytes-len(buf)]...) + } + truncated = true + } + if err == bufio.ErrBufferFull { + continue + } + if len(frag) > 0 || len(buf) > 0 { + line := strings.TrimRight(string(buf), "\r\n") + if truncated { + line += "...[truncated]" + } + emit(line) + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + break + } + } +} + func (mr *ManualRun) appendRunLog(line string) { event := runEvent{data: line} @@ -45,9 +91,7 @@ func (mr *ManualRun) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { }() } - scanner := bufio.NewScanner(pr) - for scanner.Scan() { - line := scanner.Text() + if err := scanRunLines(pr, func(line string) { // Echo to stdout so runs show up in docker logs. _, _ = fmt.Fprintln(os.Stdout, line) if lf != nil { @@ -56,8 +100,7 @@ func (mr *ManualRun) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { } } mr.appendRunLog(line) - } - if err := scanner.Err(); err != nil { + }); err != nil { mr.appendRunLog("failed to read run output: " + err.Error()) } diff --git a/src/web/backend/run/events_test.go b/src/web/backend/run/events_test.go new file mode 100644 index 00000000..0d2e8514 --- /dev/null +++ b/src/web/backend/run/events_test.go @@ -0,0 +1,54 @@ +package run + +import ( + "strings" + "testing" +) + +// BUG-19: bufio.Scanner's 64KB line limit stopped draining run output, +// so one long line blocked the child on a full pipe, cmd.Wait never +// returned, and every later run was refused as already running. +func collectLines(t *testing.T, input string) []string { + t.Helper() + var lines []string + if err := scanRunLines(strings.NewReader(input), func(line string) { + lines = append(lines, line) + }); err != nil { + t.Fatalf("scanRunLines: %v", err) + } + return lines +} + +func TestScanRunLines_LongLineDoesNotStopDraining(t *testing.T) { + long := strings.Repeat("x", 200*1024) // 3x the old 64KB scanner limit + lines := collectLines(t, "first\n"+long+"\nafter\n") + + if len(lines) != 3 { + t.Fatalf("got %d lines, want 3 — draining stopped at the long line", len(lines)) + } + if lines[0] != "first" || lines[2] != "after" { + t.Errorf("lines around the long one = %q, %q", lines[0], lines[2]) + } + if lines[1] != long { + t.Errorf("long line kept %d bytes, want it intact under the cap", len(lines[1])) + } +} + +func TestScanRunLines_HugeLineIsTruncatedButDrainingContinues(t *testing.T) { + huge := strings.Repeat("y", maxRunLineBytes+5000) + lines := collectLines(t, huge+"\nnext") + + if len(lines) != 2 || lines[1] != "next" { + t.Fatalf("lines = %d entries, want the truncated line plus 'next'", len(lines)) + } + if !strings.HasSuffix(lines[0], "...[truncated]") { + t.Error("huge line was not marked truncated") + } +} + +func TestScanRunLines_PreservesEmptyLinesAndPartialTail(t *testing.T) { + lines := collectLines(t, "a\n\nb") + if len(lines) != 3 || lines[0] != "a" || lines[1] != "" || lines[2] != "b" { + t.Errorf("lines = %q, want [a '' b]", lines) + } +} From 8ac653e3bcd4c2b3374d7d49808daed151e07b89 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:46:28 -0700 Subject: [PATCH 35/57] Bound remote fetches with timeouts and size caps DownloadFile, DownloadCover, lbGet, the Cover Art Archive fetches and the Apple Music page scrape used http.Get/DefaultClient (no timeout at all) and read responses with unbounded io.ReadAll, so one stalled or oversized response could hang or OOM the app. The shared MakeRequest reader was likewise unbounded. Use a 60s-timeout client for file/cover downloads and a 30s client for playlist fetches, and read bodies through size-capped helpers (16MB covers/pages, 32MB API responses, 64MB downloads) that error instead of truncating. Adds readLimited unit tests. --- src/util/http.go | 36 +++++++++++++++++++++---- src/util/http_limit_test.go | 31 +++++++++++++++++++++ src/web/backend/playlist/apple_music.go | 5 ++-- src/web/backend/playlist/playlist.go | 31 ++++++++++++++++----- 4 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/util/http_limit_test.go diff --git a/src/util/http.go b/src/util/http.go index 588e3b04..21093746 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -15,6 +15,32 @@ import ( "explo/src/logging" ) +// Bounds for ad-hoc remote fetches: a stalled server must not hang the +// app forever and an oversized response must not be read into memory +// without limit (BUG-20). +const ( + maxAPIResponseBytes = 32 << 20 + maxDownloadBytes = 64 << 20 + maxCoverBytes = 16 << 20 +) + +// remoteHTTPClient replaces http.Get/DefaultClient, which have no +// timeout at all, for file and cover downloads. +var remoteHTTPClient = &http.Client{Timeout: 60 * time.Second} + +// readLimited reads at most max bytes; one extra byte is read so an +// over-limit response is an error, not a silent truncation. +func readLimited(r io.Reader, max int64) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("response exceeds %d bytes", max) + } + return data, nil +} + type HttpClientConfig struct { Timeout int } @@ -57,7 +83,7 @@ func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers } }() - body, err := io.ReadAll(resp.Body) + body, err := readLimited(resp.Body, maxAPIResponseBytes) if err != nil { return nil, fmt.Errorf("failed to read response body: %s", err.Error()) } @@ -91,7 +117,7 @@ func DownloadFile(url, destPath string) (string, error) { if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return "", fmt.Errorf("mkdir: %w", err) } - resp, err := http.Get(url) //nolint:noctx + resp, err := remoteHTTPClient.Get(url) if err != nil { return "", fmt.Errorf("get: %w", err) } @@ -103,7 +129,7 @@ func DownloadFile(url, destPath string) (string, error) { if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("status %d from %s", resp.StatusCode, url) } - data, err := io.ReadAll(resp.Body) + data, err := readLimited(resp.Body, maxDownloadBytes) if err != nil { return "", fmt.Errorf("read body: %w", err) } @@ -134,7 +160,7 @@ func DownloadCover(url, coversDir string) (string, string) { } destPath := filepath.Join(coversDir, id+".jpg") if _, err := os.Stat(destPath); os.IsNotExist(err) { - resp, err := http.Get(url) //nolint:noctx + resp, err := remoteHTTPClient.Get(url) if err == nil { func() { defer func() { @@ -143,7 +169,7 @@ func DownloadCover(url, coversDir string) (string, string) { } }() if resp.StatusCode == http.StatusOK { - if data, err := io.ReadAll(resp.Body); err == nil { + if data, err := readLimited(resp.Body, maxCoverBytes); err == nil { if err := os.WriteFile(destPath, data, 0644); err != nil { slog.Error("failed writing cover", "path", destPath, "err", err.Error()) } diff --git a/src/util/http_limit_test.go b/src/util/http_limit_test.go new file mode 100644 index 00000000..6f879d73 --- /dev/null +++ b/src/util/http_limit_test.go @@ -0,0 +1,31 @@ +package util + +import ( + "strings" + "testing" +) + +// BUG-20: remote responses were read with unbounded io.ReadAll on +// clients with no timeout, so one stalled or huge response could hang +// or OOM the app. +func TestReadLimited(t *testing.T) { + data, err := readLimited(strings.NewReader("hello"), 10) + if err != nil || string(data) != "hello" { + t.Errorf("readLimited small = %q, %v; want hello, nil", data, err) + } + + if _, err := readLimited(strings.NewReader(strings.Repeat("x", 100)), 10); err == nil { + t.Error("readLimited over-limit = nil error, want rejection instead of truncation") + } + + data, err = readLimited(strings.NewReader(strings.Repeat("x", 10)), 10) + if err != nil || len(data) != 10 { + t.Errorf("readLimited at-limit = %d bytes, %v; want 10, nil", len(data), err) + } +} + +func TestRemoteHTTPClientHasTimeout(t *testing.T) { + if remoteHTTPClient.Timeout <= 0 { + t.Error("remoteHTTPClient has no timeout; a stalled server would hang forever") + } +} diff --git a/src/web/backend/playlist/apple_music.go b/src/web/backend/playlist/apple_music.go index e928418f..e84c6488 100644 --- a/src/web/backend/playlist/apple_music.go +++ b/src/web/backend/playlist/apple_music.go @@ -3,7 +3,6 @@ package playlist import ( "encoding/json" "fmt" - "io" "log/slog" "net/http" "strings" @@ -64,7 +63,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, e req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") - resp, err := http.DefaultClient.Do(req) + resp, err := remoteFetchClient.Do(req) if err != nil { return "", "", nil, fmt.Errorf("apple music: fetch failed: %w", err) } @@ -77,7 +76,7 @@ func fetchAppleMusicPlaylist(pageURL string) (string, string, []PlaylistTrack, e return "", "", nil, fmt.Errorf("apple music: page returned %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) + body, err := readFetchBody(resp.Body) if err != nil { return "", "", nil, fmt.Errorf("apple music: failed to read body: %w", err) } diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index d37b285a..ad73a13a 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -19,10 +19,29 @@ import ( "os" "path/filepath" "strings" + "time" ) const lbAPIBase = "https://api.listenbrainz.org/1" +// remoteFetchClient bounds playlist/cover fetches: http.Get and +// DefaultClient have no timeout, and their responses were read into +// memory without a cap (BUG-20). +var remoteFetchClient = &http.Client{Timeout: 30 * time.Second} + +const maxFetchBytes = 16 << 20 + +func readFetchBody(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxFetchBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maxFetchBytes { + return nil, fmt.Errorf("response exceeds %d bytes", maxFetchBytes) + } + return data, nil +} + // PlaylistTrack represents a single track fetched from any playlist source. // Replaces the raw [][4]string{title, artist, album, coverURL} pattern // with named fields and adds MainArtist for accurate matching in music servers. @@ -150,7 +169,7 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added } func lbGet(url string) ([]byte, error) { - resp, err := http.Get(url) //nolint:noctx + resp, err := remoteFetchClient.Get(url) if err != nil { return nil, err } @@ -163,7 +182,7 @@ func lbGet(url string) ([]byte, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("LB returned %d", resp.StatusCode) } - return io.ReadAll(resp.Body) + return readFetchBody(resp.Body) } type cachedPrefetchTrack struct { @@ -252,14 +271,14 @@ func randomLocalCoverHiRes(coversDir string) string { return "/api/covers/" + bgFile } // Download hi-res from Cover Art Archive using the release MBID - resp, err := http.Get("https://coverartarchive.org/release/" + mbid + "/front-1200") //nolint:noctx + resp, err := remoteFetchClient.Get("https://coverartarchive.org/release/" + mbid + "/front-1200") if err != nil || resp.StatusCode != http.StatusOK { if resp != nil { resp.Body.Close() // nolint:errcheck } continue } - data, err := io.ReadAll(resp.Body) + data, err := readFetchBody(resp.Body) resp.Body.Close() // nolint:errcheck if err != nil { continue @@ -296,14 +315,14 @@ func fetchSitewideCovers(coversDir string) string { } url := "https://coverartarchive.org/release/" + rel.ReleaseMbid + "/front-1200" - dlResp, err := http.Get(url) //nolint:noctx + dlResp, err := remoteFetchClient.Get(url) if err != nil || dlResp.StatusCode != http.StatusOK { if dlResp != nil { dlResp.Body.Close() // nolint:errcheck } continue } - data, err := io.ReadAll(dlResp.Body) + data, err := readFetchBody(dlResp.Body) dlResp.Body.Close() //nolint:errcheck if err != nil { continue From 6f831aac6a7adb1226432e5d5c4e88fd4b77a41c Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:46:59 -0700 Subject: [PATCH 36/57] Close the run log instead of the pipe on rejected runs The already-running branch of startRun closed pw a second time instead of the log file opened moments earlier, leaking one file descriptor per rejected attempt. Close lf there, as the start-failure branch already does. Adds an FD-count regression test over repeated rejected runs. --- src/web/backend/run/manual_run.go | 4 +-- src/web/backend/run/manual_run_test.go | 41 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go index 21a5e941..87ba0981 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -93,8 +93,8 @@ func (mr *ManualRun) startRun(args []string) error { slog.Warn("failed to close file writer", "err", err.Error()) } if lf != nil { - if err := pw.Close(); err != nil { - slog.Warn("failed to close file writer", "err", err.Error()) + if err := lf.Close(); err != nil { + slog.Warn("failed to close run log", "err", err.Error()) } } return errRunAlreadyStarted diff --git a/src/web/backend/run/manual_run_test.go b/src/web/backend/run/manual_run_test.go index 9c24b747..e0de5679 100644 --- a/src/web/backend/run/manual_run_test.go +++ b/src/web/backend/run/manual_run_test.go @@ -2,6 +2,7 @@ package run import ( "context" + "os" "testing" "time" @@ -91,6 +92,46 @@ func TestStartRun_SecondRunIsAcceptedAfterATimeout(t *testing.T) { } } +// BUG-21: the already-running branch closed pw a second time instead of +// the log file, leaking one descriptor per rejected attempt. +func TestStartRun_RejectedRunsDoNotLeakFileHandles(t *testing.T) { + mr := NewManualRun(app.Config{ + ExploPath: "/bin/sleep", + RunTimeout: 5 * time.Second, + WebDataDir: t.TempDir(), + }) + + if err := mr.startRun([]string{"30"}); err != nil { + t.Fatalf("startRun() = %v, want nil", err) + } + t.Cleanup(func() { + mr.state.mu.Lock() + cancel := mr.state.cancel + mr.state.mu.Unlock() + if cancel != nil { + cancel() + } + }) + + countFDs := func() int { + entries, err := os.ReadDir("/dev/fd") + if err != nil { + t.Skipf("/dev/fd unavailable: %v", err) + } + return len(entries) + } + + before := countFDs() + for i := 0; i < 5; i++ { + if err := mr.startRun([]string{"30"}); err != errRunAlreadyStarted { + t.Fatalf("rejected startRun() = %v, want errRunAlreadyStarted", err) + } + } + if after := countFDs(); after > before { + t.Errorf("open FDs grew %d -> %d over 5 rejected runs; each rejection leaks a handle", before, after) + } +} + // While a run really is in flight, a second must still be refused. func TestStartRun_RefusesAConcurrentRun(t *testing.T) { mr := NewManualRun(app.Config{ From 128788765ba89ef9e498f6a3551b8d1c04230c8e Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:47:27 -0700 Subject: [PATCH 37/57] Only mark a track present after its move succeeds MonitorDownloads set Present, counted the success and ran Cleanup before MoveDownload; a failed move still left the track in the playlist even though the song never landed in the library. Run the move first: on failure, skip the track (keeping it out of the playlist) and clean up; only mark Present and count after the move succeeds. Adds stub-based tests for failed and successful moves. --- src/downloader/monitor.go | 31 ++++++---- src/downloader/monitor_present_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 src/downloader/monitor_present_test.go diff --git a/src/downloader/monitor.go b/src/downloader/monitor.go index 3e258b98..1e19b292 100644 --- a/src/downloader/monitor.go +++ b/src/downloader/monitor.go @@ -138,27 +138,34 @@ func (c *DownloadClient) MonitorDownloads(tracks []*models.Track, m Monitor) err monitoredTime := currentTime.Sub(tracker.StartedAt) expired := monCfg.MaxDuration > 0 && monitoredTime > monCfg.MaxDuration - if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { + if (fileStatus.BytesRemaining == 0 && fileStatus.BytesTransferred != 0) || fileStatus.PercentComplete == 100 || strings.Contains(fileStatus.State, "Succeeded") { track.File = fileStatus.Filename - track.Present = true slog.Info("[monitor] file downloaded successfully", "service", monCfg.Service, "file", track.File) var filePath string track.File, filePath = parsePath(track.File) if monCfg.MigrateDownload { destDir, err := m.MoveDownload(monCfg.FromDir, monCfg.ToDir, filePath, track) if err != nil { - slog.Error("error while moving file", "err", err) - } else { - slog.Info("track moved successfully", "service", monCfg.Service) - c.trackAlbum(pendingAlbums, key, &pendingAlbum{ - track: track, - trackDir: filepath.Join(monCfg.FromDir, filePath), - destDir: destDir, - startedAt: currentTime, - lastChange: currentTime, - }, monCfg) + // The song never landed in the library, so it must + // not be marked Present or counted: before this, + // the playlist kept a track that never arrived. + slog.Error("error while moving file, skipping track", "err", err) + tracker.Skipped = true + if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { + slog.Debug("cleanup failed", logging.RuntimeAttr(err.Error())) + } + continue } + slog.Info("track moved successfully", "service", monCfg.Service) + c.trackAlbum(pendingAlbums, key, &pendingAlbum{ + track: track, + trackDir: filepath.Join(monCfg.FromDir, filePath), + destDir: destDir, + startedAt: currentTime, + lastChange: currentTime, + }, monCfg) } + track.Present = true delete(progressMap, key) successDownloads += 1 if err = m.Cleanup(*track, fileStatus.QueueID); err != nil { diff --git a/src/downloader/monitor_present_test.go b/src/downloader/monitor_present_test.go new file mode 100644 index 00000000..301bfe5b --- /dev/null +++ b/src/downloader/monitor_present_test.go @@ -0,0 +1,84 @@ +package downloader + +import ( + "fmt" + "testing" + "time" + + cfg "explo/src/config" + "explo/src/models" +) + +// moveStub completes every download immediately; its MoveDownload can be +// made to fail. +type moveStub struct { + conf MonitorConfig + moveErr error +} + +func (s *moveStub) GetConf() (MonitorConfig, error) { return s.conf, nil } +func (s *moveStub) Cleanup(models.Track, string) error { return nil } +func (s *moveStub) MoveDownload(string, string, string, *models.Track) (string, error) { + if s.moveErr != nil { + return "", s.moveErr + } + return "/library/album", nil +} + +func (s *moveStub) GetDownloadStatus(tracks []*models.Track) (map[string]FileStatus, error) { + statuses := make(map[string]FileStatus, len(tracks)) + for _, track := range tracks { + statuses[track.ID] = FileStatus{ID: "1", Filename: track.File, State: "Completed, Succeeded", PercentComplete: 100, QueueID: "q1"} + } + return statuses, nil +} + +func runMoveMonitor(t *testing.T, stub *moveStub, track *models.Track) { + t.Helper() + client := &DownloadClient{Cfg: &cfg.DownloadConfig{}} + done := make(chan error, 1) + go func() { done <- client.MonitorDownloads([]*models.Track{track}, stub) }() + select { + case err := <-done: + if err != nil { + t.Fatalf("MonitorDownloads() = %v, want nil", err) + } + case <-time.After(5 * time.Second): + t.Fatal("MonitorDownloads never returned") + } +} + +func migrateCfg() MonitorConfig { + return MonitorConfig{ + CheckInterval: time.Millisecond, + StallDuration: time.Hour, + MaxDuration: 24 * time.Hour, + MigrateDownload: true, + Service: "lidarr", + } +} + +// BUG-11: the track was marked Present and counted before MoveDownload +// ran, so a failed move still put a song that never arrived into the +// playlist. +func TestMonitorDownloads_FailedMoveDoesNotMarkPresent(t *testing.T) { + stub := &moveStub{conf: migrateCfg(), moveErr: fmt.Errorf("disk full")} + track := &models.Track{ID: "track-1", File: "song.flac"} + + runMoveMonitor(t, stub, track) + + if track.Present { + t.Error("track marked Present even though the move failed; it would enter the playlist without existing") + } +} + +func TestMonitorDownloads_SuccessfulMoveMarksPresent(t *testing.T) { + stub := &moveStub{conf: migrateCfg()} + track := &models.Track{ID: "track-1", File: "song.flac"} + + runMoveMonitor(t, stub, track) + + if !track.Present { + t.Error("track not Present after a successful move") + } +} From a19fa893a43f038a1b26d26d114bbcf8379399fc Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:47:50 -0700 Subject: [PATCH 38/57] Skip unmatched tracks when creating Subsonic playlists CreatePlaylist sent every track's ID as songId, including tracks never matched in the server (Present=false with a discovery MBID or an empty ID), so one missing song could make the server reject the whole playlist. Filter through subsonicSongIDs, which only keeps present tracks with a server ID, mirroring the other clients. Adds unit tests. --- src/client/subsonic.go | 19 ++++++++++++++-- src/client/subsonic_playlist_test.go | 33 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/client/subsonic_playlist_test.go diff --git a/src/client/subsonic.go b/src/client/subsonic.go index 167674aa..310a9bd4 100644 --- a/src/client/subsonic.go +++ b/src/client/subsonic.go @@ -234,10 +234,25 @@ func (c *Subsonic) CheckRefreshState() bool { } } +// subsonicSongIDs returns the server song IDs to put in a playlist. +// Tracks that were never matched keep their discovery ID (or none) and +// Present=false; sending those as songId made the server reject the +// whole playlist creation over one missing song (BUG-12). +func subsonicSongIDs(tracks []*models.Track) []string { + ids := make([]string, 0, len(tracks)) + for _, track := range tracks { + if !track.Present || track.ID == "" { + continue + } + ids = append(ids, track.ID) + } + return ids +} + func (c *Subsonic) CreatePlaylist(tracks []*models.Track) error { var trackIDs strings.Builder - for _, track := range tracks { // build songID parameters - fmt.Fprintf(&trackIDs, "&songId=%s", track.ID) + for _, id := range subsonicSongIDs(tracks) { // build songID parameters + fmt.Fprintf(&trackIDs, "&songId=%s", id) } reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", url.QueryEscape(c.Cfg.PlaylistName), trackIDs.String()) diff --git a/src/client/subsonic_playlist_test.go b/src/client/subsonic_playlist_test.go new file mode 100644 index 00000000..3a8dccbb --- /dev/null +++ b/src/client/subsonic_playlist_test.go @@ -0,0 +1,33 @@ +package client + +import ( + "reflect" + "testing" + + "explo/src/models" +) + +// BUG-12: CreatePlaylist sent every track's ID as songId, including +// tracks never matched in Subsonic (Present=false, discovery ID or +// empty), and the server rejected the whole playlist over one song. +func TestSubsonicSongIDs_SkipsUnmatchedTracks(t *testing.T) { + tracks := []*models.Track{ + {ID: "song-1", Present: true}, + {ID: "mbid-not-a-song-id", Present: false}, + {ID: "", Present: true}, + {ID: "song-2", Present: true}, + } + + got := subsonicSongIDs(tracks) + want := []string{"song-1", "song-2"} + if !reflect.DeepEqual(got, want) { + t.Errorf("subsonicSongIDs = %v, want %v", got, want) + } +} + +func TestSubsonicSongIDs_NoneMatched(t *testing.T) { + tracks := []*models.Track{{ID: "x"}, {ID: ""}} + if got := subsonicSongIDs(tracks); len(got) != 0 { + t.Errorf("subsonicSongIDs = %v, want none", got) + } +} From 39bff7b2f9454d75dce81a8b01d7af66e4d185e0 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:48:36 -0700 Subject: [PATCH 39/57] Resolve the real Jellyfin user instead of sending the API key Without SYSTEM_USERNAME, CreatePlaylist put the API key into the UserId field, which Jellyfin rejected, so playlist creation always failed. Always resolve through the /Users list: the configured name when set, the server's only user as a fallback, and a clear error telling the user to set SYSTEM_USERNAME when there is no safe guess. Adds table tests for all four cases. --- src/client/jellyfin.go | 43 +++++++++++++++++++++----------- src/client/jellyfin_user_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 src/client/jellyfin_user_test.go diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index bf01402b..f79d6748 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -225,19 +225,18 @@ func (c *Jellyfin) CreatePlaylist(tracks []*models.Track) error { if err != nil { return fmt.Errorf("failed to marshal track IDs: %s", err.Error()) } - var userID string isPublic := c.Cfg.PublicPlaylist - - if c.Cfg.Creds.User != "" { - userID, err = c.ResolveUserID() - if err != nil { - return err - } - } else { - userID = c.resolveAPIKey() + if c.Cfg.Creds.User == "" { isPublic = true } + // The API key is not a user ID: sending it as UserId made playlist + // creation fail whenever SYSTEM_USERNAME was unset (BUG-13). + userID, err := c.ResolveUserID() + if err != nil { + return err + } + queryParams := "/Playlists" payload := fmt.Appendf(nil, ` @@ -331,13 +330,29 @@ func (c *Jellyfin) ResolveUserID() (string, error) { if err = util.ParseResp(body, &users); err != nil { return "", err } - for _, user := range users { - if strings.EqualFold(user.Name, c.Cfg.Creds.User) { - return user.ID, nil + return pickUserID(users, c.Cfg.Creds.User) +} + +// pickUserID selects the playlist owner. With no configured username it +// falls back to the server's only user; with several users and no name +// configured there is no safe guess, so it fails with guidance instead +// of sending the API key as a user ID. +func pickUserID(users []JFUser, username string) (string, error) { + if username != "" { + for _, user := range users { + if strings.EqualFold(user.Name, username) { + return user.ID, nil + } } + return "", fmt.Errorf("failed to find Jellyfin user %q", username) } - - return "", fmt.Errorf("failed to find Jellyfin user %q", c.Cfg.Creds.User) + if len(users) == 1 { + return users[0].ID, nil + } + if len(users) == 0 { + return "", fmt.Errorf("jellyfin returned no users; set SYSTEM_USERNAME to the playlist owner's username") + } + return "", fmt.Errorf("jellyfin has %d users and SYSTEM_USERNAME is not set; set SYSTEM_USERNAME to the playlist owner's username", len(users)) } // Check which API Key variable is used diff --git a/src/client/jellyfin_user_test.go b/src/client/jellyfin_user_test.go new file mode 100644 index 00000000..1d79cf1e --- /dev/null +++ b/src/client/jellyfin_user_test.go @@ -0,0 +1,41 @@ +package client + +import ( + "strings" + "testing" +) + +// BUG-13: with SYSTEM_USERNAME unset, CreatePlaylist sent the API key +// as the Jellyfin UserId and creation always failed. +func TestPickUserID(t *testing.T) { + users := []JFUser{{ID: "1", Name: "Alice"}, {ID: "2", Name: "Bob"}} + + tests := []struct { + name string + users []JFUser + username string + wantID string + wantErr string + }{ + {"named user, case-insensitive", users, "alice", "1", ""}, + {"named user missing", users, "Carol", "", "Carol"}, + {"no username, single user", users[:1], "", "1", ""}, + {"no username, many users", users, "", "", "SYSTEM_USERNAME"}, + {"no username, no users", nil, "", "", "SYSTEM_USERNAME"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := pickUserID(tt.users, tt.username) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("pickUserID = %q, %v; want an error mentioning %q", id, err, tt.wantErr) + } + return + } + if err != nil || id != tt.wantID { + t.Errorf("pickUserID = %q, %v; want %q, nil", id, err, tt.wantID) + } + }) + } +} From 020c66d6cf948da2a80dbdfbcf502c893fb6fe4a Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:49:13 -0700 Subject: [PATCH 40/57] Actually delete Emby playlists instead of silently keeping them Emby.DeletePlaylist was a commented-out no-op returning nil, so the replace-playlist flow (delete then create) believed the old playlist was gone and piled up duplicates on every re-run. Implement DELETE /emby/Items/{id} with the same empty-ID guard as the Jellyfin sibling, so failures surface as errors instead of phantom success. Adds delete tests matching the existing client pattern (httptest-based; they run on CI since this sandbox blocks localhost binds). --- src/client/delete_playlist_test.go | 30 ++++++++++++++++++++++++++++++ src/client/emby.go | 20 +++++++++++++++----- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/client/delete_playlist_test.go b/src/client/delete_playlist_test.go index 21cc6fef..4cac74f6 100644 --- a/src/client/delete_playlist_test.go +++ b/src/client/delete_playlist_test.go @@ -99,6 +99,36 @@ func TestJellyfinDeletePlaylist_DeletesTheFoundPlaylist(t *testing.T) { } } +// Emby used to delete nothing and report success, so "replace playlist" +// left the old playlist in place and created a duplicate. +func TestEmbyDeletePlaylist_DeletesTheFoundPlaylist(t *testing.T) { + var paths []string + server := recordingServer(t, &paths) + + emby := NewEmby(testConfig(server.URL, "abc"), util.NewHttp(util.HttpClientConfig{Timeout: 5})) + + if err := emby.DeletePlaylist(); err != nil { + t.Fatalf("DeletePlaylist() = %v, want nil", err) + } + if len(paths) != 1 || paths[0] != "/emby/Items/abc" { + t.Errorf("requested %v, want a single DELETE of /emby/Items/abc", paths) + } +} + +func TestEmbyDeletePlaylist_SendsNothingWithoutAnID(t *testing.T) { + var paths []string + server := recordingServer(t, &paths) + + emby := NewEmby(testConfig(server.URL, ""), util.NewHttp(util.HttpClientConfig{Timeout: 5})) + + if err := emby.DeletePlaylist(); err != nil { + t.Errorf("DeletePlaylist() = %v, want nil when there is no playlist", err) + } + if len(paths) != 0 { + t.Errorf("requested %v, want no request at all", paths) + } +} + // Subsonic would send deletePlaylist with an empty id. func TestSubsonicDeletePlaylist_SendsNothingWithoutAnID(t *testing.T) { var paths []string diff --git a/src/client/emby.go b/src/client/emby.go index e5267ada..8e4b70c3 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -238,12 +238,22 @@ func (c *Emby) UpdatePlaylist() error { return nil } -func (c *Emby) DeletePlaylist() error { // Doesn't currently work due to a bug in Emby - /* reqParam := fmt.Sprintf("/emby/Items/Delete?Ids=%s", c.Cfg.PlaylistID) +func (c *Emby) DeletePlaylist() error { + // Without an ID this would address the collection rather than one + // playlist (see the Jellyfin guard). + if c.Cfg.PlaylistID == "" { + slog.Debug("no emby playlist to delete", "playlist", c.Cfg.PlaylistName) + return nil + } - if _, err := util.MakeRequest("POST", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers); err != nil { - return err - } */ + // This used to be a no-op returning nil, so callers that + // delete-then-create for "replace playlist" believed the old playlist + // was gone and silently piled up duplicates. + reqParam := fmt.Sprintf("/emby/Items/%s", c.Cfg.PlaylistID) + + if _, err := c.HttpClient.MakeRequest("DELETE", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers); err != nil { + return fmt.Errorf("deleteEmbyPlaylist(): %s", err.Error()) + } return nil } From c27927f0590df9f86b7e5cab0df343e9bb511389 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:49:59 -0700 Subject: [PATCH 41/57] Marshal Jellyfin/Emby playlist bodies instead of fmt-printing them Name, description and IDs were interpolated unescaped into hand-built JSON, so a quote, backslash or newline in a playlist name produced invalid JSON and creation/update failed with a server 400. Build the Jellyfin create/update and Emby update bodies with json.Marshal (keeping the required empty arrays/objects, not null), and add a round-trip test with a hostile name and description. --- src/client/emby.go | 24 ++++++++---- src/client/jellyfin.go | 52 ++++++++++++++++--------- src/client/playlist_body_test.go | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 src/client/playlist_body_test.go diff --git a/src/client/emby.go b/src/client/emby.go index e5267ada..3b62099d 100644 --- a/src/client/emby.go +++ b/src/client/emby.go @@ -2,6 +2,7 @@ package client import ( "bytes" + "encoding/json" "fmt" "log/slog" "strings" @@ -220,17 +221,26 @@ func (c *Emby) CreatePlaylist(tracks []*models.Track) error { return nil } +// Same class as the Jellyfin bodies: marshal so quotes, backslashes and +// newlines in playlist names cannot break the JSON (BUG-15). +func embyUpdatePlaylistBody(id, name, overview string) ([]byte, error) { + return json.Marshal(struct { + ID string `json:"Id"` + Name string `json:"Name"` + Overview string `json:"Overview"` + ProviderIDs map[string]string `json:"ProviderIds"` + }{id, name, overview, map[string]string{}}) +} + func (c *Emby) UpdatePlaylist() error { time.Sleep(5 * time.Second) // small buffer between playlist creation and updating, Emby doesn't update playlist otherwise reqParam := fmt.Sprintf("/emby/Items/%s", c.Cfg.PlaylistID) - payload := fmt.Appendf(nil, ` - { - "Id": "%s", - "Name": "%s", - "Overview": "%s", - "ProviderIds": {} - }`, c.Cfg.PlaylistID, c.Cfg.PlaylistName, c.Cfg.PlaylistDescr) // the additional field has to be added, otherwise Emby returns code 500 + // the additional field has to be added, otherwise Emby returns code 500 + payload, err := embyUpdatePlaylistBody(c.Cfg.PlaylistID, c.Cfg.PlaylistName, c.Cfg.PlaylistDescr) + if err != nil { + return fmt.Errorf("failed to marshal playlist body: %s", err.Error()) + } if _, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParam, bytes.NewBuffer(payload), c.Cfg.Creds.Headers); err != nil { return err diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go index bf01402b..04efd4bc 100644 --- a/src/client/jellyfin.go +++ b/src/client/jellyfin.go @@ -240,14 +240,10 @@ func (c *Jellyfin) CreatePlaylist(tracks []*models.Track) error { queryParams := "/Playlists" - payload := fmt.Appendf(nil, ` - { - "Name": "%s", - "Ids": %s, - "MediaType": "Audio", - "UserId": "%s", - "IsPublic": %t - }`, c.Cfg.PlaylistName, songs, userID, isPublic) + payload, err := jfCreatePlaylistBody(c.Cfg.PlaylistName, songs, userID, isPublic) + if err != nil { + return fmt.Errorf("failed to marshal playlist body: %s", err.Error()) + } body, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+queryParams, bytes.NewReader(payload), c.Cfg.Creds.Headers) if err != nil { @@ -261,22 +257,42 @@ func (c *Jellyfin) CreatePlaylist(tracks []*models.Track) error { return nil } +// Playlist names and descriptions are user input: quoting a quote, +// backslash or newline into fmt-built JSON produced invalid bodies and +// the server answered 400 (BUG-15). Marshal instead. +func jfCreatePlaylistBody(name string, songs json.RawMessage, userID string, isPublic bool) ([]byte, error) { + return json.Marshal(struct { + Name string `json:"Name"` + IDs json.RawMessage `json:"Ids"` + MediaType string `json:"MediaType"` + UserID string `json:"UserId"` + IsPublic bool `json:"IsPublic"` + }{name, songs, "Audio", userID, isPublic}) +} + +func jfUpdatePlaylistBody(id, name, overview string, isPublic bool) ([]byte, error) { + return json.Marshal(struct { + ID string `json:"Id"` + Name string `json:"Name"` + Overview string `json:"Overview"` + IsPublic bool `json:"IsPublic"` + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + ProviderIDs map[string]string `json:"ProviderIds"` + }{id, name, overview, isPublic, []string{}, []string{}, map[string]string{}}) +} + func (c *Jellyfin) UpdatePlaylist() error { isPublic := c.Cfg.PublicPlaylist if c.Cfg.Creds.User == "" { isPublic = true } queryParams := fmt.Sprintf("/Items/%s", c.Cfg.PlaylistID) - payload := fmt.Appendf(nil, ` - { - "Id":"%s", - "Name":"%s", - "Overview":"%s", - "IsPublic":%t, - "Genres":[], - "Tags":[], - "ProviderIds":{} - }`, c.Cfg.PlaylistID, c.Cfg.PlaylistName, c.Cfg.PlaylistDescr, isPublic) // the additional fields have to be added, otherwise JF returns code 400 + // the additional fields have to be added, otherwise JF returns code 400 + payload, err := jfUpdatePlaylistBody(c.Cfg.PlaylistID, c.Cfg.PlaylistName, c.Cfg.PlaylistDescr, isPublic) + if err != nil { + return fmt.Errorf("failed to marshal playlist body: %s", err.Error()) + } if _, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+queryParams, bytes.NewBuffer(payload), c.Cfg.Creds.Headers); err != nil { return err diff --git a/src/client/playlist_body_test.go b/src/client/playlist_body_test.go new file mode 100644 index 00000000..fc3232e2 --- /dev/null +++ b/src/client/playlist_body_test.go @@ -0,0 +1,67 @@ +package client + +import ( + "encoding/json" + "testing" +) + +// BUG-15: playlist name/description were interpolated with fmt into +// hand-built JSON, so a quote, backslash or newline produced invalid +// JSON and the server answered 400. +func TestPlaylistBodies_HostileNamesRoundTrip(t *testing.T) { + name := `My "Best"\Playlist` + "\nHits" + descr := "line1\nline2 \"quoted\" \\ backslash" + + songs := json.RawMessage(`["a","b"]`) + + create, err := jfCreatePlaylistBody(name, songs, "user-1", true) + if err != nil { + t.Fatalf("jfCreatePlaylistBody: %v", err) + } + var decoded struct { + Name string `json:"Name"` + IDs json.RawMessage `json:"Ids"` + User string `json:"UserId"` + Media string `json:"MediaType"` + } + if err := json.Unmarshal(create, &decoded); err != nil { + t.Fatalf("create body is not valid JSON: %v\n%s", err, create) + } + if decoded.Name != name || decoded.User != "user-1" || decoded.Media != "Audio" || string(decoded.IDs) != string(songs) { + t.Errorf("create body = %s, want name/ids/user/media preserved", create) + } + + update, err := jfUpdatePlaylistBody("5", name, descr, false) + if err != nil { + t.Fatalf("jfUpdatePlaylistBody: %v", err) + } + var decodedUpdate struct { + ID string `json:"Id"` + Name string `json:"Name"` + Overview string `json:"Overview"` + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + } + if err := json.Unmarshal(update, &decodedUpdate); err != nil { + t.Fatalf("update body is not valid JSON: %v\n%s", err, update) + } + if decodedUpdate.Name != name || decodedUpdate.Overview != descr || decodedUpdate.Genres == nil || decodedUpdate.Tags == nil { + t.Errorf("update body = %s, want fields preserved with empty arrays, not null", update) + } + + emby, err := embyUpdatePlaylistBody("9", name, descr) + if err != nil { + t.Fatalf("embyUpdatePlaylistBody: %v", err) + } + var decodedEmby struct { + ID string `json:"Id"` + Name string `json:"Name"` + Overview string `json:"Overview"` + } + if err := json.Unmarshal(emby, &decodedEmby); err != nil { + t.Fatalf("emby body is not valid JSON: %v\n%s", err, emby) + } + if decodedEmby.Name != name || decodedEmby.Overview != descr { + t.Errorf("emby body = %s, want name/overview preserved", emby) + } +} From decf51da20aec93a94b5dd10803e9b299416c78c Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:50:13 -0700 Subject: [PATCH 42/57] Stop MPD playlists doubling on every re-run The .m3u was opened with O_APPEND and the handle was never closed, so re-running the same playlist appended every song again. Open with O_TRUNC to create the playlist fresh like the other clients, and close the file. Adds a test running creation twice. --- src/client/mpd.go | 9 +++++++- src/client/mpd_playlist_test.go | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/client/mpd_playlist_test.go diff --git a/src/client/mpd.go b/src/client/mpd.go index 3ec9d9f6..62714bd7 100644 --- a/src/client/mpd.go +++ b/src/client/mpd.go @@ -64,10 +64,17 @@ func (c *MPD) CheckRefreshState() bool { } func (c *MPD) CreatePlaylist(tracks []*models.Track) error { - f, err := os.OpenFile(c.Cfg.PlaylistDir+c.Cfg.PlaylistName+".m3u", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0666) + // O_APPEND doubled the playlist on every re-run and the handle was + // never closed. Start fresh each time like the other clients. + f, err := os.OpenFile(c.Cfg.PlaylistDir+c.Cfg.PlaylistName+".m3u", os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0666) if err != nil { return err } + defer func() { + if err := f.Close(); err != nil { + slog.Warn("failed to close playlist file", "err", err.Error()) + } + }() for _, track := range tracks { if track.Present { diff --git a/src/client/mpd_playlist_test.go b/src/client/mpd_playlist_test.go new file mode 100644 index 00000000..4e3b9505 --- /dev/null +++ b/src/client/mpd_playlist_test.go @@ -0,0 +1,39 @@ +package client + +import ( + "os" + "path/filepath" + "testing" + + "explo/src/models" +) + +// BUG-16: the .m3u was opened with O_APPEND and the handle never +// closed, so every re-run doubled the playlist. +func TestMPDCreatePlaylist_ReRunDoesNotDuplicate(t *testing.T) { + dir := t.TempDir() + cfg := testConfig("", "") + cfg.PlaylistDir = dir + string(os.PathSeparator) + cfg.PlaylistName = "weekly" + mpd := NewMPD(cfg) + + tracks := []*models.Track{ + {File: "a.flac", Present: true}, + {File: "b.flac", Present: true}, + {File: "missing.flac", Present: false}, + } + + for i := 0; i < 2; i++ { + if err := mpd.CreatePlaylist(tracks); err != nil { + t.Fatalf("CreatePlaylist run %d: %v", i, err) + } + } + + data, err := os.ReadFile(filepath.Join(dir, "weekly.m3u")) + if err != nil { + t.Fatal(err) + } + if string(data) != "a.flac\nb.flac\n" { + t.Errorf("playlist = %q, want each present track exactly once", data) + } +} From 7045dcb4490f22d9fef101770214b0854adf28b3 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:50:32 -0700 Subject: [PATCH 43/57] Run the opened tracklist's own replace flag from its card The tracklist Run handler started openTracklist but read the replace flag for the manual-run dropdown's playlist, so clicking Run on a card could overwrite a different playlist than the one opened. Look up replacePlaylists[openTracklist] instead. Verified with npm run build (vite build succeeds). --- src/web/frontend/src/components/Settings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 3c0ddcb3..74685958 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -398,7 +398,7 @@ function HomeSection() { playlist={openTracklist} refreshTick={refreshTick} onRun={async () => { - await startRun(openTracklist, 'normal', replacePlaylists[playlist] ?? true) + await startRun(openTracklist, 'normal', replacePlaylists[openTracklist] ?? true) setRunning(true) setStatus('running…') setLogEntries([]) From ab7c9914a3e07d31f51915e8431a468aa6bd943b Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:51:10 -0700 Subject: [PATCH 44/57] Tag each song with its own disc and track number Enrichment always took Media[0].Tracks[0], so every song was tagged track 1 of disc 1, scrambling album ordering in players. Capture the recording ID on each media track entry and select the disc/track matching the recording being enriched, falling back to the first track when the API omits recording links. Adds tests for a second-disc recording, an unknown recording and empty media. --- src/discovery/listenbrainz.go | 64 +++++++++++++++++++++-------- src/discovery/release_track_test.go | 52 +++++++++++++++++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 src/discovery/release_track_test.go diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 9890129a..72d0676a 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -178,14 +178,38 @@ type MBRelease struct { // MBMedium is one disc of a release. type MBMedium struct { - Position int `json:"position"` - Format string `json:"format"` - TrackCount int `json:"track-count"` - Tracks []struct { - ID string `json:"id"` - Position int `json:"position"` - Number string `json:"number"` - } `json:"tracks"` + Position int `json:"position"` + Format string `json:"format"` + TrackCount int `json:"track-count"` + Tracks []MBReleaseTrack `json:"tracks"` +} + +// MBReleaseTrack is one track on a disc. Its ID is the release-track +// entity; Recording.ID is the recording this track performance is of, +// which is what lets enrichment find the track being enriched rather +// than always taking Media[0].Tracks[0] (BUG-22). +type MBReleaseTrack struct { + ID string `json:"id"` + Position int `json:"position"` + Number string `json:"number"` + Recording struct { + ID string `json:"id"` + } `json:"recording"` +} + +// findReleaseTrack locates the track for recordingMBID inside a +// release's media, returning the disc and the track. It returns false +// when the recording is not listed, in which case callers keep the +// previous first-track behavior. +func findReleaseTrack(release MBRelease, recordingMBID string) (MBMedium, MBReleaseTrack, bool) { + for _, medium := range release.Media { + for _, track := range medium.Tracks { + if recordingMBID != "" && track.Recording.ID == recordingMBID { + return medium, track, true + } + } + } + return MBMedium{}, MBReleaseTrack{}, false } type TitleArgs struct { @@ -540,16 +564,20 @@ func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ( canonicalTrackTotal, canonicalDiscTotal = CanonicalReleaseShape(mbData.Releases, mbReleaseGroupID) discTotal = len(bestRelease.Media) - if len(bestRelease.Media) > 0 { - firstMedia := bestRelease.Media[0] - media = firstMedia.Format - discNumber = firstMedia.Position - trackTotal = firstMedia.TrackCount - - if len(firstMedia.Tracks) > 0 { - firstTrack := firstMedia.Tracks[0] - trackNumber = firstTrack.Position - mbReleaseTrackID = firstTrack.ID + // Find the disc/track this recording is actually on. When + // the API omits recording links, fall back to the first + // track as before rather than leaving the numbers empty. + medium, mediumTrack, found := findReleaseTrack(bestRelease, track.MusicBrainzTrackID) + if !found && len(bestRelease.Media) > 0 && len(bestRelease.Media[0].Tracks) > 0 { + medium, mediumTrack = bestRelease.Media[0], bestRelease.Media[0].Tracks[0] + } + if found || len(bestRelease.Media) > 0 { + media = medium.Format + discNumber = medium.Position + trackTotal = medium.TrackCount + if len(medium.Tracks) > 0 { + trackNumber = mediumTrack.Position + mbReleaseTrackID = mediumTrack.ID } } } diff --git a/src/discovery/release_track_test.go b/src/discovery/release_track_test.go new file mode 100644 index 00000000..da7b63cd --- /dev/null +++ b/src/discovery/release_track_test.go @@ -0,0 +1,52 @@ +package discovery + +import ( + "encoding/json" + "testing" +) + +// BUG-22: enrichment always took Media[0].Tracks[0], so every song was +// tagged track 1 of disc 1. +func parseRelease(t *testing.T, raw string) MBRelease { + t.Helper() + var rel MBRelease + if err := json.Unmarshal([]byte(raw), &rel); err != nil { + t.Fatal(err) + } + return rel +} + +const twoDiscRelease = `{"media":[ + {"position":1,"format":"CD","track-count":2,"tracks":[ + {"id":"rt-a1","position":1,"number":"1","recording":{"id":"rec-a"}}, + {"id":"rt-a2","position":2,"number":"2","recording":{"id":"rec-b"}}]}, + {"position":2,"format":"CD","track-count":1,"tracks":[ + {"id":"rt-b1","position":1,"number":"1","recording":{"id":"rec-c"}}]} +]}` + +func TestFindReleaseTrack_FindsSecondDiscRecording(t *testing.T) { + rel := parseRelease(t, twoDiscRelease) + + medium, track, found := findReleaseTrack(rel, "rec-c") + if !found { + t.Fatal("recording rec-c not found") + } + if medium.Position != 2 || track.Position != 1 || track.ID != "rt-b1" { + t.Errorf("got disc %d track %+v, want disc 2 rt-b1", medium.Position, track) + } +} + +func TestFindReleaseTrack_UnknownRecordingReportsAbsent(t *testing.T) { + rel := parseRelease(t, twoDiscRelease) + + if _, _, found := findReleaseTrack(rel, "rec-missing"); found { + t.Error("unknown recording reported as found") + } +} + +func TestFindReleaseTrack_EmptyMedia(t *testing.T) { + rel := parseRelease(t, `{"media":[]}`) + if _, _, found := findReleaseTrack(rel, "rec-a"); found { + t.Error("recording found in empty media") + } +} From ae5f30539f3a7796e997b6a4c10a2b923b5ef698 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:52:08 -0700 Subject: [PATCH 45/57] Skip empty metadata keys when building ffmpeg tags The helpers checked the value but not the key, and tag sets leave fields empty for formats with no name for them, producing '=value' ffmpeg entries that fail tagging or garble tags on m4a/wav and ape-family files. Skip entries with an empty key (values were already skipped when empty). Adds tests across six file types plus direct helper checks. --- src/util/metadata.go | 7 +++-- src/util/metadata_test.go | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/util/metadata_test.go diff --git a/src/util/metadata.go b/src/util/metadata.go index e5286ae0..e53e2d8f 100644 --- a/src/util/metadata.go +++ b/src/util/metadata.go @@ -42,15 +42,18 @@ func Abs(x int) int { return x } +// An empty key produces a "=value" ffmpeg metadata entry, which fails +// tagging or writes garbled tags depending on the container (BUG-24): +// tag sets leave fields empty for formats that have no name for them. func addStringTag(metadata []string, key string, value string) []string { - if value != "" { + if key != "" && value != "" { metadata = append(metadata, key+"="+value) } return metadata } func addIntTag(metadata []string, key string, value int) []string { - if value != 0 { + if key != "" && value != 0 { metadata = append(metadata, fmt.Sprintf("%s=%d", key, value)) } return metadata diff --git a/src/util/metadata_test.go b/src/util/metadata_test.go new file mode 100644 index 00000000..57fceb40 --- /dev/null +++ b/src/util/metadata_test.go @@ -0,0 +1,65 @@ +package util + +import ( + "strings" + "testing" + + "explo/src/models" +) + +// BUG-24: tag sets leave some fields empty per format, producing +// "=value" ffmpeg metadata entries for e.g. m4a/wav and ape files. +func fullTrack() models.Track { + return models.Track{ + File: "song.m4a", + Title: "Song", + Artist: "Artist", + Album: "Album", + AlbumArtist: "Album Artist", + ArtistSort: "Artist, A", + OriginalDate: "2020-01-01", + Genres: "Rock", + Media: "CD", + ReleaseType: "album", + ReleaseStatus: "official", + MusicBrainzReleaseGroupID: "rg", + MusicBrainzAlbumArtistID: "aa", + MusicBrainzTrackID: "t", + MusicBrainzAlbumID: "a", + MusicBrainzReleaseTrackID: "rt", + MusicBrainzArtistID: "ar", + OriginalYear: 2020, + TrackNumber: 3, + TrackTotal: 12, + DiscNumber: 1, + DiscTotal: 2, + ISRCs: []string{"US123"}, + } +} + +func TestBuildffmpegMetadata_HasNoEmptyKeys(t *testing.T) { + for _, file := range []string{"song.m4a", "song.wav", "song.ape", "song.mp3", "song.flac", "song.opus"} { + track := fullTrack() + track.File = file + for _, entry := range BuildffmpegMetadata(track) { + if strings.HasPrefix(entry, "=") { + t.Errorf("%s: entry %q has an empty key", file, entry) + } + if !strings.Contains(entry, "=") { + t.Errorf("%s: entry %q is not key=value", file, entry) + } + } + } +} + +func TestAddStringTag_SkipsEmptyKey(t *testing.T) { + if got := addStringTag(nil, "", "value"); len(got) != 0 { + t.Errorf("addStringTag empty key = %v, want nothing", got) + } + if got := addIntTag(nil, "", 3); len(got) != 0 { + t.Errorf("addIntTag empty key = %v, want nothing", got) + } + if got := addStringTag(nil, "title", "Song"); len(got) != 1 { + t.Errorf("addStringTag normal = %v, want one entry", got) + } +} From 5da4767191e3c67ba78365825221a4e5ac201f5d Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:52:48 -0700 Subject: [PATCH 46/57] Proceed without cover art when the cover download fails DownloadCover returned the destination path even when the fetch or write failed, and the YouTube saver embedded that nonexistent file, so one failed artwork download failed the whole song. Only return paths for files that actually exist, and fall back to the no-cover ffmpeg options when the cover path is empty. Adds a failure-path unit test. --- src/downloader/youtube.go | 19 +++++++++++-------- src/util/cover_test.go | 20 ++++++++++++++++++++ src/util/http.go | 6 ++++++ 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 src/util/cover_test.go diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go index c2cca9a3..d47c9c17 100644 --- a/src/downloader/youtube.go +++ b/src/downloader/youtube.go @@ -258,15 +258,18 @@ func saveVideo(c Youtube, track *models.Track, stream *goutubedl.DownloadResult) if track.CoverPath == "" { _, track.CoverPath = util.DownloadCover(track.CoverURL, c.Cfg.CoversDir) } - streams = append(streams, ffmpeg.Input(track.CoverPath)) - opts = ffmpeg.KwArgs{ - "c:v": "mjpeg", - "disposition:v:0": "attached_pic", - "metadata:s:v": []string{"title=Album cover", "comment=Cover (front)"}, - "metadata": metadata, - "loglevel": "error", + if track.CoverPath != "" { + streams = append(streams, ffmpeg.Input(track.CoverPath)) + opts = ffmpeg.KwArgs{ + "c:v": "mjpeg", + "disposition:v:0": "attached_pic", + "metadata:s:v": []string{"title=Album cover", "comment=Cover (front)"}, + "metadata": metadata, + "loglevel": "error", + } } - } else { + } + if opts == nil { opts = ffmpeg.KwArgs{ "map": "0:a", "metadata": metadata, diff --git a/src/util/cover_test.go b/src/util/cover_test.go new file mode 100644 index 00000000..f41e47f5 --- /dev/null +++ b/src/util/cover_test.go @@ -0,0 +1,20 @@ +package util + +import ( + "testing" +) + +// BUG-25: DownloadCover returned the destination path even when the +// fetch failed, so callers embedded a cover from a nonexistent file +// and the track save failed with the artwork. +func TestDownloadCover_FailedFetchReturnsEmptyPaths(t *testing.T) { + api, path := DownloadCover("http://127.0.0.1:1/cover.jpg", t.TempDir()) + if api != "" || path != "" { + t.Errorf("DownloadCover(failed) = %q, %q; want empty paths so callers proceed without cover art", api, path) + } + + api, path = DownloadCover("", t.TempDir()) + if api != "" || path != "" { + t.Errorf("DownloadCover(empty url) = %q, %q; want empty paths", api, path) + } +} diff --git a/src/util/http.go b/src/util/http.go index 588e3b04..487e0d42 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -152,6 +152,12 @@ func DownloadCover(url, coversDir string) (string, string) { }() } } + // A failed fetch or write used to return the destination path anyway, + // so callers embedded a cover from a file that does not exist and the + // track save failed with it (BUG-25). Only hand out paths that exist. + if _, err := os.Stat(destPath); err != nil { + return "", "" + } apiURL := fmt.Sprintf("/api/covers/%s.jpg", id) return apiURL, destPath } From 52f6bce1197e37bd94daae981345d31bc6acc090 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:53:11 -0700 Subject: [PATCH 47/57] Retry mutating requests once on a stale CSRF token The frontend cached the CSRF token forever, but sessions are in-memory with a 7-day expiry, so after a restart or expiry every POST/PUT/PATCH/DELETE failed 403 until the page was reloaded. On a 403, drop the cached token, fetch a fresh one and retry the request exactly once; GET requests are untouched. Verified with a mocked-fetch probe (stale token 403 -> fresh token 200, single retry, no CSRF fetch for GETs) and vite build. --- src/web/frontend/src/lib/api.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index f4b98fac..afe80f9f 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -11,19 +11,31 @@ async function ensureCSRF() { async function apiFetch(url, options = {}) { const method = (options.method || 'GET').toUpperCase() + const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) const headers = new Headers(options.headers || {}) - if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { - const token = await ensureCSRF() - headers.set('X-CSRF-Token', token) - } - - return fetch(url, { + const send = () => fetch(url, { credentials: 'include', ...options, headers, }) + + if (!mutating) return send() + + headers.set('X-CSRF-Token', await ensureCSRF()) + let res = await send() + + // The cached token goes stale when the backend restarts or the session + // expires (sessions are in-memory): drop it, fetch a fresh one, and + // retry the request once instead of failing every button until reload. + if (res.status === 403) { + csrfToken = null + headers.set('X-CSRF-Token', await ensureCSRF()) + res = await send() + } + + return res } export async function checkAuth() { From 76d5ea6684d446f0202fbaa61798a91e2912534e Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:53:23 -0700 Subject: [PATCH 48/57] Show an error screen instead of a blank page on startup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup fetches had no catch and fetchConfig never checked res.ok, so a 500 or network failure left the view in its initial null state forever — a permanent blank page. Check res.ok in fetchConfig, catch startup failures into an error view with the message and a Retry button, and do the same for the post-login config load. Verified with vite build. --- src/web/frontend/src/App.jsx | 32 +++++++++++++++++++++++++++++++- src/web/frontend/src/lib/api.js | 1 + 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx index 0d69946c..dd2837f7 100644 --- a/src/web/frontend/src/App.jsx +++ b/src/web/frontend/src/App.jsx @@ -13,6 +13,7 @@ export default function App() { const [bgLoaded, setBgLoaded] = useState(false) const [fadingOut, setFadingOut] = useState(false) const [authDisabled, setAuthDisabled] = useState(false) + const [loadError, setLoadError] = useState(null) useEffect(() => { Promise.all([ @@ -26,6 +27,11 @@ export default function App() { } else { setView('login') } + }).catch((e) => { + // A server hiccup during startup used to leave the page blank + // forever: the view never left its initial null state. + setLoadError(e?.message || 'failed to reach the server') + setView('error') }) fetchBackgroundArt().then((artUrl) => { if (artUrl) setBgUrl(artUrl) @@ -33,7 +39,14 @@ export default function App() { }, []) async function handleLoginSuccess({ fromLogin = false } = {}) { - const { values, sources } = await fetchConfig() + let values, sources + try { + ({ values, sources } = await fetchConfig()) + } catch (e) { + setLoadError(e?.message || 'failed to load configuration') + setView('error') + return + } setConfig(values) setEnvSources(sources || {}) const nextView = values.WIZARD_COMPLETE === 'true' ? 'settings' : 'wizard' @@ -48,6 +61,23 @@ export default function App() { } } + if (view === 'error') { + return ( +
+
+

Couldn't load Explo

+

{loadError}

+ +
+
+ ) + } + if (view === null) return
if (view === 'login') { diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index f4b98fac..ec77044d 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -53,6 +53,7 @@ export async function login(username, password) { export async function fetchConfig() { const res = await apiFetch('/api/ui/config') + if (!res.ok) throw new Error(`config load failed (${res.status}): ${await res.text()}`) return res.json() } From 77a2b565bf8bda97657ac0e61f7727c23c9c0de0 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:54:10 -0700 Subject: [PATCH 49/57] Register the path-templates route with its leading slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern 'api/ui/path-templates' has no leading slash, so ServeMux parses 'api' as a host pattern and requests fall through to the SPA fallback — the settings screen could never load or save presets. Register '/api/ui/path-templates' (the handler already dispatches GET/POST itself). Adds a mux-pattern regression test asserting both methods reach that pattern. --- src/web/backend/routes.go | 7 +++++-- src/web/backend/routes_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/web/backend/routes_test.go diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 50be14b3..651129ad 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -57,8 +57,11 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/local-only", s.auth(s.settings.HandleSaveLocalOnly)) s.mux.Handle("POST /api/ui/config/clean-downloads", s.auth(s.settings.HandleSaveCleanDownloads)) - // Path template presets: GET list, POST add; DELETE per name under prefix - s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) + // Path template presets: GET list, POST add; DELETE per name under prefix. + // The pattern needs its leading slash: without it ServeMux parses + // "api" as a host pattern and normal requests never reach the + // handler, so the settings screen could never load presets. + s.mux.Handle("/api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.settings.HandleDeletePathTemplate)) // Wizard steps (POST) — require auth diff --git a/src/web/backend/routes_test.go b/src/web/backend/routes_test.go new file mode 100644 index 00000000..940817f3 --- /dev/null +++ b/src/web/backend/routes_test.go @@ -0,0 +1,25 @@ +package backend + +import ( + "net/http" + "testing" + + "explo/src/config" +) + +// BUG-28: the pattern lacked its leading slash, so ServeMux parsed +// "api" as a host pattern and requests fell through to the SPA +// fallback instead of the presets handler. +func TestRoute_PathTemplatesRegistered(t *testing.T) { + for _, method := range []string{http.MethodGet, http.MethodPost} { + req, err := http.NewRequest(method, "/api/ui/path-templates", nil) + if err != nil { + t.Fatal(err) + } + s := NewServer(config.ServerConfig{}) + _, pattern := s.mux.Handler(req) + if pattern != "/api/ui/path-templates" { + t.Errorf("%s matched pattern %q, want /api/ui/path-templates", method, pattern) + } + } +} From 65e2c313de99d8deb616d576d1d22dc785c4fc9a Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:54:22 -0700 Subject: [PATCH 50/57] Lock Docker-configured wizard fields in step 2 Step 2 compared envSources[key] to '.env', but the backend only emits 'env' or 'file' (and steps 1 and 3 already compare to 'env'), so the lock never engaged and Docker-set fields looked editable while edits were silently ignored. Compare to 'env' like the other steps. Verified with vite build. --- src/web/frontend/src/components/Wizard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 7f988cc5..66f9bdb7 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -168,7 +168,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { sleepMinutes, publicPlaylist, } = fields; - const isLocked = (key) => envSources[key] === ".env"; + const isLocked = (key) => envSources[key] === "env"; const urlPlaceholder = () => { const ports = { jellyfin: '8096', emby: '8096', plex: '32400', subsonic: '4533' } From 872957f7a0b5e4d64c8aaf98a6f9229fff43b8f6 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:54:43 -0700 Subject: [PATCH 51/57] Gate playlist refreshes on the freshly loaded fetch time The cron task gated on LastFetched captured at registration and only reloaded state after the gate, so once a weekly playlist was past its interval it refreshed on every daily tick. Reload the playlist via GetCustomPlaylist before evaluating, and extract the cadence check into dueForRefresh with table tests. --- src/web/backend/playlist/jobs.go | 22 +++++++++++- src/web/backend/playlist/refresh_gate_test.go | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/web/backend/playlist/refresh_gate_test.go diff --git a/src/web/backend/playlist/jobs.go b/src/web/backend/playlist/jobs.go index abf444a3..25b3c56a 100644 --- a/src/web/backend/playlist/jobs.go +++ b/src/web/backend/playlist/jobs.go @@ -10,6 +10,18 @@ import ( "github.com/go-co-op/gocron/v2" ) +// dueForRefresh reports whether a playlist whose data was last fetched +// at lastFetched is due again under a refreshDays cadence. A zero or +// negative cadence refreshes on every tick; the cron schedule itself +// sets the tick rate, so a weekly list is refreshed by its daily job +// exactly when seven days have passed since the last fetch. +func dueForRefresh(lastFetched time.Time, refreshDays int, now time.Time) bool { + if refreshDays <= 0 { + return true + } + return !now.Before(lastFetched.Add(time.Duration(refreshDays) * 24 * time.Hour)) +} + // RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist // using its stored schedule. Falls back to daily at 4 AM if no schedule is set. func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { @@ -42,7 +54,15 @@ func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { _, err := j.Scheduler.NewJob( gocron.CronJob(schedule, false), gocron.NewTask(func() { - if time.Since(plist.LastFetched) < time.Duration(plist.RefreshDays)*24*time.Hour { + // Reload the current state before gating: plist is the + // copy captured at registration, so gating on its + // LastFetched refreshed every run once the playlist was + // past its interval (daily refresh of a weekly list). + lastFetched := plist.LastFetched + if fresh := GetCustomPlaylist(p.cfg.WebDataDir, plist.ID); fresh != nil { + lastFetched = fresh.LastFetched + } + if !dueForRefresh(lastFetched, plist.RefreshDays, time.Now()) { return } slog.Info("custom-playlists: refreshing", "id", plist.ID, "name", plist.Name, "source", plist.Source) diff --git a/src/web/backend/playlist/refresh_gate_test.go b/src/web/backend/playlist/refresh_gate_test.go new file mode 100644 index 00000000..83f266bc --- /dev/null +++ b/src/web/backend/playlist/refresh_gate_test.go @@ -0,0 +1,36 @@ +package playlist + +import ( + "testing" + "time" +) + +// BUG-31: the refresh gate used LastFetched captured at registration, +// so once a weekly playlist was past its interval it refreshed on +// every daily tick. The gate must evaluate the freshly loaded state. +func TestDueForRefresh(t *testing.T) { + now := time.Now() + weekAgo := now.Add(-7 * 24 * time.Hour) + sixDaysAgo := now.Add(-6 * 24 * time.Hour) + + tests := []struct { + name string + lastFetched time.Time + days int + want bool + }{ + {"weekly, fetched a week ago: due", weekAgo, 7, true}, + {"weekly, fetched six days ago: not due", sixDaysAgo, 7, false}, + {"weekly, fetched just now: not due", now, 7, false}, + {"daily, fetched yesterday: due", now.Add(-24 * time.Hour), 1, true}, + {"zero cadence refreshes every tick", sixDaysAgo, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dueForRefresh(tt.lastFetched, tt.days, now); got != tt.want { + t.Errorf("dueForRefresh(%v, %d) = %v, want %v", tt.lastFetched, tt.days, got, tt.want) + } + }) + } +} From fcddbc1439bfd66055f76189277b9e1e182edf60 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:55:04 -0700 Subject: [PATCH 52/57] Record per-issue local branches for all 30 bug fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each remaining TODO bug now has its own local fix/bug-* branch and commit (branch + hash in the BUG entry). Nothing has been pushed and no PRs opened — branches await review, then one PR per issue. --- TODO.md | 199 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 100 insertions(+), 99 deletions(-) diff --git a/TODO.md b/TODO.md index 28059266..494bcdd4 100644 --- a/TODO.md +++ b/TODO.md @@ -20,15 +20,15 @@ Status values: `open` · `in-progress` · `blocked` · `done` · `wontfix` | Category | Total | Done | Open | |---|---|---|---| -| App crashes | 5 | 0 | 5 | -| Data loss and corruption | 3 | 0 | 3 | -| Security | 2 | 0 | 2 | -| Playlists broken or wrong | 7 | 0 | 7 | -| Hangs and resource leaks | 4 | 1 | 3 | -| Wrong songs and metadata | 4 | 0 | 4 | -| Web UI and settings screens | 4 | 0 | 4 | -| Scheduling | 2 | 0 | 2 | -| **Total** | **31** | **1** | **30** | +| App crashes | 5 | 5 | 0 | +| Data loss and corruption | 3 | 3 | 0 | +| Security | 2 | 2 | 0 | +| Playlists broken or wrong | 7 | 7 | 0 | +| Hangs and resource leaks | 4 | 4 | 0 | +| Wrong songs and metadata | 4 | 4 | 0 | +| Web UI and settings screens | 4 | 4 | 0 | +| Scheduling | 2 | 2 | 0 | +| **Total** | **31** | **31** | **0** | Update this table whenever an issue changes status. @@ -36,147 +36,147 @@ Update this table whenever an issue changes status. ## App crashes -- [ ] **BUG-01 — Session race crashes the app** · Severity: High +- [x] **BUG-01 — Session race crashes the app** · Severity: High - Location: `src/web/backend/auth/session.go:141` - User impact: Using the UI in multiple tabs or clicking fast can crash the whole app. - Detail: `Session.Get/Put/Delete` access `s.data`/`s.id` with no lock (only `Migrate` locks). Concurrent requests sharing one session cause a fatal `concurrent map writes` panic. - Fix direction: Lock all session access (guard `Get`/`Put`/`Delete` and `writeCookieIfNecessary`'s read of `session.id`). - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-01-session-race` (c85aaaf, unpushed) · Verification: new -race regression test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-02 — Lidarr album cache race crashes the app** · Severity: High +- [x] **BUG-02 — Lidarr album cache race crashes the app** · Severity: High - Location: `src/downloader/lidarr.go:353` (also `:519`, panic at `:527`) - User impact: Fetching several albums at once can crash the app instead of downloading them. - Detail: `AlbumCache` map is read without `CacheMu` while `cacheAlbum` writes it under lock from concurrent goroutines. At `:519` a nil cache entry is then dereferenced at `:527`. - Fix direction: `RLock` the reads and add a nil guard before use. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-02-lidarr-cache-race` (88bac9b, unpushed) · Verification: new -race regression test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-03 — Empty artist list panics the run** · Severity: High +- [x] **BUG-03 — Empty artist list panics the run** · Severity: High - Location: `src/discovery/listenbrainz.go:393` - User impact: One unusual track from ListenBrainz can crash the whole run so no playlist gets made. - Detail: `recArtists[0]` is indexed unconditionally; ListenBrainz metadata with an empty/missing artists array panics. - Fix direction: Guard `len(recArtists)` and degrade gracefully (skip or fall back to a joined/empty artist). - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-03-empty-artist-panic` (1b16855, unpushed) · Verification: new unit tests pass; full discovery package passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-04 — Config typo panics instead of erroring** · Severity: High +- [x] **BUG-04 — Config typo panics instead of erroring** · Severity: High - Location: `src/discovery/discovery.go:21`, dereferenced at `src/main/main.go:198` - User impact: One typo in your config crashes the app with no hint about what you typed wrong. - Detail: `NewDiscoverer` returns nil for any `DISCOVERY_SERVICE` other than `listenbrainz`, and no config validation catches it. - Fix direction: Validate the value in config loading, or return an error from `NewDiscoverer` and fail fast with the bad value in the message. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-04-config-typo-panic` (8c7fbf1, unpushed) · Verification: new unit tests pass; go vet ./... clean; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-05 — Plex library panic** · Severity: Medium +- [x] **BUG-05 — Plex library panic** · Severity: Medium - Location: `src/client/plex.go:381` - User impact: Plex hiccups while creating a library can crash the run instead of showing an error. - Detail: `AddLibrary()` indexes `MediaContainer.Library[0]` with no length check when Plex returns an empty container. - Fix direction: Length-check and return a descriptive error. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-05-plex-library-panic` (1d957aa, unpushed) · Verification: new unit tests pass; go vet + golangci-lint clean + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Data loss and corruption -- [ ] **BUG-06 — Custom playlists can be wiped by a race** · Severity: High +- [x] **BUG-06 — Custom playlists can be wiped by a race** · Severity: High - Location: `src/web/backend/playlist/custom_playlists.go:107` - User impact: Adding or removing playlists at the wrong moment can lose or wipe all your custom playlists. - Detail: `saveCustomPlaylists` does an unlocked, non-atomic `os.WriteFile` while import/delete/refresh/cron all do unsynchronized load-modify-save. A reader mid-truncate gets a parse error, the loader returns nil, and the next save wipes `custom-playlists.json`. - Fix direction: Add a mutex around load-modify-save and write via temp file + rename. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-06-custom-playlists-race` (51474f7, unpushed) · Verification: new -race regression tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-07 — File move can erase an existing song** · Severity: Medium +- [x] **BUG-07 — File move can erase an existing song** · Severity: Medium - Location: `src/downloader/downloader.go:400` (see also `src/downloader/slskd_album.go:347`) - User impact: Moving files can silently erase an existing song in your library. - Detail: `moveFile` truncates the destination with `os.Create` before copying, with no same-file or existing-file check. If `src == dst` (or a sibling name collides with a library file), the source/existing file is zeroed while the track is still reported present. - Fix direction: Refuse or skip when source and destination are the same file or the destination exists. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-07-file-move-erases-song` (8b4d1c3, unpushed) · Verification: new unit tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-08 — Settings saves can be lost or corrupt the file** · Severity: Medium +- [x] **BUG-08 — Settings saves can be lost or corrupt the file** · Severity: Medium - Location: `src/web/backend/settings/settings.go:124` - User impact: Saving settings in quick succession can silently undo some of your changes, and a crash mid-save can corrupt the file. - Detail: `UpdateEnvKeys` does an unsynchronized read-modify-write with a truncating `os.WriteFile`; concurrent handlers lose each other's updates, and a crash mid-write leaves a truncated `.env`. - Fix direction: Lock the read-modify-write and write via temp file + rename. Related: BUG-06 (same pattern), BUG-10 (same file). - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-08-settings-save-race` (95d8455, unpushed) · Verification: new -race regression test + full settings package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Security -- [ ] **BUG-09 — Settings value can inject extra config keys** · Severity: High +- [x] **BUG-09 — Settings value can inject extra config keys** · Severity: High - Location: `src/web/backend/settings/settings.go:134` - User impact: A value containing a line break can silently overwrite your other settings on the next start. - Detail: `formatEnvValue` quotes only on `"$#?'`, never newline/CR, and `UpdateEnvKeys` writes `key=value` raw — a value like `x\nAPI_KEY=pwned` injects arbitrary extra `.env` lines. - Fix direction: Reject or escape CR/LF in keys and values before writing. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-09-env-injection` (3c0cd50, unpushed) · Verification: new unit tests pass; full settings package passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-10 — Admin API key shown in plain text** · Severity: Low +- [x] **BUG-10 — Admin API key shown in plain text** · Severity: Low - Location: `src/web/frontend/src/lib/utils.js:5` (rendered at `src/web/frontend/src/components/Settings.jsx:888`) - User impact: An admin API key shows in plain text on the settings screen where it should be hidden. - Detail: `SENSITIVE_KEY_RE` matches `api_key` but not `APIKEY` (no underscore), so keys such as `ADMIN_SYSTEM_APIKEY` are not masked. - Fix direction: Add `apikey` to the sensitive-key pattern and audit for other unmasked key spellings. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-10-sensitive-key-mask` (65d43d2, unpushed) · Verification: Node probe over 15 sensitive + 5 plain spellings ALL OK; no JS test runner in repo so no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Playlists broken or wrong -- [ ] **BUG-11 — Playlists gain songs that never arrived** · Severity: High +- [x] **BUG-11 — Playlists gain songs that never arrived** · Severity: High - Location: `src/downloader/monitor.go:135` (error only logged at `:140`) - User impact: Playlists can include songs that never actually landed in your library, and they never get retried. - Detail: The track is marked `Present`, counted as success, and cleaned up *before* `MoveDownload` runs; if the move fails, the playlist still keeps the track. - Fix direction: Set `Present` and count success only after `MoveDownload` succeeds. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-11-move-before-present` (1287887, unpushed) · Verification: new stub tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-12 — One missing song can block a whole Subsonic playlist** · Severity: High +- [x] **BUG-12 — One missing song can block a whole Subsonic playlist** · Severity: High - Location: `src/client/subsonic.go:238` - User impact: One song that could not be found can stop the entire playlist from being created in Navidrome/Subsonic. - Detail: `CreatePlaylist` appends `&songId=` for every track without checking `track.Present` or an empty ID (unlike the Jellyfin/Emby formatters), so an absent track sends `&songId=` and the server can reject the whole create. - Fix direction: Skip tracks that are not present or have an empty ID. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-12-subsonic-missing-song` (a19fa89, unpushed) · Verification: new unit tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-13 — Jellyfin playlist creation fails without a username** · Severity: Medium +- [x] **BUG-13 — Jellyfin playlist creation fails without a username** · Severity: Medium - Location: `src/client/jellyfin.go:236` - User impact: Without a username set, playlist creation in Jellyfin always fails. - Detail: With `SYSTEM_USERNAME` unset, `CreatePlaylist` sends the API key (`resolveAPIKey()`) as the Jellyfin `UserId`, which the server rejects with 400/404. - Fix direction: Resolve the real user ID from the API, or omit the user where the API allows it; fail with a clear message otherwise. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-13-jellyfin-userid` (39bff7b, unpushed) · Verification: new table tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-14 — “Replace playlist” duplicates playlists on Emby** · Severity: Medium +- [x] **BUG-14 — “Replace playlist” duplicates playlists on Emby** · Severity: Medium - Location: `src/client/emby.go:241` - User impact: Choosing “replace playlist” on Emby quietly piles up duplicate playlists instead of replacing. - Detail: `DeletePlaylist()` is a commented-out no-op returning nil, so callers believe the old playlist was deleted before creating the new one. - Fix direction: Implement the Emby delete call, or make replace explicitly delete-then-create. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-14-emby-delete-noop` (020c66d, unpushed) · Verification: go vet + golangci-lint clean; httptest delete tests committed — run on CI (sandbox blocks localhost binds) + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-15 — Playlist names with quotes break Jellyfin/Emby** · Severity: Medium +- [x] **BUG-15 — Playlist names with quotes break Jellyfin/Emby** · Severity: Medium - Location: `src/client/jellyfin.go:243`, `src/client/emby.go:227` - User impact: Playlist names containing quotes break creation with a server error. - Detail: Playlist name/description are interpolated unescaped into JSON built with `fmt`, so a quote, backslash, or newline produces invalid JSON and the server returns 400. - Fix direction: Build the bodies with `json.Marshal` instead of string formatting. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-15-playlist-json-escape` (c27927f, unpushed) · Verification: new round-trip test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-16 — MPD playlists double on every re-run** · Severity: Medium +- [x] **BUG-16 — MPD playlists double on every re-run** · Severity: Medium - Location: `src/client/mpd.go:67` - User impact: Re-running the same playlist in MPD adds every song again, doubling it each time. - Detail: The `.m3u` is opened with `O_APPEND`, never truncated, and the handle is never closed. - Fix direction: Open with `O_TRUNC` (create fresh like the other clients) and close the file. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-16-mpd-append` (decf51d, unpushed) · Verification: new unit test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-17 — Playlist card Run overwrites the wrong playlist** · Severity: Medium +- [x] **BUG-17 — Playlist card Run overwrites the wrong playlist** · Severity: Medium - Location: `src/web/frontend/src/components/Settings.jsx:401` - User impact: Running a playlist from its card can overwrite a different playlist than the one you clicked. - Detail: The tracklist Run handler passes `replacePlaylists[playlist]` (the manual-run selector's state) instead of the opened tracklist's own flag. - Fix direction: Use `openTracklist` when looking up the replace flag. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-17-tracklist-replace-flag` (7045dcb, unpushed) · Verification: vite build passes; no JS test runner in repo so no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Hangs and resource leaks @@ -188,115 +188,115 @@ Update this table whenever an issue changes status. - Status: done · PR/commit: branch `feat/download-provider-fallback` (this commit) · Verification: `go vet ./...` clean; `go test ./src/downloader/ -run TestMonitorDownloads` passes including `-race`; new regression tests `TestMonitorDownloads_TricklingTrackHitsMaxDuration`, `TestMonitorDownloads_FallbackAfterAbandonsUnresolvedTracks`, `TestMonitorDownloads_FallbackAfterZeroWaitsForCompletion`. Full downloader package still fails only on pre-existing sandbox `httptest: bind: operation not permitted` (confirmed identical on untouched `dev`). - Handoff: Fixed together with the provider-fallback port: the progress branch no longer bypasses `MaxDuration`, and the new `SLSKD_FALLBACK_AFTER`/`LIDARR_FALLBACK_AFTER` whole-provider budget (default 0 = disabled) abandons unresolved tracks (with `Cleanup`) so the next `DOWNLOAD_SERVICES` provider gets a turn. `dfaaedb`'s separate `SLSKD_MONITOR_MAX_RUNTIME` knob was not ported — its intent is now covered by the fixed `MaxDuration` plus `FallbackAfter`, avoiding two near-identical caps. -- [ ] **BUG-19 — One long log line wedges all future runs** · Severity: Medium +- [x] **BUG-19 — One long log line wedges all future runs** · Severity: Medium - Location: `src/web/backend/run/events.go:48` (wait at `:65`) - User impact: One very long log line can wedge a run so every later run is refused as “already running”. - Detail: `collectRunOutput` uses `bufio.Scanner` with the default 64KB line limit; an over-long line stops draining the pipe, the child blocks on write, and `cmd.Wait` never returns. - Fix direction: Enlarge the scanner buffer and keep draining even after a scan error. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-19-long-log-line` (63d5cf3, unpushed) · Verification: new unit tests + full run package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-20 — Stalled or huge remote downloads can hang or OOM the app** · Severity: Medium +- [x] **BUG-20 — Stalled or huge remote downloads can hang or OOM the app** · Severity: Medium - Location: `src/util/http.go:94` (also `:106`, cover path at `:137`) - User impact: One stalled or oversized download can hang the app or eat all its memory. - Detail: `http.Get`/default client with no timeout, plus unbounded `io.ReadAll`, on remote responses (same pattern in playlist/apple-music fetching). - Fix direction: Use a timeout client and cap reads with `io.LimitReader`. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-20-http-timeouts` (8ac653e, unpushed) · Verification: new util unit tests pass; playlist package has no tests; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-21 — Rejected runs leak file handles** · Severity: Low +- [x] **BUG-21 — Rejected runs leak file handles** · Severity: Low - Location: `src/web/backend/run/manual_run.go:95` - User impact: Repeatedly trying to start a run while one is running slowly leaks system resources until the app can fail. - Detail: The already-running branch closes `pw` a second time instead of closing the log file `lf` opened earlier, leaking one descriptor per rejected attempt. - Fix direction: Close `lf` on that branch. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-21-rejected-run-leak` (6f831aa, unpushed) · Verification: new FD-count regression test + full run package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Wrong songs and metadata -- [ ] **BUG-22 — Every song tagged as track 1, disc 1** · Severity: Medium +- [x] **BUG-22 — Every song tagged as track 1, disc 1** · Severity: Medium - Location: `src/discovery/listenbrainz.go:542` - User impact: Every song gets tagged as “track 1” of disc 1, scrambling album ordering in your player. - Detail: Enrichment always takes `Media[0].Tracks[0]`, so track number, disc number, and release-track ID come from the first track of the first medium for every song. - Fix direction: Select the track matching the recording/release track being enriched. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-22-track-number` (ab7c991, unpushed) · Verification: new unit tests + full discovery package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-23 — Playlist order is shuffled between runs** · Severity: Medium +- [x] **BUG-23 — Playlist order is shuffled between runs** · Severity: Medium - Location: `src/discovery/listenbrainz.go:357` - User impact: Your playlist order changes randomly between runs instead of following the recommendation ranking. - Detail: `getTracks` iterates the `Recordings` map, losing the recommendation score order from the API response. - Fix direction: Iterate the ordered API slice, not the map. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-23-playlist-order` (8228f08, unpushed) · Verification: new unit tests + full discovery package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-24 — Bad metadata entries for some file types** · Severity: Medium +- [x] **BUG-24 — Bad metadata entries for some file types** · Severity: Medium - Location: `src/util/metadata.go:45` (tag sets at `:99`, `:162`) - User impact: Some songs fail tagging or get garbled tags depending on file type. - Detail: Tag helpers check the value but not an empty key, and several tag sets leave fields empty — producing `=value` ffmpeg metadata entries for formats like m4a/wav and ape-family files. - Fix direction: Skip empty keys (and empty values) when building ffmpeg metadata args. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-24-metadata-empty-keys` (ae5f305, unpushed) · Verification: new unit tests + full util package pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-25 — Failed cover art can fail the song** · Severity: Medium +- [x] **BUG-25 — Failed cover art can fail the song** · Severity: Medium - Location: `src/util/http.go:136` (caller at `src/downloader/youtube.go:259`) - User impact: When cover art fails to download the song can fail too, instead of just skipping the artwork. - Detail: `DownloadCover` returns a destination path even when the fetch or write failed, so callers embed a cover from a file that does not exist and the track save fails. - Fix direction: Return an error (or empty path) on failure and let callers proceed without cover art. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-25-cover-failure` (5da4767, unpushed) · Verification: new failure-path unit test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Web UI and settings screens -- [ ] **BUG-26 — Buttons break after a restart or a week left open (CSRF)** · Severity: Medium +- [x] **BUG-26 — Buttons break after a restart or a week left open (CSRF)** · Severity: Medium - Location: `src/web/frontend/src/lib/api.js:1` (backend check: `src/web/backend/auth/middleware.go:119`) - User impact: After a restart or a week left open, every button that changes something fails until you reload the page. - Detail: The frontend caches the CSRF token forever in a module variable, but the backend mints a new token per session. Sessions are in-memory with a 7-day expiry, so any restart or expiry strands the open page with a stale token and every POST/DELETE returns `403 CSRF token mismatch`. Login/logout do not rotate the token, so they are not a trigger. - Fix direction: In `apiFetch`, on 403 clear the cached token, re-fetch `/api/ui/csrf`, and retry the request once. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-26-csrf-retry` (52f6bce, unpushed) · Verification: mocked-fetch Node probe ALL OK + vite build; no JS test runner in repo so no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-27 — Server hiccup leaves a permanent blank page** · Severity: Medium +- [x] **BUG-27 — Server hiccup leaves a permanent blank page** · Severity: Medium - Location: `src/web/frontend/src/App.jsx:17`, `src/web/frontend/src/lib/api.js:54` - User impact: If the server hiccups while the page loads you get a permanent blank page instead of an error or login screen. - Detail: Startup fetches have no `catch` and `fetchConfig` does not check `res.ok`, so a 500/network failure throws and the view never leaves its initial state. - Fix direction: Check `res.ok`, catch startup errors, and render an error/retry state. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-27-startup-blank-page` (76d5ea6, unpushed) · Verification: vite build passes; no JS test runner in repo so no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-28 — Path-template settings page can never load** · Severity: Medium +- [x] **BUG-28 — Path-template settings page can never load** · Severity: Medium - Location: `src/web/backend/routes.go:61` - User impact: The path-template settings screen simply returns “not found” and can never be used. - Detail: The pattern `api/ui/path-templates` lacks a leading `/`, so `ServeMux` parses `api` as a host pattern and normal requests 404. - Fix direction: Register `/api/ui/path-templates`. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-28-path-templates-route` (77a2b56, unpushed) · Verification: new mux-pattern test passes; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-29 — Wizard fields look editable but edits are ignored** · Severity: Medium +- [x] **BUG-29 — Wizard fields look editable but edits are ignored** · Severity: Medium - Location: `src/web/frontend/src/components/Wizard.jsx:171` (backend values: `src/web/backend/settings/handlers.go:40`) - User impact: Fields that should be locked by your Docker config look editable, but your edits are silently ignored. - Detail: Step 2 checks `envSources[key] === '.env'`, but the backend only emits `'env'` or `'file'`, so the lock never engages. - Fix direction: Compare against `'env'` (as Steps 1 and 3 already do). - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-29-wizard-env-lock` (65e2c31, unpushed) · Verification: vite build passes; no JS test runner in repo so no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue ## Scheduling -- [ ] **BUG-30 — Legacy schedule setting disables all scheduling** · Severity: High +- [x] **BUG-30 — Legacy schedule setting disables all scheduling** · Severity: High - Location: `docker/start.sh:66` - User impact: If you set the old schedule option, none of your schedules run and music just never arrives. - Detail: The legacy `CRON_SCHEDULE` branch runs `crond -f` in the foreground, blocking the later per-playlist schedules, `EXECUTE_ON_START`, and the final `crond` invocation. - Fix direction: Don't run `crond` in the foreground in that branch; register the cron entry and let the normal startup path start the daemon. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-30-legacy-cron-schedule` (526fb4b, unpushed) · Verification: sh -n clean + registration simulation OK; shell script, no committed test + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue -- [ ] **BUG-31 — Weekly playlists start refreshing daily** · Severity: Medium +- [x] **BUG-31 — Weekly playlists start refreshing daily** · Severity: Medium - Location: `src/web/backend/playlist/jobs.go:45` - User impact: A playlist set to refresh weekly starts refreshing every day once it is old enough. - Detail: The cron closure gates on the `LastFetched` value captured at registration and never reloads it before the check (the fresh load happens only after the gate), so once a playlist is past its refresh interval it refreshes on every daily tick. - Fix direction: Reload the playlist state before evaluating the refresh gate. - - Status: open · PR/commit: — · Verification: — - - Handoff: — + - Status: done · PR/commit: local branch `fix/bug-31-weekly-refresh` (872957f, unpushed) · Verification: new table tests pass; golangci-lint 0 issues + - Handoff: fixed locally, not pushed and no PR opened yet — review the branch, then push + open PR per issue --- @@ -307,4 +307,5 @@ Record one line per working session so progress survives across sessions. | Date | Session / author | Issues touched | Outcome / handoff | |---|---|---|---| | 2026-10-19 | Bug review | BUG-01 – BUG-31 created | Tracker created; all items open and unverified for fixes. | +| 2026-10-19 | Per-issue local fixes | BUG-01–17, BUG-19–31 fixed | All 30 remaining bugs fixed, each on its own local fix/bug-* branch with its own commit (see BUG entries). Nothing pushed; branches await review, then push + one PR per issue. | | 2026-10-19 | Provider fallback port | BUG-18 done | Ported `FallbackAfter` from stale `b7122e3` onto `dev` (branch `feat/download-provider-fallback`, PR #25) and fixed BUG-18 deadline bypass. `feat/lidarr`/`feat/provider-fallback` remote branches deleted after the port landed on the new branch. | From 0902c74fe3485076098a41a60c90c05f256226e2 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:39:36 -0700 Subject: [PATCH 53/57] Reject line breaks in settings written to .env UpdateEnvKeys wrote key=value lines raw and formatEnvValue never quoted CR/LF, so a settings value like 'x\nAPI_KEY=pwned' injected arbitrary extra config keys that took effect on the next start. Validate every update before touching the file: keys must be plain env identifiers and values must not contain CR or LF; any violation rejects the whole update with the file untouched. Adds regression tests for newline/CR/key injection and for normal values. --- .../backend/settings/env_injection_test.go | 67 +++++++++++++++++++ src/web/backend/settings/settings.go | 27 ++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/web/backend/settings/env_injection_test.go diff --git a/src/web/backend/settings/env_injection_test.go b/src/web/backend/settings/env_injection_test.go new file mode 100644 index 00000000..d25d6e25 --- /dev/null +++ b/src/web/backend/settings/env_injection_test.go @@ -0,0 +1,67 @@ +package settings + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "explo/src/web/backend/app" +) + +// BUG-09: a settings value containing a line break was written raw into +// .env, injecting arbitrary extra keys on the next start. +func TestUpdateEnvKeys_RejectsLineBreakInjection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + original := "EXISTING=1\n" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + s := &Settings{cfg: app.Config{WebEnvPath: path}} + + for name, updates := range map[string]map[string]string{ + "value newline": {"NOTE": "x\nAPI_KEY=pwned"}, + "value CR": {"NOTE": "x\rAPI_KEY=pwned"}, + "key newline": {"NOTE\nAPI_KEY": "pwned"}, + "key equals": {"NOTE=API_KEY": "pwned"}, + "empty key": {"": "pwned"}, + } { + t.Run(name, func(t *testing.T) { + if err := s.UpdateEnvKeys(updates, nil); err == nil { + t.Errorf("UpdateEnvKeys(%v) = nil, want rejection", updates) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Errorf("env file changed after rejected update: %q", data) + } + }) + } +} + +func TestUpdateEnvKeys_StillWritesNormalValues(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("EXISTING=1\n"), 0600); err != nil { + t.Fatal(err) + } + s := &Settings{cfg: app.Config{WebEnvPath: path}} + + if err := s.UpdateEnvKeys(map[string]string{"NOTE": "hello world #1", "NEW_KEY": "abc"}, nil); err != nil { + t.Fatalf("UpdateEnvKeys normal values: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := s.ParseEnvText(string(data)) + if got["NOTE"] != "hello world #1" || got["NEW_KEY"] != "abc" || got["EXISTING"] != "1" { + t.Errorf("parsed env = %v, want NOTE/NEW_KEY written and EXISTING preserved; raw:\n%s", got, data) + } + if strings.Contains(string(data), "pwned") { + t.Error("injected content present") + } +} diff --git a/src/web/backend/settings/settings.go b/src/web/backend/settings/settings.go index abf00e7e..1aa49cdf 100644 --- a/src/web/backend/settings/settings.go +++ b/src/web/backend/settings/settings.go @@ -95,9 +95,36 @@ func writeEnvFileAtomic(path string, data []byte) error { return os.Rename(tmpName, path) } +// validateEnvUpdate rejects keys and values that could inject extra .env +// lines. A value containing CR/LF would otherwise be written raw as +// key=value and the remainder parsed as additional keys on next start. +func validateEnvUpdate(key, value string) error { + if key == "" { + return fmt.Errorf("env key must not be empty") + } + for i, r := range key { + switch { + case r == '_' || ('A' <= r && r <= 'Z') || ('a' <= r && r <= 'z'): + case '0' <= r && r <= '9' && i > 0: + default: + return fmt.Errorf("invalid env key %q", key) + } + } + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("env value for %s must not contain line breaks", key) + } + return nil +} + // updateEnvKeys reads the env file (falling back to fallback if missing), updates the // given key=value pairs in-place preserving comments, and writes the result back. func (s *Settings) UpdateEnvKeys(updates map[string]string, fallback []byte) error { + for k, v := range updates { + if err := validateEnvUpdate(k, v); err != nil { + return err + } + } + envFileMu.Lock() defer envFileMu.Unlock() From fbd364df5cf7e3e8410f750148eafdee172979a8 Mon Sep 17 00:00:00 2001 From: gabriellewis Date: Mon, 19 Oct 2026 21:51:40 -0700 Subject: [PATCH 54/57] Keep recommendation order when building tracks from recordings getTracks iterated the Recordings response map, so Go map ordering shuffled the playlist away from the recommendation ranking on every run. Intersect the requested MBID ranking with the returned recordings, keeping request order (unreturned recordings are dropped, as before). Adds ordering unit tests. --- src/discovery/listenbrainz.go | 20 +++++++++++++++++-- src/discovery/recording_order_test.go | 28 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/discovery/recording_order_test.go diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 8c3579af..8a5eb4d5 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -379,8 +379,11 @@ func (c *ListenBrainz) getTracks(mbids []string, singleArtist bool) ([]*models.T } tracks := make([]*models.Track, 0, len(recordings)) - for mbTrackID, recording := range recordings { - tracks = append(tracks, c.trackFromRecording(mbTrackID, recording, singleArtist)) + // Iterate the requested MBIDs, not the response map: map order would + // shuffle the playlist away from the recommendation ranking on every + // run (BUG-23). Recordings the API did not return are dropped. + for _, mbTrackID := range orderedRecordingMBIDs(mbids, recordings) { + tracks = append(tracks, c.trackFromRecording(mbTrackID, recordings[mbTrackID], singleArtist)) } return tracks, nil @@ -433,6 +436,19 @@ func (c *ListenBrainz) trackFromRecording(mbTrackID string, recording Metadata, } } +// orderedRecordingMBIDs intersects the requested ranking with the +// recordings the API actually returned, keeping request order. +func orderedRecordingMBIDs(mbids []string, recordings Recordings) []string { + ordered := make([]string, 0, len(recordings)) + for _, mbid := range mbids { + if _, ok := recordings[mbid]; ok { + ordered = append(ordered, mbid) + } + } + return ordered +} + + func (c *ListenBrainz) enrichTracks(tracks []*models.Track, singleArtist bool) ([]*models.Track, error) { mbids := make([]string, 0, len(tracks)) // wait time in s between MusicBrainz requests diff --git a/src/discovery/recording_order_test.go b/src/discovery/recording_order_test.go new file mode 100644 index 00000000..8282b4ca --- /dev/null +++ b/src/discovery/recording_order_test.go @@ -0,0 +1,28 @@ +package discovery + +import ( + "reflect" + "testing" +) + +// BUG-23: getTracks iterated the Recordings response map, so playlist +// order changed randomly between runs instead of following the ranking. +func TestOrderedRecordingMBIDs_KeepsRequestOrder(t *testing.T) { + mk := func() Recordings { + return Recordings{"c": {}, "a": {}, "b": {}} + } + got := orderedRecordingMBIDs([]string{"a", "b", "c"}, mk()) + if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) { + t.Errorf("ordered = %v, want %v", got, want) + } +} + +func TestOrderedRecordingMBIDs_DropsMissingRecordings(t *testing.T) { + got := orderedRecordingMBIDs( + []string{"a", "missing", "b"}, + Recordings{"b": {}, "a": {}}, + ) + if want := []string{"a", "b"}; !reflect.DeepEqual(got, want) { + t.Errorf("ordered = %v, want %v", got, want) + } +} From 452a55edf56af82da631d4d107850ae8afcd80fc Mon Sep 17 00:00:00 2001 From: Gabriel Lewis Date: Tue, 20 Oct 2026 19:10:45 -0700 Subject: [PATCH 55/57] Prefer multi-file releases over single-file strays in album selection A lone file in an album-named folder outscores the real release on name match alone (BUG-35: 1-file Outfield dir won with score 180). Track the best fuller release alongside the historic best and take it whenever one exists; the stray stays eligible as a last resort so a track with nothing better still downloads. --- src/downloader/slskd_album.go | 15 ++++++- src/downloader/slskd_album_test.go | 65 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go index c89381e1..de289400 100644 --- a/src/downloader/slskd_album.go +++ b/src/downloader/slskd_album.go @@ -184,6 +184,8 @@ func (c Slskd) CollectAlbumFiles(track models.Track, results SearchResults) ([]F var best *peerDir var bestScore int + var bestFull *peerDir + var bestFullScore int for i := range candidates { dir := &candidates[i] @@ -202,9 +204,20 @@ func (c Slskd) CollectAlbumFiles(track models.Track, results SearchResults) ([]F if c.Cfg.PreferOriginalRelease { trimToOriginal(dir, track) } - if score := c.scoreDirWithPreference(*dir, track); best == nil || score > bestScore { + score := c.scoreDirWithPreference(*dir, track) + if best == nil || score > bestScore { best, bestScore = dir, score } + // A lone file in an album-named folder is a stray, not a release -- + // the one-song download in place of the album. It stays eligible so + // a track with nothing better still downloads, but any fuller + // release outranks it regardless of score. + if len(dir.files) > 1 && (bestFull == nil || score > bestFullScore) { + bestFull, bestFullScore = dir, score + } + } + if bestFull != nil { + best, bestScore = bestFull, bestFullScore } if best == nil { diff --git a/src/downloader/slskd_album_test.go b/src/downloader/slskd_album_test.go index e55b1ff4..bed3a8f8 100644 --- a/src/downloader/slskd_album_test.go +++ b/src/downloader/slskd_album_test.go @@ -165,6 +165,71 @@ func TestCollectAlbumFiles_DoesNotApplyTrackDurationToSiblings(t *testing.T) { } } +// selectionClient builds the same album-mode client as albumClient but +// without the httptest server: release selection never touches the network, +// so these tests also run where binding a local port is forbidden. +func selectionClient() *Slskd { + client := NewSlskd(cfg.Slskd{ + URL: "http://localhost:5030", + APIKey: "test-key", + Timeout: 5, + AlbumMode: true, + Filters: cfg.Filters{ + Extensions: []string{"flac", "mp3"}, + MinBitRate: 256, + MinBitDepth: 8, + FilterList: []string{"live", "remix"}, + }, + }, "/data/") + client.AddHeader() + + return client +} + +// A lone file in an album-named folder is a stray, not the album: the fuller +// release must win even when the stray scores higher on name match alone. +func TestCollectAlbumFiles_PrefersFullReleaseOverSingleFileStray(t *testing.T) { + client := selectionClient() + + // The stray outscores the real thing on name match alone (artist and + // album in the folder vs album only), so only the full-release + // preference can save the correct pick. + results := albumResults( + peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, + []string{"02 Bad Guy.flac"}}, + peer{"peer2", `@@y\Music\When We All Fall Asleep`, + []string{"01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac"}}, + ) + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if files[0].Username != "peer2" { + t.Errorf("selected peer %q, want peer2 -- the single-file stray won over the full release", files[0].Username) + } + if len(files) != 3 { + t.Errorf("got %d files, want the whole 3-track release", len(files)) + } +} + +// The stray stays eligible: with nothing fuller available, one song still +// beats no song. +func TestCollectAlbumFiles_FallsBackToSingleFileWhenNothingElse(t *testing.T) { + client := selectionClient() + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, + []string{"02 Bad Guy.flac"}}) + + files, err := client.CollectAlbumFiles(albumTrack(), results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 1 { + t.Errorf("got %d files, want the lone stray as a last resort", len(files)) + } +} + // One request per release, carrying every file, and the track left pointing at // the recommended one so the monitor and the playlist still work. func TestQueueAlbumDownload_QueuesEveryFileInOneRequest(t *testing.T) { From 70894c83f01bc00fde89092d3fff03ae43ae400e Mon Sep 17 00:00:00 2001 From: Gabriel Lewis Date: Tue, 20 Oct 2026 19:11:55 -0700 Subject: [PATCH 56/57] Exclude oversized dumps from the strict album selection pass A directory holding more than twice the expected track count is a compilation dump, not the album (BUG-35: 64-file Weezer folder won a ~10-track album with score 210). oversizedDir fires only when the expected count is known -- enrichment off, lookup failed, or multi-disc all fall back to historic behavior -- and the dump stays eligible as a last resort, so availability never drops below today. --- src/downloader/preference.go | 19 ++++++++++ src/downloader/preference_test.go | 34 ++++++++++++++++++ src/downloader/slskd_album.go | 10 +++--- src/downloader/slskd_album_test.go | 57 ++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/downloader/preference.go b/src/downloader/preference.go index 7e066cb5..a21e7ef1 100644 --- a/src/downloader/preference.go +++ b/src/downloader/preference.go @@ -228,6 +228,25 @@ func expectedTrackCount(track models.Track) (int, bool) { return expected, true } +// oversizedReleaseCap bounds the strict selection pass: a candidate holding +// more than this multiple of the expected track count is a compilation dump, +// not the album (BUG-35: a 64-file folder won a 10-track album). Deluxe +// padding stays well under it, and anything bigger still wins when nothing +// better exists -- the cap only decides between candidates, never eligibility +// outright. +const oversizedReleaseCap = 2 + +// oversizedDir reports whether dir holds far more files than the album should +// have. Without a usable expected count it never fires: same unknown-count +// cases as releaseSizeScore, same refusal to guess. +func oversizedDir(dir peerDir, track models.Track) bool { + expected, ok := expectedTrackCount(track) + if !ok { + return false + } + return len(dir.files) > oversizedReleaseCap*expected +} + // releaseSizeScore rewards a release for being the size it ought to be. // // Without a usable expected count -- enrichment off, the lookup failed, or a diff --git a/src/downloader/preference_test.go b/src/downloader/preference_test.go index 7c1e38be..91b70daa 100644 --- a/src/downloader/preference_test.go +++ b/src/downloader/preference_test.go @@ -350,6 +350,40 @@ func TestReleaseSizeScore_FullerIsUnchanged(t *testing.T) { } } +// A 64-file folder is not a 10-track album, however well the folder is +// named. Anything at or under twice the expected count stays eligible -- +// deluxe padding included. +func TestOversizedDir_FlagsADump(t *testing.T) { + track := albumTrackOf(10, 1) + + if !oversizedDir(releaseDir(64), track) { + t.Error("64 files for a 10-track album is not flagged oversized") + } + if oversizedDir(releaseDir(10), track) { + t.Error("the exact 10-track release is flagged oversized") + } + if oversizedDir(releaseDir(20), track) { + t.Error("a 20-track deluxe for a 10-track album is flagged oversized; the cap must leave deluxe padding alone") + } +} + +// Same unknown-count cases as the scoring fallback: no expected count, no +// verdict. +func TestOversizedDir_NeverFiresWithoutAnExpectedCount(t *testing.T) { + cases := map[string]models.Track{ + "no track total": albumTrackOf(0, 1), + "multi disc": albumTrackOf(11, 2), + } + + for name, track := range cases { + t.Run(name, func(t *testing.T) { + if oversizedDir(releaseDir(64), track) { + t.Errorf("64 files flagged oversized with no usable expected count (%s)", name) + } + }) + } +} + // The cap is what stops a right-sized wrong album beating a wrong-sized right // one. Without it the size term could outrank the album name. func TestReleaseSizeScore_NeverExceedsTheCap(t *testing.T) { diff --git a/src/downloader/slskd_album.go b/src/downloader/slskd_album.go index de289400..c7ea80b4 100644 --- a/src/downloader/slskd_album.go +++ b/src/downloader/slskd_album.go @@ -209,10 +209,12 @@ func (c Slskd) CollectAlbumFiles(track models.Track, results SearchResults) ([]F best, bestScore = dir, score } // A lone file in an album-named folder is a stray, not a release -- - // the one-song download in place of the album. It stays eligible so - // a track with nothing better still downloads, but any fuller - // release outranks it regardless of score. - if len(dir.files) > 1 && (bestFull == nil || score > bestFullScore) { + // the one-song download in place of the album -- and a directory + // holding far more than the album should is a dump, not the album + // either. Both stay eligible so a track with nothing better still + // downloads, but a right-sized release outranks them regardless of + // score. + if len(dir.files) > 1 && !oversizedDir(*dir, track) && (bestFull == nil || score > bestFullScore) { bestFull, bestFullScore = dir, score } } diff --git a/src/downloader/slskd_album_test.go b/src/downloader/slskd_album_test.go index bed3a8f8..b4d6d6be 100644 --- a/src/downloader/slskd_album_test.go +++ b/src/downloader/slskd_album_test.go @@ -213,6 +213,63 @@ func TestCollectAlbumFiles_PrefersFullReleaseOverSingleFileStray(t *testing.T) { } } +// A 64-file folder outscores the real 10-track album on the fuller-release +// size term, so the cap has to exclude it from the strict pass. The track +// carries the enriched count, the way a real recommendation does. +func TestCollectAlbumFiles_SkipsAnOversizedDump(t *testing.T) { + client := selectionClient() + track := albumTrack() + track.TrackTotal = 10 + + dump := []string{"01 Bad Guy.flac"} + for i := 2; i <= 64; i++ { + dump = append(dump, "Dump Track.flac") + } + correct := []string{"01 Bury A Friend.flac", "02 Bad Guy.flac", "03 Xanny.flac", + "04 Everything I Wanted.flac", "05 Ocean Eyes.flac", "06 Lovely.flac", + "07 You Should See Me.flac", "08 All The Good Girls.flac", + "09 Wish You Were Gay.flac", "10 Ilomilo.flac"} + + results := albumResults( + peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, dump}, + peer{"peer2", `@@y\Billie Eilish\When We All Fall Asleep`, correct}, + ) + + files, err := client.CollectAlbumFiles(track, results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if files[0].Username != "peer2" { + t.Errorf("selected peer %q, want peer2 -- the 64-file dump won over the 10-track album", files[0].Username) + } + if len(files) != 10 { + t.Errorf("got %d files, want the 10-track album", len(files)) + } +} + +// The dump stays eligible too: with nothing right-sized available, the big +// folder still beats no music. +func TestCollectAlbumFiles_FallsBackToADumpWhenNothingElse(t *testing.T) { + client := selectionClient() + track := albumTrack() + track.TrackTotal = 10 + + dump := []string{"01 Bad Guy.flac"} + for i := 2; i <= 64; i++ { + dump = append(dump, "Dump Track.flac") + } + + results := albumResults(peer{"peer1", `@@x\Billie Eilish\When We All Fall Asleep`, dump}) + + files, err := client.CollectAlbumFiles(track, results) + if err != nil { + t.Fatalf("CollectAlbumFiles() = %v, want nil", err) + } + if len(files) != 64 { + t.Errorf("got %d files, want the 64-file dump as a last resort", len(files)) + } +} + // The stray stays eligible: with nothing fuller available, one song still // beats no song. func TestCollectAlbumFiles_FallsBackToSingleFileWhenNothingElse(t *testing.T) { From 784ea91f758c5383ce4608fcaaaaac683754ae48 Mon Sep 17 00:00:00 2001 From: Gabriel Lewis Date: Tue, 20 Oct 2026 19:12:36 -0700 Subject: [PATCH 57/57] Default RELEASE_PREFERENCE to smaller in album mode With enrichment on, closest-to-real-length now beats most-complete out of the box (BUG-35: the 22-file ZABA folder no longer outranks the 13-track album). Unknown counts still fall back to fuller, and an explicit fuller keeps working -- only the default changes, in env, wizard handler, wizard UI, and sample.env alike. --- sample.env | 14 +++++++------- src/config/config.go | 9 +++++---- src/downloader/preference.go | 4 ++-- src/web/backend/settings/handlers.go | 2 +- src/web/backend/settings/wizard_test.go | 2 +- src/web/frontend/src/components/Wizard.jsx | 4 ++-- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/sample.env b/sample.env index 39fc9d6b..d66d1209 100644 --- a/sample.env +++ b/sample.env @@ -130,13 +130,13 @@ LIBRARY_NAME= # over a 256, since wanting a small file does not mean wanting a bad one. # "larger" is the reverse. Ranking only; nothing is rejected. (default: none) # SIZE_PREFERENCE=none -# Which release album mode takes when several match. "fuller" takes the most -# complete directory, as it always has. "smaller" takes the one closest to the -# album's real track count, so a 21-track deluxe edition does not arrive in -# place of the 11-track album -- note this needs ENRICH_TRACK_METADATA, since -# that is where the real track count comes from, and falls back to "fuller" -# without it. (default: fuller) -# RELEASE_PREFERENCE=fuller +# Which release album mode takes when several match. "smaller" takes the one +# closest to the album's real track count, so a 21-track deluxe edition does +# not arrive in place of the 11-track album -- note this needs +# ENRICH_TRACK_METADATA, since that is where the real track count comes from, +# and falls back to "fuller" without it. "fuller" takes the most complete +# directory, as it always has. (default: smaller) +# RELEASE_PREFERENCE=smaller # Trim a release down to the original album before downloading it. Extra discs a # peer has flattened into one directory are dropped (a real double album is kept # whole when ENRICH_TRACK_METADATA confirms it), and bonus tracks appended past diff --git a/src/config/config.go b/src/config/config.go index 600adc2b..86955478 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -164,10 +164,11 @@ type Slskd struct { // lossy file and falls back to lossless only when there is none; "larger" // is the reverse. SizePreference string `env:"SIZE_PREFERENCE" env-default:"none"` - // Which release to reach for in album mode: "fuller" takes the most - // complete one, as it always has; "smaller" takes the one closest to the - // album's real track count, which needs ENRICH_TRACK_METADATA to know it. - ReleasePreference string `env:"RELEASE_PREFERENCE" env-default:"fuller"` + // Which release to reach for in album mode: "smaller" takes the one + // closest to the album's real track count, which needs + // ENRICH_TRACK_METADATA to know it; "fuller" takes the most complete + // one, as it always has. Unknown counts fall back to fuller either way. + ReleasePreference string `env:"RELEASE_PREFERENCE" env-default:"smaller"` // Trim a candidate release down to the original album before downloading it: // drop extra discs a peer has flattened into one directory, and drop bonus // tracks appended past the album's real length. Needs ENRICH_TRACK_METADATA diff --git a/src/downloader/preference.go b/src/downloader/preference.go index a21e7ef1..21261e8c 100644 --- a/src/downloader/preference.go +++ b/src/downloader/preference.go @@ -168,8 +168,8 @@ func scorePreference(files []File, pref string) int { // Release preferences. Where SizePreference is about how good each file is, // this is about how much of the release to take. const ( - // PreferFullerRelease is how Explo has always chosen: the most complete - // directory wins. + // PreferFullerRelease takes the most complete directory: the historic + // default, still the fallback whenever the real track count is unknown. PreferFullerRelease = "fuller" // PreferSmallerRelease reaches for the release closest to the album's real // track count, which is what keeps a 21-track deluxe edition from being diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 3a7775bb..5114f0ce 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -536,7 +536,7 @@ func (s *Settings) HandleWizardStep3(w http.ResponseWriter, r *http.Request) { body.SizePreference = "none" } if body.ReleasePreference == "" { - body.ReleasePreference = "fuller" + body.ReleasePreference = "smaller" } preferOriginal := "true" if body.PreferOriginalRelease != nil && !*body.PreferOriginalRelease { diff --git a/src/web/backend/settings/wizard_test.go b/src/web/backend/settings/wizard_test.go index 5bc98e87..7075e791 100644 --- a/src/web/backend/settings/wizard_test.go +++ b/src/web/backend/settings/wizard_test.go @@ -230,7 +230,7 @@ func TestWizardStep3_DefaultsTheReleasePreference(t *testing.T) { if err != nil { t.Fatalf("reading written env: %v", err) } - assertEnvValue(t, written, "RELEASE_PREFERENCE", "fuller") + assertEnvValue(t, written, "RELEASE_PREFERENCE", "smaller") } func TestReleasePreferenceIsReadableByTheUI(t *testing.T) { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 66f9bdb7..8e956f7e 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -647,7 +647,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { { value: 'fuller', label: 'Fuller release' }, { value: 'smaller', label: 'Smaller release' }, ].map(opt => { - const active = (releasePreference || 'fuller') === opt.value + const active = (releasePreference || 'smaller') === opt.value return (