Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
4 changes: 2 additions & 2 deletions src/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)

Expand All @@ -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")
Expand Down
319 changes: 319 additions & 0 deletions src/discovery/fresh_releases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
package discovery

import (
"fmt"
"log/slog"
"net/url"
"sort"
"strings"
"time"

cfg "explo/src/config"
"explo/src/models"
"explo/src/util"
)

const (
freshArtistCount = 100
mbMinInterval = 1100 * time.Millisecond

@LumePart LumePart Aug 10, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would change interval to 1.5 seconds minimum. During testing, I still got 503 responses from MusicBrainz. Metadata fetching had a similar issue when it was being implemented.

)

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, config cfg.Listenbrainz) ([]*models.Track, error) {
return (&ListenBrainz{
HttpClient: httpClient,
cfg: config,
}).getFreshReleaseTracks(config.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), 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)
if err != nil {
return nil, err
}
return pickFreshReleases(releases, c.cfg.FreshReleaseLimit), 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",
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 {
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, len(ranked))
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 limit > 0 && len(out) >= limit {
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-%s", coverID, c.cfg.CoverArtSize)

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 ""
}
Loading