From be797f9d93fbd62347c721b77620d0dc7b9d4c0d Mon Sep 17 00:00:00 2001 From: keligrubb Date: Sat, 25 Jul 2026 11:16:44 -0400 Subject: [PATCH 1/5] add fresh-releases playlist from ListenBrainz Pulls recent albums from artists you already listen to, expands them through MusicBrainz, and wires into the usual Explo schedule/UI path. --- src/config/flags.go | 4 +- src/discovery/fresh_releases.go | 321 ++++++++++++++++++ src/discovery/fresh_releases_test.go | 55 +++ src/discovery/listenbrainz.go | 12 +- src/web/backend/defs/defs.go | 2 + src/web/backend/playlist/handlers.go | 7 +- src/web/backend/playlist/playlist.go | 8 + src/web/frontend/src/components/Settings.jsx | 3 +- src/web/frontend/src/components/Wizard.jsx | 2 + .../src/components/ui/PlaylistCard.jsx | 7 +- 10 files changed, 412 insertions(+), 9 deletions(-) create mode 100644 src/discovery/fresh_releases.go create mode 100644 src/discovery/fresh_releases_test.go diff --git a/src/config/flags.go b/src/config/flags.go index 5d7d6f5c..54e2e96a 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -11,7 +11,7 @@ import ( ) var ( - validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams", "on-repeat"} + validPlaylists = []string{"weekly-exploration", "weekly-jams", "daily-jams", "on-repeat", "fresh-releases"} validDownloadMode = []string{"normal", "skip", "force"} ) @@ -28,7 +28,7 @@ func (cfg *Config) GetFlags() error { var cleanDownloads bool // Long flags flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file") - flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") + flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat, fresh-releases") flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") flag.BoolVar(&persist, "persist", true, "Keep playlists between generations (DEPRECATED - use --replace-playlist instead)") flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") diff --git a/src/discovery/fresh_releases.go b/src/discovery/fresh_releases.go new file mode 100644 index 00000000..1ffda361 --- /dev/null +++ b/src/discovery/fresh_releases.go @@ -0,0 +1,321 @@ +package discovery + +import ( + "fmt" + "log/slog" + "net/url" + "sort" + "strings" + "time" + + cfg "explo/src/config" + "explo/src/models" + "explo/src/util" +) + +const ( + freshDays = 30 + freshMaxReleases = 8 + freshArtistCount = 100 + mbMinInterval = 1100 * time.Millisecond +) + +type freshReleasesResp struct { + Payload struct { + Releases []freshRelease `json:"releases"` + } `json:"payload"` +} + +type freshRelease struct { + ArtistCreditName string `json:"artist_credit_name"` + ArtistMbids []string `json:"artist_mbids"` + CaaReleaseMbid string `json:"caa_release_mbid"` + ReleaseDate string `json:"release_date"` + ReleaseGroupMbid string `json:"release_group_mbid"` + ReleaseGroupPrimaryType string `json:"release_group_primary_type"` + ReleaseMbid string `json:"release_mbid"` + ReleaseName string `json:"release_name"` + Confidence float64 `json:"confidence"` +} + +type topArtistsResp struct { + Payload struct { + Artists []struct { + ArtistMbid string `json:"artist_mbid"` + ArtistName string `json:"artist_name"` + } `json:"artists"` + } `json:"payload"` +} + +type mbRelease struct { + Status string `json:"status"` + ArtistCredit []struct { + Name string `json:"name"` + Artist struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"artist"` + } `json:"artist-credit"` + Media []struct { + Position int `json:"position"` + Tracks []struct { + Position int `json:"position"` + Title string `json:"title"` + Length int `json:"length"` + Recording struct { + ID string `json:"id"` + Title string `json:"title"` + Length int `json:"length"` + } `json:"recording"` + } `json:"tracks"` + } `json:"media"` +} + +// FetchFreshReleaseTracks loads Fresh Releases tracks for UI prefetch. +func FetchFreshReleaseTracks(httpClient *util.HttpClient, user string) ([]*models.Track, error) { + return (&ListenBrainz{ + HttpClient: httpClient, + cfg: cfg.Listenbrainz{User: user, CoverArtSize: "250"}, + }).getFreshReleaseTracks(user) +} + +func (c *ListenBrainz) getFreshReleaseTracks(user string) ([]*models.Track, error) { + releases, err := c.resolveFreshReleases(user) + if err != nil { + return nil, err + } + if len(releases) == 0 { + return nil, fmt.Errorf("no fresh releases for %s", user) + } + + tracks := make([]*models.Track, 0) + for i, rel := range releases { + if i > 0 { + time.Sleep(mbMinInterval) + } + albumTracks, err := c.tracksFromRelease(rel) + if err != nil { + slog.Warn("fresh-releases: skip release", "album", rel.ReleaseName, "err", err) + continue + } + tracks = append(tracks, albumTracks...) + } + if len(tracks) == 0 { + return nil, fmt.Errorf("could not get any tracks from fresh releases") + } + slog.Info("fresh-releases: ready", "user", user, "tracks", len(tracks)) + return tracks, nil +} + +func (c *ListenBrainz) resolveFreshReleases(user string) ([]freshRelease, error) { + path := fmt.Sprintf( + "user/%s/fresh_releases?days=%d&past=true&future=false&sort=confidence", + url.PathEscape(user), freshDays, + ) + if releases, err := c.fetchFreshReleases(path); err != nil { + slog.Debug("fresh-releases: personalized unavailable", "err", err) + } else if picked := pickFreshReleases(releases); len(picked) > 0 { + return picked, nil + } + + releases, err := c.fetchMatchedFreshReleases(user) + if err != nil { + return nil, err + } + return pickFreshReleases(releases), nil +} + +func (c *ListenBrainz) fetchFreshReleases(path string) ([]freshRelease, error) { + body, err := c.lbRequest(path) + if err != nil { + return nil, err + } + var resp freshReleasesResp + if err := util.ParseResp(body, &resp); err != nil { + return nil, err + } + return resp.Payload.Releases, nil +} + +func (c *ListenBrainz) fetchMatchedFreshReleases(user string) ([]freshRelease, error) { + body, err := c.lbRequest(fmt.Sprintf( + "stats/user/%s/artists?range=year&count=%d", + url.PathEscape(user), freshArtistCount, + )) + if err != nil { + return nil, fmt.Errorf("top artists: %w", err) + } + var artists topArtistsResp + if err := util.ParseResp(body, &artists); err != nil { + return nil, err + } + if len(artists.Payload.Artists) == 0 { + return nil, fmt.Errorf("no artist stats for %s", user) + } + + mbids := map[string]struct{}{} + names := map[string]struct{}{} + for _, a := range artists.Payload.Artists { + if a.ArtistMbid != "" { + mbids[a.ArtistMbid] = struct{}{} + } + if a.ArtistName != "" { + names[normalizeName(a.ArtistName)] = struct{}{} + } + } + + releases, err := c.fetchFreshReleases(fmt.Sprintf( + "explore/fresh-releases/?days=%d&past=true&future=false&sort=release_date", + freshDays, + )) + if err != nil { + return nil, err + } + + out := make([]freshRelease, 0) + for _, rel := range releases { + if matchesArtists(rel, mbids, names) { + out = append(out, rel) + } + } + return out, nil +} + +func matchesArtists(rel freshRelease, mbids, names map[string]struct{}) bool { + for _, id := range rel.ArtistMbids { + if _, ok := mbids[id]; ok { + return true + } + } + _, ok := names[normalizeName(rel.ArtistCreditName)] + return ok +} + +func normalizeName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func pickFreshReleases(in []freshRelease) []freshRelease { + ranked := append([]freshRelease(nil), in...) + sort.SliceStable(ranked, func(i, j int) bool { + pi, pj := typeRank(ranked[i].ReleaseGroupPrimaryType), typeRank(ranked[j].ReleaseGroupPrimaryType) + if pi != pj { + return pi < pj + } + if ranked[i].Confidence != ranked[j].Confidence { + return ranked[i].Confidence > ranked[j].Confidence + } + return ranked[i].ReleaseDate > ranked[j].ReleaseDate + }) + + out := make([]freshRelease, 0, freshMaxReleases) + seen := map[string]struct{}{} + for _, rel := range ranked { + if rel.ReleaseMbid == "" { + continue + } + key := rel.ReleaseGroupMbid + if key == "" { + key = rel.ReleaseMbid + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, rel) + if len(out) >= freshMaxReleases { + break + } + } + return out +} + +func typeRank(t string) int { + switch strings.ToLower(t) { + case "album": + return 0 + case "ep": + return 1 + case "single": + return 2 + default: + return 3 + } +} + +func (c *ListenBrainz) tracksFromRelease(rel freshRelease) ([]*models.Track, error) { + body, err := c.HttpClient.MakeRequest("GET", + "https://musicbrainz.org/ws/2/release/"+url.PathEscape(rel.ReleaseMbid)+"?inc=recordings+artists&fmt=json", + nil, nil) + if err != nil { + return nil, err + } + var mb mbRelease + if err := util.ParseResp(body, &mb); err != nil { + return nil, err + } + if mb.Status != "" && !strings.EqualFold(mb.Status, "Official") { + return nil, fmt.Errorf("non-official release (%s)", mb.Status) + } + + artist := rel.ArtistCreditName + mainArtist, mainArtistID := artist, "" + if len(mb.ArtistCredit) > 0 { + ac := mb.ArtistCredit[0] + mainArtistID = ac.Artist.ID + if name := firstNonEmpty(ac.Artist.Name, ac.Name); name != "" { + mainArtist = name + } + if artist == "" { + artist = mainArtist + } + } + + coverID := firstNonEmpty(rel.CaaReleaseMbid, rel.ReleaseMbid) + coverURL := fmt.Sprintf("https://coverartarchive.org/release/%s/front-250", coverID) + + tracks := make([]*models.Track, 0) + for _, media := range mb.Media { + for _, t := range media.Tracks { + title := firstNonEmpty(t.Recording.Title, t.Title) + if title == "" { + continue + } + length := t.Recording.Length + if length == 0 { + length = t.Length + } + tracks = append(tracks, &models.Track{ + Album: rel.ReleaseName, + AlbumArtist: artist, + Artist: artist, + MainArtist: mainArtist, + MainArtistID: mainArtistID, + CleanTitle: title, + Title: title, + Duration: length, + CoverURL: coverURL, + OriginalDate: rel.ReleaseDate, + ReleaseType: rel.ReleaseGroupPrimaryType, + TrackNumber: t.Position, + MusicBrainzTrackID: t.Recording.ID, + MusicBrainzAlbumID: rel.ReleaseMbid, + MusicBrainzReleaseGroupID: rel.ReleaseGroupMbid, + MusicBrainzArtistID: mainArtistID, + }) + } + } + if len(tracks) == 0 { + return nil, fmt.Errorf("no recordings on %s", rel.ReleaseMbid) + } + return tracks, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/src/discovery/fresh_releases_test.go b/src/discovery/fresh_releases_test.go new file mode 100644 index 00000000..dd1a5181 --- /dev/null +++ b/src/discovery/fresh_releases_test.go @@ -0,0 +1,55 @@ +package discovery + +import "testing" + +func TestMatchesArtists(t *testing.T) { + mbids := map[string]struct{}{"artist-1": {}} + names := map[string]struct{}{"artist one": {}} + + cases := []struct { + name string + rel freshRelease + want bool + }{ + { + name: "mbid hit", + rel: freshRelease{ArtistMbids: []string{"artist-1"}}, + want: true, + }, + { + name: "normalized name hit", + rel: freshRelease{ArtistCreditName: " Artist One "}, + want: true, + }, + { + name: "miss", + rel: freshRelease{ArtistCreditName: "Artist Two", ArtistMbids: []string{"artist-2"}}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := matchesArtists(tc.rel, mbids, names); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestPickFreshReleases(t *testing.T) { + in := []freshRelease{ + {ReleaseMbid: "single", ReleaseGroupMbid: "single-group", ReleaseGroupPrimaryType: "Single", ReleaseDate: "2026-01-03"}, + {ReleaseMbid: "album-old", ReleaseGroupMbid: "album-group", ReleaseGroupPrimaryType: "Album", ReleaseDate: "2026-01-01", Confidence: 0.2}, + {ReleaseMbid: "album-new", ReleaseGroupMbid: "album-group", ReleaseGroupPrimaryType: "Album", ReleaseDate: "2026-01-02", Confidence: 0.8}, + {ReleaseMbid: "", ReleaseGroupMbid: "invalid-group", ReleaseGroupPrimaryType: "Album"}, + {ReleaseMbid: "ep", ReleaseGroupMbid: "ep-group", ReleaseGroupPrimaryType: "EP", ReleaseDate: "2026-01-02"}, + } + got := pickFreshReleases(in) + if len(got) != 3 { + t.Fatalf("len=%d want 3: %+v", len(got), got) + } + if got[0].ReleaseMbid != "album-new" || got[1].ReleaseMbid != "ep" || got[2].ReleaseMbid != "single" { + t.Fatalf("unexpected order: %#v", got) + } +} diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 993b5c2d..3e86ef01 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -206,9 +206,15 @@ func NewListenBrainz(cfg cfg.DiscoveryConfig, httpClient *util.HttpClient) *List } } func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { - // Stats-based playlists bypass the discovery mode switch - if c.cfg.ImportPlaylist == "on-repeat" { - tracks, err := c.getTopRecordings(c.cfg.User) + // on-repeat and fresh-releases skip the discovery mode switch + if c.cfg.ImportPlaylist == "on-repeat" || c.cfg.ImportPlaylist == "fresh-releases" { + var tracks []*models.Track + var err error + if c.cfg.ImportPlaylist == "on-repeat" { + tracks, err = c.getTopRecordings(c.cfg.User) + } else { + tracks, err = c.getFreshReleaseTracks(c.cfg.User) + } if err != nil { return nil, err } diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 6a3c289d..c78336f7 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -180,6 +180,7 @@ var PlaylistDefs = map[string]playlistDef{ "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, + "fresh-releases": {"FRESH_RELEASES", "30 08 * * *", "--playlist fresh-releases"}, } // allConfigKeys is the complete set of env keys the web UI reads and writes. @@ -189,6 +190,7 @@ var AllConfigKeys = []string{ "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", + "FRESH_RELEASES_SCHEDULE", "FRESH_RELEASES_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", diff --git a/src/web/backend/playlist/handlers.go b/src/web/backend/playlist/handlers.go index 49ff8faa..0c5a3d54 100644 --- a/src/web/backend/playlist/handlers.go +++ b/src/web/backend/playlist/handlers.go @@ -380,9 +380,12 @@ func (p *Playlist) HandlePrefetchCovers(w http.ResponseWriter, r *http.Request) } var tracks []PlaylistTrack var err error - if pt == "on-repeat" { + switch pt { + case "on-repeat": tracks, err = fetchOnRepeatTracks(body.User) - } else { + case "fresh-releases": + tracks, err = fetchFreshReleaseTracks(body.User) + default: tracks, err = fetchMostRecentLBPlaylist(body.User, pt) } if err != nil { diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index d37b285a..63123208 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -71,6 +71,14 @@ func fetchOnRepeatTracks(username string) ([]PlaylistTrack, error) { return modelTracksToPlaylistTracks(tracks), nil } +func fetchFreshReleaseTracks(username string) ([]PlaylistTrack, error) { + tracks, err := discovery.FetchFreshReleaseTracks(util.NewHttp(util.HttpClientConfig{Timeout: 60}), username) + if err != nil { + return nil, err + } + return modelTracksToPlaylistTracks(tracks), nil +} + func fetchMostRecentLBPlaylist(username, playlistType string) ([]PlaylistTrack, error) { tracks, err := discovery.FetchMostRecentPlaylistByType(util.NewHttp(util.HttpClientConfig{Timeout: 30}), username, playlistType) if err != nil { diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 2908642f..a616c80c 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -86,6 +86,7 @@ const PLAYLISTS = [ { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', flagsKey: 'WEEKLY_JAMS_FLAGS', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', flagsKey: 'DAILY_JAMS_FLAGS', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', flagsKey: 'ON_REPEAT_FLAGS', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, + { value: 'fresh-releases', name: 'Fresh Releases', scheduleKey: 'FRESH_RELEASES_SCHEDULE', flagsKey: 'FRESH_RELEASES_FLAGS', defaultDay: -1, defaultHour: 8, defaultMinute: 30 }, ] const SCHEDULE_DAYS = [ @@ -963,7 +964,7 @@ export default function Settings({ authDisabled, onWizard, onLogout }) { useEffect(() => { if (_bgCoverCache) return - Promise.all(['weekly-exploration', 'weekly-jams', 'daily-jams', 'on-repeat'].map( + Promise.all(['weekly-exploration', 'weekly-jams', 'daily-jams', 'on-repeat', 'fresh-releases'].map( t => fetchPlaylistTracks(t).catch(() => ({ tracks: [] })) )).then(results => { const covers = results.flatMap(r => (r.tracks ?? []).map(t => t.coverUrl).filter(Boolean)) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 56421f23..7dc169f0 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -43,6 +43,7 @@ const PLAYLISTS = [ { value: 'weekly-exploration', name: 'Weekly Exploration', desc: '~50 tracks · refreshes every Tuesday' }, { value: 'weekly-jams', name: 'Weekly Jams', desc: '~25 tracks · refreshes every Monday' }, { value: 'daily-jams', name: 'Daily Jams', desc: '~25 tracks · refreshes daily' }, + { value: 'fresh-releases', name: 'Fresh Releases', desc: 'new albums from artists you listen to · daily' }, ]; function Step1({ fields, setField, envSources, onNext, saving }) { @@ -744,6 +745,7 @@ export default function Wizard({ "weekly-exploration": !!config.WEEKLY_EXPLORATION_SCHEDULE, "weekly-jams": !!config.WEEKLY_JAMS_SCHEDULE, "daily-jams": !!config.DAILY_JAMS_SCHEDULE, + "fresh-releases": !!config.FRESH_RELEASES_SCHEDULE, }, // Step 2 system: config.EXPLO_SYSTEM || "", diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index 69b84868..aa1312d2 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -96,7 +96,7 @@ function nextUpdateLabel(playlistType) { if (playlistType === 'on-repeat') { return 'Updates as you listen' } - if (playlistType === 'daily-jams') { + if (playlistType === 'daily-jams' || playlistType === 'fresh-releases') { const tomorrow = new Date(now) tomorrow.setDate(tomorrow.getDate() + 1) return `Next update tomorrow (${tomorrow.toLocaleDateString([], { weekday: 'long' })})` @@ -264,6 +264,11 @@ const PRESETS = { accent: '#fb7185', label: 'MONTHLY', }, + 'fresh-releases': { + background: cardGradient('#0d9488', '#14b8a6', '#5eead4'), + accent: '#2dd4bf', + label: 'DAILY', + }, } const FALLBACK = { From b73e580ac0205bba545cb201ee70d49c9a4e05da Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Thu, 6 Aug 2026 12:18:07 -0400 Subject: [PATCH 2/5] configure and document Fresh Releases limits Keep full album tracklists while allowing the lookup window and release count to be tuned through the existing environment configuration. Document the new playlist and settings in the README and sample env files. --- README.md | 1 + sample.env | 4 ++++ src/config/config.go | 2 ++ src/discovery/fresh_releases.go | 24 ++++++++++------------ src/discovery/fresh_releases_test.go | 8 +++++++- src/web/backend/defs/defs.go | 1 + src/web/backend/playlist/handlers.go | 2 +- src/web/backend/playlist/playlist.go | 30 ++++++++++++++++++++++++++-- src/web/sample.env | 4 ++++ 9 files changed, 59 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index be864f50..dac0ab9a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t - Weekly Exploration - Weekly Jams - Daily Jams + - Fresh Releases - Import custom playlists from: - Apple Music - ListenBrainz diff --git a/sample.env b/sample.env index f6f33587..a2e674ba 100644 --- a/sample.env +++ b/sample.env @@ -12,6 +12,10 @@ LISTENBRAINZ_USER= LISTENBRAINZ_USER_TOKEN= # 'playlist' to fetch weekly playlist (50 songs), 'api' for fewer songs (good for testing) (default: playlist) # LISTENBRAINZ_DISCOVERY=playlist +# Number of days included in Fresh Releases (default: 30) +# FRESH_RELEASES_DAYS=30 +# Maximum releases included in Fresh Releases (default: 8, 0 for no limit) +# FRESH_RELEASES_MAX_RELEASES=8 # Enrich playlist tracks with full metadata from metadata/recording endpoint (default: false) # ENRICH_TRACK_METADATA=false diff --git a/src/config/config.go b/src/config/config.go index 2205cdc1..9e6a3086 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -191,6 +191,8 @@ type Listenbrainz struct { ImportPlaylist string SingleArtist bool `env:"SINGLE_ARTIST" env-default:"true"` CoverArtSize string `env:"COVER_ART_SIZE" env-default:"250"` + FreshReleaseDays int `env:"FRESH_RELEASES_DAYS" env-default:"30"` + FreshReleaseLimit int `env:"FRESH_RELEASES_MAX_RELEASES" env-default:"8"` EnrichTrackMetadata bool `env:"ENRICH_TRACK_METADATA" env-default:"false"` } diff --git a/src/discovery/fresh_releases.go b/src/discovery/fresh_releases.go index 1ffda361..f0afbb93 100644 --- a/src/discovery/fresh_releases.go +++ b/src/discovery/fresh_releases.go @@ -14,8 +14,6 @@ import ( ) const ( - freshDays = 30 - freshMaxReleases = 8 freshArtistCount = 100 mbMinInterval = 1100 * time.Millisecond ) @@ -72,11 +70,11 @@ type mbRelease struct { } // FetchFreshReleaseTracks loads Fresh Releases tracks for UI prefetch. -func FetchFreshReleaseTracks(httpClient *util.HttpClient, user string) ([]*models.Track, error) { +func FetchFreshReleaseTracks(httpClient *util.HttpClient, config cfg.Listenbrainz) ([]*models.Track, error) { return (&ListenBrainz{ HttpClient: httpClient, - cfg: cfg.Listenbrainz{User: user, CoverArtSize: "250"}, - }).getFreshReleaseTracks(user) + cfg: config, + }).getFreshReleaseTracks(config.User) } func (c *ListenBrainz) getFreshReleaseTracks(user string) ([]*models.Track, error) { @@ -110,11 +108,11 @@ func (c *ListenBrainz) getFreshReleaseTracks(user string) ([]*models.Track, erro func (c *ListenBrainz) resolveFreshReleases(user string) ([]freshRelease, error) { path := fmt.Sprintf( "user/%s/fresh_releases?days=%d&past=true&future=false&sort=confidence", - url.PathEscape(user), freshDays, + url.PathEscape(user), c.cfg.FreshReleaseDays, ) if releases, err := c.fetchFreshReleases(path); err != nil { slog.Debug("fresh-releases: personalized unavailable", "err", err) - } else if picked := pickFreshReleases(releases); len(picked) > 0 { + } else if picked := pickFreshReleases(releases, c.cfg.FreshReleaseLimit); len(picked) > 0 { return picked, nil } @@ -122,7 +120,7 @@ func (c *ListenBrainz) resolveFreshReleases(user string) ([]freshRelease, error) if err != nil { return nil, err } - return pickFreshReleases(releases), nil + return pickFreshReleases(releases, c.cfg.FreshReleaseLimit), nil } func (c *ListenBrainz) fetchFreshReleases(path string) ([]freshRelease, error) { @@ -166,7 +164,7 @@ func (c *ListenBrainz) fetchMatchedFreshReleases(user string) ([]freshRelease, e releases, err := c.fetchFreshReleases(fmt.Sprintf( "explore/fresh-releases/?days=%d&past=true&future=false&sort=release_date", - freshDays, + c.cfg.FreshReleaseDays, )) if err != nil { return nil, err @@ -195,7 +193,7 @@ func normalizeName(name string) string { return strings.ToLower(strings.TrimSpace(name)) } -func pickFreshReleases(in []freshRelease) []freshRelease { +func pickFreshReleases(in []freshRelease, limit int) []freshRelease { ranked := append([]freshRelease(nil), in...) sort.SliceStable(ranked, func(i, j int) bool { pi, pj := typeRank(ranked[i].ReleaseGroupPrimaryType), typeRank(ranked[j].ReleaseGroupPrimaryType) @@ -208,7 +206,7 @@ func pickFreshReleases(in []freshRelease) []freshRelease { return ranked[i].ReleaseDate > ranked[j].ReleaseDate }) - out := make([]freshRelease, 0, freshMaxReleases) + out := make([]freshRelease, 0, len(ranked)) seen := map[string]struct{}{} for _, rel := range ranked { if rel.ReleaseMbid == "" { @@ -223,7 +221,7 @@ func pickFreshReleases(in []freshRelease) []freshRelease { } seen[key] = struct{}{} out = append(out, rel) - if len(out) >= freshMaxReleases { + if limit > 0 && len(out) >= limit { break } } @@ -272,7 +270,7 @@ func (c *ListenBrainz) tracksFromRelease(rel freshRelease) ([]*models.Track, err } coverID := firstNonEmpty(rel.CaaReleaseMbid, rel.ReleaseMbid) - coverURL := fmt.Sprintf("https://coverartarchive.org/release/%s/front-250", coverID) + coverURL := fmt.Sprintf("https://coverartarchive.org/release/%s/front-%s", coverID, c.cfg.CoverArtSize) tracks := make([]*models.Track, 0) for _, media := range mb.Media { diff --git a/src/discovery/fresh_releases_test.go b/src/discovery/fresh_releases_test.go index dd1a5181..0965ba2d 100644 --- a/src/discovery/fresh_releases_test.go +++ b/src/discovery/fresh_releases_test.go @@ -45,11 +45,17 @@ func TestPickFreshReleases(t *testing.T) { {ReleaseMbid: "", ReleaseGroupMbid: "invalid-group", ReleaseGroupPrimaryType: "Album"}, {ReleaseMbid: "ep", ReleaseGroupMbid: "ep-group", ReleaseGroupPrimaryType: "EP", ReleaseDate: "2026-01-02"}, } - got := pickFreshReleases(in) + got := pickFreshReleases(in, 8) if len(got) != 3 { t.Fatalf("len=%d want 3: %+v", len(got), got) } if got[0].ReleaseMbid != "album-new" || got[1].ReleaseMbid != "ep" || got[2].ReleaseMbid != "single" { t.Fatalf("unexpected order: %#v", got) } + if got := pickFreshReleases(in, 2); len(got) != 2 { + t.Fatalf("limited len=%d want 2", len(got)) + } + if got := pickFreshReleases(in, 0); len(got) != 3 { + t.Fatalf("unlimited len=%d want 3", len(got)) + } } diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index c78336f7..c839dd3a 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -191,6 +191,7 @@ var AllConfigKeys = []string{ "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "FRESH_RELEASES_SCHEDULE", "FRESH_RELEASES_FLAGS", + "FRESH_RELEASES_DAYS", "FRESH_RELEASES_MAX_RELEASES", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", diff --git a/src/web/backend/playlist/handlers.go b/src/web/backend/playlist/handlers.go index 0c5a3d54..8b7a4cac 100644 --- a/src/web/backend/playlist/handlers.go +++ b/src/web/backend/playlist/handlers.go @@ -384,7 +384,7 @@ func (p *Playlist) HandlePrefetchCovers(w http.ResponseWriter, r *http.Request) case "on-repeat": tracks, err = fetchOnRepeatTracks(body.User) case "fresh-releases": - tracks, err = fetchFreshReleaseTracks(body.User) + tracks, err = p.fetchFreshReleaseTracks(body.User) default: tracks, err = fetchMostRecentLBPlaylist(body.User, pt) } diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index 63123208..3c91cafa 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -3,6 +3,7 @@ package playlist import ( "bytes" "encoding/json" + cfg "explo/src/config" "explo/src/discovery" "explo/src/models" "explo/src/util" @@ -18,6 +19,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" ) @@ -71,8 +73,32 @@ func fetchOnRepeatTracks(username string) ([]PlaylistTrack, error) { return modelTracksToPlaylistTracks(tracks), nil } -func fetchFreshReleaseTracks(username string) ([]PlaylistTrack, error) { - tracks, err := discovery.FetchFreshReleaseTracks(util.NewHttp(util.HttpClientConfig{Timeout: 60}), username) +func (p *Playlist) fetchFreshReleaseTracks(username string) ([]PlaylistTrack, error) { + config := cfg.Listenbrainz{ + User: username, + CoverArtSize: "250", + FreshReleaseDays: 30, + FreshReleaseLimit: 8, + } + values := map[string]string{} + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + values = p.settings.ParseEnvText(string(data)) + } + for _, key := range []string{"COVER_ART_SIZE", "FRESH_RELEASES_DAYS", "FRESH_RELEASES_MAX_RELEASES"} { + if value, ok := os.LookupEnv(key); ok { + values[key] = value + } + } + if value := values["COVER_ART_SIZE"]; value != "" { + config.CoverArtSize = value + } + if value, err := strconv.Atoi(values["FRESH_RELEASES_DAYS"]); err == nil { + config.FreshReleaseDays = value + } + if value, err := strconv.Atoi(values["FRESH_RELEASES_MAX_RELEASES"]); err == nil { + config.FreshReleaseLimit = value + } + tracks, err := discovery.FetchFreshReleaseTracks(util.NewHttp(util.HttpClientConfig{Timeout: 60}), config) if err != nil { return nil, err } diff --git a/src/web/sample.env b/src/web/sample.env index f6f33587..a2e674ba 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -12,6 +12,10 @@ LISTENBRAINZ_USER= LISTENBRAINZ_USER_TOKEN= # 'playlist' to fetch weekly playlist (50 songs), 'api' for fewer songs (good for testing) (default: playlist) # LISTENBRAINZ_DISCOVERY=playlist +# Number of days included in Fresh Releases (default: 30) +# FRESH_RELEASES_DAYS=30 +# Maximum releases included in Fresh Releases (default: 8, 0 for no limit) +# FRESH_RELEASES_MAX_RELEASES=8 # Enrich playlist tracks with full metadata from metadata/recording endpoint (default: false) # ENRICH_TRACK_METADATA=false From 22137d1036c1d364233aea0a64ec6b7318bdcad2 Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Thu, 6 Aug 2026 12:56:11 -0400 Subject: [PATCH 3/5] schedule Fresh Releases weekly instead of daily Album releases do not change often enough to justify a daily full-album import. Default to Wednesday 08:30 so it sits between Weekly Jams and Weekly Exploration. --- src/web/backend/defs/defs.go | 2 +- src/web/frontend/src/components/Settings.jsx | 2 +- src/web/frontend/src/components/Wizard.jsx | 2 +- src/web/frontend/src/components/ui/PlaylistCard.jsx | 10 ++++++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index c839dd3a..6fb1314f 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -180,7 +180,7 @@ var PlaylistDefs = map[string]playlistDef{ "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, - "fresh-releases": {"FRESH_RELEASES", "30 08 * * *", "--playlist fresh-releases"}, + "fresh-releases": {"FRESH_RELEASES", "30 08 * * 3", "--playlist fresh-releases"}, } // allConfigKeys is the complete set of env keys the web UI reads and writes. diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index a616c80c..b9e1797a 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -86,7 +86,7 @@ const PLAYLISTS = [ { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', flagsKey: 'WEEKLY_JAMS_FLAGS', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', flagsKey: 'DAILY_JAMS_FLAGS', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', flagsKey: 'ON_REPEAT_FLAGS', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, - { value: 'fresh-releases', name: 'Fresh Releases', scheduleKey: 'FRESH_RELEASES_SCHEDULE', flagsKey: 'FRESH_RELEASES_FLAGS', defaultDay: -1, defaultHour: 8, defaultMinute: 30 }, + { value: 'fresh-releases', name: 'Fresh Releases', scheduleKey: 'FRESH_RELEASES_SCHEDULE', flagsKey: 'FRESH_RELEASES_FLAGS', defaultDay: 3, defaultHour: 8, defaultMinute: 30 }, ] const SCHEDULE_DAYS = [ diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 7dc169f0..e5697895 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -43,7 +43,7 @@ const PLAYLISTS = [ { value: 'weekly-exploration', name: 'Weekly Exploration', desc: '~50 tracks · refreshes every Tuesday' }, { value: 'weekly-jams', name: 'Weekly Jams', desc: '~25 tracks · refreshes every Monday' }, { value: 'daily-jams', name: 'Daily Jams', desc: '~25 tracks · refreshes daily' }, - { value: 'fresh-releases', name: 'Fresh Releases', desc: 'new albums from artists you listen to · daily' }, + { value: 'fresh-releases', name: 'Fresh Releases', desc: 'new albums from artists you listen to · refreshes every Wednesday' }, ]; function Step1({ fields, setField, envSources, onNext, saving }) { diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index aa1312d2..87f47a45 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -96,11 +96,17 @@ function nextUpdateLabel(playlistType) { if (playlistType === 'on-repeat') { return 'Updates as you listen' } - if (playlistType === 'daily-jams' || playlistType === 'fresh-releases') { + if (playlistType === 'daily-jams') { const tomorrow = new Date(now) tomorrow.setDate(tomorrow.getDate() + 1) return `Next update tomorrow (${tomorrow.toLocaleDateString([], { weekday: 'long' })})` } + if (playlistType === 'fresh-releases') { + const daysUntilWednesday = (3 - now.getDay() + 7) % 7 || 7 + const nextWednesday = new Date(now) + nextWednesday.setDate(now.getDate() + daysUntilWednesday) + return `Next update ${nextWednesday.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })}` + } // Weekly playlists: LB generates on Mondays const daysUntilMonday = (8 - now.getDay()) % 7 || 7 const nextMonday = new Date(now) @@ -267,7 +273,7 @@ const PRESETS = { 'fresh-releases': { background: cardGradient('#0d9488', '#14b8a6', '#5eead4'), accent: '#2dd4bf', - label: 'DAILY', + label: 'WEEKLY', }, } From b4286a715bd95b16aab87c0548715bf4dc20ae76 Mon Sep 17 00:00:00 2001 From: keligrubb Date: Thu, 20 Aug 2026 22:10:54 -0400 Subject: [PATCH 4/5] slow MusicBrainz fresh-releases pacing to 1.5s 1.1s still triggered 503s in review testing, matching earlier metadata enrichment experience. --- src/discovery/fresh_releases.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discovery/fresh_releases.go b/src/discovery/fresh_releases.go index f0afbb93..56fb85aa 100644 --- a/src/discovery/fresh_releases.go +++ b/src/discovery/fresh_releases.go @@ -15,7 +15,7 @@ import ( const ( freshArtistCount = 100 - mbMinInterval = 1100 * time.Millisecond + mbMinInterval = 1500 * time.Millisecond ) type freshReleasesResp struct { From 921469ad15fcafe06df8dc016a809c3452708c4c Mon Sep 17 00:00:00 2001 From: keligrubb Date: Mon, 12 Oct 2026 21:26:02 -0400 Subject: [PATCH 5/5] Trim fresh-releases discovery and prefetch path. Use personalized ListenBrainz only, default prefetch like on-repeat, and skip metadata enrich after MusicBrainz expansion. --- src/discovery/fresh_releases.go | 91 +++------------------------- src/discovery/fresh_releases_test.go | 35 ----------- src/discovery/listenbrainz.go | 4 +- src/web/backend/playlist/handlers.go | 2 +- src/web/backend/playlist/playlist.go | 30 +-------- 5 files changed, 16 insertions(+), 146 deletions(-) diff --git a/src/discovery/fresh_releases.go b/src/discovery/fresh_releases.go index 56fb85aa..9f6e1441 100644 --- a/src/discovery/fresh_releases.go +++ b/src/discovery/fresh_releases.go @@ -13,10 +13,7 @@ import ( "explo/src/util" ) -const ( - freshArtistCount = 100 - mbMinInterval = 1500 * time.Millisecond -) +const mbMinInterval = 1500 * time.Millisecond type freshReleasesResp struct { Payload struct { @@ -36,15 +33,6 @@ type freshRelease struct { Confidence float64 `json:"confidence"` } -type topArtistsResp struct { - Payload struct { - Artists []struct { - ArtistMbid string `json:"artist_mbid"` - ArtistName string `json:"artist_name"` - } `json:"artists"` - } `json:"payload"` -} - type mbRelease struct { Status string `json:"status"` ArtistCredit []struct { @@ -70,11 +58,16 @@ type mbRelease struct { } // FetchFreshReleaseTracks loads Fresh Releases tracks for UI prefetch. -func FetchFreshReleaseTracks(httpClient *util.HttpClient, config cfg.Listenbrainz) ([]*models.Track, error) { +func FetchFreshReleaseTracks(httpClient *util.HttpClient, user string) ([]*models.Track, error) { return (&ListenBrainz{ HttpClient: httpClient, - cfg: config, - }).getFreshReleaseTracks(config.User) + cfg: cfg.Listenbrainz{ + User: user, + CoverArtSize: "250", + FreshReleaseDays: 30, + FreshReleaseLimit: 8, + }, + }).getFreshReleaseTracks(user) } func (c *ListenBrainz) getFreshReleaseTracks(user string) ([]*models.Track, error) { @@ -110,13 +103,7 @@ func (c *ListenBrainz) resolveFreshReleases(user string) ([]freshRelease, error) "user/%s/fresh_releases?days=%d&past=true&future=false&sort=confidence", url.PathEscape(user), c.cfg.FreshReleaseDays, ) - if releases, err := c.fetchFreshReleases(path); err != nil { - slog.Debug("fresh-releases: personalized unavailable", "err", err) - } else if picked := pickFreshReleases(releases, c.cfg.FreshReleaseLimit); len(picked) > 0 { - return picked, nil - } - - releases, err := c.fetchMatchedFreshReleases(user) + releases, err := c.fetchFreshReleases(path) if err != nil { return nil, err } @@ -135,64 +122,6 @@ func (c *ListenBrainz) fetchFreshReleases(path string) ([]freshRelease, error) { return resp.Payload.Releases, nil } -func (c *ListenBrainz) fetchMatchedFreshReleases(user string) ([]freshRelease, error) { - body, err := c.lbRequest(fmt.Sprintf( - "stats/user/%s/artists?range=year&count=%d", - url.PathEscape(user), freshArtistCount, - )) - if err != nil { - return nil, fmt.Errorf("top artists: %w", err) - } - var artists topArtistsResp - if err := util.ParseResp(body, &artists); err != nil { - return nil, err - } - if len(artists.Payload.Artists) == 0 { - return nil, fmt.Errorf("no artist stats for %s", user) - } - - mbids := map[string]struct{}{} - names := map[string]struct{}{} - for _, a := range artists.Payload.Artists { - if a.ArtistMbid != "" { - mbids[a.ArtistMbid] = struct{}{} - } - if a.ArtistName != "" { - names[normalizeName(a.ArtistName)] = struct{}{} - } - } - - releases, err := c.fetchFreshReleases(fmt.Sprintf( - "explore/fresh-releases/?days=%d&past=true&future=false&sort=release_date", - c.cfg.FreshReleaseDays, - )) - if err != nil { - return nil, err - } - - out := make([]freshRelease, 0) - for _, rel := range releases { - if matchesArtists(rel, mbids, names) { - out = append(out, rel) - } - } - return out, nil -} - -func matchesArtists(rel freshRelease, mbids, names map[string]struct{}) bool { - for _, id := range rel.ArtistMbids { - if _, ok := mbids[id]; ok { - return true - } - } - _, ok := names[normalizeName(rel.ArtistCreditName)] - return ok -} - -func normalizeName(name string) string { - return strings.ToLower(strings.TrimSpace(name)) -} - func pickFreshReleases(in []freshRelease, limit int) []freshRelease { ranked := append([]freshRelease(nil), in...) sort.SliceStable(ranked, func(i, j int) bool { diff --git a/src/discovery/fresh_releases_test.go b/src/discovery/fresh_releases_test.go index 0965ba2d..c7d5492f 100644 --- a/src/discovery/fresh_releases_test.go +++ b/src/discovery/fresh_releases_test.go @@ -2,41 +2,6 @@ package discovery import "testing" -func TestMatchesArtists(t *testing.T) { - mbids := map[string]struct{}{"artist-1": {}} - names := map[string]struct{}{"artist one": {}} - - cases := []struct { - name string - rel freshRelease - want bool - }{ - { - name: "mbid hit", - rel: freshRelease{ArtistMbids: []string{"artist-1"}}, - want: true, - }, - { - name: "normalized name hit", - rel: freshRelease{ArtistCreditName: " Artist One "}, - want: true, - }, - { - name: "miss", - rel: freshRelease{ArtistCreditName: "Artist Two", ArtistMbids: []string{"artist-2"}}, - want: false, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := matchesArtists(tc.rel, mbids, names); got != tc.want { - t.Fatalf("got %v, want %v", got, tc.want) - } - }) - } -} - func TestPickFreshReleases(t *testing.T) { in := []freshRelease{ {ReleaseMbid: "single", ReleaseGroupMbid: "single-group", ReleaseGroupPrimaryType: "Single", ReleaseDate: "2026-01-03"}, diff --git a/src/discovery/listenbrainz.go b/src/discovery/listenbrainz.go index 3e86ef01..a2bb30aa 100644 --- a/src/discovery/listenbrainz.go +++ b/src/discovery/listenbrainz.go @@ -213,12 +213,14 @@ func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) { if c.cfg.ImportPlaylist == "on-repeat" { tracks, err = c.getTopRecordings(c.cfg.User) } else { + // Fresh releases already expand full tracklists via MusicBrainz. tracks, err = c.getFreshReleaseTracks(c.cfg.User) } if err != nil { return nil, err } - if c.cfg.EnrichTrackMetadata && len(tracks) > 0 { + // Skip enrich for fresh-releases: tracksFromRelease already populated MB fields. + if c.cfg.ImportPlaylist == "on-repeat" && c.cfg.EnrichTrackMetadata && len(tracks) > 0 { enrichedTracks, err := c.enrichTracks(tracks, c.cfg.SingleArtist) if err != nil { slog.Warn("failed to enrich playlist metadata", "error", err) diff --git a/src/web/backend/playlist/handlers.go b/src/web/backend/playlist/handlers.go index 8b7a4cac..0c5a3d54 100644 --- a/src/web/backend/playlist/handlers.go +++ b/src/web/backend/playlist/handlers.go @@ -384,7 +384,7 @@ func (p *Playlist) HandlePrefetchCovers(w http.ResponseWriter, r *http.Request) case "on-repeat": tracks, err = fetchOnRepeatTracks(body.User) case "fresh-releases": - tracks, err = p.fetchFreshReleaseTracks(body.User) + tracks, err = fetchFreshReleaseTracks(body.User) default: tracks, err = fetchMostRecentLBPlaylist(body.User, pt) } diff --git a/src/web/backend/playlist/playlist.go b/src/web/backend/playlist/playlist.go index 3c91cafa..63123208 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -3,7 +3,6 @@ package playlist import ( "bytes" "encoding/json" - cfg "explo/src/config" "explo/src/discovery" "explo/src/models" "explo/src/util" @@ -19,7 +18,6 @@ import ( "net/http" "os" "path/filepath" - "strconv" "strings" ) @@ -73,32 +71,8 @@ func fetchOnRepeatTracks(username string) ([]PlaylistTrack, error) { return modelTracksToPlaylistTracks(tracks), nil } -func (p *Playlist) fetchFreshReleaseTracks(username string) ([]PlaylistTrack, error) { - config := cfg.Listenbrainz{ - User: username, - CoverArtSize: "250", - FreshReleaseDays: 30, - FreshReleaseLimit: 8, - } - values := map[string]string{} - if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { - values = p.settings.ParseEnvText(string(data)) - } - for _, key := range []string{"COVER_ART_SIZE", "FRESH_RELEASES_DAYS", "FRESH_RELEASES_MAX_RELEASES"} { - if value, ok := os.LookupEnv(key); ok { - values[key] = value - } - } - if value := values["COVER_ART_SIZE"]; value != "" { - config.CoverArtSize = value - } - if value, err := strconv.Atoi(values["FRESH_RELEASES_DAYS"]); err == nil { - config.FreshReleaseDays = value - } - if value, err := strconv.Atoi(values["FRESH_RELEASES_MAX_RELEASES"]); err == nil { - config.FreshReleaseLimit = value - } - tracks, err := discovery.FetchFreshReleaseTracks(util.NewHttp(util.HttpClientConfig{Timeout: 60}), config) +func fetchFreshReleaseTracks(username string) ([]PlaylistTrack, error) { + tracks, err := discovery.FetchFreshReleaseTracks(util.NewHttp(util.HttpClientConfig{Timeout: 60}), username) if err != nil { return nil, err }