Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ explo
src/web/dist/
src/web/frontend/node_modules/
/cache
data/
/data/
/.data/
.zed/
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/u2takey/ffmpeg-go v0.5.0
github.com/wader/goutubedl v0.0.0-20250417150709-083444e4ab87
golang.org/x/crypto v0.46.0
golang.org/x/net v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.32.0
maunium.net/go/mautrix v0.26.0
Expand Down Expand Up @@ -43,7 +44,6 @@ require (
github.com/u2takey/go-utils v0.3.1 // indirect
go.mau.fi/util v0.9.3 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
Expand Down
55 changes: 54 additions & 1 deletion src/client/client.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
package client

import (
"bytes"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"

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

// uploadPlaylistArtwork POSTs raw image bytes to a music app's artwork endpoint.
// Plex, Jellyfin, and Emby all accept the same format — POST + Content-Type: image/jpeg + raw body.
// The only per-client difference is the URL path, which each caller builds before invoking.
func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, headers map[string]string) error {
data, err := os.ReadFile(localPath)
if err != nil {
return fmt.Errorf("read artwork: %w", err)
}
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "image/jpeg")
req.ContentLength = int64(len(data))
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := hc.Client.Do(req)
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer func() {
if cerr := resp.Body.Close(); cerr != nil {
slog.Warn("artwork upload: response body close failed", "err", cerr.Error())
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload artwork: status %d, body: %s", resp.StatusCode, string(body))
}
return nil
}

// Client manages interactions with the selected music system
type Client struct {
System string
Expand All @@ -31,6 +68,12 @@ type APIClient interface {
DeletePlaylist() error
}

// ArtworkUploader is an optional capability for clients that support setting
// playlist artwork. Use a type assertion: if u, ok := c.API.(client.ArtworkUploader); ok {...}.
type ArtworkUploader interface {
SetPlaylistArtwork(localPath string) error
}

// NewClient initializes a client and sets up authentication
func NewClient(cfg *config.Config) (*Client, error) {
c := &Client{
Expand Down Expand Up @@ -70,6 +113,16 @@ func NewClient(cfg *config.Config) (*Client, error) {
return c, nil
}

// TriggerRefresh Runs a trigger to refresh the users app music library
// Useful for one-shot operations
func TriggerRefresh(cfg *config.Config) error {
c, err := NewClient(cfg)
if err != nil {
return fmt.Errorf("client setup: %w", err)
}
return c.API.RefreshLibrary()
}

// systemSetup checks needed credentials and initializes the selected system
func (c *Client) systemSetup() error {
switch c.System {
Expand Down Expand Up @@ -111,7 +164,7 @@ func (c *Client) systemSetup() error {
}

}

if err := c.API.AddHeader(); err != nil {
return err
}
Expand Down
14 changes: 11 additions & 3 deletions src/client/emby.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func (c *Emby) CheckRefreshState() bool {

func (c *Emby) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle))
reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)))

body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers)
if err != nil {
Expand Down Expand Up @@ -162,7 +162,7 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error {
}

func (c *Emby) SearchPlaylist() error {
params := fmt.Sprintf("/emby/Items?SearchTerm=%s&Recursive=true&IncludeItemTypes=Playlist", c.Cfg.PlaylistName)
params := fmt.Sprintf("/emby/Items?SearchTerm=%s&Recursive=true&IncludeItemTypes=Playlist", url.QueryEscape(c.Cfg.PlaylistName))

body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers)
if err != nil {
Expand All @@ -185,7 +185,7 @@ func (c *Emby) SearchPlaylist() error {
func (c *Emby) CreatePlaylist(tracks []*models.Track) error {
songIDs := formatEmbySongs(tracks)

reqParam := fmt.Sprintf("/emby/Playlists?Name=%s&Ids=%s&MediaType=Music", c.Cfg.PlaylistName, songIDs)
reqParam := fmt.Sprintf("/emby/Playlists?Name=%s&Ids=%s&MediaType=Music", url.QueryEscape(c.Cfg.PlaylistName), songIDs)


body, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers)
Expand Down Expand Up @@ -227,6 +227,14 @@ func (c *Emby) DeletePlaylist() error { // Doesn't currently work due to a bug i
return nil
}

// SetPlaylistArtwork uploads a JPEG as the playlist's primary image.
func (c *Emby) SetPlaylistArtwork(localPath string) error {
if c.Cfg.PlaylistID == "" {
return fmt.Errorf("emby: no PlaylistID set")
}
return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/emby/Items/"+c.Cfg.PlaylistID+"/Images/Primary", localPath, c.Cfg.Creds.Headers)
}

func formatEmbySongs(tracks []*models.Track) string {
songIDs := make([]string, 0, len(tracks))
for _, track := range tracks {
Expand Down
12 changes: 10 additions & 2 deletions src/client/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (c *Jellyfin) CheckRefreshState() bool {

func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(track.CleanTitle))
reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path", url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)))

body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers)
if err != nil {
Expand Down Expand Up @@ -177,7 +177,7 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
}

func (c *Jellyfin) SearchPlaylist() error {
queryParams := fmt.Sprintf("/Search/Hints?IncludeItemTypes=Playlist&SearchTerm=%s", c.Cfg.PlaylistName)
queryParams := fmt.Sprintf("/Search/Hints?IncludeItemTypes=Playlist&SearchTerm=%s", url.QueryEscape(c.Cfg.PlaylistName))
body, err := c.HttpClient.MakeRequest("GET", c.Cfg.URL+queryParams, nil, c.Cfg.Creds.Headers)
if err != nil {
return err
Expand Down Expand Up @@ -259,6 +259,14 @@ func (c *Jellyfin) DeletePlaylist() error {
return nil
}

// SetPlaylistArtwork uploads a JPEG as the playlist's primary image.
func (c *Jellyfin) SetPlaylistArtwork(localPath string) error {
if c.Cfg.PlaylistID == "" {
return fmt.Errorf("jellyfin: no PlaylistID set")
}
return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/Items/"+c.Cfg.PlaylistID+"/Images/Primary", localPath, c.Cfg.Creds.Headers)
}

func formatJFSongs(tracks []*models.Track) ([]byte, error) { // marshal track IDs
songIDs := make([]string, 0, len(tracks))
for _, track := range tracks {
Expand Down
16 changes: 12 additions & 4 deletions src/client/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
params := fmt.Sprintf(
"/hubs/search?query=%s&limit=10",
url.QueryEscape(track.CleanTitle),
url.QueryEscape(util.CleanSearchTitle(track.CleanTitle)),
)

var body []byte
Expand Down Expand Up @@ -524,6 +524,14 @@ func (c *Plex) DeletePlaylist() error {
return nil
}

// SetPlaylistArtwork uploads an image as the playlist's poster.
func (c *Plex) SetPlaylistArtwork(localPath string) error {
if c.Cfg.PlaylistID == "" {
return fmt.Errorf("plex: no PlaylistID set")
}
return uploadPlaylistArtwork(c.HttpClient, c.Cfg.URL+"/library/metadata/"+c.Cfg.PlaylistID+"/posters", localPath, c.Cfg.Creds.Headers)
}

func (c *Plex) getServer() error {
params := "/identity"

Expand All @@ -542,16 +550,16 @@ func (c *Plex) getServer() error {
}

func getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
loweredArtist := strings.ToLower(track.MainArtist)
normArtist := util.AlnumOnly(strings.ToLower(track.MainArtist))

for _, md := range metadata {
if md.Type != "track" {
continue
}

titleMatch := util.NormalizeTitle(md.Title) == util.NormalizeTitle(track.Title)
albumMatch := strings.EqualFold(md.ParentTitle, track.Album)
artistMatch := strings.Contains(strings.ToLower(md.OriginalTitle), loweredArtist) || strings.Contains(strings.ToLower(md.GrandparentTitle), loweredArtist)
albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == util.AlnumOnly(strings.ToLower(track.Album))
artistMatch := strings.Contains(util.AlnumOnly(strings.ToLower(md.OriginalTitle)), normArtist) || strings.Contains(util.AlnumOnly(strings.ToLower(md.GrandparentTitle)), normArtist)

if titleMatch && (albumMatch || artistMatch) {
slog.Debug(fmt.Sprintf("matched track via metadata: %s by %s", track.Title, track.Artist))
Expand Down
4 changes: 2 additions & 2 deletions src/client/subsonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (c *Subsonic) AddLibrary() error {

func (c *Subsonic) SearchSongs(tracks []*models.Track) error {
for _, track := range tracks {
searchQuery := fmt.Sprintf("%s %s", track.CleanTitle, track.MainArtist)
searchQuery := fmt.Sprintf("%s %s", util.CleanSearchTitle(track.CleanTitle), track.MainArtist)
reqParam := fmt.Sprintf("search3?query=%s&f=json", url.QueryEscape(searchQuery))

body, err := c.subsonicRequest(reqParam)
Expand Down Expand Up @@ -221,7 +221,7 @@ func (c *Subsonic) CreatePlaylist(tracks []*models.Track) error {
fmt.Fprintf(&trackIDs, "&songId=%s", track.ID)
}

reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", c.Cfg.PlaylistName, trackIDs.String())
reqParam := fmt.Sprintf("createPlaylist?name=%s%s&f=json", url.QueryEscape(c.Cfg.PlaylistName), trackIDs.String())

body, err := c.subsonicRequest(reqParam)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Flags struct {
ExcludeLocal bool
Persist bool
PersistSet bool
RefreshOnly bool
}

type ServerConfig struct {
Expand Down
14 changes: 9 additions & 5 deletions src/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"fmt"
"os"

flag "github.com/spf13/pflag"
"slices"
"strings"

flag "github.com/spf13/pflag"
)

var (
Expand All @@ -21,13 +22,15 @@ func (cfg *Config) GetFlags() error {
var excludeLocal bool
var persist bool
var showVersion bool
var refreshOnly 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(&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.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist")
flag.BoolVar(&persist, "persist", true, "Keep playlists between generations")
flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit")
flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads")

flag.Parse()

Expand All @@ -38,9 +41,9 @@ func (cfg *Config) GetFlags() error {
persistSet := flag.Lookup("persist").Changed
cfgSet := flag.Lookup("config").Changed

// Validation for playlist
if !contains(validPlaylists, playlist) {
return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s)",
// Validation for playlist — built-in types or user-imported custom-* IDs
if !contains(validPlaylists, playlist) && !strings.HasPrefix(playlist, "custom-") {
return fmt.Errorf("flag validation error: invalid playlist %s (must be one of: %s, or a custom-* id)",
playlist, strings.Join(validPlaylists, ", "))
}

Expand All @@ -56,6 +59,7 @@ func (cfg *Config) GetFlags() error {
cfg.Flags.DownloadMode = downloadMode
cfg.Flags.ExcludeLocal = excludeLocal
cfg.Flags.Persist = persist
cfg.Flags.RefreshOnly = refreshOnly

// for deprecation purposes (can be removed at a later date)
cfg.Flags.PersistSet = persistSet
Expand All @@ -68,7 +72,7 @@ func (cfg *Config) MergeFlags() {
cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal

if cfg.Flags.CfgSet {
cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath
cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath
}

if cfg.Flags.PersistSet {
Expand Down
36 changes: 30 additions & 6 deletions src/discovery/listenbrainz.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func (c *ListenBrainz) QueryTracks() ([]*models.Track, error) {
if err != nil {
return nil, err
}
tracks, err = c.parsePlaylist(id, c.cfg.SingleArtist)
_, tracks, err = c.parsePlaylist(id, c.cfg.SingleArtist)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -615,19 +615,43 @@ func (c *ListenBrainz) getImportPlaylist(user string) (string, error) {
return bestID, nil
}

func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*models.Track, error) {

// FetchPlaylistByMBID fetches a LB playlist by MBID. For use outside the discovery flow.
func FetchPlaylistByMBID(httpClient *util.HttpClient, mbid string) (string, []*models.Track, error) {
lb := &ListenBrainz{HttpClient: httpClient}
return lb.parsePlaylist(mbid, false)
}

// FetchTopRecordings returns the user's top recordings for the current month.
func FetchTopRecordings(httpClient *util.HttpClient, user string) ([]*models.Track, error) {
lb := &ListenBrainz{HttpClient: httpClient}
return lb.getTopRecordings(user)
}

// FetchMostRecentPlaylistByType finds and fetches the most recent LB-generated playlist of the given type for the user.
func FetchMostRecentPlaylistByType(httpClient *util.HttpClient, user, playlistType string) ([]*models.Track, error) {
lb := &ListenBrainz{HttpClient: httpClient, cfg: cfg.Listenbrainz{ImportPlaylist: playlistType}}
id, err := lb.getImportPlaylist(user)
if err != nil {
return nil, err
}
_, tracks, err := lb.parsePlaylist(id, false)
return tracks, err
}

func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) (string, []*models.Track, error) {
body, err := c.lbRequest(fmt.Sprintf("playlist/%s", identifier))
if err != nil {
return nil, fmt.Errorf("parsePlaylist: %s", err.Error())
return "", nil, fmt.Errorf("parsePlaylist: %s", err.Error())
}
var exploration Exploration
err = util.ParseResp(body, &exploration)
if err != nil {
return nil, fmt.Errorf("parsePlaylist: %s", err.Error())
return "", nil, fmt.Errorf("parsePlaylist: %s", err.Error())
}
srcTracks := exploration.Playlist.Tracks
if len(srcTracks) == 0 {
return nil, fmt.Errorf("no tracks found in playlist %s", identifier)
return "", nil, fmt.Errorf("no tracks found in playlist %s", identifier)
}

tracks := make([]*models.Track, 0, len(srcTracks))
Expand Down Expand Up @@ -687,7 +711,7 @@ func (c *ListenBrainz) parsePlaylist(identifier string, singleArtist bool) ([]*m
})
}

return tracks, nil
return exploration.Playlist.Title, tracks, nil

}

Expand Down
2 changes: 1 addition & 1 deletion src/downloader/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ func tracksProcessed(tracks []*models.Track, progressMap map[string]*DownloadMon
key := fmt.Sprintf("%s|%s", track.ID, track.File)
tracker, exists := progressMap[key]
if !track.Present && exists && !tracker.Skipped {
slog.Info("file still present", "file", track.File)
slog.Info("[monitor] track download still in progress", "title", track.CleanTitle, "artist", track.MainArtist, "file", track.File)
return false
}
}
Expand Down
Loading
Loading