From 0a22035b726d583271fa3866925aa50b698a77fb Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:03:26 -0700
Subject: [PATCH 1/8] remove stale persist and exclude local toggles from
startRun function in frontend
---
src/web/backend/run/handlers.go | 4 +-
src/web/backend/run/manual_run.go | 8 +-
src/web/backend/server.go | 1204 +++++++++++++++++-
src/web/frontend/src/components/Settings.jsx | 17 +-
src/web/frontend/src/lib/api.js | 4 +-
5 files changed, 1180 insertions(+), 57 deletions(-)
diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go
index 595090f..b8265c8 100644
--- a/src/web/backend/run/handlers.go
+++ b/src/web/backend/run/handlers.go
@@ -15,9 +15,7 @@ func (mr *ManualRun) HandleRun(w http.ResponseWriter, r *http.Request) {
return
}
- args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"),
- r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true",
- mr.cfg.WebEnvPath)
+ args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), mr.cfg.WebEnvPath)
if err := mr.startRun(args); err != nil {
if errors.Is(err, errRunAlreadyStarted) {
diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go
index 5999793..53dd24e 100644
--- a/src/web/backend/run/manual_run.go
+++ b/src/web/backend/run/manual_run.go
@@ -164,7 +164,7 @@ func (mr *ManualRun) finishRun(code int) {
}
// helper to build flag arguments
-func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string {
+func buildArgs(playlist, downloadMode, WebEnvPath string) []string {
args := []string{"--config", WebEnvPath}
if playlist != "" {
args = append(args, "--playlist", playlist)
@@ -172,11 +172,5 @@ func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebE
if downloadMode != "" {
args = append(args, "--download-mode", downloadMode)
}
- if noPersist {
- args = append(args, "--persist=false")
- }
- if excludeLocal {
- args = append(args, "--exclude-local")
- }
return args
}
\ No newline at end of file
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index bcd2c3c..38ebdf8 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -1,67 +1,98 @@
package backend
import (
+ "bufio"
+ "context"
"encoding/json"
+ "errors"
"fmt"
+ "io"
"io/fs"
"log/slog"
"net/http"
"os"
+ "os/exec"
"path/filepath"
"strings"
+ "sync"
+ "syscall"
"time"
"explo/src/config"
"explo/src/web"
- "explo/src/web/backend/app"
- "explo/src/web/backend/run"
- "explo/src/web/backend/playlist"
- "explo/src/web/backend/jobs"
- "explo/src/web/backend/settings"
- "explo/src/web/backend/auth"
)
+// Option is a value/label pair for select-type fields.
+type Option struct {
+ Value string `json:"value"`
+ Label string `json:"label"`
+}
+
+// Condition expresses a dependency on another field's value.
+// All non-zero properties are ANDed together.
+type Condition struct {
+ Field string `json:"field"`
+ Eq string `json:"eq,omitempty"` // field === value
+ In []string `json:"in,omitempty"` // field is one of values
+ Contains string `json:"contains,omitempty"` // value appears in field's comma-separated list
+}
+
// ConfigResponse is returned by GET /api/config.
type ConfigResponse struct {
Values map[string]string `json:"values"`
Sources map[string]string `json:"sources"` // "env" | "file"
}
+// runEvent is an SSE event sent to connected browser clients.
+type runEvent struct {
+ typ string
+ data string
+}
+
+// RunStatus is returned by GET /api/run/status.
+type RunStatus struct {
+ Running bool `json:"running"`
+ ExitCode *int `json:"exit_code,omitempty"`
+}
+
+type manualRunState struct {
+ mu sync.Mutex
+ running bool
+ cancel context.CancelFunc
+ exitCode *int
+ logs []string
+ subscribers map[chan runEvent]struct{}
+}
+
+func newManualRunState() manualRunState {
+ return manualRunState{subscribers: make(map[chan runEvent]struct{})}
+}
+
type Server struct {
cfg config.ServerConfig
mux *http.ServeMux
server *http.Server
- authStore *auth.AuthStore
- settings *settings.Settings
- cronJobs *jobs.Jobs
- manualRun *run.ManualRun
- customPlaylist *playlist.Playlist
+ authStore *AuthStore
+ cronJobs *Jobs
+ sessionManager *SessionManager
+ manualRun manualRunState
}
func NewServer(cfg config.ServerConfig) *Server {
- sessionManager := auth.NewSessionManager(
- auth.NewInMemorySessionStore(),
+ sessionManager := NewSessionManager(
+ NewInMemorySessionStore(),
1*time.Hour,
7*(24*time.Hour),
"session",
)
- authStore := auth.NewAuthStore(
+ authStore := NewAuthStore(
cfg.Username,
cfg.Password,
sessionManager,
)
- webCfg := app.Config{
- WebEnvPath: cfg.WebEnvPath,
- WebDataDir: cfg.WebDataDir,
- ExploPath: cfg.ExploPath,
- }
-
- settings := settings.NewSettings(webCfg)
- cronJobs := jobs.NewJobs()
- manualRun := run.NewManualRun(webCfg)
- playlist := playlist.NewPlaylist(webCfg, settings)
+ cronJobs := NewJobs()
mux := http.NewServeMux()
s := &Server{
@@ -72,10 +103,9 @@ func NewServer(cfg config.ServerConfig) *Server {
Handler: sessionManager.Handle(mux),
},
authStore: authStore,
- settings: settings,
cronJobs: cronJobs,
- manualRun: manualRun,
- customPlaylist: playlist,
+ sessionManager: sessionManager,
+ manualRun: newManualRunState(),
}
s.registerRoutes()
@@ -83,11 +113,11 @@ func NewServer(cfg config.ServerConfig) *Server {
}
func (s *Server) Start() error {
- s.manualRun.InitServerLog()
+ s.initServerLog()
s.startJobs()
coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
if _, err := os.Stat(coversDir); os.IsNotExist(err) {
- s.customPlaylist.PrefetchCovers()
+ s.PrefetchCovers()
}
slog.Info("Explo web UI started", "addr", s.server.Addr)
go checkForUpdate()
@@ -149,13 +179,23 @@ func (s *Server) startJobs() {
slog.Warn("failed to register cover cleanup job", "err", err.Error())
}
- if err := s.customPlaylist.RegisterCustomPlaylistRefresh(s.cronJobs); err != nil {
+ if err := s.cronJobs.RegisterCustomPlaylistRefresh(s.cfg.WebDataDir, s.cfg.WebEnvPath); err != nil {
slog.Warn("failed to register custom playlist refresh 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.
@@ -168,4 +208,1106 @@ func spaFS() (fs.FS, []byte) {
embedded, _ := fs.Sub(web.DistFiles, "dist")
index, _ := fs.ReadFile(embedded, "index.html")
return embedded, index
-}
\ No newline at end of file
+}
+
+func (s *Server) registerRoutes() {
+ distFS, indexHTML := spaFS()
+ fileServer := http.FileServer(http.FS(distFS))
+
+ // SPA fallback: serve static assets when they exist, otherwise serve index.html.
+ s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path != "" {
+ if _, err := fs.Stat(distFS, path); err == nil {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if _, err := w.Write(indexHTML); err != nil {
+ slog.Error("failed writing to http", "msg", err.Error())
+ }
+ })
+
+ // /api/ui/config — GET = read, POST = save (both require auth)
+ s.mux.HandleFunc("/api/ui/config", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfig)).ServeHTTP(w, r)
+ case http.MethodPost:
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveConfig)).ServeHTTP(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+ s.mux.Handle("/api/ui/config/raw", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfigRaw)))
+ s.mux.Handle("/api/ui/config/reset", s.authStore.RequireAuth(http.HandlerFunc(s.handleResetConfig)))
+ s.mux.Handle("/api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule)))
+ s.mux.Handle("/api/ui/config/path-template", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePathTemplate)))
+ s.mux.Handle("/api/ui/config/enrich-metadata", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveEnrichMetadata)))
+ s.mux.Handle("/api/ui/config/persist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePersist)))
+ s.mux.Handle("/api/ui/config/clean-downloads", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveCleanDownloads)))
+
+ // Path template presets: GET list, POST add; DELETE per name under prefix
+ s.mux.HandleFunc("/api/ui/path-templates", func(w http.ResponseWriter, r *http.Request) {
+ s.authStore.RequireAuth(http.HandlerFunc(s.handlePathTemplates)).ServeHTTP(w, r)
+ })
+ s.mux.HandleFunc("/api/ui/path-templates/", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodDelete {
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleDeletePathTemplate)).ServeHTTP(w, r)
+ return
+ }
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ })
+
+ // Wizard steps (POST) — require auth
+ s.mux.Handle("/api/ui/wizard/step1", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep1)))
+ s.mux.Handle("/api/ui/wizard/step2", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep2)))
+ s.mux.Handle("/api/ui/wizard/step3", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep3)))
+
+ s.mux.Handle("/api/ui/browse", s.authStore.RequireAuth(http.HandlerFunc(s.handleBrowse)))
+ s.mux.Handle("/api/ui/run", s.authStore.RequireAuth(http.HandlerFunc(s.handleRun)))
+ s.mux.Handle("/api/ui/run/events", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunEvents)))
+ s.mux.Handle("/api/ui/run/stop", s.authStore.RequireAuth(http.HandlerFunc(s.handleStopRun)))
+ s.mux.Handle("/api/ui/run/status", s.authStore.RequireAuth(http.HandlerFunc(s.handleRunStatus)))
+
+ s.mux.Handle("/api/ui/logs", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetLog)))
+ s.mux.Handle("/api/ui/playlists", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetPlaylist)))
+ s.mux.Handle("/api/ui/playlists/prefetch", s.authStore.RequireAuth(http.HandlerFunc(s.handlePrefetchCovers)))
+
+ // TODO: Uncomment when jeffs branch is in
+ // custom playlists: GET list, POST import (same path); per-ID actions under prefix
+ s.mux.HandleFunc("/api/ui/custom-playlists", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleGetCustomPlaylists)).ServeHTTP(w, r)
+ case http.MethodPost:
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleImportCustomPlaylist)).ServeHTTP(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+ // ID-specific routes: DELETE /api/ui/custom-playlists/{id} and POST .../{id}/refresh
+ s.mux.HandleFunc("/api/ui/custom-playlists/{id}/refresh", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleRefreshCustomPlaylist)).ServeHTTP(w, r)
+ })
+ s.mux.HandleFunc("/api/ui/custom-playlists/{id}", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist)).ServeHTTP(w, r)
+ })
+
+ s.mux.Handle("/api/ui/logout", s.authStore.RequireAuth(http.HandlerFunc(s.handleLogout)))
+
+ // public/special routes
+ s.mux.HandleFunc("/api/ui/csrf", s.csrfHandler)
+ s.mux.HandleFunc("/api/ui/login", s.handleLogin)
+ s.mux.HandleFunc("/api/ui/auth/status", s.handleAuthStatus)
+ s.mux.HandleFunc("/api/ui/background-art", s.handleBackgroundArt)
+ s.mux.HandleFunc("/api/ui/setup-status", s.handleSetupStatus)
+
+ coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers")
+ s.mux.Handle("/api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir))))
+}
+
+// ── Logging ────────────────────────────────────────────────────────────────
+
+// logPath returns the path to the single rolling log file.
+func (s *Server) logPath() string {
+ return filepath.Join(s.cfg.WebDataDir, "logs", "explo.log")
+}
+
+// initServerLog redirects the default slog handler so all server log output
+// goes to both stderr and the rolling log file.
+func (s *Server) initServerLog() {
+ lf, err := s.openRunLog()
+ if err != nil {
+ return
+ }
+ w := io.MultiWriter(os.Stderr, lf)
+ slog.SetDefault(slog.New(slog.NewTextHandler(w, nil)))
+}
+
+// openRunLog opens the single rolling log file in append mode.
+func (s *Server) openRunLog() (*os.File, error) {
+ p := s.logPath()
+ if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+ return nil, err
+ }
+ return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
+}
+
+// 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.WebEnvPath); err == nil {
+ wizardComplete = parseEnvText(string(data))["WIZARD_COMPLETE"] == "true"
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(map[string]bool{"wizard_complete": wizardComplete}); err != nil {
+ slog.Error("failed encoding setup status", "err", err.Error())
+ }
+}
+
+func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
+ sess := s.sessionManager.GetSession(r)
+ auth, _ := sess.Get("authenticated").(bool)
+ if !auth {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ err := http.StatusMethodNotAllowed
+ http.Error(w, "Invalid request method", err)
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if !s.authStore.CompareCreds(username, password) {
+ http.Error(w, "invalid credentials", http.StatusUnauthorized)
+ return
+ }
+ sess := s.sessionManager.GetSession(r)
+ sess.Put("authenticated", true)
+ sess.Put("username", username)
+ //s.sessionManager.Migrate(sess)
+ slog.Info("successful login", "user", username)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ sess := s.sessionManager.GetSession(r)
+ sess.Delete("authenticated")
+ sess.Delete("username")
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleGetLog returns the contents of the rolling log file.
+func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) {
+ data, err := os.ReadFile(s.logPath())
+ if err != nil && !os.IsNotExist(err) {
+ http.Error(w, "failed to read log", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ if _, err := w.Write(data); err != nil {
+ slog.Error("failed writing http response", "msg", err.Error())
+ }
+}
+
+func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) {
+ session := s.sessionManager.GetSession(r)
+
+ token, _ := session.Get("csrf_token").(string)
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(map[string]string{
+ "csrf_token": token,
+ }); err != nil {
+ slog.Error("failed encoding token to http", "msg", err.Error())
+ }
+}
+
+// ── Config ─────────────────────────────────────────────────────────────────
+
+// parseEnvText parses key=value lines, ignoring comments, blanks and unquotes variables
+// .
+func parseEnvText(text string) map[string]string {
+ out := map[string]string{}
+ for line := range strings.SplitSeq(text, "\n") {
+ t := strings.TrimSpace(line)
+ if t == "" || strings.HasPrefix(t, "#") {
+ continue
+ }
+ k, v, ok := strings.Cut(t, "=")
+ if !ok {
+ continue
+ }
+ if k = strings.TrimSpace(k); k != "" {
+ v = strings.TrimSpace(v)
+
+ // unquote if quoted
+ if len(v) >= 2 {
+ if (v[0] == '\'' && v[len(v)-1] == '\'') ||
+ (v[0] == '"' && v[len(v)-1] == '"') {
+ v = v[1 : len(v)-1]
+ }
+ }
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// handleGetConfig returns resolved config as JSON: { values, sources }.
+// File keys are checked first because cleanenv sets them as OS env vars on startup,
+// so checking os.LookupEnv first would misclassify all file keys as "env".
+// Only keys present in the OS environment but absent from the file are marked "env".
+func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
+ data, err := os.ReadFile(s.cfg.WebEnvPath)
+ var fileValues map[string]string
+ if err == nil {
+ fileValues = parseEnvText(string(data))
+ } else {
+ fileValues = parseEnvText(string(web.SampleEnv))
+ }
+
+ values := make(map[string]string, len(allConfigKeys))
+ sources := make(map[string]string, len(allConfigKeys))
+ for _, key := range allConfigKeys {
+ if v, ok := fileValues[key]; ok && v != "" {
+ values[key] = v
+ sources[key] = "file"
+ } else if v, ok := os.LookupEnv(key); ok && v != "" {
+ values[key] = v
+ sources[key] = "env"
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(ConfigResponse{Values: values, Sources: sources}); err != nil {
+ slog.Error("failed encoding config to http", "msg", err.Error())
+ }
+}
+
+// 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.WebEnvPath)
+ if err != nil {
+ data = web.SampleEnv
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ if _, err := w.Write(data); err != nil {
+ slog.Error("failed writing http response", "msg", err.Error())
+ }
+}
+
+// handleSaveConfig writes the posted plain-text body directly to the .env file.
+func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
+ data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := os.WriteFile(s.cfg.WebEnvPath, data, 0600); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleResetConfig resets all settings and restarts the container.
+func (s *Server) handleResetConfig(w http.ResponseWriter, r *http.Request) {
+ if err := os.WriteFile(s.cfg.WebEnvPath, web.SampleEnv, 0600); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ go func() {
+ time.Sleep(300 * time.Millisecond)
+ if err := syscall.Kill(1, syscall.SIGTERM); err != nil {
+ slog.Warn("failed to kill process", "msg", err.Error())
+ }
+
+ }()
+}
+
+// handleSaveSchedule updates a single playlist's schedule in the .env file.
+func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ Day int `json:"day"` // 0=Sun…6=Sat, -1=every day
+ Hour int `json:"hour"`
+ Minute int `json:"minute"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ var envPrefix string
+ var defaultFlags string
+
+ if def, ok := playlistDefs[body.Name]; ok {
+ envPrefix = def.EnvPrefix
+ defaultFlags = def.DefaultFlags
+ } else if customIDRe.MatchString(body.Name) {
+ envPrefix = customEnvPrefix(body.Name)
+ defaultFlags = "--playlist " + body.Name
+ } else {
+ http.Error(w, "unknown playlist name", http.StatusBadRequest)
+ return
+ }
+
+ // Carry over --persist=false / --clean-downloads if globally set
+ data, _ := os.ReadFile(s.cfg.WebEnvPath)
+ for k, v := range parseEnvText(string(data)) {
+ if strings.HasSuffix(k, "_FLAGS") && v != "" {
+ if strings.Contains(v, "--persist=false") {
+ defaultFlags = addFlag(defaultFlags, "--persist=false")
+ }
+ if strings.Contains(v, "--clean-downloads") {
+ defaultFlags = addFlag(defaultFlags, "--clean-downloads")
+ }
+ break
+ }
+ }
+
+ updates := map[string]string{}
+ if !body.Enabled {
+ // Toggle off — truly disable, regardless of day value carried over from state
+ updates[envPrefix+"_SCHEDULE"] = ""
+ updates[envPrefix+"_FLAGS"] = ""
+ } else if body.Day == -2 {
+ // "Never" — keep playlist active for manual runs but remove auto-schedule
+ updates[envPrefix+"_SCHEDULE"] = ""
+ updates[envPrefix+"_FLAGS"] = defaultFlags
+ } else {
+ dom := "*"
+ dow := "*"
+ if body.Day == 100 {
+ dom = "1"
+ } else if body.Day >= 0 {
+ dow = fmt.Sprintf("%d", body.Day)
+ }
+ updates[envPrefix+"_SCHEDULE"] = fmt.Sprintf("%d %d %s * %s", body.Minute, body.Hour, dom, dow)
+ updates[envPrefix+"_FLAGS"] = defaultFlags
+ }
+
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleSavePathTemplate writes the PATH_TEMPLATE key to the .env file.
+func (s *Server) handleSavePathTemplate(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Template string `json:"template"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PATH_TEMPLATE": body.Template}, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleSaveEnrichMetadata writes ENRICH_TRACK_METADATA=true/false to the .env file.
+func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Enabled bool `json:"enabled"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ val := "false"
+ if body.Enabled {
+ val = "true"
+ }
+ if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"ENRICH_TRACK_METADATA": val}, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleSavePersist toggles persist by injecting/removing --persist=false
+// from every active *_FLAGS entry, which is what start.sh feeds to the CLI.
+func (s *Server) handleSavePersist(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Enabled bool `json:"enabled"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if err := s.toggleFlagInEnv(!body.Enabled, "--persist=false"); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Clean up the deprecated PERSIST env var if present
+ data, _ := os.ReadFile(s.cfg.WebEnvPath)
+ if _, ok := parseEnvText(string(data))["PERSIST"]; ok {
+ _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PERSIST": ""}, web.SampleEnv)
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleSaveCleanDownloads toggles --clean-downloads in every active *_FLAGS entry.
+func (s *Server) handleSaveCleanDownloads(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var body struct {
+ Enabled bool `json:"enabled"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if err := s.toggleFlagInEnv(body.Enabled, "--clean-downloads"); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// toggleFlagInEnv adds or removes a CLI flag from every active *_FLAGS entry.
+func (s *Server) toggleFlagInEnv(add bool, flag string) error {
+ data, _ := os.ReadFile(s.cfg.WebEnvPath)
+ env := parseEnvText(string(data))
+ updates := map[string]string{}
+ for k, v := range env {
+ if !strings.HasSuffix(k, "_FLAGS") || v == "" {
+ continue
+ }
+ var updated string
+ if add {
+ updated = addFlag(v, flag)
+ } else {
+ updated = removeFlag(v, flag)
+ }
+ if updated != v {
+ updates[k] = updated
+ }
+ }
+ return updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv)
+}
+
+func addFlag(flags, flag string) string {
+ if strings.Contains(flags, flag) {
+ return flags
+ }
+ return strings.TrimSpace(flags + " " + flag)
+}
+
+func removeFlag(flags, flag string) string {
+ f := strings.ReplaceAll(flags, flag, "")
+ for strings.Contains(f, " ") {
+ f = strings.ReplaceAll(f, " ", " ")
+ }
+ return strings.TrimSpace(f)
+}
+
+// updateEnvKeys reads the env file (falling back to fallback if missing), updates the
+// given key=value pairs in-place preserving comments, and writes the result back.
+func updateEnvKeys(path string, updates map[string]string, fallback []byte) error {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ data = fallback
+ } else if err != nil {
+ return err
+ }
+
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ touched := make(map[string]bool)
+
+ for i, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ key, _, ok := strings.Cut(trimmed, "=")
+ if !ok {
+ continue
+ }
+ key = strings.TrimSpace(key)
+ if val, ok := updates[key]; ok {
+ if val == "" {
+ lines[i] = "" // remove by blanking
+ } else {
+ lines[i] = key + "=" + formatEnvValue(val)
+ }
+ touched[key] = true
+ }
+ }
+
+ // Append any keys that weren't already in the file
+ for k, v := range updates {
+ if !touched[k] && v != "" {
+ lines = append(lines, k+"="+formatEnvValue(v))
+ }
+ }
+
+ // Filter out consecutive blank lines left by removals
+ out := make([]string, 0, len(lines))
+ prevBlank := false
+ for _, l := range lines {
+ blank := strings.TrimSpace(l) == ""
+ if blank && prevBlank {
+ continue
+ }
+ out = append(out, l)
+ prevBlank = blank
+ }
+
+ return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0600)
+}
+
+// Check for special chars in env vars that might need quoting
+func formatEnvValue(v string) string {
+ // preserve already quoted values
+ if strings.HasPrefix(v, "'") && strings.HasSuffix(v, "'") {
+ return v
+ }
+
+ if strings.ContainsAny(v, `"$#?'`) {
+ // escape single quotes inside value
+ v = strings.ReplaceAll(v, `'`, `'\''`)
+ return fmt.Sprintf(`'%s'`, v)
+ }
+
+ return v
+}
+
+// ── Wizard ─────────────────────────────────────────────────────────────────
+
+// handleWizardStep1 saves discovery settings (username + enabled playlists with default schedules).
+func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ User string `json:"user"`
+ Playlists []string `json:"playlists"`
+ DiscoveryMode string `json:"discovery_mode"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ if body.User == "" {
+ http.Error(w, "user is required", http.StatusBadRequest)
+ return
+ }
+
+ enabled := make(map[string]bool, len(body.Playlists))
+ for _, p := range body.Playlists {
+ enabled[p] = true
+ }
+
+ updates := map[string]string{
+ "LISTENBRAINZ_USER": body.User,
+ "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode,
+ }
+ for name, def := range playlistDefs {
+ if enabled[name] {
+ updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule
+ updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags
+ } else {
+ updates[def.EnvPrefix+"_SCHEDULE"] = ""
+ updates[def.EnvPrefix+"_FLAGS"] = ""
+ }
+ }
+
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleWizardStep2 saves media system configuration.
+func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ System string `json:"system"`
+ URL string `json:"url"`
+ APIKey string `json:"api_key"`
+ LibraryName string `json:"library_name"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ PlaylistDir string `json:"playlist_dir"`
+ Sleep string `json:"sleep"`
+ AdminAPIKey string `json:"admin_api_key"`
+ AdminSystemUsername string `json:"admin_system_username"`
+ AdminSystemPassword string `json:"admin_system_password"`
+
+ PublicPlaylist bool `json:"public_playlist"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ if body.System == "" {
+ http.Error(w, "system is required", http.StatusBadRequest)
+ return
+ }
+
+ publicPlaylist := ""
+ if body.PublicPlaylist {
+ publicPlaylist = "true"
+ }
+ updates := map[string]string{
+ "EXPLO_SYSTEM": body.System,
+ "SYSTEM_URL": body.URL,
+ "API_KEY": body.APIKey,
+ "LIBRARY_NAME": body.LibraryName,
+ "SYSTEM_USERNAME": body.Username,
+ "SYSTEM_PASSWORD": body.Password,
+ "PLAYLIST_DIR": body.PlaylistDir,
+ "SLEEP": body.Sleep,
+ "PUBLIC_PLAYLIST": publicPlaylist,
+ "ADMIN_SYSTEM_USERNAME": body.AdminSystemUsername,
+ "ADMIN_SYSTEM_PASSWORD": body.AdminSystemPassword,
+ "ADMIN_SYSTEM_APIKEY": body.AdminAPIKey,
+ }
+
+ if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleWizardStep3 saves downloader configuration.
+func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ DownloadDir string `json:"download_dir"`
+ UseSubdirectory bool `json:"use_subdirectory"`
+ MigrateDownloads bool `json:"migrate_downloads"`
+ DownloadServices []string `json:"download_services"`
+ YoutubeAPIKey string `json:"youtube_api_key"`
+ 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)
+ return
+ }
+ if len(body.DownloadServices) == 0 {
+ http.Error(w, "at least one download service is required", http.StatusBadRequest)
+ return
+ }
+ joined := strings.Join(body.DownloadServices, ",")
+
+ useSubdir := "false"
+ if body.UseSubdirectory {
+ useSubdir = "true"
+ }
+ migrateDL := "false"
+ if body.MigrateDownloads {
+ migrateDL = "true"
+ }
+ updates := map[string]string{
+ "DOWNLOAD_DIR": body.DownloadDir,
+ "USE_SUBDIRECTORY": useSubdir,
+ "MIGRATE_DOWNLOADS": migrateDL,
+ "DOWNLOAD_SERVICES": joined,
+ "YOUTUBE_API_KEY": body.YoutubeAPIKey,
+ "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.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// handleBrowse returns subdirectories of the requested path for filesystem autocomplete.
+func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) {
+ path := filepath.Clean(r.URL.Query().Get("path"))
+ if path == "" || path == "." {
+ path = "/"
+ }
+ if !filepath.IsAbs(path) {
+ http.Error(w, "path must be absolute", http.StatusBadRequest)
+ return
+ }
+
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode([]string{}); err != nil {
+ slog.Error("failed to encode empty slice", "msg", err.Error())
+ }
+ return
+ }
+
+ dirs := make([]string, 0)
+ for _, e := range entries {
+ if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
+ dirs = append(dirs, filepath.Join(path, e.Name()))
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(dirs); err != nil {
+ slog.Warn("failed to encode directories to response", "err", err.Error())
+ }
+}
+
+// ── Manual run ─────────────────────────────────────────────────────────────
+
+var errRunAlreadyStarted = errors.New("run already in progress")
+
+// handleRun starts an explo run in the background. Clients follow output via /api/ui/run/events.
+func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
+ http.Error(w, "bad form data", http.StatusBadRequest)
+ return
+ }
+
+ args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), s.cfg.WebEnvPath)
+
+ if err := s.startRun(args); err != nil {
+ if errors.Is(err, errRunAlreadyStarted) {
+ http.Error(w, "a run is already in progress", http.StatusConflict)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil {
+ slog.Warn("failed to encode current run status", "msg", err.Error())
+ }
+}
+
+// triggerLibraryRefresh spawns the CLI with --refresh-only in the background to
+// nudge the configured media server's library scan. Fire-and-forget: errors are
+// logged but do not block the caller.
+func (s *Server) triggerLibraryRefresh() {
+ go func() {
+ cmd := exec.Command(s.cfg.ExploPath, "--refresh-only", "--config", s.cfg.WebEnvPath)
+ env := make([]string, 0, len(os.Environ()))
+ for _, e := range os.Environ() {
+ if !strings.HasPrefix(e, "WEB_UI=") {
+ env = append(env, e)
+ }
+ }
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ slog.Warn("library refresh failed", "err", err.Error(), "output", string(out))
+ return
+ }
+ slog.Info("library refresh complete")
+ }()
+}
+
+func (s *Server) startRun(args []string) error {
+ ctx, cancel := context.WithCancel(context.Background())
+ 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() {
+ if !strings.HasPrefix(e, "WEB_UI=") {
+ env = append(env, e)
+ }
+ }
+ cmd.Env = env
+
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ cancel()
+ return fmt.Errorf("failed to create pipe: %w", err)
+ }
+ cmd.Stdout = pw
+ cmd.Stderr = pw
+
+ lf, err := s.openRunLog()
+ if err != nil {
+ slog.Warn("failed to open run log", "err", err.Error())
+ }
+
+ s.manualRun.mu.Lock()
+ if s.manualRun.running {
+ s.manualRun.mu.Unlock()
+ cancel()
+ if err := pr.Close(); err != nil {
+ slog.Warn("failed to close file reader", "err", err.Error())
+ }
+
+ if err := pw.Close(); err != nil {
+ slog.Warn("failed to close file writer", "err", err.Error())
+ }
+ if lf != nil {
+ if err := pw.Close(); err != nil {
+ slog.Warn("failed to close file writer", "err", err.Error())
+ }
+ }
+ return errRunAlreadyStarted
+ }
+ s.manualRun.running = true
+ s.manualRun.cancel = cancel
+ s.manualRun.exitCode = nil
+ s.manualRun.logs = nil
+ s.manualRun.mu.Unlock()
+
+ if err := cmd.Start(); err != nil {
+ s.finishRun(1)
+ cancel()
+ if err := pr.Close(); err != nil {
+ slog.Warn("failed to close file reader", "err", err.Error())
+ }
+
+ if err := pw.Close(); err != nil {
+ slog.Warn("failed to close file writer", "err", err.Error())
+ }
+ if lf != nil {
+ if err := lf.Close(); err != nil {
+ slog.Warn("failed to close run log", "err", err.Error())
+ }
+ }
+ return fmt.Errorf("failed to start explo: %w", err)
+ }
+
+ // Close write end in parent so reader gets EOF when child exits.
+ if err := pw.Close(); err != nil {
+ slog.Warn("failed to close file writer", "err", err.Error())
+ }
+
+ go s.collectRunOutput(cmd, pr, lf)
+ return nil
+}
+
+func (s *Server) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) {
+ defer func() {
+ if cerr := pr.Close(); cerr != nil {
+ slog.Error("failed to close source file", "err", cerr.Error())
+ }
+ }()
+
+ if lf != nil {
+ defer func() {
+ if cerr := lf.Close(); cerr != nil {
+ slog.Error("failed to close source file", "err", cerr.Error())
+ }
+ }()
+ }
+
+ scanner := bufio.NewScanner(pr)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if lf != nil {
+ if _, err := fmt.Fprintln(lf, line); err != nil {
+ s.appendRunLog("failed to write run output: " + err.Error())
+ }
+ }
+ s.appendRunLog(line)
+ }
+ if err := scanner.Err(); err != nil {
+ s.appendRunLog("failed to read run output: " + err.Error())
+ }
+
+ code := 0
+ if err := cmd.Wait(); err != nil && cmd.ProcessState == nil {
+ code = 1
+ }
+ if cmd.ProcessState != nil {
+ code = cmd.ProcessState.ExitCode()
+ }
+ s.finishRun(code)
+}
+
+func (s *Server) handleStopRun(w http.ResponseWriter, r *http.Request) {
+ s.manualRun.mu.Lock()
+ cancel := s.manualRun.cancel
+ running := s.manualRun.running
+ s.manualRun.mu.Unlock()
+
+ if !running || cancel == nil {
+ http.Error(w, "no run is currently in progress", http.StatusConflict)
+ return
+ }
+
+ cancel()
+ w.WriteHeader(http.StatusAccepted)
+}
+
+func (s *Server) currentRunStatus() RunStatus {
+ s.manualRun.mu.Lock()
+ defer s.manualRun.mu.Unlock()
+
+ var exitCode *int
+ if s.manualRun.exitCode != nil {
+ code := *s.manualRun.exitCode
+ exitCode = &code
+ }
+ return RunStatus{Running: s.manualRun.running, ExitCode: exitCode}
+}
+
+func (s *Server) handleRunStatus(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(s.currentRunStatus()); err != nil {
+ slog.Warn("failed encoding current run status to response")
+ }
+}
+
+// ── SSE event stream ───────────────────────────────────────────────────────
+
+func (s *Server) appendRunLog(line string) {
+ event := runEvent{data: line}
+
+ s.manualRun.mu.Lock()
+ s.manualRun.logs = append(s.manualRun.logs, line)
+ subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers))
+ for ch := range s.manualRun.subscribers {
+ subscribers = append(subscribers, ch)
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, ch := range subscribers {
+ select {
+ case ch <- event:
+ default:
+ }
+ }
+}
+
+func (s *Server) finishRun(code int) {
+ done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)}
+
+ s.manualRun.mu.Lock()
+ s.manualRun.running = false
+ s.manualRun.cancel = nil
+ s.manualRun.exitCode = &code
+ subscribers := make([]chan runEvent, 0, len(s.manualRun.subscribers))
+ for ch := range s.manualRun.subscribers {
+ subscribers = append(subscribers, ch)
+ delete(s.manualRun.subscribers, ch)
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, ch := range subscribers {
+ select {
+ case ch <- done:
+ default:
+ }
+ close(ch)
+ }
+}
+
+// handleRunEvents streams the current in-memory run log, then follows new lines
+// until the active run exits. Safe to reconnect after a browser refresh.
+func (s *Server) handleRunEvents(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ sendEvent := func(typ, data string) {
+ if typ != "" {
+ if _, err := fmt.Fprintf(w, "event: %s\n", typ); err != nil {
+ slog.Warn("failed handling run event", "err", err.Error())
+ }
+ }
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
+ slog.Warn("failed handling run event", "err", err.Error())
+ }
+ flusher.Flush()
+ }
+
+ ch := make(chan runEvent, 256)
+ s.manualRun.mu.Lock()
+ lines := append([]string(nil), s.manualRun.logs...)
+ running := s.manualRun.running
+ var exitCode *int
+ if s.manualRun.exitCode != nil {
+ code := *s.manualRun.exitCode
+ exitCode = &code
+ }
+ if running {
+ s.manualRun.subscribers[ch] = struct{}{}
+ }
+ s.manualRun.mu.Unlock()
+
+ for _, line := range lines {
+ sendEvent("", line)
+ }
+ if !running {
+ if exitCode != nil {
+ sendEvent("done", fmt.Sprintf("%d", *exitCode))
+ }
+ return
+ }
+
+ defer s.unsubscribeRun(ch)
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case ev, ok := <-ch:
+ if !ok {
+ return
+ }
+ sendEvent(ev.typ, ev.data)
+ if ev.typ == "done" {
+ return
+ }
+ }
+ }
+}
+
+func (s *Server) unsubscribeRun(ch chan runEvent) {
+ s.manualRun.mu.Lock()
+ delete(s.manualRun.subscribers, ch)
+ s.manualRun.mu.Unlock()
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+func buildArgs(playlist, downloadMode, WebEnvPath string) []string {
+ args := []string{"--config", WebEnvPath}
+ if playlist != "" {
+ args = append(args, "--playlist", playlist)
+ }
+ if downloadMode != "" {
+ args = append(args, "--download-mode", downloadMode)
+ }
+ return args
+}
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index ec4fb7e..9d0335c 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -211,8 +211,6 @@ function HomeSection() {
const [playlist, setPlaylist] = useState('weekly-exploration')
const [dlmode, setDlmode] = useState('normal')
- const [noPersist, setNoPersist] = useState(false)
- const [excludeLocal, setExcludeLocal] = useState(false)
const [running, setRunning] = useState(false)
const [status, setStatus] = useState('')
@@ -229,7 +227,6 @@ function HomeSection() {
]).then(([{ values, sources }, customList]) => {
setEnvSources(sources || {})
setLbUser(values.LISTENBRAINZ_USER || '')
- setNoPersist((values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '').includes('--persist=false'))
setCustomPlaylists(customList)
const s = {}
@@ -337,7 +334,7 @@ function HomeSection() {
setLogEntries([])
setStatus('running…')
try {
- await startRun(playlist, dlmode, !noPersist, excludeLocal)
+ await startRun(playlist, dlmode)
connect()
} catch (e) {
if (e.conflict) { setStatus('already running'); setRunning(false); return }
@@ -381,7 +378,7 @@ function HomeSection() {
playlist={openTracklist}
refreshTick={refreshTick}
onRun={async () => {
- await startRun(openTracklist, 'normal', true, false)
+ await startRun(openTracklist, 'normal')
setRunning(true)
setStatus('running…')
setLogEntries([])
@@ -422,7 +419,7 @@ function HomeSection() {
setShowImportModal(false)
}}
onSync={async (id) => {
- await startRun(id, 'normal', true, false)
+ await startRun(id, 'normal')
setRunning(true)
setStatus('running…')
setLogEntries([])
@@ -452,7 +449,7 @@ function HomeSection() {
- {/* Replace playlist toggle */}
-
-
- Update playlist in place
- Keep a single playlist per type and refresh it with new recommendations each run. When off, a new playlist is created every time and previous ones are kept.
-