diff --git a/docker-compose.yaml b/docker-compose.yaml
index 6fb5c5a..4ce7d13 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -17,6 +17,7 @@ services:
# - /path/to/cookies.txt:/opt/explo/cookies.txt # Path to optional cookies file (for yt-dlp)
environment:
- TZ=UTC # Change this to the timezone set in ListenBrainz (default is UTC)
+ - WEB_UI=true
# Web UI credentials (required for login)
- UI_USERNAME=
- UI_PASSWORD=
diff --git a/docker/start.sh b/docker/start.sh
index 9b9e5fd..b11dddc 100644
--- a/docker/start.sh
+++ b/docker/start.sh
@@ -1,19 +1,19 @@
#!/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"
+WEB_ENV_PATH="${WEB_ENV_PATH:-/opt/explo/.env}"
+if [ -d "$WEB_ENV_PATH" ]; then
+ WEB_ENV_PATH="$WEB_ENV_PATH/.env"
+ echo "[setup] Config path is a directory, using $WEB_ENV_PATH"
fi
-WEB_UI=true WEB_CFG_PATH="$WEB_CFG_PATH" WEB_ADDR="${WEB_ADDR:-:7288}" /opt/explo/explo &
+WEB_UI=true WEB_ENV_PATH="$WEB_ENV_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}"
+_cfg="${WEB_ENV_PATH:-/opt/explo/.env}"
if [ -f "$_cfg" ]; then
while IFS= read -r _line; do
case "$_line" in \#*|'') continue ;; esac
diff --git a/go.mod b/go.mod
index dc72d96..5b3bc9e 100644
--- a/go.mod
+++ b/go.mod
@@ -5,11 +5,13 @@ go 1.24.0
toolchain go1.24.3
require (
+ github.com/go-co-op/gocron/v2 v2.21.1
github.com/ilyakaznacheev/cleanenv v1.5.0
github.com/nikoksr/notify v1.4.0
github.com/spf13/pflag v1.0.10
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/sync v0.19.0
golang.org/x/text v0.32.0
maunium.net/go/mautrix v0.26.0
@@ -21,12 +23,16 @@ require (
github.com/aws/aws-sdk-go v1.38.20 // indirect
github.com/bwmarrin/discordgo v0.29.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
+ github.com/jonboulle/clockwork v0.5.0 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
@@ -36,7 +42,6 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/u2takey/go-utils v0.3.1 // indirect
go.mau.fi/util v0.9.3 // indirect
- golang.org/x/crypto v0.46.0 // 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
diff --git a/go.sum b/go.sum
index c5ff089..363c6d6 100644
--- a/go.sum
+++ b/go.sum
@@ -7,6 +7,7 @@ github.com/aws/aws-sdk-go v1.38.20/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -15,6 +16,8 @@ github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44am
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
+github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
@@ -22,6 +25,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -34,11 +39,17 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -53,11 +64,16 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
github.com/nikoksr/notify v1.4.0 h1:pnIU0FB5IgIZ5B+YVwpuqVwh5ZmZqLEuc4NXeqUP39s=
github.com/nikoksr/notify v1.4.0/go.mod h1:qHDdy6k9D90hPQ48PSHm4AUCCCpryv1OxFW+9pgo7hw=
github.com/panjf2000/ants/v2 v2.4.2/go.mod h1:f6F0NZVFsGCp5A7QW/Zj/m92atWwOkY0OIhFxRNFr4A=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@@ -93,6 +109,8 @@ github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071 h1:QkrG4Zr5OVFuC9
github.com/wader/osleaktest v0.0.0-20191111175233-f643b0fed071/go.mod h1:XD6emOFPHVzb0+qQpiNOdPL2XZ0SRUM0N5JHuq6OmXo=
go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
gocv.io/x/gocv v0.25.0/go.mod h1:Rar2PS6DV+T4FL+PM535EImD/h13hGVaHhnCu1xarBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -128,8 +146,9 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
diff --git a/src/config/config.go b/src/config/config.go
index fcc9dd2..3eb49c4 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -21,6 +21,7 @@ type Config struct {
DiscoveryCfg DiscoveryConfig
ClientCfg ClientConfig
NotifyCfg NotifyConfig
+ ServerCfg ServerConfig
Flags Flags
PersistENV bool `env:"PERSIST" env-default:"true"`
Persist bool
@@ -31,6 +32,7 @@ type Config struct {
type Flags struct {
CfgPath string
+ CfgSet bool
Playlist string
DownloadMode string
ExcludeLocal bool
@@ -38,6 +40,17 @@ type Flags struct {
PersistSet bool
}
+type ServerConfig struct {
+ Enabled bool `env:"WEB_UI" env-default:"false"`
+ Port string `env:"WEB_ADDR" env-default:":7288"`
+ Username string `env:"UI_USERNAME"`
+ Password string `env:"UI_PASSWORD"`
+ WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"`
+ WebEnvPath string `env:"WEB_ENV_PATH" env-default:"/opt/explo/.env"`
+ CacheSizeMB int64 `env:"WEB_CACHE_MB" env-default:"500"`
+ ExploPath string
+}
+
type ClientConfig struct {
ClientID string `env:"CLIENT_ID" env-default:"explo"`
LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"`
@@ -89,7 +102,7 @@ type DownloadConfig struct {
}
type Filters struct {
- Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"`
+ Extensions []string `env:"EXTENSIONS" env-default:"flac,mp3"` // slskd
MinBitDepth int `env:"MIN_BIT_DEPTH" env-default:"8"`
MinBitRate int `env:"MIN_BITRATE" env-default:"256"`
FilterList []string `env:"FILTER_LIST" env-default:"live,remix,instrumental,extended,clean,acapella"`
@@ -99,7 +112,7 @@ type Youtube struct {
APIKey string `env:"YOUTUBE_API_KEY"`
FfmpegPath string `env:"FFMPEG_PATH"`
YtdlpPath string `env:"YTDLP_PATH"`
- FileExtension string `env:"TRACK_EXTENSION" env-default:"opus"`
+ FileExtension string `env:"TRACK_EXTENSION" env-default:"opus"` // yt-dlp
CookiesPath string `env:"COOKIES_PATH" env-default:"./cookies.txt"`
Filters Filters
}
diff --git a/src/config/flags.go b/src/config/flags.go
index 02b37f3..890f199 100644
--- a/src/config/flags.go
+++ b/src/config/flags.go
@@ -36,6 +36,7 @@ func (cfg *Config) GetFlags() error {
os.Exit(0)
}
persistSet := flag.Lookup("persist").Changed
+ cfgSet := flag.Lookup("config").Changed
// Validation for playlist
if !contains(validPlaylists, playlist) {
@@ -50,6 +51,7 @@ func (cfg *Config) GetFlags() error {
}
cfg.Flags.CfgPath = configPath
+ cfg.Flags.CfgSet = cfgSet
cfg.Flags.Playlist = playlist
cfg.Flags.DownloadMode = downloadMode
cfg.Flags.ExcludeLocal = excludeLocal
@@ -64,6 +66,11 @@ func (cfg *Config) GetFlags() error {
func (cfg *Config) MergeFlags() {
cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist
cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal
+
+ if cfg.Flags.CfgSet {
+ cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath
+ }
+
if cfg.Flags.PersistSet {
cfg.Persist = cfg.Flags.Persist
} else {
diff --git a/src/main/main.go b/src/main/main.go
index 31b5b5a..4343bcf 100644
--- a/src/main/main.go
+++ b/src/main/main.go
@@ -36,23 +36,6 @@ func setup(cfg *config.Config) {
}
func main() {
- if os.Getenv("WEB_UI") == "true" {
- cfgPath := os.Getenv("WEB_CFG_PATH")
- if cfgPath == "" {
- cfgPath = ".env"
- }
- exploPath, err := os.Executable()
- if err != nil {
- log.Fatal("could not determine executable path: ", err)
- }
- addr := os.Getenv("WEB_ADDR")
- if addr == "" {
- addr = ":7288"
- }
- srv := backend.NewServer(addr, cfgPath, exploPath)
- log.Fatal(srv.Start())
- }
-
var cfg config.Config
if err := cfg.GetFlags(); err != nil {
log.Fatal(err)
@@ -62,6 +45,17 @@ func main() {
setup(&cfg)
slog.Info("Starting Explo...")
+ if cfg.ServerCfg.Enabled {
+
+ exploPath, err := os.Executable()
+ if err != nil {
+ log.Fatal("could not determine executable path: ", err)
+ }
+
+ cfg.ServerCfg.ExploPath = exploPath
+ srv := backend.NewServer(cfg.ServerCfg)
+ log.Fatal(srv.Start())
+ }
httpClient := initHttpClient()
discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient)
tracks, err := discovery.Discover()
@@ -104,11 +98,13 @@ func main() {
}
}
- added := make(map[string]bool)
- for _, t := range tracks {
- added[t.CleanTitle+"|"+t.Artist] = true
+ if cfg.ServerCfg.Enabled {
+ added := make(map[string]bool)
+ for _, t := range tracks {
+ added[t.CleanTitle+"|"+t.Artist] = true
+ }
+ backend.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added)
}
- backend.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added)
if err := client.CreatePlaylist(tracks); err != nil {
slog.Warn(err.Error())
diff --git a/src/web/backend/jobs.go b/src/web/backend/jobs.go
new file mode 100644
index 0000000..096b7e2
--- /dev/null
+++ b/src/web/backend/jobs.go
@@ -0,0 +1,89 @@
+package backend
+
+// Jobs running on a schedule go here i.e cache cleanups (and playlist imports in the future)
+
+import (
+ "path/filepath"
+ "log/slog"
+ "os"
+ "slices"
+ "time"
+
+ "github.com/go-co-op/gocron/v2"
+)
+
+
+type Jobs struct {
+ scheduler gocron.Scheduler
+}
+
+type fileInfo struct {
+ path string
+ size int64
+ modTime time.Time
+}
+
+func NewJobs() (*Jobs) {
+ scheduler, err := gocron.NewScheduler()
+ if err != nil {
+ slog.Error("failed creating cron scheduler")
+ }
+
+ return &Jobs{ scheduler: scheduler}
+}
+
+func (j *Jobs) Start() {
+ j.scheduler.Start()
+}
+
+func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64) error {
+ _, err := j.scheduler.NewJob(
+ gocron.CronJob(schedule, false),
+ gocron.NewTask(func() {
+ slog.Info("running cache cleanup")
+
+ trimCacheDir(coversDir, maxBytes)
+ }),
+ )
+
+ return err
+}
+
+func trimCacheDir(dataDir string, maxBytes int64) {
+
+ var files []fileInfo
+ var total int64
+
+ err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+
+ files = append(files, fileInfo{
+ path: path,
+ size: info.Size(),
+ modTime: info.ModTime(),
+ })
+
+ total += info.Size()
+ return nil
+ })
+
+ if err != nil || total <= maxBytes {
+ return
+ }
+
+ slices.SortFunc(files, func(a, b fileInfo) int {
+ return a.modTime.Compare(b.modTime)
+ })
+
+ for _, f := range files {
+ if total <= maxBytes {
+ break
+ }
+
+ if err := os.Remove(f.path); err == nil {
+ total -= f.size
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go
index 6173c80..8bd1e0f 100644
--- a/src/web/backend/playlists.go
+++ b/src/web/backend/playlists.go
@@ -37,7 +37,7 @@ func (s *Server) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
return
}
- cachePath := filepath.Join(filepath.Dir(s.configPath), "cache", playlistType+".json")
+ cachePath := filepath.Join(s.cfg.WebDataDir, "cache", playlistType+".json")
if raw, err := os.ReadFile(cachePath); err == nil {
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(raw); err != nil {
@@ -169,8 +169,7 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added
Tracks []cachedTrack `json:"tracks"`
}
- cfgDir := filepath.Dir(cfgPath)
- coversDir := filepath.Join(cfgDir, "cache", "covers")
+ coversDir := filepath.Join(cfgPath, "cache", "covers")
if err := os.MkdirAll(coversDir, 0755); err != nil {
slog.Error("failed making directory", "msg", err.Error())
}
@@ -197,7 +196,7 @@ func WritePlaylistCache(cfgPath, playlist string, tracks []*models.Track, added
if err != nil {
return
}
- cacheDir := filepath.Join(cfgDir, "cache")
+ cacheDir := filepath.Join(cfgPath, "cache")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
slog.Error("failed creating cache dir", "msg", err.Error())
}
@@ -271,8 +270,6 @@ func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) {
http.Error(w, "user and playlists are required", http.StatusBadRequest)
return
}
-
- cfgDir := filepath.Dir(s.configPath)
forceRefresh := body.Source == "wizard"
w.WriteHeader(http.StatusAccepted)
@@ -285,7 +282,7 @@ func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) {
}
// Normal prefetch keeps an existing cache intact; wizard prefetch refreshes it
// after the user updates discovery settings.
- cachePath := filepath.Join(cfgDir, "cache", pt+".json")
+ cachePath := filepath.Join(s.cfg.WebDataDir, "cache", pt+".json")
if _, err := os.Stat(cachePath); err == nil && !forceRefresh {
slog.Info("prefetch: cache already exists, skipping", "playlist", pt)
continue
@@ -296,7 +293,7 @@ func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) {
continue
}
slog.Info("prefetch: fetched tracks", "playlist", pt, "count", len(tracks))
- writePrefetchCache(cfgDir, pt, tracks)
+ writePrefetchCache(s.cfg.WebDataDir, pt, tracks)
}
}()
}
@@ -354,7 +351,7 @@ type sitewideReleasesResp struct {
// It picks a random local cover if any exist; otherwise it fetches the top global
// albums from ListenBrainz and downloads cover art for the first available one.
func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) {
- coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers")
+ coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
url := randomLocalCoverHiRes(coversDir)
if url == "" {
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index ebf04c1..1495ec1 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -18,6 +18,7 @@ import (
"syscall"
"time"
+ "explo/src/config"
"explo/src/web"
)
@@ -68,16 +69,16 @@ func newManualRunState() manualRunState {
}
type Server struct {
- configPath string
- exploPath string
+ cfg config.ServerConfig
mux *http.ServeMux
server *http.Server
authStore *AuthStore
+ cronJobs *Jobs
sessionManager *SessionManager
manualRun manualRunState
}
-func NewServer(addr, configPath, exploPath string) *Server {
+func NewServer(cfg config.ServerConfig) *Server {
sessionManager := NewSessionManager(
NewInMemorySessionStore(),
1*time.Hour,
@@ -86,21 +87,23 @@ func NewServer(addr, configPath, exploPath string) *Server {
)
authStore := NewAuthStore(
- os.Getenv("UI_USERNAME"),
- os.Getenv("UI_PASSWORD"),
+ cfg.Username,
+ cfg.Password,
sessionManager,
)
+ cronJobs := NewJobs()
+
mux := http.NewServeMux()
s := &Server{
- configPath: configPath,
- exploPath: exploPath,
+ cfg: cfg,
mux: mux,
server: &http.Server{
- Addr: addr,
+ Addr: cfg.Port,
Handler: sessionManager.Handle(mux),
},
authStore: authStore,
+ cronJobs: cronJobs,
sessionManager: sessionManager,
manualRun: newManualRunState(),
}
@@ -111,10 +114,35 @@ func NewServer(addr, configPath, exploPath string) *Server {
func (s *Server) Start() error {
s.initServerLog()
+ s.startJobs()
+ s.PrefetchCovers()
slog.Info("Explo web UI started", "addr", s.server.Addr)
return s.server.ListenAndServe()
}
+// Jobs to register on startup
+func (s *Server) startJobs() {
+
+ coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
+ if err := s.cronJobs.RegisterCoverCleanup(
+ "0 3 * * *", coversDir, s.cfg.CacheSizeMB<<20); err != nil {
+ slog.Warn("failed to register cover cleanup job", "err", err.Error())
+ }
+
+
+ s.cronJobs.Start()
+}
+
+func(s *Server) PrefetchCovers() {
+
+ coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
+
+ url := randomLocalCoverHiRes(coversDir)
+ if url == "" {
+ fetchSitewideCovers(coversDir)
+ }
+}
+
// spaFS returns the filesystem to serve the frontend from.
// When WEB_DEV=true, serves directly from src/web/dist on disk so that
// running "npm run build" reflects changes without recompiling the binary.
@@ -170,7 +198,7 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt)
s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus)
- coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers")
+ coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir))))
}
@@ -178,7 +206,7 @@ func (s *Server) registerRoutes() {
// logPath returns the path to the single rolling log file.
func (s *Server) logPath() string {
- return filepath.Join(filepath.Dir(s.configPath), "logs", "explo.log")
+ return filepath.Join(s.cfg.WebDataDir, "logs", "explo.log")
}
// initServerLog redirects the default slog handler so all server log output
@@ -204,7 +232,7 @@ func (s *Server) openRunLog() (*os.File, error) {
// handleSetupStatus returns {"wizard_complete": bool} for first time setups. Public — no auth required.
func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
wizardComplete := false
- if data, err := os.ReadFile(s.configPath); err == nil {
+ if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil {
wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true"
}
w.Header().Set("Content-Type", "application/json")
@@ -307,7 +335,7 @@ func parseEnvText(text string) map[string]string {
// handleGetConfig returns resolved config as JSON: { values, sources }.
// Sources are "env" when set via os.Environ (takes precedence), "file" otherwise.
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
- data, err := os.ReadFile(s.configPath)
+ data, err := os.ReadFile(s.cfg.WebEnvPath)
var fileValues map[string]string
if err == nil {
fileValues = parseEnvText(string(data))
@@ -318,10 +346,10 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
values := make(map[string]string, len(allConfigKeys))
sources := make(map[string]string, len(allConfigKeys))
for _, key := range allConfigKeys {
- if v, ok := os.LookupEnv(key); ok {
+ if v, ok := os.LookupEnv(key); ok && v != "" {
values[key] = v
sources[key] = "env"
- } else if v := fileValues[key]; v != "" {
+ } else if v, ok := fileValues[key]; ok {
values[key] = v
sources[key] = "file"
}
@@ -335,7 +363,7 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
// handleGetConfigRaw returns the raw .env file contents as plain text.
func (s *Server) handleGetConfigRaw(w http.ResponseWriter, r *http.Request) {
- data, err := os.ReadFile(s.configPath)
+ data, err := os.ReadFile(s.cfg.WebEnvPath)
if err != nil {
data = web.SampleEnv
}
@@ -352,7 +380,7 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
- if err := os.WriteFile(s.configPath, data, 0600); err != nil {
+ if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -361,7 +389,7 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
// handleResetConfig resets all settings and restarts the container.
func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
- if err := os.WriteFile(s.configPath, web.SampleEnv, 0600); err != nil {
+ if err := os.WriteFile(s.cfg.WebEnvPath, web.SampleEnv, 0600); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -411,7 +439,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) {
updates[def.EnvPrefix+"_FLAGS"] = ""
}
- if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil {
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -510,7 +538,7 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) {
}
}
- if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil {
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -555,7 +583,7 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) {
"PUBLIC_PLAYLIST": publicPlaylist,
}
- if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil {
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -570,10 +598,11 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
MigrateDownloads bool `json:"migrate_downloads"`
DownloadServices []string `json:"download_services"`
YoutubeAPIKey string `json:"youtube_api_key"`
- TrackExtension string `json:"track_extension"`
+ TrackExtension string `json:"track_extension"` // yt-dlp
FilterList string `json:"filter_list"`
SlskdURL string `json:"slskd_url"`
SlskdAPIKey string `json:"slskd_api_key"`
+ Extensions string `json:"extensions"` // slskd
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -584,12 +613,6 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
return
}
joined := strings.Join(body.DownloadServices, ",")
- hasYoutube := strings.Contains(joined, "youtube")
- hasSlskd := strings.Contains(joined, "slskd")
- if (hasYoutube || (hasSlskd && body.MigrateDownloads)) && body.DownloadDir == "" {
- http.Error(w, "download_dir is required", http.StatusBadRequest)
- return
- }
useSubdir := "false"
if body.UseSubdirectory {
@@ -605,13 +628,15 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
"MIGRATE_DOWNLOADS": migrateDL,
"DOWNLOAD_SERVICES": joined,
"YOUTUBE_API_KEY": body.YoutubeAPIKey,
- "TRACK_EXTENSION": body.TrackExtension,
+ "TRACK_EXTENSION": body.TrackExtension, // yt-dlp
"FILTER_LIST": body.FilterList,
"SLSKD_URL": body.SlskdURL,
"SLSKD_API_KEY": body.SlskdAPIKey,
+ "EXTENSIONS": body.Extensions, // slskd
+ "WIZARD_COMPLETE": "true",
}
- if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil {
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -663,7 +688,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"),
r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true",
- s.configPath)
+ s.cfg.WebEnvPath)
if err := s.startRun(args); err != nil {
if errors.Is(err, errRunAlreadyStarted) {
@@ -683,7 +708,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
func (s *Server) startRun(args []string) error {
ctx, cancel := context.WithCancel(context.Background())
- cmd := exec.CommandContext(ctx, s.exploPath, args...)
+ cmd := exec.CommandContext(ctx, s.cfg.ExploPath, args...)
// Strip WEB_UI from env so the child process runs normally, not as web server.
env := make([]string, 0, len(os.Environ()))
for _, e := range os.Environ() {
@@ -949,8 +974,8 @@ func (s *Server) unsubscribeRun(ch chan runEvent) {
// ── Helpers ────────────────────────────────────────────────────────────────
-func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, cfgPath string) []string {
- args := []string{"--config", cfgPath}
+func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string {
+ args := []string{"--config", WebEnvPath}
if playlist != "" {
args = append(args, "--playlist", playlist)
}
diff --git a/src/web/frontend/src/App.jsx b/src/web/frontend/src/App.jsx
index 7edf141..c4819e3 100644
--- a/src/web/frontend/src/App.jsx
+++ b/src/web/frontend/src/App.jsx
@@ -17,9 +17,7 @@ export default function App() {
Promise.all([
checkAuth(),
fetchSetupStatus(),
- fetchBackgroundArt(),
- ]).then(([authed, status, artUrl]) => {
- if (artUrl) setBgUrl(artUrl)
+ ]).then(([authed, status]) => {
setIsFirstTime(status ? !status.wizard_complete : false)
if (authed) {
handleLoginSuccess({ fromLogin: false })
@@ -27,6 +25,9 @@ export default function App() {
setView('login')
}
})
+ fetchBackgroundArt().then((artUrl) => {
+ if (artUrl) setBgUrl(artUrl)
+ })
}, [])
async function handleLoginSuccess({ fromLogin = false } = {}) {
diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx
index 06140b5..bddacb5 100644
--- a/src/web/frontend/src/components/Wizard.jsx
+++ b/src/web/frontend/src/components/Wizard.jsx
@@ -158,8 +158,8 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) {
@@ -208,7 +208,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) {
)}
- {system && system !== 'mpd' && (
+ {API_KEY_SYSTEMS.includes(system) && (
By default, slskd saves tracks to whichever download path is configured in your slskd instance.
@@ -348,9 +361,10 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
{/* Only show download dir here when YouTube isn't also enabled — otherwise it lives in the YouTube section */}