Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8c85e7b
add web UI for configuration and manual runs, prototype to test funti…
dammitjeff Apr 9, 2026
47472c7
Wire up wizard backend, switch UI to Alpine.js
dammitjeff Apr 9, 2026
8523be4
Add wizard step 2 for media system configuration, add filesystem auto…
dammitjeff Apr 9, 2026
253b19e
Moved to Go Templates and schema backed config, added Step 3 download…
dammitjeff Apr 9, 2026
9befb50
Added more conition schemas, gave more info in wizard steps 2 + 3
dammitjeff Apr 19, 2026
7d6a961
Added Reset All envs button, add download directory and playlist subf…
dammitjeff Apr 19, 2026
f6074ad
Reformatted Log output presentation, added Stop button to manual run
dammitjeff Apr 19, 2026
89b6480
add config editor and log viewer, implement run control settings
dammitjeff Apr 21, 2026
0713385
update .gitignore to exclude logs and tmp
dammitjeff Apr 22, 2026
39a30a6
Moved schedules out of Docker setup, new feedback for locked settings…
dammitjeff Apr 22, 2026
36fc72f
added docker internal host gateway for easier access to plex/jellyfin…
dammitjeff Apr 22, 2026
34fc767
add POST /api/config/schedules function, inputs now run on logic, ne…
dammitjeff Apr 22, 2026
8d81662
remove WEEKLY_EXPLORATION_SCHEDULE from dockerfile
dammitjeff Apr 23, 2026
2a44c1f
fixed resetConfig functionality to restart container
dammitjeff Apr 23, 2026
fd07e14
add migrateDownloads option to wizard step 3
dammitjeff Apr 23, 2026
a74de8f
Removed compiled binary from tracked files
dammitjeff Apr 24, 2026
a1de45e
Migrate WebUI to React/Vite
dammitjeff Apr 25, 2026
87f7ce6
Apply suggestion from @nironics to fix manual run ignoring playlist s…
dammitjeff Apr 25, 2026
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: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.env
.git
.DS_Store
tmp/
logs/
explo
src/web/frontend/node_modules/
src/web/dist/
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.env
.DS_Store
/main
tmp/
.air.toml
logs/
explo
src/web/dist/
src/web/frontend/node_modules/
16 changes: 14 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
FROM node:20-alpine AS ui-builder
WORKDIR /app/src/web/frontend
COPY src/web/frontend/package*.json ./
RUN npm ci
COPY src/web/frontend/ ./
RUN npm run build

FROM golang:1.24-alpine AS builder

# Set the working directory
Expand All @@ -6,6 +13,9 @@ WORKDIR /app
# Copy the Go source code into the container
COPY ./ .

# Copy the built React frontend into the embed path
COPY --from=ui-builder /app/src/web/dist ./src/web/dist

# Build the Go binary based on the target architecture
ARG TARGETARCH
RUN GOOS=linux GOARCH=$TARGETARCH go build -o explo ./src/main/
Expand Down Expand Up @@ -35,7 +45,9 @@ COPY src/downloader/youtube_music/search_ytmusic.py .

RUN chmod +x /start.sh ./explo

# Can be defined from compose as well
ENV WEEKLY_EXPLORATION_SCHEDULE="15 0 * * 2"

ENV WEB_ADDR=":7288"

EXPOSE 7288

CMD ["/start.sh"]
21 changes: 12 additions & 9 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ services:
image: ghcr.io/lumepart/explo:latest
restart: unless-stopped
container_name: explo
ports:
- "7288:7288"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /path/to/.env:/opt/explo/.env
- /path/to/musiclibrary/explo:/data/ # has to be in the same path you have your music system pointed to (it's recommended to put explo under a subfolder)
Expand All @@ -13,15 +17,14 @@ services:
environment:
- TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC)

- WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2 # Runs weekly, every Tuesday 15 minutes past midnight
- WEEKLY_EXPLORATION_FLAGS= # Run weekly exploration with default settings

# Uncomment _SCHEDULE and _FLAGS variables to enable fetching different playlist
#- WEEKLY_JAMS_SCHEDULE=30 00 * * 1 # Runs weekly, every Monday 30 minutes past midnight
#- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip # Get tracks from weekly-jams, and only add tracks that are found locally to playlist

#- DAILY_JAMS_SCHEDULE=15 01 * * * # Runs daily, every day 15 minutes past 1PM
#- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip # Get tracks from daily-jams, and only add tracks that are found locally to playlist
# [Legacy] Schedules are managed through the web UI.
# These can still be set here for backwards compatibility — they will take precedence over the UI.
#- WEEKLY_EXPLORATION_SCHEDULE=15 00 * * 2
#- WEEKLY_EXPLORATION_FLAGS=
#- WEEKLY_JAMS_SCHEDULE=30 00 * * 1
#- WEEKLY_JAMS_FLAGS=--playlist=weekly-jams --download-mode=skip
#- DAILY_JAMS_SCHEDULE=15 01 * * *
#- DAILY_JAMS_FLAGS=--playlist=daily-jams --download-mode=skip

# Uncomment for testing (runs explo right after launcing the container)
#- EXECUTE_ON_START=false # Whether to run explo when starting the container (useful for testing)
Expand Down
27 changes: 27 additions & 0 deletions docker/start.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
#!/bin/sh
echo "[setup] Starting web UI..."
# If user incorectly mounts the config path as a directory, we'll try to automatically append it to .env inside it instead of failing.
WEB_CFG_PATH="${WEB_CFG_PATH:-/opt/explo/.env}"
if [ -d "$WEB_CFG_PATH" ]; then
WEB_CFG_PATH="$WEB_CFG_PATH/.env"
echo "[setup] Config path is a directory, using $WEB_CFG_PATH"
fi
WEB_UI=true WEB_CFG_PATH="$WEB_CFG_PATH" WEB_ADDR="${WEB_ADDR:-:7288}" /opt/explo/explo &
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I love the web interface, but it might be nice to have an option to disable it if someone doesn't want / need it?

echo "[setup] Web UI available at http://localhost:${WEB_ADDR##*:}"

echo "[setup] Initializing cron jobs..."

# Load *_SCHEDULE and *_FLAGS from .env if not already set in the environment.
# This allows the web UI to configure schedules by writing to the .env file.
_cfg="${WEB_CFG_PATH:-/opt/explo/.env}"
if [ -f "$_cfg" ]; then
while IFS= read -r _line; do
case "$_line" in \#*|'') continue ;; esac
_key="${_line%%=*}"
case "$_key" in
*_SCHEDULE|*_FLAGS)
if [ -z "$(printenv "$_key" 2>/dev/null)" ]; then
export "$_key=${_line#*=}"
fi
;;
esac
done < "$_cfg"
fi


# $CRON_SHCEDULE was deprecated in v0.11.0, keeping this block for backwards compatibility
if [ -n "$CRON_SCHEDULE" ]; then
Expand Down
2 changes: 1 addition & 1 deletion sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,4 @@ YOUTUBE_API_KEY=
# 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
71 changes: 34 additions & 37 deletions src/client/plex.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ type Libraries struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Title1 string `json:"title1"`
Library []struct {
Title string `json:"title"`
Key string `json:"key"`
Location []struct {
Library []struct {
Title string `json:"title"`
Key string `json:"key"`
Location []struct {
ID int `json:"id"`
Path string `json:"path"`
} `json:"Location"`
Expand All @@ -50,27 +50,27 @@ type PlexSearch struct {
SearchResult []struct {
Score float64 `json:"score"`
Metadata struct {
LibrarySectionTitle string `json:"librarySectionTitle"`
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
Type string `json:"type"`
Title string `json:"title"` // Track
GrandparentTitle string `json:"grandparentTitle"` // Artist
ParentTitle string `json:"parentTitle"` // Album
OriginalTitle string `json:"originalTitle"`
Summary string `json:"summary"`
Duration int `json:"duration"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
Media []struct {
ID int `json:"id"`
Duration int `json:"duration"`
Part []struct {
ID int `json:"id"`
Key string `json:"key"`
Duration int `json:"duration"`
File string `json:"file"`
Size int `json:"size"`
LibrarySectionTitle string `json:"librarySectionTitle"`
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
Type string `json:"type"`
Title string `json:"title"` // Track
GrandparentTitle string `json:"grandparentTitle"` // Artist
ParentTitle string `json:"parentTitle"` // Album
OriginalTitle string `json:"originalTitle"`
Summary string `json:"summary"`
Duration int `json:"duration"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
Media []struct {
ID int `json:"id"`
Duration int `json:"duration"`
Part []struct {
ID int `json:"id"`
Key string `json:"key"`
Duration int `json:"duration"`
File string `json:"file"`
Size int `json:"size"`
} `json:"Part"`
AudioChannels int `json:"audioChannels"`
AudioCodec string `json:"audioCodec"`
Expand All @@ -81,7 +81,6 @@ type PlexSearch struct {
} `json:"MediaContainer"`
}


type PlexServer struct {
MediaContainer struct {
Size int `json:"size"`
Expand Down Expand Up @@ -112,15 +111,15 @@ type PlexPlaylist struct {
}

type Plex struct {
machineID string
LibraryID string
machineID string
LibraryID string
HttpClient *util.HttpClient
Cfg config.ClientConfig
Cfg config.ClientConfig
}

func NewPlex(cfg config.ClientConfig, httpClient *util.HttpClient) *Plex {
return &Plex{
Cfg: cfg,
Cfg: cfg,
HttpClient: httpClient}
}

Expand All @@ -135,7 +134,7 @@ func (c *Plex) AddHeader() error {
c.Cfg.Creds.Headers["X-Plex-Token"] = c.Cfg.Creds.APIKey
if err := c.getServer(); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("couldn't get API key")
Expand All @@ -154,7 +153,6 @@ func (c *Plex) GetAuth() error { // Get user token from plex
return fmt.Errorf("failed to marshal payload: %s", err.Error())
}


body, err := c.HttpClient.MakeRequest("POST", "https://plex.tv/users/sign_in.json", bytes.NewBuffer(payloadBytes), c.Cfg.Creds.Headers)
if err != nil {
return fmt.Errorf("%s", err.Error())
Expand Down Expand Up @@ -236,7 +234,7 @@ func (c *Plex) SearchSongs(tracks []*models.Track) error {
slog.Warn("search request failed for '%s': %s", track.Title, err.Error())
continue
}

var searchResults PlexSearch
if err = util.ParseResp(body, &searchResults); err != nil {
slog.Warn("failed to parse response for '%s': %s", track.Title, err.Error())
Expand Down Expand Up @@ -277,7 +275,6 @@ func (c *Plex) SearchPlaylist() error {
return nil
}


func (c *Plex) CreatePlaylist(tracks []*models.Track) error {
params := fmt.Sprintf("/playlists?title=%s&type=audio&smart=0&uri=server://%s/com.plexapp.plugins.library/%s", c.Cfg.PlaylistName, c.machineID, c.LibraryID)

Expand All @@ -302,7 +299,7 @@ func (c *Plex) CreatePlaylist(tracks []*models.Track) error {
func (c *Plex) UpdatePlaylist() error {
params := fmt.Sprintf("/playlists/%s?summary=%s", c.Cfg.PlaylistID, url.QueryEscape(c.Cfg.PlaylistDescr))

if _, err := c.HttpClient.MakeRequest("PUT",c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil {
if _, err := c.HttpClient.MakeRequest("PUT", c.Cfg.URL+params, nil, c.Cfg.Creds.Headers); err != nil {
return err
}
return nil
Expand Down Expand Up @@ -358,7 +355,7 @@ func getPlexSong(track *models.Track, searchResults PlexSearch) (string, error)

media := md.Media[0]
pathMatch := strings.Contains(strings.ToLower(media.Part[0].File), strings.ToLower(track.File))
durationMatch := util.Abs(media.Duration - track.Duration) < 10000 // duration within 10s
durationMatch := util.Abs(media.Duration-track.Duration) < 10000 // duration within 10s

if durationMatch && pathMatch {
slog.Debug(fmt.Sprintf("matched track via path: %s by %s", track.Title, track.Artist))
Expand All @@ -380,4 +377,4 @@ func (c *Plex) addtoPlaylist(tracks []*models.Track) {
}
}
}
}
}
Loading