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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ Explo uses the [ListenBrainz](https://listenbrainz.org/) recommendation engine t
- Weekly Exploration
- Weekly Jams
- Daily Jams
- Import custom playlists from:
- Apple Music
- ListenBrainz
- Spotify
- Request tracks from YouTube, Soulseek, or both
- Add metadata (title, artist, album) to YouTube downloads
- Add metadata to downloaded tracks
- Create playlists in your music system
- Keep previous playlists for later listening
---
Expand Down Expand Up @@ -57,6 +61,6 @@ Explo uses the following 3rd-party libraries:

## Contributing

Contributions are always welcome! If you have any suggestions, bug reports, or feature requests, please open an issue or submit a pull request.
Contributions are always welcome! If you have any suggestions, bug reports, or feature requests, please open an issue or submit a pull request (be sure to [read the development section](https://github.com/LumePart/Explo/wiki/7.-Development) of our wiki).

For discussion regarding development or help, join our [Discord!](https://discord.gg/uFWWPaN2zk)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
golang.org/x/net v0.48.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.32.0
golang.org/x/time v0.15.0
maunium.net/go/mautrix v0.26.0
)

Expand Down Expand Up @@ -43,7 +44,6 @@ require (
go.mau.fi/util v0.9.3 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/time v0.15.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect
)
18 changes: 11 additions & 7 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ SYSTEM_URL=
SYSTEM_USERNAME=
# Password for the user (required for subsonic, recommended for plex)
SYSTEM_PASSWORD=
# Optional admin username for systems like Navidrome/Subsonic/Plex (used to trigger operations that need elevated permissions for multi-user setups)
# ADMIN_SYSTEM_USERNAME=
# Optional admin password for systems like Navidrome/Subsonic/Plex
# ADMIN_SYSTEM_PASSWORD=
# API Key from your media system (required for emby and jellyfin, optional for plex)
API_KEY=
# Name of the music library in your system (emby, jellyfin, plex)
LIBRARY_NAME=
# Mark playlist as public (subsonic, jellyfin)
# PUBLIC_PLAYLIST=false

# Optional admin username for systems like Navidrome/Subsonic/Plex (used to trigger operations that need elevated permissions for multi-user setups)
# ADMIN_SYSTEM_USERNAME=
# Optional admin password for systems like Navidrome/Subsonic/Plex
# ADMIN_SYSTEM_PASSWORD=
# Admin API Key for Jellyfin (or Plex when using MFA) (used for multi-user setups)
# ADMIN_API_KEY=
# === Downloader Configuration ===

# Directory to store downloaded tracks. It's recommended to make a separate directory (under the music library) for Explo
Expand All @@ -57,8 +59,8 @@ LIBRARY_NAME=

# YouTube Data API key (optional, fall back is unofficial ytmusic API)
# YOUTUBE_API_KEY=
# Custom file extension for tracks (e.g mp3) (default: opus)
# TRACK_EXTENSION=opus
# Custom file extension for tracks (default: mp3)
# TRACK_EXTENSION=mp3
# Include cover art in downloaded files (default: false)
# EMBED_COVER_ART=false
# Custom path to ffmpeg binary (default: defined in $PATH)
Expand Down Expand Up @@ -139,7 +141,9 @@ LIBRARY_NAME=
# WIZARD_COMPLETE=false
# Minutes to sleep between library scans (default: 2)
# SLEEP=2
# Comma-separated list of MusicBrainz Artist IDs to exclude from import
# ARTIST_BLACKLIST=
# Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO)
# LOG_LEVEL=INFO
# Set a custom HTTP timeout for music servers (in seconds) (default: 10)
# CLIENT_HTTP_TIMEOUT=10
# CLIENT_HTTP_TIMEOUT=10
4 changes: 2 additions & 2 deletions src/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ func (c *Client) systemSetup() error {
return c.API.GetAuth()

case "jellyfin":
if c.Cfg.Creds.APIKey == "" {
return fmt.Errorf("Jellyfin API_KEY is required")
if c.Cfg.Creds.APIKey == "" && c.Cfg.AdminCreds.APIKey == "" {
return fmt.Errorf("Jellyfin API_KEY or ADMIN_API_KEY is required")
}
if c.Cfg.Creds.User == "" {
slog.Warn("It is recommended to set SYSTEM_USERNAME for Jellyfin")
Expand Down
5 changes: 2 additions & 3 deletions src/client/emby.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,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(util.CleanSearchTitle(track.CleanTitle)))
reqParam := fmt.Sprintf("/emby/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", 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 All @@ -143,14 +143,13 @@ func (c *Emby) SearchSongs(tracks []*models.Track) error {
return err
}

normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, item := range results.Items {

normalizedItemTitle := util.NormalizeTitle(item.Name)

musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID
titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle
titleMatch := normalizedItemTitle == normalizedCleanTitle
artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist))
pathMatch := util.ContainsFold(item.Path,track.File)

Expand Down
28 changes: 20 additions & 8 deletions src/client/jellyfin.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,15 @@ func (c *Jellyfin) AddHeader() error {
c.Cfg.Creds.Headers = make(map[string]string)
}

if c.Cfg.Creds.APIKey != "" {
c.Cfg.Creds.Headers["Authorization"] = fmt.Sprintf("MediaBrowser Token=%s, Client=%s", c.Cfg.Creds.APIKey, c.Cfg.ClientID)
return nil
apiKey := c.resolveAPIKey()

if apiKey == "" {
return fmt.Errorf("API_KEY or ADMIN_API_KEY not set")
}
return fmt.Errorf("API_KEY not set")

c.Cfg.Creds.Headers["Authorization"] = fmt.Sprintf("MediaBrowser Token=%s, Client=%s", apiKey, c.Cfg.ClientID)

return nil
}

func (c *Jellyfin) GetAuth() error {
Expand Down Expand Up @@ -146,7 +150,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(util.CleanSearchTitle(track.CleanTitle)))
reqParam := fmt.Sprintf("/Items?IncludeMediaTypes=Audio&SearchTerm=%s&Recursive=true&Fields=Path,ProviderIDs", 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 All @@ -157,14 +161,13 @@ func (c *Jellyfin) SearchSongs(tracks []*models.Track) error {
if err = util.ParseResp(body, &results); err != nil {
return err
}
normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, item := range results.Items {

normalizedItemTitle := util.NormalizeTitle(item.Name)

musicBrainzMatch := track.MusicBrainzTrackID != "" && item.ProviderIds.MusicBrainzTrack == track.MusicBrainzTrackID
titleMatch := normalizedItemTitle == normalizedTrackTitle || normalizedItemTitle == normalizedCleanTitle
titleMatch := normalizedItemTitle == normalizedCleanTitle
artistMatch := strings.EqualFold(item.AlbumArtist, track.MainArtist) || (len(item.Artists) > 0 && strings.EqualFold(item.Artists[0], track.MainArtist))
pathMatch := util.ContainsFold(item.Path,track.File)

Expand Down Expand Up @@ -216,13 +219,14 @@ func (c *Jellyfin) CreatePlaylist(tracks []*models.Track) error {
}
var userID string
isPublic := c.Cfg.PublicPlaylist

if c.Cfg.Creds.User != "" {
userID, err = c.ResolveUserID()
if err != nil {
return err
}
} else {
userID = c.Cfg.Creds.APIKey
userID = c.resolveAPIKey()
isPublic = true
}

Expand Down Expand Up @@ -320,3 +324,11 @@ func (c *Jellyfin) ResolveUserID() (string, error) {

return "", fmt.Errorf("failed to find Jellyfin user %q", c.Cfg.Creds.User)
}

// Check which API Key variable is used
func (c *Jellyfin) resolveAPIKey() string {
if c.Cfg.AdminCreds.APIKey != "" {
return c.Cfg.AdminCreds.APIKey
}
return c.Cfg.Creds.APIKey
}
3 changes: 1 addition & 2 deletions src/client/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,6 @@ func (c *Plex) getServer() error {

func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (string, error) {
normArtist := util.AlnumOnly(track.MainArtist)
normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
normalizedAlbum := util.AlnumOnly(strings.ToLower(track.Album))

Expand All @@ -593,7 +592,7 @@ func (c *Plex) getPlexSong(track *models.Track, metadata []SongMetadata) (string

normalizedSongTitle := util.NormalizeTitle(md.Title)
musicBrainzMatch := mbid != "" && track.MusicBrainzReleaseTrackID == mbid
titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle
titleMatch := normalizedSongTitle == normalizedCleanTitle
albumMatch := util.AlnumOnly(strings.ToLower(md.ParentTitle)) == normalizedAlbum
artistMatch := util.ContainsFold(util.AlnumOnly(md.OriginalTitle), normArtist) || util.ContainsFold(util.AlnumOnly(md.GrandparentTitle), normArtist)

Expand Down
3 changes: 1 addition & 2 deletions src/client/subsonic.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,15 +145,14 @@ func (c *Subsonic) SearchSongs(tracks []*models.Track) error {
slog.Debug(fmt.Sprintf("[subsonic] no results found for %s", searchQuery))
continue
}
normalizedTrackTitle := util.NormalizeTitle(track.Title)
normalizedCleanTitle := util.NormalizeTitle(track.CleanTitle)
for _, song := range songs {
normalizedSongTitle := util.NormalizeTitle(song.Title)

musicBrainzMatch := track.MusicBrainzTrackID != "" && song.MusicBrainzID == track.MusicBrainzTrackID
artistMatch := util.ContainsFold(song.Artist, track.MainArtist)
albumMatch := util.ContainsFold(song.Album, track.Album)
titleMatch := normalizedSongTitle == normalizedTrackTitle || normalizedSongTitle == normalizedCleanTitle
titleMatch := normalizedSongTitle == normalizedCleanTitle
durationMatch := util.Abs(song.Duration - (track.Duration / 1000)) < 10
pathMatch := util.ContainsFold(song.Path, track.File)

Expand Down
10 changes: 6 additions & 4 deletions src/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ func (cfg *Config) HandleDeprecation() { //
}
}

func (cfg *Config) GenPlaylistName() { // Generate playlist name and description
// Generate playlist name and description
func (cfg *Config) GenPlaylistDetails() {

cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist)
cfg.ClientCfg.PlaylistDescr = fmt.Sprintf(
Expand All @@ -280,16 +281,17 @@ func (cfg *Config) GenPlaylistName() { // Generate playlist name and description
}

func getPlaylistName(playlistType, format string, persist bool) string {
now := time.Now()


toTitle := cases.Title(language.Und)
base := toTitle.String(playlistType)

// Non-persistent playlists always use base name
if !persist {
// Non-persistent or custom playlists always use base name
if !persist || strings.HasPrefix(playlistType, "custom-") {
return base
}

now := time.Now()
// Explicit date-based naming
if format == "date" {
return fmt.Sprintf(
Expand Down
1 change: 1 addition & 0 deletions src/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (c DiscoverClient) filterArtists(tracks []*models.Track) []*models.Track {
slog.Debug("filtered out artist",
"name", track.MainArtist,
"mbid", track.MusicBrainzArtistID,
"track", track.CleanTitle,
)
continue
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func setup(cfg *config.Config) {
cfg.HandleDeprecation()
notifyClient := logging.InitNotify(cfg.NotifyCfg)
logging.Init(cfg.LogLevel, notifyClient)
cfg.GenPlaylistName()
cfg.GenPlaylistDetails()
}
func runSearchTest(cfg *config.Config, httpClient *util.HttpClient) {
lb := discovery.NewListenBrainz(cfg.DiscoveryCfg, httpClient)
Expand Down
2 changes: 1 addition & 1 deletion src/web/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ func (s *Server) handleSavePathTemplate(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusOK)
}

// handleSaveEnrichMetadata writes ENRICH_METADATA=true/false to the .env file.
// handleSaveEnrichMetadata writes ENRICH_TRACK_METADATA=true/false to the .env file.
func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
Expand Down
2 changes: 1 addition & 1 deletion src/web/frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ function DownloadPathSection() {
return (
<div className="mt-6">
<SectionLabel>Folder Structure</SectionLabel>
{/* ENRICH_METADATA toggle */}
{/* ENRICH_TRACK_METADATA toggle */}
<div className="flex items-start justify-between mt-3 mb-1 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[13px] text-white">Auto-tag songs</span>
Expand Down
30 changes: 1 addition & 29 deletions src/web/frontend/src/components/Wizard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ function Step1({ fields, setField, envSources, onNext, saving }) {
<div>
<div className="text-[11px] text-muted uppercase tracking-[1px] mb-7">Step 1 of 3 — Discovery</div>
<p className="text-[13px] text-muted mb-7 leading-relaxed">
Explo uses your ListenBrainz listening history to find music
recommendations.
Explo uses your ListenBrainz listening history to find music recommendations.
</p>

<div className="flex flex-col gap-5">
Expand All @@ -64,33 +63,6 @@ function Step1({ fields, setField, envSources, onNext, saving }) {
<input id="lb-user" type="text" className={inputCls} placeholder="e.g. musiclover42"
autoComplete="off" spellCheck={false} value={user} onChange={e => setField('user', e.target.value)}
disabled={isLocked('LISTENBRAINZ_USER')} />
label="ListenBrainz username"
labelFor="lb-user"
hint={
<>
Don't have an account?{" "}
<a
href="https://listenbrainz.org"
target="_blank"
rel="noreferrer"
className="text-accent"
>
Sign up free.
</a>
</>
}
>
<input
id="lb-user"
type="text"
className={inputCls}
placeholder="e.g. musiclover42"
autoComplete="off"
spellCheck={false}
value={user}
onChange={(e) => setField("user", e.target.value)}
disabled={isLocked("LISTENBRAINZ_USER")}
/>
</TextField>

<div className="flex flex-col gap-2">
Expand Down
20 changes: 13 additions & 7 deletions src/web/sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ SYSTEM_URL=
SYSTEM_USERNAME=
# Password for the user (required for subsonic, recommended for plex)
SYSTEM_PASSWORD=
# Optional admin username for systems like Navidrome/Subsonic/Plex (used to trigger operations that need elevated permissions for multi-user setups)
# ADMIN_SYSTEM_USERNAME=
# Optional admin password for systems like Navidrome/Subsonic/Plex
# ADMIN_SYSTEM_PASSWORD=
# API Key from your media system (required for emby and jellyfin, optional for plex)
API_KEY=
# Name of the music library in your system (emby, jellyfin, plex)
LIBRARY_NAME=
# Mark playlist as public (subsonic, jellyfin)
# PUBLIC_PLAYLIST=false

# Optional admin username for systems like Navidrome/Subsonic/Plex (used to trigger operations that need elevated permissions for multi-user setups)
# ADMIN_SYSTEM_USERNAME=
# Optional admin password for systems like Navidrome/Subsonic/Plex
# ADMIN_SYSTEM_PASSWORD=
# Admin API Key for Jellyfin (or Plex when using MFA) (used for multi-user setups)
# ADMIN_API_KEY=
# === Downloader Configuration ===

# Directory to store downloaded tracks. It's recommended to make a separate directory (under the music library) for Explo
Expand All @@ -47,6 +49,8 @@ LIBRARY_NAME=
# KEEP_PERMISSIONS=true
# Comma-separated list (no spaces) of download services, in priority order (default: youtube)
# DOWNLOAD_SERVICES=youtube
# Path templating, Options are Artist, Album, TrackName, TrackNumber, File, Ext (eg. "{{Artist}}/{{Album}}/{{File}}")
# PATH_TEMPLATING=""

# Directory for writing .m3u playlists (required only for MPD)
# PLAYLIST_DIR=/path/to/playlist/folder/
Expand All @@ -55,8 +59,8 @@ LIBRARY_NAME=

# YouTube Data API key (optional, fall back is unofficial ytmusic API)
# YOUTUBE_API_KEY=
# Custom file extension for tracks (e.g mp3) (default: opus)
# TRACK_EXTENSION=opus
# Custom file extension for tracks (default: mp3)
# TRACK_EXTENSION=mp3
# Include cover art in downloaded files (default: false)
# EMBED_COVER_ART=false
# Custom path to ffmpeg binary (default: defined in $PATH)
Expand Down Expand Up @@ -137,7 +141,9 @@ LIBRARY_NAME=
# WIZARD_COMPLETE=false
# Minutes to sleep between library scans (default: 2)
# SLEEP=2
# Comma-separated list of MusicBrainz Artist IDs to exclude from import
# ARTIST_BLACKLIST=
# Set the log level (DEBUG, INFO, WARN, ERROR) (default: INFO)
# LOG_LEVEL=INFO
# Set a custom HTTP timeout for music servers (in seconds) (default: 10)
# CLIENT_HTTP_TIMEOUT=10
# CLIENT_HTTP_TIMEOUT=10
Loading