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
4 changes: 4 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

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

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

const mbMinInterval = 1500 * 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 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",
FreshReleaseDays: 30,
FreshReleaseLimit: 8,
},
}).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), c.cfg.FreshReleaseDays,
)
releases, err := c.fetchFreshReleases(path)
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 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 ""
}
26 changes: 26 additions & 0 deletions src/discovery/fresh_releases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package discovery

import "testing"

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, 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))
}
}
16 changes: 12 additions & 4 deletions src/discovery/listenbrainz.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,21 @@ 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 {
// 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)
Expand Down
3 changes: 3 additions & 0 deletions src/web/backend/defs/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * 3", "--playlist fresh-releases"},
}

// allConfigKeys is the complete set of env keys the web UI reads and writes.
Expand All @@ -189,6 +190,8 @@ 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",
"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",
Expand Down
7 changes: 5 additions & 2 deletions src/web/backend/playlist/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/web/backend/playlist/playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading