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
536eb9b
Set up automatic Lidarr downloading system based on Plex track ratings
nironics Apr 30, 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 &
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
233 changes: 233 additions & 0 deletions src/client/lidarr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package client

import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"strings"

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

type Lidarr struct {
Cfg config.LidarrConfig
HttpClient *util.HttpClient
Headers map[string]string
}

type LidarrSystemStatus struct {
Version string `json:"version"`
AppName string `json:"appName"`
InstanceID string `json:"instanceName"`
}

type LidarrAddOptions struct {
Monitor string `json:"monitor"`
SearchForMissingAlbums bool `json:"searchForMissingAlbums"`
}

type LidarrArtist struct {
ID int `json:"id,omitempty"`
ForeignArtistID string `json:"foreignArtistId"`
ArtistName string `json:"artistName"`
Monitored bool `json:"monitored"`
MonitorNewItems string `json:"monitorNewItems,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
MetadataProfileID int `json:"metadataProfileId,omitempty"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
AddOptions *LidarrAddOptions `json:"addOptions,omitempty"`
}

type LidarrAlbum struct {
ID int `json:"id"`
ForeignAlbumID string `json:"foreignAlbumId"`
Title string `json:"title"`
ArtistID int `json:"artistId"`
Monitored bool `json:"monitored"`
}

type LidarrCommand struct {
Name string `json:"name"`
AlbumIDs []int `json:"albumIds,omitempty"`
ArtistID int `json:"artistId,omitempty"`
}

type LidarrRootFolder struct {
ID int `json:"id"`
Path string `json:"path"`
Accessible bool `json:"accessible"`
}

type LidarrQualityProfile struct {
ID int `json:"id"`
Name string `json:"name"`
}

type LidarrMetadataProfile struct {
ID int `json:"id"`
Name string `json:"name"`
}

func NewLidarr(cfg config.LidarrConfig, httpClient *util.HttpClient) *Lidarr {
return &Lidarr{
Cfg: cfg,
HttpClient: httpClient,
Headers: map[string]string{
"X-Api-Key": cfg.APIKey,
},
}
}

func (c *Lidarr) endpoint(path string) string {
return strings.TrimRight(c.Cfg.URL, "/") + path
}

func (c *Lidarr) TestConnection() (string, error) {
body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/system/status"), nil, c.Headers)
if err != nil {
return "", err
}
var status LidarrSystemStatus
if err := util.ParseResp(body, &status); err != nil {
return "", err
}
return status.Version, nil
}

func (c *Lidarr) LookupArtist(mbid string) ([]LidarrArtist, error) {
if mbid == "" {
return nil, fmt.Errorf("empty MBID")
}
params := "/api/v1/artist/lookup?term=" + url.QueryEscape("lidarr:"+mbid)
body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers)
if err != nil {
return nil, err
}
var results []LidarrArtist
if err := util.ParseResp(body, &results); err != nil {
return nil, err
}
return results, nil
}

func (c *Lidarr) LookupArtistByName(name string) ([]LidarrArtist, error) {
params := "/api/v1/artist/lookup?term=" + url.QueryEscape(name)
body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers)
if err != nil {
return nil, err
}
var results []LidarrArtist
if err := util.ParseResp(body, &results); err != nil {
return nil, err
}
return results, nil
}

func (c *Lidarr) GetArtists() ([]LidarrArtist, error) {
body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/artist"), nil, c.Headers)
if err != nil {
return nil, err
}
var results []LidarrArtist
if err := util.ParseResp(body, &results); err != nil {
return nil, err
}
return results, nil
}

func (c *Lidarr) AddArtist(artist LidarrArtist) (*LidarrArtist, error) {
payload, err := json.Marshal(artist)
if err != nil {
return nil, fmt.Errorf("failed to marshal artist: %s", err.Error())
}
body, err := c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/artist"), bytes.NewBuffer(payload), c.Headers)
if err != nil {
return nil, err
}
var created LidarrArtist
if err := util.ParseResp(body, &created); err != nil {
return nil, err
}
return &created, nil
}

func (c *Lidarr) RefreshArtist(artistID int) error {
cmd := LidarrCommand{Name: "RefreshArtist", ArtistID: artistID}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed to marshal command: %s", err.Error())
}
_, err = c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/command"), bytes.NewBuffer(payload), c.Headers)
return err
}

func (c *Lidarr) GetAlbumsByArtist(artistID int) ([]LidarrAlbum, error) {
params := fmt.Sprintf("/api/v1/album?artistId=%d", artistID)
body, err := c.HttpClient.MakeRequest("GET", c.endpoint(params), nil, c.Headers)
if err != nil {
return nil, err
}
var albums []LidarrAlbum
if err := util.ParseResp(body, &albums); err != nil {
return nil, err
}
return albums, nil
}

func (c *Lidarr) MonitorAlbum(album LidarrAlbum) error {
album.Monitored = true
payload, err := json.Marshal(album)
if err != nil {
return fmt.Errorf("failed to marshal album: %s", err.Error())
}
_, err = c.HttpClient.MakeRequest("PUT", c.endpoint(fmt.Sprintf("/api/v1/album/%d", album.ID)), bytes.NewBuffer(payload), c.Headers)
return err
}

func (c *Lidarr) SearchAlbum(albumID int) error {
cmd := LidarrCommand{Name: "AlbumSearch", AlbumIDs: []int{albumID}}
payload, err := json.Marshal(cmd)
if err != nil {
return fmt.Errorf("failed to marshal command: %s", err.Error())
}
_, err = c.HttpClient.MakeRequest("POST", c.endpoint("/api/v1/command"), bytes.NewBuffer(payload), c.Headers)
return err
}

func (c *Lidarr) GetRootFolders() ([]LidarrRootFolder, error) {
body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/rootfolder"), nil, c.Headers)
if err != nil {
return nil, err
}
var folders []LidarrRootFolder
if err := util.ParseResp(body, &folders); err != nil {
return nil, err
}
return folders, nil
}

func (c *Lidarr) GetQualityProfiles() ([]LidarrQualityProfile, error) {
body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/qualityprofile"), nil, c.Headers)
if err != nil {
return nil, err
}
var profiles []LidarrQualityProfile
if err := util.ParseResp(body, &profiles); err != nil {
return nil, err
}
return profiles, nil
}

func (c *Lidarr) GetMetadataProfiles() ([]LidarrMetadataProfile, error) {
body, err := c.HttpClient.MakeRequest("GET", c.endpoint("/api/v1/metadataprofile"), nil, c.Headers)
if err != nil {
return nil, err
}
var profiles []LidarrMetadataProfile
if err := util.ParseResp(body, &profiles); err != nil {
return nil, err
}
return profiles, nil
}
Loading