From 6078ec976029fc041c02bb37bb4fff5a97e871d9 Mon Sep 17 00:00:00 2001 From: keligrubb Date: Sat, 25 Jul 2026 11:16:44 -0400 Subject: [PATCH 1/3] 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 6e5d6b3a..211c7fa2 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 ddb25df8..6a1f8a8a 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 5b4756dd..c9ed2eed 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -167,6 +167,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. @@ -176,6 +177,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 88b26181..da8a66a0 100644 --- a/src/web/backend/playlist/playlist.go +++ b/src/web/backend/playlist/playlist.go @@ -70,6 +70,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 e65fffca..2874016b 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({ 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 2744d8ca..8e3f539d 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 }) { @@ -640,6 +641,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 a3401179..ae4f0405 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 64687177a0274386f8009872594a67a7348b16a6 Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Thu, 6 Aug 2026 12:18:07 -0400 Subject: [PATCH 2/3] 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 | 6 +++++- 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 | 6 +++++- 9 files changed, 61 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e4a89951..eb8d285e 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 68511ba1..06ddcd94 100644 --- a/sample.env +++ b/sample.env @@ -13,7 +13,11 @@ LISTENBRAINZ_USER_TOKEN= # 'playlist' to fetch weekly playlist (50 songs), 'api' for fewer songs (good for testing) (default: playlist) # LISTENBRAINZ_DISCOVERY=playlist # Size of the cover art downloaded from coverartarchive (250 or 500) (default: 250) -# COVERART_SIZE=250 +# COVER_ART_SIZE=250 +# 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 874e56a6..9c4c88c4 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -162,6 +162,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 c9ed2eed..4e28b9c5 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -178,6 +178,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 da8a66a0..10011e7e 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" ) @@ -70,8 +72,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 68511ba1..06ddcd94 100644 --- a/src/web/sample.env +++ b/src/web/sample.env @@ -13,7 +13,11 @@ LISTENBRAINZ_USER_TOKEN= # 'playlist' to fetch weekly playlist (50 songs), 'api' for fewer songs (good for testing) (default: playlist) # LISTENBRAINZ_DISCOVERY=playlist # Size of the cover art downloaded from coverartarchive (250 or 500) (default: 250) -# COVERART_SIZE=250 +# COVER_ART_SIZE=250 +# 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 b3afbd646040ad3d25f080fed54e685006d93b8b Mon Sep 17 00:00:00 2001 From: Madison Grubb Date: Thu, 6 Aug 2026 12:56:11 -0400 Subject: [PATCH 3/3] 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 4e28b9c5..ea0d711c 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -167,7 +167,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 2874016b..45899af4 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 8e3f539d..118bada3 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 ae4f0405..59878317 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', }, }