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() { diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index de148f4..f0d590a 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -114,12 +114,10 @@ export async function fetchBrowse(path) { return res.json() } -export async function startRun(playlist, download_mode, persist, exclude_local) { +export async function startRun(playlist, download_mode) { const form = new FormData() form.set('playlist', playlist) form.set('download_mode', download_mode) - form.set('persist', persist ? 'true' : 'false') - form.set('exclude_local', exclude_local ? 'true' : 'false') const res = await apiFetch('/api/ui/run', { method: 'POST', body: form }) if (res.status === 409) throw Object.assign(new Error('already running'), { conflict: true }) if (!res.ok) throw new Error(await res.text()) From 8b9190fce9d73b166d00de669a7ea257dea36d8a Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:42:15 -0700 Subject: [PATCH 2/8] replace 'persist' flag with 'replace-playlist' across the application --- src/config/config.go | 21 ++++++++++---------- src/config/flags.go | 16 +++++++-------- src/main/main.go | 2 +- src/web/backend/server.go | 20 +++++++------------ src/web/frontend/src/components/Settings.jsx | 6 +++--- src/web/frontend/src/lib/api.js | 4 ++-- 6 files changed, 31 insertions(+), 38 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 512c6a9..2761018 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -23,8 +23,8 @@ type Config struct { NotifyCfg NotifyConfig ServerCfg ServerConfig Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool + ReplacePlaylistENV bool `env:"REPLACE_PLAYLIST" env-default:"true"` + ReplacePlaylist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` @@ -37,8 +37,8 @@ type Flags struct { PlaylistSet bool DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + ReplacePlaylist bool + ReplacePlaylistSet bool SearchMBID string RefreshOnly bool CleanDownloads bool @@ -258,8 +258,8 @@ func (cfg *Config) HandleDeprecation() { // slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" } - if cfg.Flags.PersistSet { - slog.Warn("--persist flag now only handles playlist deletion, use toggle in UI or --clean-downloads to delete tracks") + if cfg.Flags.ReplacePlaylistSet { + slog.Warn("--replace flag is deprecated, use the toggle in the UI instead") } if cfg.DiscoveryCfg.Listenbrainz.UserToken == "" { @@ -274,7 +274,7 @@ func (cfg *Config) HandleDeprecation() { // // Generate playlist name and description func (cfg *Config) GenPlaylistDetails() { - cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist) + cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.ReplacePlaylist) cfg.ClientCfg.PlaylistDescr = fmt.Sprintf( "Created for %s by Explo, using ListenBrainz recommendations.", cfg.DiscoveryCfg.Listenbrainz.User) @@ -287,14 +287,13 @@ func (cfg *Config) GenPlaylistDetails() { } } -func getPlaylistName(playlistType, format string, persist bool) string { - +func getPlaylistName(playlistType, format string, replace bool) string { toTitle := cases.Title(language.Und) base := toTitle.String(playlistType) - // Non-persistent or custom playlists always use base name - if !persist || strings.HasPrefix(playlistType, "custom-") { + // When replacing or for custom playlists, always use base name + if replace || strings.HasPrefix(playlistType, "custom-") { return base } diff --git a/src/config/flags.go b/src/config/flags.go index dfeac6e..e5a39d8 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -20,7 +20,7 @@ func (cfg *Config) GetFlags() error { var playlist string var downloadMode string var excludeLocal bool - var persist bool + var replace bool var showVersion bool var searchMBID string var refreshOnly bool @@ -30,7 +30,7 @@ func (cfg *Config) GetFlags() error { flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") - flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") + flag.BoolVar(&replace, "replace", true, "Replace existing playlist with the same name") flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") flag.StringVar(&searchMBID, "search-mbid", "", "Test Plex search for a single recording MBID (resolves via ListenBrainz, then searches your library)") flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") @@ -42,7 +42,7 @@ func (cfg *Config) GetFlags() error { fmt.Println(Version) os.Exit(0) } - persistSet := flag.Lookup("persist").Changed + replaceSet := flag.Lookup("replace").Changed cfgSet := flag.Lookup("config").Changed playlistSet := flag.Lookup("playlist").Changed @@ -65,13 +65,13 @@ func (cfg *Config) GetFlags() error { cfg.Flags.PlaylistSet = playlistSet cfg.Flags.DownloadMode = downloadMode cfg.Flags.ExcludeLocal = excludeLocal - cfg.Flags.Persist = persist + cfg.Flags.ReplacePlaylist = replace cfg.Flags.SearchMBID = searchMBID cfg.Flags.RefreshOnly = refreshOnly cfg.Flags.CleanDownloads = cleanDownloads // for deprecation purposes (can be removed at a later date) - cfg.Flags.PersistSet = persistSet + cfg.Flags.ReplacePlaylistSet = replaceSet return nil } @@ -84,10 +84,10 @@ func (cfg *Config) MergeFlags() { cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } - if cfg.Flags.PersistSet { - cfg.Persist = cfg.Flags.Persist + if cfg.Flags.ReplacePlaylistSet { + cfg.ReplacePlaylist = cfg.Flags.ReplacePlaylist } else { - cfg.Persist = cfg.PersistENV + cfg.ReplacePlaylist = cfg.ReplacePlaylistENV } } diff --git a/src/main/main.go b/src/main/main.go index 25c196a..8382230 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -201,7 +201,7 @@ func main() { slog.Error(err.Error(), "notify", true) os.Exit(1) } - if !cfg.Persist { + if cfg.ReplacePlaylist { err := client.DeletePlaylist() if err != nil { slog.Warn(err.Error(), "notify", true) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 38ebdf8..e1d3875 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -245,7 +245,7 @@ func (s *Server) registerRoutes() { 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/replace-playlist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveReplacePlaylist))) 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 @@ -557,12 +557,12 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { return } - // Carry over --persist=false / --clean-downloads if globally set + // Carry over --replace=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, "--replace=false") { + defaultFlags = addFlag(defaultFlags, "--replace=false") } if strings.Contains(v, "--clean-downloads") { defaultFlags = addFlag(defaultFlags, "--clean-downloads") @@ -643,9 +643,9 @@ func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) } -// handleSavePersist toggles persist by injecting/removing --persist=false +// handleSaveReplacePlaylist toggles replace by injecting/removing --replace=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) { +func (s *Server) handleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -658,16 +658,10 @@ func (s *Server) handleSavePersist(w http.ResponseWriter, r *http.Request) { return } - if err := s.toggleFlagInEnv(!body.Enabled, "--persist=false"); err != nil { + if err := s.toggleFlagInEnv(!body.Enabled, "--replace=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) } diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 9d0335c..021ecdd 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -15,7 +15,7 @@ import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata, - savePersist, saveCleanDownloads, + saveReplacePlaylist, saveCleanDownloads, fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' @@ -532,7 +532,7 @@ function DownloadPathSection() { ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' - setReplacePlaylist(anyFlags.includes('--persist=false')) + setReplacePlaylist(!anyFlags.includes('--replace=false')) setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' if (t) { @@ -564,7 +564,7 @@ function DownloadPathSection() { const handleReplaceToggle = async () => { const next = !replacePlaylist setReplacePlaylist(next) - try { await savePersist(!next) } catch { setReplacePlaylist(!next) } + try { await saveReplacePlaylist(next) } catch { setReplacePlaylist(!next) } } const handleCleanToggle = async () => { diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index f0d590a..b86cf2d 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -229,8 +229,8 @@ export async function saveEnrichMetadata(enabled) { if (!res.ok) throw new Error(await res.text()) } -export async function savePersist(enabled) { - const res = await apiFetch('/api/ui/config/persist', { +export async function saveReplacePlaylist(enabled) { + const res = await apiFetch('/api/ui/config/replace-playlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), From 71ea877f93f1de8a53f69dead507d7d1ac912c2f Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:57:45 -0700 Subject: [PATCH 3/8] update REPLACE_PLAYLIST handling in backend and frontend components --- src/web/backend/defs/defs.go | 2 +- src/web/backend/server.go | 15 +++++++-------- src/web/frontend/src/components/Settings.jsx | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 5b4756d..4c990af 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -178,7 +178,7 @@ var AllConfigKeys = []string{ "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", - "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", + "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "REPLACE_PLAYLIST", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", diff --git a/src/web/backend/server.go b/src/web/backend/server.go index e1d3875..882cd07 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -557,13 +557,10 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { return } - // Carry over --replace=false / --clean-downloads if globally set + // Carry over --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, "--replace=false") { - defaultFlags = addFlag(defaultFlags, "--replace=false") - } if strings.Contains(v, "--clean-downloads") { defaultFlags = addFlag(defaultFlags, "--clean-downloads") } @@ -643,8 +640,7 @@ func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) } -// handleSaveReplacePlaylist toggles replace by injecting/removing --replace=false -// from every active *_FLAGS entry, which is what start.sh feeds to the CLI. +// handleSaveReplacePlaylist writes REPLACE_PLAYLIST=true/false to the .env file. func (s *Server) handleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -657,8 +653,11 @@ func (s *Server) handleSaveReplacePlaylist(w http.ResponseWriter, r *http.Reques http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } - - if err := s.toggleFlagInEnv(!body.Enabled, "--replace=false"); err != nil { + val := "false" + if body.Enabled { + val = "true" + } + if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"REPLACE_PLAYLIST": val}, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 021ecdd..3d92a23 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -531,8 +531,8 @@ function DownloadPathSection() { ...jsonPresets, ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') + setReplacePlaylist(values.REPLACE_PLAYLIST !== 'false') const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' - setReplacePlaylist(!anyFlags.includes('--replace=false')) setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' if (t) { From bd3a9bf059c0acc233ee5990cccf280d20448c03 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:01:00 -0700 Subject: [PATCH 4/8] created migrate PERSIST environment variable to REPLACE_PLAYLIST on first time startup --- src/web/backend/server.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 882cd07..2c27ac1 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -112,7 +112,37 @@ func NewServer(cfg config.ServerConfig) *Server { return s } +// migratePersistEnv converts the old PERSIST env var to REPLACE_PLAYLIST (inverted). +// TODO: REMOVE THIS AFTER NEXT RELEASE — one-time migration for existing users. +func (s *Server) migratePersistEnv() { + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil { + return + } + env := parseEnvText(string(data)) + if _, hasPersist := env["PERSIST"]; !hasPersist { + return + } + if _, hasReplace := env["REPLACE_PLAYLIST"]; hasReplace { + // Already migrated, just clean up old key + _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PERSIST": ""}, web.SampleEnv) + return + } + // PERSIST=true (accumulate) → REPLACE_PLAYLIST=false + // PERSIST=false (replace) → REPLACE_PLAYLIST=true + val := "true" + if env["PERSIST"] == "true" { + val = "false" + } + _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{ + "REPLACE_PLAYLIST": val, + "PERSIST": "", + }, web.SampleEnv) + slog.Info("migrated PERSIST env var to REPLACE_PLAYLIST", "value", val) +} + func (s *Server) Start() error { + s.migratePersistEnv() s.initServerLog() s.startJobs() coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") From 424f2248f0819a1b22bde0d28b388ce580089999 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:45 -0700 Subject: [PATCH 5/8] refactor: remove stale 'locked' logic and unused state in HomeSection and PlaylistCard --- src/web/backend/server.go | 1227 +---------------- src/web/frontend/src/components/Settings.jsx | 13 +- .../src/components/ui/PlaylistCard.jsx | 34 +- 3 files changed, 45 insertions(+), 1229 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 2c27ac1..bcd2c3c 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -1,98 +1,67 @@ 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 *AuthStore - cronJobs *Jobs - sessionManager *SessionManager - manualRun manualRunState + authStore *auth.AuthStore + settings *settings.Settings + cronJobs *jobs.Jobs + manualRun *run.ManualRun + customPlaylist *playlist.Playlist } func NewServer(cfg config.ServerConfig) *Server { - sessionManager := NewSessionManager( - NewInMemorySessionStore(), + sessionManager := auth.NewSessionManager( + auth.NewInMemorySessionStore(), 1*time.Hour, 7*(24*time.Hour), "session", ) - authStore := NewAuthStore( + authStore := auth.NewAuthStore( cfg.Username, cfg.Password, sessionManager, ) + webCfg := app.Config{ + WebEnvPath: cfg.WebEnvPath, + WebDataDir: cfg.WebDataDir, + ExploPath: cfg.ExploPath, + } - cronJobs := NewJobs() + settings := settings.NewSettings(webCfg) + + cronJobs := jobs.NewJobs() + manualRun := run.NewManualRun(webCfg) + playlist := playlist.NewPlaylist(webCfg, settings) mux := http.NewServeMux() s := &Server{ @@ -103,51 +72,22 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, + settings: settings, cronJobs: cronJobs, - sessionManager: sessionManager, - manualRun: newManualRunState(), + manualRun: manualRun, + customPlaylist: playlist, } s.registerRoutes() return s } -// migratePersistEnv converts the old PERSIST env var to REPLACE_PLAYLIST (inverted). -// TODO: REMOVE THIS AFTER NEXT RELEASE — one-time migration for existing users. -func (s *Server) migratePersistEnv() { - data, err := os.ReadFile(s.cfg.WebEnvPath) - if err != nil { - return - } - env := parseEnvText(string(data)) - if _, hasPersist := env["PERSIST"]; !hasPersist { - return - } - if _, hasReplace := env["REPLACE_PLAYLIST"]; hasReplace { - // Already migrated, just clean up old key - _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PERSIST": ""}, web.SampleEnv) - return - } - // PERSIST=true (accumulate) → REPLACE_PLAYLIST=false - // PERSIST=false (replace) → REPLACE_PLAYLIST=true - val := "true" - if env["PERSIST"] == "true" { - val = "false" - } - _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{ - "REPLACE_PLAYLIST": val, - "PERSIST": "", - }, web.SampleEnv) - slog.Info("migrated PERSIST env var to REPLACE_PLAYLIST", "value", val) -} - func (s *Server) Start() error { - s.migratePersistEnv() - s.initServerLog() + s.manualRun.InitServerLog() s.startJobs() coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") if _, err := os.Stat(coversDir); os.IsNotExist(err) { - s.PrefetchCovers() + s.customPlaylist.PrefetchCovers() } slog.Info("Explo web UI started", "addr", s.server.Addr) go checkForUpdate() @@ -209,23 +149,13 @@ func (s *Server) startJobs() { slog.Warn("failed to register cover cleanup job", "err", err.Error()) } - if err := s.cronJobs.RegisterCustomPlaylistRefresh(s.cfg.WebDataDir, s.cfg.WebEnvPath); err != nil { + if err := s.customPlaylist.RegisterCustomPlaylistRefresh(s.cronJobs); 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. @@ -238,1099 +168,4 @@ func spaFS() (fs.FS, []byte) { embedded, _ := fs.Sub(web.DistFiles, "dist") index, _ := fs.ReadFile(embedded, "index.html") return embedded, index -} - -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/replace-playlist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveReplacePlaylist))) - 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 --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, "--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) -} - -// handleSaveReplacePlaylist writes REPLACE_PLAYLIST=true/false to the .env file. -func (s *Server) handleSaveReplacePlaylist(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{"REPLACE_PLAYLIST": val}, web.SampleEnv); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - 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 -} +} \ No newline at end of file diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 3d92a23..c4eb6c0 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -34,7 +34,7 @@ const tabBtnCls = active => // ── Home Tab ────────────────────────────────────────────────────────────────── // Manages scheduled playlists, manual runs, and live run output. -// Fetches its own config on mount to initialise schedule state and locked keys. +// Fetches its own config on mount to initialise schedule state. // Streams live run output from /api/ui/run/events function useSSE({ onLine, onDone }) { @@ -202,7 +202,6 @@ function CustomPlaylistsSection({ function HomeSection() { const [schedules, setSchedules] = useState(null) - const [envSources, setEnvSources] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) const [lbUser, setLbUser] = useState('') const [openTracklist, setOpenTracklist] = useState(null) @@ -224,8 +223,7 @@ function HomeSection() { Promise.all([ fetchConfig(), fetchCustomPlaylists().catch(() => []) - ]).then(([{ values, sources }, customList]) => { - setEnvSources(sources || {}) + ]).then(([{ values }, customList]) => { setLbUser(values.LISTENBRAINZ_USER || '') setCustomPlaylists(customList) @@ -277,11 +275,6 @@ function HomeSection() { return () => disconnect() }, [connect, disconnect]) - const isScheduleLocked = id => { - const p = PLAYLISTS.find(p => p.value === id) - return p ? envSources[p.scheduleKey] === 'env' : false - } - const nextRunText = id => { const s = schedules[id] if (!s?.enabled) return 'Disabled' @@ -308,7 +301,6 @@ function HomeSection() { ...prev, [id]: { ...prev[id], editing: !prev[id].editing } })), onSave: () => { - if (isScheduleLocked(id)) return saveSchedule(id, s.enabled, s.day, s.hour, s.minute) .then(() => flashStatus(id, 'Saved.')) .catch(() => flashStatus(id, 'Error saving.')) @@ -362,7 +354,6 @@ function HomeSection() { key={p.value} playlist={p} {...scheduleProps(p.value)} - locked={isScheduleLocked(p.value)} fixedSchedule={!!p.fixedSchedule} index={i} nextRunText={nextRunText(p.value)} diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index d5e1570..3f6d7b5 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -358,7 +358,6 @@ const NOISE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' export function PlaylistCard({ playlist, schedule: s, - locked, fixedSchedule = false, index = 0, nextRunText, @@ -446,7 +445,7 @@ export function PlaylistCard({ const [copyLabel, setCopyLabel] = useState('Copy URL') const [cardHovered, setCardHovered] = useState(false) const menuBtnRef = useRef(null) - const canEdit = !locked && !fixedSchedule && !!onToggleEdit + const canEdit = !fixedSchedule && !!onToggleEdit const hasMenu = canEdit || !!onDelete || !!sourceUrl useEffect(() => { @@ -601,25 +600,16 @@ export function PlaylistCard({ {/* Toggle — bottom right */} {onToggle && ( - <> - - {locked && ( - ENV - )} - + )} @@ -748,7 +738,7 @@ export function PlaylistCard({ {/* Inline schedule editor */} - {!locked && !fixedSchedule && ( + {!fixedSchedule && ( Date: Sun, 12 Jul 2026 23:49:21 -0700 Subject: [PATCH 6/8] add handler for replace_playlist flag for frontend, cleanup ReplacePlaylistENV and ReplacePlaylistSet flag --- src/config/config.go | 12 ++---- src/config/flags.go | 12 +----- src/web/backend/defs/defs.go | 2 +- src/web/backend/routes.go | 1 + src/web/backend/run/handlers.go | 3 +- src/web/backend/run/manual_run.go | 5 ++- src/web/backend/settings/handlers.go | 56 ++++++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/config/config.go b/src/config/config.go index 2761018..568a10d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -23,8 +23,7 @@ type Config struct { NotifyCfg NotifyConfig ServerCfg ServerConfig Flags Flags - ReplacePlaylistENV bool `env:"REPLACE_PLAYLIST" env-default:"true"` - ReplacePlaylist bool + ReplacePlaylist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` @@ -37,8 +36,7 @@ type Flags struct { PlaylistSet bool DownloadMode string ExcludeLocal bool - ReplacePlaylist bool - ReplacePlaylistSet bool + ReplacePlaylist bool SearchMBID string RefreshOnly bool CleanDownloads bool @@ -258,11 +256,7 @@ func (cfg *Config) HandleDeprecation() { // slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" } - if cfg.Flags.ReplacePlaylistSet { - slog.Warn("--replace flag is deprecated, use the toggle in the UI instead") - } - - if cfg.DiscoveryCfg.Listenbrainz.UserToken == "" { +if cfg.DiscoveryCfg.Listenbrainz.UserToken == "" { slog.Warn("Provide a ListenBrainz user token via Wizard or env (LISTENBRAINZ_USER_TOKEN)") } diff --git a/src/config/flags.go b/src/config/flags.go index e5a39d8..5213c4d 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -30,7 +30,7 @@ func (cfg *Config) GetFlags() error { flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") - flag.BoolVar(&replace, "replace", true, "Replace existing playlist with the same name") + flag.BoolVar(&replace, "replace-playlist", true, "Replace existing playlist with the same name") flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") flag.StringVar(&searchMBID, "search-mbid", "", "Test Plex search for a single recording MBID (resolves via ListenBrainz, then searches your library)") flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") @@ -42,7 +42,6 @@ func (cfg *Config) GetFlags() error { fmt.Println(Version) os.Exit(0) } - replaceSet := flag.Lookup("replace").Changed cfgSet := flag.Lookup("config").Changed playlistSet := flag.Lookup("playlist").Changed @@ -70,9 +69,6 @@ func (cfg *Config) GetFlags() error { cfg.Flags.RefreshOnly = refreshOnly cfg.Flags.CleanDownloads = cleanDownloads - // for deprecation purposes (can be removed at a later date) - cfg.Flags.ReplacePlaylistSet = replaceSet - return nil } @@ -84,11 +80,7 @@ func (cfg *Config) MergeFlags() { cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } - if cfg.Flags.ReplacePlaylistSet { - cfg.ReplacePlaylist = cfg.Flags.ReplacePlaylist - } else { - cfg.ReplacePlaylist = cfg.ReplacePlaylistENV - } + cfg.ReplacePlaylist = cfg.Flags.ReplacePlaylist } func contains(valid []string, val string) bool { diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go index 4c990af..5b4756d 100644 --- a/src/web/backend/defs/defs.go +++ b/src/web/backend/defs/defs.go @@ -178,7 +178,7 @@ var AllConfigKeys = []string{ "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", - "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "REPLACE_PLAYLIST", + "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", "LISTENBRAINZ_USER_TOKEN", diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 3ae56be..6293379 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -53,6 +53,7 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.settings.HandleSaveSchedule)) s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.settings.HandleSavePathTemplate)) s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata)) + s.mux.Handle("POST /api/ui/config/replace-playlist", s.auth(s.settings.HandleSaveReplacePlaylist)) // Path template presets: GET list, POST add; DELETE per name under prefix s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go index b8265c8..7633aa4 100644 --- a/src/web/backend/run/handlers.go +++ b/src/web/backend/run/handlers.go @@ -15,7 +15,8 @@ func (mr *ManualRun) HandleRun(w http.ResponseWriter, r *http.Request) { return } - args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), mr.cfg.WebEnvPath) + replacePlaylist := r.FormValue("replace_playlist") != "false" + args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), mr.cfg.WebEnvPath, replacePlaylist) 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 53dd24e..7dacdc3 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, WebEnvPath string) []string { +func buildArgs(playlist, downloadMode, WebEnvPath string, replacePlaylist bool) []string { args := []string{"--config", WebEnvPath} if playlist != "" { args = append(args, "--playlist", playlist) @@ -172,5 +172,8 @@ func buildArgs(playlist, downloadMode, WebEnvPath string) []string { if downloadMode != "" { args = append(args, "--download-mode", downloadMode) } + if !replacePlaylist { + args = append(args, "--replace-playlist=false") + } return args } \ No newline at end of file diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 6ca56f9..b03ea23 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -191,6 +191,62 @@ func (s *Settings) HandleSaveEnrichMetadata(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusOK) } +// HandleSaveReplacePlaylist injects or removes --replace-playlist=false from a playlist's FLAGS env var. +func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Replace bool `json:"replace"` + } + 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 := defs.PlaylistDefs[body.Name]; ok { + envPrefix = def.EnvPrefix + defaultFlags = def.DefaultFlags + } else if defs.CustomIDRe.MatchString(body.Name) { + envPrefix = util.CustomEnvPrefix(body.Name) + defaultFlags = "--playlist " + body.Name + } else { + http.Error(w, "unknown playlist name", http.StatusBadRequest) + return + } + + flagsKey := envPrefix + "_FLAGS" + const replaceFlag = "--replace-playlist=false" + + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil && !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + current := s.ParseEnvText(string(data)) + currentFlags := current[flagsKey] + if currentFlags == "" { + currentFlags = defaultFlags + } + + hasFlag := strings.Contains(currentFlags, replaceFlag) + newFlags := currentFlags + if !body.Replace && !hasFlag { + newFlags = strings.TrimSpace(currentFlags + " " + replaceFlag) + } else if body.Replace && hasFlag { + newFlags = strings.TrimSpace(strings.ReplaceAll(currentFlags, replaceFlag, "")) + for strings.Contains(newFlags, " ") { + newFlags = strings.ReplaceAll(newFlags, " ", " ") + } + } + + if err := s.UpdateEnvKeys(map[string]string{flagsKey: newFlags}, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // handleWizardStep1 saves discovery settings (username + enabled playlists with default schedules). func (s *Settings) HandleWizardStep1(w http.ResponseWriter, r *http.Request) { var body struct { From 5fdf155d9a937b1e1e7e2d04cf4450f3525f56eb Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:38:10 -0700 Subject: [PATCH 7/8] update frontend, add toggle for replace playlist functionality --- src/web/frontend/src/components/Settings.jsx | 49 ++++++++----------- .../src/components/ui/PlaylistCard.jsx | 37 +++++++++++++- src/web/frontend/src/lib/api.js | 7 +-- 3 files changed, 60 insertions(+), 33 deletions(-) diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index c4eb6c0..35b1181 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -82,10 +82,10 @@ function useSSE({ onLine, onDone }) { } const PLAYLISTS = [ - { value: 'weekly-exploration', name: 'Weekly Exploration', scheduleKey: 'WEEKLY_EXPLORATION_SCHEDULE', defaultDay: 2, defaultHour: 0, defaultMinute: 15 }, - { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, - { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, - { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, + { value: 'weekly-exploration', name: 'Weekly Exploration', scheduleKey: 'WEEKLY_EXPLORATION_SCHEDULE', flagsKey: 'WEEKLY_EXPLORATION_FLAGS', defaultDay: 2, defaultHour: 0, defaultMinute: 15 }, + { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', flagsKey: 'WEEKLY_JAMS_FLAGS', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, + { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', flagsKey: 'DAILY_JAMS_FLAGS', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, + { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', flagsKey: 'ON_REPEAT_FLAGS', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, ] const SCHEDULE_DAYS = [ @@ -202,6 +202,7 @@ function CustomPlaylistsSection({ function HomeSection() { const [schedules, setSchedules] = useState(null) + const [replacePlaylists, setReplacePlaylists] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) const [lbUser, setLbUser] = useState('') const [openTracklist, setOpenTracklist] = useState(null) @@ -227,6 +228,12 @@ function HomeSection() { setLbUser(values.LISTENBRAINZ_USER || '') setCustomPlaylists(customList) + const rp = {} + for (const p of PLAYLISTS) { + rp[p.value] = !(values[p.flagsKey] || '').includes('--replace-playlist=false') + } + setReplacePlaylists(rp) + const s = {} for (const p of PLAYLISTS) { const cron = values[p.scheduleKey] @@ -309,6 +316,14 @@ function HomeSection() { onCancelEdit: () => setSchedules(prev => ({ ...prev, [id]: { ...prev[id], editing: false } })), + replacePlaylist: replacePlaylists[id] ?? true, + onReplaceToggle: () => { + const next = !(replacePlaylists[id] ?? true) + setReplacePlaylists(prev => ({ ...prev, [id]: next })) + saveReplacePlaylist(id, next).catch(() => + setReplacePlaylists(prev => ({ ...prev, [id]: !next })) + ) + }, onDayChange: day => setSchedules(prev => ({ ...prev, [id]: { ...prev[id], day } })), @@ -326,7 +341,7 @@ function HomeSection() { setLogEntries([]) setStatus('running…') try { - await startRun(playlist, dlmode) + await startRun(playlist, dlmode, replacePlaylists[playlist] ?? true) connect() } catch (e) { if (e.conflict) { setStatus('already running'); setRunning(false); return } @@ -508,7 +523,6 @@ function DownloadPathSection() { const [showModal, setShowModal] = useState(false) const [openMenuIdx, setOpenMenuIdx] = useState(null) const [enrichEnabled, setEnrichEnabled] = useState(false) - const [replacePlaylist, setReplacePlaylist] = useState(true) const [cleanDownloads, setCleanDownloads] = useState(false) const [templateEnabled, setTemplateEnabled] = useState(false) @@ -522,7 +536,6 @@ function DownloadPathSection() { ...jsonPresets, ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') - setReplacePlaylist(values.REPLACE_PLAYLIST !== 'false') const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' @@ -552,12 +565,6 @@ function DownloadPathSection() { try { await saveEnrichMetadata(next) } catch { setEnrichEnabled(!next) } } - const handleReplaceToggle = async () => { - const next = !replacePlaylist - setReplacePlaylist(next) - try { await saveReplacePlaylist(next) } catch { setReplacePlaylist(!next) } - } - const handleCleanToggle = async () => { const next = !cleanDownloads setCleanDownloads(next) @@ -649,22 +656,6 @@ function DownloadPathSection() { - {/* 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. -
- -
- {/* Clean old downloads toggle */}
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index 3f6d7b5..35e2e63 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -372,6 +372,8 @@ export function PlaylistCard({ tracklistOpen, onTracklistToggle, onDelete, + replacePlaylist = true, + onReplaceToggle, trackId, artworkUrl, sourceUrl, @@ -444,9 +446,10 @@ export function PlaylistCard({ const [deleteTracksChecked, setDeleteTracksChecked] = useState(false) const [copyLabel, setCopyLabel] = useState('Copy URL') const [cardHovered, setCardHovered] = useState(false) + const [replaceInfoOpen, setReplaceInfoOpen] = useState(false) const menuBtnRef = useRef(null) const canEdit = !fixedSchedule && !!onToggleEdit - const hasMenu = canEdit || !!onDelete || !!sourceUrl + const hasMenu = canEdit || !!onDelete || !!sourceUrl || !!onReplaceToggle useEffect(() => { if (!menuOpen) { setConfirmDelete(false); setDeleteTracksChecked(false); return } @@ -672,6 +675,38 @@ export function PlaylistCard({ Edit Schedule )} + {onReplaceToggle && ( +
+ + {replaceInfoOpen && ( + + When on, creates a new dated playlist each run instead of overwriting. + + )} +
+ )} {onDelete && !confirmDelete && (