From 6078ec976029fc041c02bb37bb4fff5a97e871d9 Mon Sep 17 00:00:00 2001 From: keligrubb Date: Sat, 25 Jul 2026 11:16:44 -0400 Subject: [PATCH] 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 6e5d6b3..211c7fa 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 0000000..1ffda36 --- /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 0000000..dd1a518 --- /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 ddb25df..6a1f8a8 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 5b4756d..c9ed2ee 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 49ff8fa..0c5a3d5 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 88b2618..da8a66a 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 e65fffc..2874016 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 2744d8c..8e3f539 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 a340117..ae4f040 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 = {