From f00954017d87834dc327826ee1e6a3e7491dba13 Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 6 May 2026 20:39:24 +0300 Subject: [PATCH 01/21] bring UI variables to config struct --- src/config/config.go | 10 ++++++++++ src/config/flags.go | 7 +++++++ src/main/main.go | 37 ++++++++++++++++--------------------- src/web/backend/server.go | 11 ++++++----- 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index fcc9dd2..2283627 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 + CfgPathSet bool Playlist string DownloadMode string ExcludeLocal bool @@ -38,6 +40,14 @@ type Flags struct { PersistSet bool } +type ServerConfig struct { + Enabled bool `env:"WEB_UI" env-default:"true"` + Port string `env:"WEB_ADDR" env-default:":7288"` + Username string `env:"UI_USERNAME"` + Password string `env:"UI_PASSWORD"` + WebConfPath string `env:"WEB_CFG_PATH" env-default:".env"` +} + type ClientConfig struct { ClientID string `env:"CLIENT_ID" env-default:"explo"` LibraryName string `env:"LIBRARY_NAME" env-default:"Explo"` diff --git a/src/config/flags.go b/src/config/flags.go index 02b37f3..53cae5d 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 + cfgPathSet := 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.CfgPathSet = cfgPathSet 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.CfgPathSet { + cfg.ServerCfg.WebConfPath = 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..4176774 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,16 @@ 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) + } + + srv := backend.NewServer(cfg.ServerCfg, exploPath) + log.Fatal(srv.Start()) + } httpClient := initHttpClient() discovery := discovery.NewDiscoverer(cfg.DiscoveryCfg, httpClient) tracks, err := discovery.Discover() @@ -104,11 +97,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/server.go b/src/web/backend/server.go index ebf04c1..04f4c0b 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" ) @@ -77,7 +78,7 @@ type Server struct { manualRun manualRunState } -func NewServer(addr, configPath, exploPath string) *Server { +func NewServer(cfg config.ServerConfig, exploPath string) *Server { sessionManager := NewSessionManager( NewInMemorySessionStore(), 1*time.Hour, @@ -86,18 +87,18 @@ func NewServer(addr, configPath, exploPath string) *Server { ) authStore := NewAuthStore( - os.Getenv("UI_USERNAME"), - os.Getenv("UI_PASSWORD"), + cfg.Username, + cfg.Password, sessionManager, ) mux := http.NewServeMux() s := &Server{ - configPath: configPath, + configPath: cfg.WebConfPath, exploPath: exploPath, mux: mux, server: &http.Server{ - Addr: addr, + Addr: cfg.Port, Handler: sessionManager.Handle(mux), }, authStore: authStore, From ab741fc36e29026154269c2a31c6d88da653362c Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 6 May 2026 20:41:23 +0300 Subject: [PATCH 02/21] disable system select if defined in .env --- src/web/frontend/src/components/Wizard.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 06140b5..3df853b 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 }) { From a60223c42e71b4bcbca5266900c7e3fd7889f80e Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 6 May 2026 20:42:16 +0300 Subject: [PATCH 03/21] restrict sleep to API systems --- src/web/frontend/src/components/Wizard.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 3df853b..b9993d5 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -208,7 +208,7 @@ function Step2({ fields, setField, envSources, onBack, onNext, saving }) { )} - {system && system !== 'mpd' && ( + {API_KEY_SYSTEMS.includes(system) && ( Date: Sat, 9 May 2026 19:30:52 +0300 Subject: [PATCH 04/21] remove requirement to set download dir --- src/web/backend/server.go | 6 ------ src/web/frontend/src/components/Wizard.jsx | 7 ++++--- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 04f4c0b..4878dbf 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -585,12 +585,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 { diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index b9993d5..0210dd9 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -261,7 +261,6 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const valid = () => { if (!Object.values(dlServices).some(Boolean)) return false - if ((dlServices.youtube || (dlServices.slskd && migrateDownloads)) && !downloadDir.trim()) return false if (dlServices.slskd && (!slskdUrl.trim() || !slskdApiKey.trim())) return false return true } @@ -301,7 +300,8 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('filterList', e.target.value)} placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> - + setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} placeholder="e.g. /data/music/" /> @@ -348,7 +348,8 @@ 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 */}
- + setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} placeholder="e.g. /data/music/" /> From bf4bcde46e946b033f5a4d74ed41b9e1253c7b90 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 19:32:12 +0300 Subject: [PATCH 05/21] fetch background art separately from Promise --- src/web/frontend/src/App.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 } = {}) { From 034a6645d00035823bb89fb90ee0aa4082185340 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 19:33:18 +0300 Subject: [PATCH 06/21] set wizard complete on final step --- src/web/backend/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 4878dbf..45b8516 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -604,6 +604,7 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "FILTER_LIST": body.FilterList, "SLSKD_URL": body.SlskdURL, "SLSKD_API_KEY": body.SlskdAPIKey, + "WIZARD_COMPLETE": "true", } if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil { From 6aba4f3ad061af189d3ab5326f9b2b23ac19e51e Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 19:35:33 +0300 Subject: [PATCH 07/21] enable webui in docker --- docker-compose.yaml | 1 + src/config/config.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/src/config/config.go b/src/config/config.go index 2283627..bf495f1 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -41,7 +41,7 @@ type Flags struct { } type ServerConfig struct { - Enabled bool `env:"WEB_UI" env-default:"true"` + 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"` From f7771556adf03322abf0a9fbccedb2dc1fb5a93b Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 19:51:32 +0300 Subject: [PATCH 08/21] fetch background art on startup --- src/web/backend/playlists.go | 11 +++++++++++ src/web/backend/server.go | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 6173c80..2e43c9a 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -367,6 +367,17 @@ func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) { } } +// Called on server startup to check if cover art exists locally; otherwise fetch it early from web +func (s *Server) prefetchBackgroundArt() { + + coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers") + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + url = fetchSitewideCovers(coversDir) + } +} + // randomLocalCoverHiRes picks a random cover from the existing library, ensures a // 1200px background version is cached (as {mbid}-bg.jpg), and returns its API URL. // Playlist thumbnails are stored at 250px; this fetches full-res on demand from CAA. diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 45b8516..f6a092e 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -112,10 +112,21 @@ func NewServer(cfg config.ServerConfig, exploPath string) *Server { func (s *Server) Start() error { s.initServerLog() + s.prefetchBackgroundArt() slog.Info("Explo web UI started", "addr", s.server.Addr) return s.server.ListenAndServe() } +func(s *Server) PrefetchCovers() { + + coversDir := filepath.Join(filepath.Dir(s.configPath), "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. From ac70c3ca445a953b68548435df43bcaa3717d174 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 23:05:09 +0300 Subject: [PATCH 09/21] separate env path and web data path --- src/config/config.go | 5 +++-- src/config/flags.go | 8 ++++---- src/web/backend/playlists.go | 19 +++++++++--------- src/web/backend/server.go | 38 +++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index bf495f1..7a418c3 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -32,7 +32,7 @@ type Config struct { type Flags struct { CfgPath string - CfgPathSet bool + CfgSet bool Playlist string DownloadMode string ExcludeLocal bool @@ -45,7 +45,8 @@ type ServerConfig struct { Port string `env:"WEB_ADDR" env-default:":7288"` Username string `env:"UI_USERNAME"` Password string `env:"UI_PASSWORD"` - WebConfPath string `env:"WEB_CFG_PATH" env-default:".env"` + WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"` + WebConfPath string `env:"WEB_CFG_PATH" env-default:"/opt/explo/.env"` } type ClientConfig struct { diff --git a/src/config/flags.go b/src/config/flags.go index 53cae5d..504f497 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -36,7 +36,7 @@ func (cfg *Config) GetFlags() error { os.Exit(0) } persistSet := flag.Lookup("persist").Changed - cfgPathSet := flag.Lookup("config").Changed + cfgSet := flag.Lookup("config").Changed // Validation for playlist if !contains(validPlaylists, playlist) { @@ -51,7 +51,7 @@ func (cfg *Config) GetFlags() error { } cfg.Flags.CfgPath = configPath - cfg.Flags.CfgPathSet = cfgPathSet + cfg.Flags.CfgSet = cfgSet cfg.Flags.Playlist = playlist cfg.Flags.DownloadMode = downloadMode cfg.Flags.ExcludeLocal = excludeLocal @@ -67,8 +67,8 @@ func (cfg *Config) MergeFlags() { cfg.DiscoveryCfg.Listenbrainz.ImportPlaylist = cfg.Flags.Playlist cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal - if cfg.Flags.CfgPathSet { - cfg.ServerCfg.WebConfPath = cfg.Flags.CfgPath + if cfg.Flags.CfgSet { + cfg.ServerCfg.WebConfPath = cfg.Flags.CfgPath } if cfg.Flags.PersistSet { diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 2e43c9a..99f96ff 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.dataDir, "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.dataDir, "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.dataDir, 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.dataDir, "cache", "covers") url := randomLocalCoverHiRes(coversDir) if url == "" { @@ -370,7 +367,7 @@ func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) { // Called on server startup to check if cover art exists locally; otherwise fetch it early from web func (s *Server) prefetchBackgroundArt() { - coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers") + coversDir := filepath.Join(s.dataDir, "cache", "covers") url := randomLocalCoverHiRes(coversDir) if url == "" { @@ -429,6 +426,7 @@ func randomLocalCoverHiRes(coversDir string) string { // returns a "/api/covers/.jpg" URL for the first one that meets the // minimum resolution requirement (1000px). func fetchSitewideCovers(coversDir string) string { + fmt.Println(coversDir) if err := os.MkdirAll(coversDir, 0755); err != nil { return "" } @@ -471,6 +469,7 @@ func fetchSitewideCovers(coversDir string) string { slog.Error("background-art: failed to write sitewide cover", "err", err.Error()) continue } + fmt.Println("wrote cover to " + destPath) return "/api/covers/" + rel.ReleaseMbid + ".jpg" } return "" diff --git a/src/web/backend/server.go b/src/web/backend/server.go index f6a092e..96edbfb 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -69,7 +69,8 @@ func newManualRunState() manualRunState { } type Server struct { - configPath string + envPath string + dataDir string exploPath string mux *http.ServeMux server *http.Server @@ -94,7 +95,8 @@ func NewServer(cfg config.ServerConfig, exploPath string) *Server { mux := http.NewServeMux() s := &Server{ - configPath: cfg.WebConfPath, + envPath: cfg.WebConfPath, + dataDir: cfg.WebDataDir, exploPath: exploPath, mux: mux, server: &http.Server{ @@ -112,14 +114,14 @@ func NewServer(cfg config.ServerConfig, exploPath string) *Server { func (s *Server) Start() error { s.initServerLog() - s.prefetchBackgroundArt() + s.PrefetchCovers() slog.Info("Explo web UI started", "addr", s.server.Addr) return s.server.ListenAndServe() } func(s *Server) PrefetchCovers() { - coversDir := filepath.Join(filepath.Dir(s.configPath), "cache", "covers") + coversDir := filepath.Join(s.dataDir, "cache", "covers") url := randomLocalCoverHiRes(coversDir) if url == "" { @@ -182,7 +184,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.dataDir, "cache", "covers") s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) } @@ -190,7 +192,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.dataDir, "logs", "explo.log") } // initServerLog redirects the default slog handler so all server log output @@ -216,7 +218,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.envPath); err == nil { wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true" } w.Header().Set("Content-Type", "application/json") @@ -319,7 +321,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.envPath) var fileValues map[string]string if err == nil { fileValues = parseEnvText(string(data)) @@ -347,7 +349,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.envPath) if err != nil { data = web.SampleEnv } @@ -364,7 +366,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.envPath, data, 0600); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -373,7 +375,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.envPath, web.SampleEnv, 0600); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -423,7 +425,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.envPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -522,7 +524,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.envPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -567,7 +569,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.envPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -618,7 +620,7 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "WIZARD_COMPLETE": "true", } - if err := updateEnvKeys(s.configPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.envPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -670,7 +672,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.envPath) if err := s.startRun(args); err != nil { if errors.Is(err, errRunAlreadyStarted) { @@ -956,8 +958,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, envPath string) []string { + args := []string{"--config", envPath} if playlist != "" { args = append(args, "--playlist", playlist) } From 25e52be839e06c9dce041e14ffc53c581bc29efc Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 23:16:08 +0300 Subject: [PATCH 10/21] fix getting config --- src/web/backend/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 96edbfb..234a02b 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -332,10 +332,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" } From 327aff08b3e0ef0d630f5ad773e0d205186c12a1 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 9 May 2026 23:28:26 +0300 Subject: [PATCH 11/21] remove dupe function --- src/web/backend/playlists.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 99f96ff..d1dd3a1 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -364,17 +364,6 @@ func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) { } } -// Called on server startup to check if cover art exists locally; otherwise fetch it early from web -func (s *Server) prefetchBackgroundArt() { - - coversDir := filepath.Join(s.dataDir, "cache", "covers") - - url := randomLocalCoverHiRes(coversDir) - if url == "" { - url = fetchSitewideCovers(coversDir) - } -} - // randomLocalCoverHiRes picks a random cover from the existing library, ensures a // 1200px background version is cached (as {mbid}-bg.jpg), and returns its API URL. // Playlist thumbnails are stored at 250px; this fetches full-res on demand from CAA. From d3e1bbcc0a0ee34b926607f8b5838eba3339bfa5 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 10 May 2026 00:09:16 +0300 Subject: [PATCH 12/21] remove print --- src/web/backend/playlists.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index d1dd3a1..7bddf03 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -415,7 +415,6 @@ func randomLocalCoverHiRes(coversDir string) string { // returns a "/api/covers/.jpg" URL for the first one that meets the // minimum resolution requirement (1000px). func fetchSitewideCovers(coversDir string) string { - fmt.Println(coversDir) if err := os.MkdirAll(coversDir, 0755); err != nil { return "" } @@ -458,7 +457,6 @@ func fetchSitewideCovers(coversDir string) string { slog.Error("background-art: failed to write sitewide cover", "err", err.Error()) continue } - fmt.Println("wrote cover to " + destPath) return "/api/covers/" + rel.ReleaseMbid + ".jpg" } return "" From 79f2105617117e0cbb6db9b8f6aef4d568217472 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 10 May 2026 00:09:59 +0300 Subject: [PATCH 13/21] use default as placeholder --- src/web/frontend/src/components/Wizard.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 0210dd9..28f4997 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -303,7 +303,7 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} - placeholder="e.g. /data/music/" /> + placeholder="/data/" /> setField('downloadDir', v)} disabled={isLocked('DOWNLOAD_DIR')} - placeholder="e.g. /data/music/" /> + placeholder="/data/" /> Date: Sat, 9 May 2026 18:37:32 -0700 Subject: [PATCH 14/21] add keyword exclusions and file extension options for slskd in wizard step 3, added better labeling to differentiate the two --- src/config/config.go | 4 ++-- src/web/backend/server.go | 6 ++++-- src/web/frontend/src/components/Wizard.jsx | 14 +++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 7a418c3..8d407f6 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -100,7 +100,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"` @@ -110,7 +110,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/web/backend/server.go b/src/web/backend/server.go index 234a02b..4afc853 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -584,10 +584,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) @@ -613,10 +614,11 @@ 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", } diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index 28f4997..e45f00d 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -256,7 +256,7 @@ function Collapse({ open, children }) { function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { const { downloadDir, useSubdirectory, migrateDownloads, dlServices, - youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey } = fields + youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, extensions } = fields const isLocked = key => envSources[key] === 'env' const valid = () => { @@ -334,6 +334,16 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('slskdApiKey', e.target.value)} autoComplete="off" spellCheck={false} disabled={isLocked('SLSKD_API_KEY')} /> + + setField('extensions', e.target.value)} + placeholder="flac,mp3" autoComplete="off" spellCheck={false} disabled={isLocked('EXTENSIONS')} /> + + + setField('filterList', e.target.value)} + placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> +

By default, slskd saves tracks to whichever download path is configured in your slskd instance. @@ -414,6 +424,7 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad, filterList: config.FILTER_LIST || '', slskdUrl: config.SLSKD_URL || '', slskdApiKey: config.SLSKD_API_KEY || '', + extensions: config.EXTENSIONS || '', } }) @@ -465,6 +476,7 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad, migrate_downloads: fields.migrateDownloads, download_services: services, youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension, filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey, + extensions: fields.extensions, }) onComplete() } catch (e) { From 3bc236ccf975b04a288d6e1cc2fc4d07b59c341a Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 10 May 2026 18:46:57 +0300 Subject: [PATCH 15/21] show keyword exclusion only if youtube is not selected --- src/web/frontend/src/components/Wizard.jsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx index e45f00d..bddacb5 100644 --- a/src/web/frontend/src/components/Wizard.jsx +++ b/src/web/frontend/src/components/Wizard.jsx @@ -339,11 +339,14 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) { setField('extensions', e.target.value)} placeholder="flac,mp3" autoComplete="off" spellCheck={false} disabled={isLocked('EXTENSIONS')} /> - - setField('filterList', e.target.value)} - placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> - + {/* Show keyword exclusion when YouTube isn't enabled — otherwise it lives in the YouTube section */} + + + setField('filterList', e.target.value)} + placeholder="live,remix,instrumental,extended,clean,acapella" autoComplete="off" spellCheck={false} disabled={isLocked('FILTER_LIST')} /> + +

By default, slskd saves tracks to whichever download path is configured in your slskd instance. From f76fe152dc90bf7e7f584103f58bf8a539c64d6d Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 20:33:37 +0300 Subject: [PATCH 16/21] use cfg struct for server --- src/config/config.go | 1 + src/main/main.go | 3 ++- src/web/backend/playlists.go | 8 +++---- src/web/backend/server.go | 42 ++++++++++++++++-------------------- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 8d407f6..b04ee7f 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -47,6 +47,7 @@ type ServerConfig struct { Password string `env:"UI_PASSWORD"` WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"` WebConfPath string `env:"WEB_CFG_PATH" env-default:"/opt/explo/.env"` + ExploPath string } type ClientConfig struct { diff --git a/src/main/main.go b/src/main/main.go index 4176774..4343bcf 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -52,7 +52,8 @@ func main() { log.Fatal("could not determine executable path: ", err) } - srv := backend.NewServer(cfg.ServerCfg, exploPath) + cfg.ServerCfg.ExploPath = exploPath + srv := backend.NewServer(cfg.ServerCfg) log.Fatal(srv.Start()) } httpClient := initHttpClient() diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 7bddf03..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(s.dataDir, "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 { @@ -282,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(s.dataDir, "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 @@ -293,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(s.dataDir, pt, tracks) + writePrefetchCache(s.cfg.WebDataDir, pt, tracks) } }() } @@ -351,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(s.dataDir, "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 4afc853..930aca9 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -69,9 +69,7 @@ func newManualRunState() manualRunState { } type Server struct { - envPath string - dataDir string - exploPath string + cfg config.ServerConfig mux *http.ServeMux server *http.Server authStore *AuthStore @@ -79,7 +77,7 @@ type Server struct { manualRun manualRunState } -func NewServer(cfg config.ServerConfig, exploPath string) *Server { +func NewServer(cfg config.ServerConfig) *Server { sessionManager := NewSessionManager( NewInMemorySessionStore(), 1*time.Hour, @@ -95,9 +93,7 @@ func NewServer(cfg config.ServerConfig, exploPath string) *Server { mux := http.NewServeMux() s := &Server{ - envPath: cfg.WebConfPath, - dataDir: cfg.WebDataDir, - exploPath: exploPath, + cfg: cfg, mux: mux, server: &http.Server{ Addr: cfg.Port, @@ -121,7 +117,7 @@ func (s *Server) Start() error { func(s *Server) PrefetchCovers() { - coversDir := filepath.Join(s.dataDir, "cache", "covers") + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") url := randomLocalCoverHiRes(coversDir) if url == "" { @@ -184,7 +180,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(s.dataDir, "cache", "covers") + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) } @@ -192,7 +188,7 @@ func (s *Server) registerRoutes() { // logPath returns the path to the single rolling log file. func (s *Server) logPath() string { - return filepath.Join(s.dataDir, "logs", "explo.log") + return filepath.Join(s.cfg.WebDataDir, "logs", "explo.log") } // initServerLog redirects the default slog handler so all server log output @@ -218,7 +214,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.envPath); err == nil { + if data, err := os.ReadFile(s.cfg.WebConfPath); err == nil { wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true" } w.Header().Set("Content-Type", "application/json") @@ -321,7 +317,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.envPath) + data, err := os.ReadFile(s.cfg.WebConfPath) var fileValues map[string]string if err == nil { fileValues = parseEnvText(string(data)) @@ -349,7 +345,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.envPath) + data, err := os.ReadFile(s.cfg.WebConfPath) if err != nil { data = web.SampleEnv } @@ -366,7 +362,7 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := os.WriteFile(s.envPath, data, 0600); err != nil { + if err := os.WriteFile(s.cfg.WebConfPath, data, 0600); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -375,7 +371,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.envPath, web.SampleEnv, 0600); err != nil { + if err := os.WriteFile(s.cfg.WebConfPath, web.SampleEnv, 0600); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -425,7 +421,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { updates[def.EnvPrefix+"_FLAGS"] = "" } - if err := updateEnvKeys(s.envPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -524,7 +520,7 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { } } - if err := updateEnvKeys(s.envPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -569,7 +565,7 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { "PUBLIC_PLAYLIST": publicPlaylist, } - if err := updateEnvKeys(s.envPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -622,7 +618,7 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "WIZARD_COMPLETE": "true", } - if err := updateEnvKeys(s.envPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -674,7 +670,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.envPath) + s.cfg.WebConfPath) if err := s.startRun(args); err != nil { if errors.Is(err, errRunAlreadyStarted) { @@ -694,7 +690,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() { @@ -960,8 +956,8 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { // ── Helpers ──────────────────────────────────────────────────────────────── -func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, envPath string) []string { - args := []string{"--config", envPath} +func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebConfPath string) []string { + args := []string{"--config", WebConfPath} if playlist != "" { args = append(args, "--playlist", playlist) } From 3611c56f71d56160af7365049b721f801f545266 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 20:36:44 +0300 Subject: [PATCH 17/21] Change variable naming for more clarity --- docker/start.sh | 12 ++++++------ src/config/config.go | 2 +- src/config/flags.go | 2 +- src/web/backend/server.go | 24 ++++++++++++------------ 4 files changed, 20 insertions(+), 20 deletions(-) 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/src/config/config.go b/src/config/config.go index b04ee7f..919cbc5 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -46,7 +46,7 @@ type ServerConfig struct { Username string `env:"UI_USERNAME"` Password string `env:"UI_PASSWORD"` WebDataDir string `env:"WEB_DATA_PATH" env-default:"/opt/explo/config/"` - WebConfPath string `env:"WEB_CFG_PATH" env-default:"/opt/explo/.env"` + WebEnvPath string `env:"WEB_ENV_PATH" env-default:"/opt/explo/.env"` ExploPath string } diff --git a/src/config/flags.go b/src/config/flags.go index 504f497..890f199 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -68,7 +68,7 @@ func (cfg *Config) MergeFlags() { cfg.DownloadCfg.ExcludeLocal = cfg.Flags.ExcludeLocal if cfg.Flags.CfgSet { - cfg.ServerCfg.WebConfPath = cfg.Flags.CfgPath + cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } if cfg.Flags.PersistSet { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 930aca9..b31e636 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -214,7 +214,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.cfg.WebConfPath); 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") @@ -317,7 +317,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.cfg.WebConfPath) + data, err := os.ReadFile(s.cfg.WebEnvPath) var fileValues map[string]string if err == nil { fileValues = parseEnvText(string(data)) @@ -345,7 +345,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.cfg.WebConfPath) + data, err := os.ReadFile(s.cfg.WebEnvPath) if err != nil { data = web.SampleEnv } @@ -362,7 +362,7 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - if err := os.WriteFile(s.cfg.WebConfPath, data, 0600); err != nil { + if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -371,7 +371,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.cfg.WebConfPath, 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 } @@ -421,7 +421,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { updates[def.EnvPrefix+"_FLAGS"] = "" } - if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -520,7 +520,7 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { } } - if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -565,7 +565,7 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) { "PUBLIC_PLAYLIST": publicPlaylist, } - if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -618,7 +618,7 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) { "WIZARD_COMPLETE": "true", } - if err := updateEnvKeys(s.cfg.WebConfPath, updates, web.SampleEnv); err != nil { + if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -670,7 +670,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.cfg.WebConfPath) + s.cfg.WebEnvPath) if err := s.startRun(args); err != nil { if errors.Is(err, errRunAlreadyStarted) { @@ -956,8 +956,8 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { // ── Helpers ──────────────────────────────────────────────────────────────── -func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebConfPath string) []string { - args := []string{"--config", WebConfPath} +func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { + args := []string{"--config", WebEnvPath} if playlist != "" { args = append(args, "--playlist", playlist) } From eff7f7e0069b4b71664c74d8998f738f4912628e Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 20:43:48 +0300 Subject: [PATCH 18/21] Add base for internal cron management --- go.mod | 7 ++++++- go.sum | 21 ++++++++++++++++++++- src/config/config.go | 1 + src/web/backend/server.go | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) 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 919cbc5..3eb49c4 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -47,6 +47,7 @@ type ServerConfig struct { 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 } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index b31e636..59b73e5 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -73,6 +73,7 @@ type Server struct { mux *http.ServeMux server *http.Server authStore *AuthStore + cronJobs *Jobs sessionManager *SessionManager manualRun manualRunState } @@ -91,6 +92,8 @@ func NewServer(cfg config.ServerConfig) *Server { sessionManager, ) + cronJobs := NewJobs() + mux := http.NewServeMux() s := &Server{ cfg: cfg, @@ -100,6 +103,7 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, + cronJobs: cronJobs, sessionManager: sessionManager, manualRun: newManualRunState(), } @@ -110,11 +114,23 @@ func NewServer(cfg config.ServerConfig) *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") + s.cronJobs.RegisterCoverCleanup( + "0 3 * * *", coversDir, s.cfg.CacheSizeMB) + + + s.cronJobs.Start() +} + func(s *Server) PrefetchCovers() { coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") From 4333cac9fd33fcdbe9e900fea65a83a4a8a0b6e0 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 20:44:08 +0300 Subject: [PATCH 19/21] Add base for internal cron management --- src/web/backend/jobs.go | 89 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/web/backend/jobs.go 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 From 5e5762b911263791e2c6d74e5cbd09bee6143bf8 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 20:47:14 +0300 Subject: [PATCH 20/21] fix linter --- src/web/backend/server.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 59b73e5..e8df7ec 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -124,8 +124,10 @@ func (s *Server) Start() error { func (s *Server) startJobs() { coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") - s.cronJobs.RegisterCoverCleanup( - "0 3 * * *", coversDir, s.cfg.CacheSizeMB) + if err := s.cronJobs.RegisterCoverCleanup( + "0 3 * * *", coversDir, s.cfg.CacheSizeMB); err != nil { + slog.Warn("failed to register cover cleanup job", "err", err.Error()) + } s.cronJobs.Start() From f196e39810bbd2a463b53b368c4f765cca58c867 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 11 May 2026 22:39:43 +0300 Subject: [PATCH 21/21] convert MB to Bytes --- src/web/backend/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index e8df7ec..1495ec1 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -125,7 +125,7 @@ func (s *Server) startJobs() { coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") if err := s.cronJobs.RegisterCoverCleanup( - "0 3 * * *", coversDir, s.cfg.CacheSizeMB); err != nil { + "0 3 * * *", coversDir, s.cfg.CacheSizeMB<<20); err != nil { slog.Warn("failed to register cover cleanup job", "err", err.Error()) }