From 1ad7950212b5dc2ba105144013085a321f2ac9ba Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 13 Jun 2026 23:46:45 +0300 Subject: [PATCH 01/35] separate file for routes --- src/web/backend/routes.go | 106 ++++++++++++++++++++++++++++++++++++++ src/web/backend/server.go | 105 ------------------------------------- 2 files changed, 106 insertions(+), 105 deletions(-) create mode 100644 src/web/backend/routes.go diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go new file mode 100644 index 0000000..88c09ec --- /dev/null +++ b/src/web/backend/routes.go @@ -0,0 +1,106 @@ +package backend + +import ( + "log/slog" + "net/http" + "strings" + "io/fs" + "path/filepath" +) + +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()) + } + }) + + s.registerAuthRoutes() + s.registerConfigRoutes() + s.registerWizardRoutes() + s.registerPlaylistRoutes() + s.registerRunRoutes() + s.registerMiscRoutes() + +} + +func (s *Server) registerAuthRoutes() { + s.mux.Handle("POST /api/ui/logout", s.auth(s.handleLogout)) + + // Public routes + s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) + s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) + s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) +} + +func (s *Server) registerConfigRoutes() { + s.mux.Handle("GET /api/ui/config", s.auth(s.handleGetConfig)) + s.mux.Handle("POST /api/ui/config", s.auth(s.handleSaveConfig)) + + s.mux.Handle("GET /api/ui/config/raw", s.auth(s.handleGetConfigRaw)) + s.mux.Handle("POST /api/ui/config/reset", s.auth(s.handleResetConfig)) + s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.handleSaveSchedule)) + s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.handleSavePathTemplate)) + s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.handleSaveEnrichMetadata)) + + // Path template presets: GET list, POST add; DELETE per name under prefix + s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) + s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) + +} + +func (s *Server) registerWizardRoutes() { + // Wizard steps (POST) — require auth + s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.handleWizardStep1)) + s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.handleWizardStep2)) + s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.handleWizardStep3)) + + // Public + s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) +} + +func (s *Server) registerPlaylistRoutes() { + s.mux.Handle("GET /api/ui/playlists", s.auth(s.handleGetPlaylist)) + s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.handlePrefetchCovers)) + + // custom playlists: GET list, POST import (same path); per-ID actions under prefix + s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.handleGetCustomPlaylists)) + s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.handleImportCustomPlaylist)) + + // ID-specific routes: DELETE /api/ui/custom-playlists/{id} and POST .../{id}/refresh + s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.handleRefreshCustomPlaylist)) + s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.handleDeleteCustomPlaylist)) +} + +func (s *Server) registerRunRoutes() { + s.mux.Handle("POST /api/ui/run", s.auth(s.handleRun)) + s.mux.Handle("GET /api/ui/run/events", s.auth(s.handleRunEvents)) + s.mux.Handle("POST /api/ui/run/stop", s.auth(s.handleStopRun)) + s.mux.Handle("GET /api/ui/run/status", s.auth(s.handleRunStatus)) +} + +func (s *Server) registerMiscRoutes() { + s.mux.Handle("GET /api/ui/logs", s.auth(s.handleGetLog)) + s.mux.Handle("GET /api/ui/browse", s.auth(s.handleBrowse)) + s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) + + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") + s.mux.Handle("GET /api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) +} + +// small helper func for auth routing +func (s *Server) auth(h http.HandlerFunc) http.Handler { + return s.authStore.RequireAuth(h) +} \ No newline at end of file diff --git a/src/web/backend/server.go b/src/web/backend/server.go index ffa1403..26370e1 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -210,110 +210,6 @@ func spaFS() (fs.FS, []byte) { 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))) - - // 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. @@ -426,7 +322,6 @@ func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { // ── 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") { From 7899c44ae1528252b6481f1e0cd77f08a8dd2f41 Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:27:38 +0300 Subject: [PATCH 02/35] split run state, handlers and events --- src/web/backend/routes.go | 8 ++++---- src/web/backend/server.go | 42 +++++++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 88c09ec..1680ae4 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -85,10 +85,10 @@ func (s *Server) registerPlaylistRoutes() { } func (s *Server) registerRunRoutes() { - s.mux.Handle("POST /api/ui/run", s.auth(s.handleRun)) - s.mux.Handle("GET /api/ui/run/events", s.auth(s.handleRunEvents)) - s.mux.Handle("POST /api/ui/run/stop", s.auth(s.handleStopRun)) - s.mux.Handle("GET /api/ui/run/status", s.auth(s.handleRunStatus)) + s.mux.Handle("POST /api/ui/run", s.auth(s.manualRun.HandleRun)) + s.mux.Handle("GET /api/ui/run/events", s.auth(s.manualRun.HandleRunEvents)) + s.mux.Handle("POST /api/ui/run/stop", s.auth(s.manualRun.HandleStopRun)) + s.mux.Handle("GET /api/ui/run/status", s.auth(s.manualRun.HandleRunStatus)) } func (s *Server) registerMiscRoutes() { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 26370e1..7a9649d 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -1,25 +1,22 @@ 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" + "os/exec" "explo/src/config" "explo/src/web" + "explo/src/web/backend/run" ) // Option is a value/label pair for select-type fields. @@ -43,7 +40,7 @@ type ConfigResponse struct { Sources map[string]string `json:"sources"` // "env" | "file" } -// runEvent is an SSE event sent to connected browser clients. +/* // runEvent is an SSE event sent to connected browser clients. type runEvent struct { typ string data string @@ -66,7 +63,7 @@ type manualRunState struct { func newManualRunState() manualRunState { return manualRunState{subscribers: make(map[chan runEvent]struct{})} -} +} */ type Server struct { cfg config.ServerConfig @@ -75,7 +72,7 @@ type Server struct { authStore *AuthStore cronJobs *Jobs sessionManager *SessionManager - manualRun manualRunState + manualRun *run.ManualRun } func NewServer(cfg config.ServerConfig) *Server { @@ -93,6 +90,7 @@ func NewServer(cfg config.ServerConfig) *Server { ) cronJobs := NewJobs() + manualRun := run.NewManualRun(cfg.WebDataDir, cfg.WebEnvPath, cfg.ExploPath) mux := http.NewServeMux() s := &Server{ @@ -105,7 +103,7 @@ func NewServer(cfg config.ServerConfig) *Server { authStore: authStore, cronJobs: cronJobs, sessionManager: sessionManager, - manualRun: newManualRunState(), + manualRun: manualRun, } s.registerRoutes() @@ -157,6 +155,28 @@ func checkForUpdate() { } } +// 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 parseVer(v string) [3]int { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 3) @@ -778,7 +798,7 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { // ── Manual run ───────────────────────────────────────────────────────────── -var errRunAlreadyStarted = errors.New("run already in progress") +/* 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) { @@ -1093,7 +1113,7 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { s.manualRun.mu.Lock() delete(s.manualRun.subscribers, ch) s.manualRun.mu.Unlock() -} +} */ // ── Helpers ──────────────────────────────────────────────────────────────── From ce037585c7b62c033f929586a9941d84de88d220 Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:28:02 +0300 Subject: [PATCH 03/35] split run state, handlers and events --- src/web/backend/run/events.go | 101 ++++++++++++++++ src/web/backend/run/handlers.go | 125 ++++++++++++++++++++ src/web/backend/run/manual_run.go | 190 ++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 src/web/backend/run/events.go create mode 100644 src/web/backend/run/handlers.go create mode 100644 src/web/backend/run/manual_run.go diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go new file mode 100644 index 0000000..e306fad --- /dev/null +++ b/src/web/backend/run/events.go @@ -0,0 +1,101 @@ +package run + +import ( + "fmt" + "log/slog" + "os" + "io" + "path/filepath" + "bufio" + "os/exec" +) + + +func (mr *ManualRun) appendRunLog(line string) { + event := runEvent{data: line} + + mr.state.mu.Lock() + mr.state.logs = append(mr.state.logs, line) + subscribers := make([]chan runEvent, 0, len(mr.state.subscribers)) + for ch := range mr.state.subscribers { + subscribers = append(subscribers, ch) + } + mr.state.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- event: + default: + } + } +} + +func (mr *ManualRun) 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 { + mr.appendRunLog("failed to write run output: " + err.Error()) + } + } + mr.appendRunLog(line) + } + if err := scanner.Err(); err != nil { + mr.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() + } + mr.finishRun(code) +} + +func (mr *ManualRun) unsubscribeRun(ch chan runEvent) { + mr.state.mu.Lock() + delete(mr.state.subscribers, ch) + mr.state.mu.Unlock() +} + +// logPath returns the path to the single rolling log file. +func (mr *ManualRun) logPath() string { + return filepath.Join(mr.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 (mr *ManualRun) initServerLog() { + lf, err := mr.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 (mr *ManualRun) openRunLog() (*os.File, error) { + p := mr.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) +} \ No newline at end of file diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go new file mode 100644 index 0000000..8b48133 --- /dev/null +++ b/src/web/backend/run/handlers.go @@ -0,0 +1,125 @@ +package run + +import ( + "net/http" + "errors" + "encoding/json" + "log/slog" + "fmt" +) + +// handleRun starts an explo run in the background. Clients follow output via /api/ui/run/events. +func (mr *ManualRun) 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"), + r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", + mr.cfg.webEnvPath) + + if err := mr.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(mr.currentRunStatus()); err != nil { + slog.Warn("failed to encode current run status", "msg", err.Error()) + } +} + +func (mr *ManualRun) HandleStopRun(w http.ResponseWriter, r *http.Request) { + mr.state.mu.Lock() + cancel := mr.state.cancel + running := mr.state.running + mr.state.mu.Unlock() + + if !running || cancel == nil { + http.Error(w, "no run is currently in progress", http.StatusConflict) + return + } + + cancel() + w.WriteHeader(http.StatusAccepted) +} + +func (mr *ManualRun) HandleRunStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mr.currentRunStatus()); err != nil { + slog.Warn("failed encoding current run status to response") + } +} + +// 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 (mr *ManualRun) 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) + mr.state.mu.Lock() + lines := append([]string(nil), mr.state.logs...) + running := mr.state.running + var exitCode *int + if mr.state.exitCode != nil { + code := *mr.state.exitCode + exitCode = &code + } + if running { + mr.state.subscribers[ch] = struct{}{} + } + mr.state.mu.Unlock() + + for _, line := range lines { + sendEvent("", line) + } + if !running { + if exitCode != nil { + sendEvent("done", fmt.Sprintf("%d", *exitCode)) + } + return + } + + defer mr.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 + } + } + } +} \ No newline at end of file diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go new file mode 100644 index 0000000..6628f61 --- /dev/null +++ b/src/web/backend/run/manual_run.go @@ -0,0 +1,190 @@ +package run + +import ( + "fmt" + "errors" + "log/slog" + "os" + "os/exec" + "strings" + "context" + "sync" +) + +// RunStatus is returned by GET /api/run/status. +type RunStatus struct { + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` +} + +type Config struct { + webDataDir string + webEnvPath string + exploPath string +} + +type manualRunState struct { + mu sync.Mutex + running bool + cancel context.CancelFunc + exitCode *int + logs []string + subscribers map[chan runEvent]struct{} +} + +// runEvent is an SSE event sent to connected browser clients. +type runEvent struct { + typ string + data string +} + +type ManualRun struct { + cfg Config + state manualRunState +} + +var errRunAlreadyStarted = errors.New("run already in progress") + +func NewManualRun(dataDir, envPath, exploPath string) *ManualRun { + return &ManualRun{ + cfg: Config{ + webDataDir: dataDir, + webEnvPath: envPath, + exploPath: exploPath, + }, + state: newManualRunState(), + } +} + +func newManualRunState() manualRunState { + return manualRunState{subscribers: make(map[chan runEvent]struct{})} +} + +func (mr *ManualRun) startRun(args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, mr.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 := mr.openRunLog() + if err != nil { + slog.Warn("failed to open run log", "err", err.Error()) + } + + mr.state.mu.Lock() + if mr.state.running { + mr.state.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 + } + mr.state.running = true + mr.state.cancel = cancel + mr.state.exitCode = nil + mr.state.logs = nil + mr.state.mu.Unlock() + + if err := cmd.Start(); err != nil { + mr.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 mr.collectRunOutput(cmd, pr, lf) + return nil +} + +func (mr *ManualRun) currentRunStatus() RunStatus { + mr.state.mu.Lock() + defer mr.state.mu.Unlock() + + var exitCode *int + if mr.state.exitCode != nil { + code := *mr.state.exitCode + exitCode = &code + } + return RunStatus{Running: mr.state.running, ExitCode: exitCode} +} + +func (mr *ManualRun) finishRun(code int) { + done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)} + + mr.state.mu.Lock() + mr.state.running = false + mr.state.cancel = nil + mr.state.exitCode = &code + subscribers := make([]chan runEvent, 0, len(mr.state.subscribers)) + for ch := range mr.state.subscribers { + subscribers = append(subscribers, ch) + delete(mr.state.subscribers, ch) + } + mr.state.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- done: + default: + } + close(ch) + } +} + +// helper to build flag arguments +func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { + args := []string{"--config", WebEnvPath} + if playlist != "" { + args = append(args, "--playlist", playlist) + } + 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 From e9570898336ab56f0be997b446711359e094f545 Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:30:56 +0300 Subject: [PATCH 04/35] remove functions from server.go --- src/web/backend/server.go | 365 +------------------------------------- 1 file changed, 1 insertion(+), 364 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 7a9649d..3d91e68 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -40,31 +40,6 @@ type ConfigResponse struct { 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 @@ -794,342 +769,4 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { 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"), - r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - 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 string, noPersist, excludeLocal bool, WebEnvPath string) []string { - args := []string{"--config", WebEnvPath} - if playlist != "" { - args = append(args, "--playlist", playlist) - } - 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 From e38880b81562bbee72e4f56519f81d10af15a62f Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:45:31 -0700 Subject: [PATCH 05/35] Fixed bug where playlist cards wouldn't automatically refresh after a run, moved up tracklist pull priority in run --- src/main/main.go | 8 +++++++- src/web/frontend/src/components/Settings.jsx | 14 +++++++++++++- .../frontend/src/components/ui/PlaylistCard.jsx | 7 ++++--- src/web/frontend/src/lib/listenbrainz.js | 5 +++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/main.go b/src/main/main.go index 7fe3d0d..1a91115 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -165,6 +165,8 @@ func main() { log.Fatal(srv.Start()) } + slog.Info("Pulling playlist", "playlist", cfg.Flags.Playlist) + var tracks []*models.Track var err error if strings.HasPrefix(cfg.Flags.Playlist, "custom-") { @@ -178,11 +180,15 @@ func main() { tracks, err = disc.Discover() } - if err != nil { + if err != nil { slog.Error(err.Error(), "notify", true) os.Exit(1) } allTracks := append([]*models.Track(nil), tracks...) + if cfg.ServerCfg.WebDataDir != "" { + backend.WritePlaylistCache(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist, allTracks, nil) + slog.Info("Saved playlist", "playlist", cfg.Flags.Playlist, "tracks", len(allTracks)) + } client, err := client.NewClient(&cfg) if err != nil { diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 172b805..ec4fb7e 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -19,7 +19,7 @@ import { fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' -import { fetchPlaylistTracks } from '../lib/listenbrainz' +import { fetchPlaylistTracks, clearPlaylistCache } from '../lib/listenbrainz' import { motion, AnimatePresence } from 'motion/react' import { Toggle } from './ui/Toggle' import { Button, SectionLabel, Panel, LogRow } from './ui/common' @@ -134,6 +134,7 @@ function CustomPlaylistsSection({ onDelete, showImportModal, setShowImportModal, + refreshTick = 0, }) { return (
@@ -170,6 +171,7 @@ function CustomPlaylistsSection({ onTracklistToggle={() => setOpenTracklist(v => v === cp.id ? null : cp.id)} sourceUrl={cp.source_url || undefined} onDelete={(opts) => onDelete(cp.id, opts)} + refreshTick={refreshTick} /> ) })} @@ -181,6 +183,7 @@ function CustomPlaylistsSection({ playlist={openTracklist} lbUser={null} onRun={() => onSync(openTracklist)} + refreshTick={refreshTick} /> @@ -217,6 +220,8 @@ function HomeSection() { const [rawLog, setRawLog] = useState(false) const logRef = useRef(null) + const [refreshTick, setRefreshTick] = useState(0) + useEffect(() => { Promise.all([ fetchConfig(), @@ -255,6 +260,10 @@ function HomeSection() { const onDone = useCallback(code => { setStatus(code === 0 ? 'done ✓' : code === null ? 'error' : `failed (exit ${code})`) setRunning(false) + if (code !== null) { + clearPlaylistCache() + setRefreshTick(t => t + 1) + } }, []) const { connect, disconnect } = useSSE({ onLine, onDone }) @@ -362,6 +371,7 @@ function HomeSection() { nextRunText={nextRunText(p.value)} tracklistOpen={openTracklist === p.value} onTracklistToggle={() => setOpenTracklist(v => v === p.value ? null : p.value)} + refreshTick={refreshTick} /> ))}
@@ -369,6 +379,7 @@ function HomeSection() { { await startRun(openTracklist, 'normal', true, false) setRunning(true) @@ -384,6 +395,7 @@ function HomeSection() { {/* Custom Playlists */} { if (!playlist) return return loadTracks(false) - }, [playlist]) + }, [playlist, refreshTick]) const handleFetch = () => { if (!lbUser) return @@ -376,6 +376,7 @@ export function PlaylistCard({ trackId, artworkUrl, sourceUrl, + refreshTick = 0, }) { const { value, name } = playlist // trackFetchId: use real playlist ID (custom playlists) if provided, else fall back to value @@ -428,7 +429,7 @@ export function PlaylistCard({ cancelled = true if (retryTimer) clearTimeout(retryTimer) } - }, [trackFetchId, s.enabled]) + }, [trackFetchId, s.enabled, refreshTick]) useEffect(() => { if (bgCovers.length < 2) return diff --git a/src/web/frontend/src/lib/listenbrainz.js b/src/web/frontend/src/lib/listenbrainz.js index 583f2e7..0ea3b2f 100644 --- a/src/web/frontend/src/lib/listenbrainz.js +++ b/src/web/frontend/src/lib/listenbrainz.js @@ -1,6 +1,11 @@ // Session-level cache — avoids repeat fetches on open/close within the same page load. const memCache = new Map() +export function clearPlaylistCache(playlistType) { + if (playlistType) memCache.delete(playlistType) + else memCache.clear() +} + export async function fetchPlaylistTracks(playlistType, options = {}) { const key = playlistType if (!options.force && memCache.has(key)) return memCache.get(key) From d1c0aa08d1c6a23999f6abe8c7c67abac1b80d83 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:56:35 -0700 Subject: [PATCH 06/35] dead code cleanup --- src/main/main.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/main.go b/src/main/main.go index 1a91115..2c81834 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -223,14 +223,6 @@ func main() { } } - if cfg.ServerCfg.Enabled { - added := make(map[string]bool) - for _, t := range tracks { - added[t.CleanTitle+"|"+t.Artist] = true - } - backend.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) - } - if err := client.CreatePlaylist(tracks); err != nil { slog.Warn(err.Error()) } else { From 6b817f6c18a5c2bbb6916e215e05ee1c6b440eee Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:28:15 -0700 Subject: [PATCH 07/35] echo run output to stdout for visibility in docker logs --- src/web/backend/server.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index c319fe3..90bf134 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -1129,6 +1129,8 @@ func (s *Server) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { scanner := bufio.NewScanner(pr) for scanner.Scan() { line := scanner.Text() + // Echo to stdout so runs show up in docker logs. + _, _ = fmt.Fprintln(os.Stdout, line) if lf != nil { if _, err := fmt.Fprintln(lf, line); err != nil { s.appendRunLog("failed to write run output: " + err.Error()) From 198f8fad60519397a6b97141d55a63daa5d3dc9d Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 17:33:32 +0300 Subject: [PATCH 08/35] create app package for backend configs --- src/web/backend/app/app.go | 8 ++++++++ src/web/backend/app/paths.go | 17 +++++++++++++++++ src/web/backend/run/manual_run.go | 10 ++++++---- src/web/backend/server.go | 9 ++++++++- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 src/web/backend/app/app.go create mode 100644 src/web/backend/app/paths.go diff --git a/src/web/backend/app/app.go b/src/web/backend/app/app.go new file mode 100644 index 0000000..a302987 --- /dev/null +++ b/src/web/backend/app/app.go @@ -0,0 +1,8 @@ +package app + + +type Config struct { + WebEnvPath string + WebDataDir string + ExploPath string +} \ No newline at end of file diff --git a/src/web/backend/app/paths.go b/src/web/backend/app/paths.go new file mode 100644 index 0000000..7f003cb --- /dev/null +++ b/src/web/backend/app/paths.go @@ -0,0 +1,17 @@ +package app + +import( + "path/filepath" +) + +func (c Config) CacheDir() string { + return filepath.Join(c.WebDataDir, "cache") +} + +func (c Config) CoversDir() string { + return filepath.Join(c.CacheDir(), "covers") +} + +func (c Config) LogsDir() string { + return filepath.Join(c.WebDataDir, "logs") +} \ No newline at end of file diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go index 6628f61..0be2eb3 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -9,6 +9,8 @@ import ( "strings" "context" "sync" + + "explo/src/web/backend/app" ) // RunStatus is returned by GET /api/run/status. @@ -45,12 +47,12 @@ type ManualRun struct { var errRunAlreadyStarted = errors.New("run already in progress") -func NewManualRun(dataDir, envPath, exploPath string) *ManualRun { +func NewManualRun(cfg app.Config) *ManualRun { return &ManualRun{ cfg: Config{ - webDataDir: dataDir, - webEnvPath: envPath, - exploPath: exploPath, + webDataDir: cfg.WebDataDir, + webEnvPath: cfg.WebEnvPath, + exploPath: cfg.ExploPath, }, state: newManualRunState(), } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 3d91e68..1c933ee 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -17,6 +17,7 @@ import ( "explo/src/config" "explo/src/web" "explo/src/web/backend/run" + "explo/src/web/backend/app" ) // Option is a value/label pair for select-type fields. @@ -64,8 +65,14 @@ func NewServer(cfg config.ServerConfig) *Server { sessionManager, ) + appCfg := app.Config{ + WebEnvPath: cfg.WebEnvPath, + WebDataDir: cfg.WebDataDir, + ExploPath: cfg.ExploPath, + } + cronJobs := NewJobs() - manualRun := run.NewManualRun(cfg.WebDataDir, cfg.WebEnvPath, cfg.ExploPath) + manualRun := run.NewManualRun(appCfg) mux := http.NewServeMux() s := &Server{ From 97b180405ca748a2e15673e0c60c5c443a6e9675 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 19:17:47 +0300 Subject: [PATCH 09/35] move defs under separate package, move customID regex under defs --- src/web/backend/custom_playlists.go | 5 +- src/web/backend/defs/defs.go | 185 ++++++++++++++++++++++++++++ src/web/backend/playlists.go | 6 +- src/web/backend/server.go | 3 +- 4 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 src/web/backend/defs/defs.go diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/custom_playlists.go index f9c8a4d..947421d 100644 --- a/src/web/backend/custom_playlists.go +++ b/src/web/backend/custom_playlists.go @@ -15,6 +15,7 @@ import ( "explo/src/discovery" "explo/src/util" "explo/src/web" + "explo/src/web/backend/defs" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -388,7 +389,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // Equivalent to manually triggering the nightly refresh cron job for a single playlist. func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !customIDRe.MatchString(id) { + if !defs.CustomIDRe.MatchString(id) { http.Error(w, "invalid playlist id", http.StatusBadRequest) return } @@ -440,7 +441,7 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ // playlist's download subdirectories from DOWNLOAD_DIR. func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !customIDRe.MatchString(id) { + if !defs.CustomIDRe.MatchString(id) { slog.Warn("custom-playlists: invalid id in delete request", "id", id) http.Error(w, "invalid playlist id", http.StatusBadRequest) return diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go new file mode 100644 index 0000000..84fe591 --- /dev/null +++ b/src/web/backend/defs/defs.go @@ -0,0 +1,185 @@ +package defs + +import ( + "regexp" +) +// place for confs/variables in use by the UI or backend + + +// Custom playlist regex validation +var CustomIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`) + +// configFields is the single source of truth for the settings this web UI +// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard +// uses bespoke HTML but references the same logical rules. + +/* var configFields = []FieldDef{ + // ── Discovery ────────────────────────────────────────────────── + { + Key: "LISTENBRAINZ_USER", Label: "ListenBrainz Username", + Type: "text", Section: "discovery", + Placeholder: "e.g. musiclover42", Required: true, + }, + + // ── Media System ─────────────────────────────────────────────── + { + Key: "EXPLO_SYSTEM", Label: "Media System", + Type: "select", Section: "system", Required: true, + Options: []Option{ + {Value: "jellyfin", Label: "Jellyfin"}, + {Value: "emby", Label: "Emby"}, + {Value: "plex", Label: "Plex"}, + {Value: "subsonic", Label: "Subsonic"}, + {Value: "mpd", Label: "MPD"}, + }, + }, + { + Key: "SYSTEM_URL", Label: "Server URL", + Type: "url", Section: "system", + Placeholder: "e.g. http://192.168.1.100:8096", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, + }, + { + Key: "API_KEY", Label: "API Key", + Type: "text", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "LIBRARY_NAME", Label: "Library Name", + Type: "text", Section: "system", + Placeholder: "e.g. Music", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "SYSTEM_USERNAME", Label: "Username", + Type: "text", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + { + Key: "SYSTEM_PASSWORD", Label: "Password", + Type: "password", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + { + Key: "PLAYLIST_DIR", Label: "Playlist Directory", + Type: "text", Section: "system", + Hint: "Explo writes .m3u files here — MPD reads them as playlists.", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, + }, + { + Key: "SLEEP", Label: "Library Scan Wait (minutes)", + Type: "text", Section: "system", + Placeholder: "2", + Hint: "How long to wait after triggering a library scan before creating playlists.", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "PUBLIC_PLAYLIST", Label: "Public Playlists", + Type: "text", Section: "system", + Hint: "Set to true to make playlists visible to all users (Subsonic).", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + + // ── Downloader ───────────────────────────────────────────────── + { + Key: "DOWNLOAD_DIR", Label: "Download directory", + Type: "text", Section: "downloader", + Placeholder: "e.g. /data/ or ./downloads/", + Required: true, + }, + { + Key: "USE_SUBDIRECTORY", Label: "Use playlist subfolders", + Type: "text", Section: "downloader", + Hint: "When enabled, Explo creates a subfolder per playlist inside the download directory.", + }, + { + Key: "YOUTUBE_API_KEY", Label: "YouTube API Key", + Type: "text", Section: "downloader", + Placeholder: "AIza…", + Hint: "Required when using YouTube. Enable the YouTube Data API v3.", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, + }, + { + Key: "SLSKD_URL", Label: "Slskd URL", + Type: "url", Section: "downloader", + Placeholder: "e.g. http://192.168.1.100:5030", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + }, + { + Key: "SLSKD_API_KEY", Label: "Slskd API Key", + Type: "text", Section: "downloader", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + }, +} */ + +// 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 +} + +// FieldDef describes a single configurable env var. +// Injected into the page as window.__FIELDS__ for the settings UI to consume. +type FieldDef struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` // text | password | url | select + Section string `json:"section"` // discovery | system | downloader + Placeholder string `json:"placeholder,omitempty"` + Hint string `json:"hint,omitempty"` + Required bool `json:"required,omitempty"` + Options []Option `json:"options,omitempty"` // for type=select + VisibleWhen *Condition `json:"visibleWhen,omitempty"` // hide field when condition is false + RequiredWhen *Condition `json:"requiredWhen,omitempty"` // conditionally required +} + +/* var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"} +var apiKeySystems = []string{"jellyfin", "emby", "plex"} */ + +// playlistDef is the single source of truth for a supported playlist type. +// To add a new playlist: append one entry here and add the matching entry in +// PLAYLISTS in the frontend Settings.jsx. +type playlistDef struct { + EnvPrefix string // e.g. "WEEKLY_EXPLORATION" + DefaultSchedule string // cron expression + DefaultFlags string // CLI flags for the run +} + +var PlaylistDefs = map[string]playlistDef{ + "weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"}, + "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, + "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, + "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, +} + +// allConfigKeys is the complete set of env keys the web UI reads and writes. +var AllConfigKeys = []string{ + "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY", + "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS", + "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", + "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", + "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_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", + "SLSKD_URL", "SLSKD_API_KEY", + "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", +} \ No newline at end of file diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 8db584f..961e685 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -6,6 +6,7 @@ import ( "explo/src/discovery" "explo/src/models" "explo/src/util" + "explo/src/web/backend/defs" "fmt" "image" _ "image/jpeg" @@ -15,7 +16,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strings" ) @@ -41,11 +41,9 @@ var validPlaylistTypes = func() map[string]bool { return m }() -var customIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`) - // isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal). func isValidPlaylistID(t string) bool { - return validPlaylistTypes[t] || customIDRe.MatchString(t) + return validPlaylistTypes[t] || defs.CustomIDRe.MatchString(t) } // handleGetPlaylist serves the tracklist cache written by explo during its last run. diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 1c933ee..61715e8 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -18,6 +18,7 @@ import ( "explo/src/web" "explo/src/web/backend/run" "explo/src/web/backend/app" + "explo/src/web/backend/defs" ) // Option is a value/label pair for select-type fields. @@ -444,7 +445,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { if def, ok := playlistDefs[body.Name]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags - } else if customIDRe.MatchString(body.Name) { + } else if defs.CustomIDRe.MatchString(body.Name) { envPrefix = customEnvPrefix(body.Name) defaultFlags = "--playlist " + body.Name } else { From e96d5997e20f4cbc5366f0474e57c94e0c3ce94e Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 19:19:29 +0300 Subject: [PATCH 10/35] remove defs from backend package --- src/web/backend/defs.go | 163 ----------------------------------- src/web/backend/playlists.go | 4 +- src/web/backend/server.go | 10 +-- 3 files changed, 7 insertions(+), 170 deletions(-) delete mode 100644 src/web/backend/defs.go diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go deleted file mode 100644 index 8a22dee..0000000 --- a/src/web/backend/defs.go +++ /dev/null @@ -1,163 +0,0 @@ -// place for confs/variables in use by the UI - -package backend - -// configFields is the single source of truth for the settings this web UI -// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard -// uses bespoke HTML but references the same logical rules. - -/* var configFields = []FieldDef{ - // ── Discovery ────────────────────────────────────────────────── - { - Key: "LISTENBRAINZ_USER", Label: "ListenBrainz Username", - Type: "text", Section: "discovery", - Placeholder: "e.g. musiclover42", Required: true, - }, - - // ── Media System ─────────────────────────────────────────────── - { - Key: "EXPLO_SYSTEM", Label: "Media System", - Type: "select", Section: "system", Required: true, - Options: []Option{ - {Value: "jellyfin", Label: "Jellyfin"}, - {Value: "emby", Label: "Emby"}, - {Value: "plex", Label: "Plex"}, - {Value: "subsonic", Label: "Subsonic"}, - {Value: "mpd", Label: "MPD"}, - }, - }, - { - Key: "SYSTEM_URL", Label: "Server URL", - Type: "url", Section: "system", - Placeholder: "e.g. http://192.168.1.100:8096", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, - }, - { - Key: "API_KEY", Label: "API Key", - Type: "text", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "LIBRARY_NAME", Label: "Library Name", - Type: "text", Section: "system", - Placeholder: "e.g. Music", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "SYSTEM_USERNAME", Label: "Username", - Type: "text", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - { - Key: "SYSTEM_PASSWORD", Label: "Password", - Type: "password", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - { - Key: "PLAYLIST_DIR", Label: "Playlist Directory", - Type: "text", Section: "system", - Hint: "Explo writes .m3u files here — MPD reads them as playlists.", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, - }, - { - Key: "SLEEP", Label: "Library Scan Wait (minutes)", - Type: "text", Section: "system", - Placeholder: "2", - Hint: "How long to wait after triggering a library scan before creating playlists.", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "PUBLIC_PLAYLIST", Label: "Public Playlists", - Type: "text", Section: "system", - Hint: "Set to true to make playlists visible to all users (Subsonic).", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - - // ── Downloader ───────────────────────────────────────────────── - { - Key: "DOWNLOAD_DIR", Label: "Download directory", - Type: "text", Section: "downloader", - Placeholder: "e.g. /data/ or ./downloads/", - Required: true, - }, - { - Key: "USE_SUBDIRECTORY", Label: "Use playlist subfolders", - Type: "text", Section: "downloader", - Hint: "When enabled, Explo creates a subfolder per playlist inside the download directory.", - }, - { - Key: "YOUTUBE_API_KEY", Label: "YouTube API Key", - Type: "text", Section: "downloader", - Placeholder: "AIza…", - Hint: "Required when using YouTube. Enable the YouTube Data API v3.", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, - }, - { - Key: "SLSKD_URL", Label: "Slskd URL", - Type: "url", Section: "downloader", - Placeholder: "e.g. http://192.168.1.100:5030", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - }, - { - Key: "SLSKD_API_KEY", Label: "Slskd API Key", - Type: "text", Section: "downloader", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - }, -} */ - -// FieldDef describes a single configurable env var. -// Injected into the page as window.__FIELDS__ for the settings UI to consume. -type FieldDef struct { - Key string `json:"key"` - Label string `json:"label"` - Type string `json:"type"` // text | password | url | select - Section string `json:"section"` // discovery | system | downloader - Placeholder string `json:"placeholder,omitempty"` - Hint string `json:"hint,omitempty"` - Required bool `json:"required,omitempty"` - Options []Option `json:"options,omitempty"` // for type=select - VisibleWhen *Condition `json:"visibleWhen,omitempty"` // hide field when condition is false - RequiredWhen *Condition `json:"requiredWhen,omitempty"` // conditionally required -} - -/* var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"} -var apiKeySystems = []string{"jellyfin", "emby", "plex"} */ - -// playlistDef is the single source of truth for a supported playlist type. -// To add a new playlist: append one entry here and add the matching entry in -// PLAYLISTS in the frontend Settings.jsx. -type playlistDef struct { - EnvPrefix string // e.g. "WEEKLY_EXPLORATION" - DefaultSchedule string // cron expression - DefaultFlags string // CLI flags for the run -} - -var playlistDefs = map[string]playlistDef{ - "weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"}, - "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, - "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, - "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, -} - -// allConfigKeys is the complete set of env keys the web UI reads and writes. -var allConfigKeys = []string{ - "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY", - "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS", - "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", - "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", - "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_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", - "SLSKD_URL", "SLSKD_API_KEY", - "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", -} diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 961e685..f46b594 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -34,8 +34,8 @@ type PlaylistTrack struct { // validPlaylistTypes is derived from playlistDefs — no manual sync needed. var validPlaylistTypes = func() map[string]bool { - m := make(map[string]bool, len(playlistDefs)) - for k := range playlistDefs { + m := make(map[string]bool, len(defs.PlaylistDefs)) + for k := range defs.PlaylistDefs { m[k] = true } return m diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 61715e8..2f898db 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -365,9 +365,9 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { fileValues = parseEnvText(string(web.SampleEnv)) } - values := make(map[string]string, len(allConfigKeys)) - sources := make(map[string]string, len(allConfigKeys)) - for _, key := range allConfigKeys { + values := make(map[string]string, len(defs.AllConfigKeys)) + sources := make(map[string]string, len(defs.AllConfigKeys)) + for _, key := range defs.AllConfigKeys { if v, ok := fileValues[key]; ok && v != "" { values[key] = v sources[key] = "file" @@ -442,7 +442,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { var envPrefix string var defaultFlags string - if def, ok := playlistDefs[body.Name]; ok { + if def, ok := defs.PlaylistDefs[body.Name]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags } else if defs.CustomIDRe.MatchString(body.Name) { @@ -623,7 +623,7 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { "LISTENBRAINZ_USER": body.User, "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, } - for name, def := range playlistDefs { + for name, def := range defs.PlaylistDefs { if enabled[name] { updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags From 9129febf44dd2f90863c39a568ceae603306723e Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 23:15:38 +0300 Subject: [PATCH 11/35] move files under separate packages, add backend util functions --- src/main/main.go | 9 +- src/util/backend.go | 50 ++ src/web/backend/jobs.go | 150 ----- src/web/backend/jobs/jobs.go | 90 +++ src/web/backend/{ => playlist}/apple_music.go | 2 +- src/web/backend/playlist/custom_playlists.go | 211 ++++++++ .../handlers.go} | 424 +++++++-------- src/web/backend/playlist/jobs.go | 79 +++ .../{playlists.go => playlist/playlist.go} | 108 +--- src/web/backend/{ => playlist}/spotify.go | 4 +- src/web/backend/routes.go | 46 +- src/web/backend/server.go | 512 +----------------- src/web/backend/settings/handlers.go | 352 ++++++++++++ src/web/backend/settings/settings.go | 122 +++++ 14 files changed, 1148 insertions(+), 1011 deletions(-) create mode 100644 src/util/backend.go delete mode 100644 src/web/backend/jobs.go create mode 100644 src/web/backend/jobs/jobs.go rename src/web/backend/{ => playlist}/apple_music.go (99%) create mode 100644 src/web/backend/playlist/custom_playlists.go rename src/web/backend/{custom_playlists.go => playlist/handlers.go} (52%) create mode 100644 src/web/backend/playlist/jobs.go rename src/web/backend/{playlists.go => playlist/playlist.go} (74%) rename src/web/backend/{ => playlist}/spotify.go (99%) create mode 100644 src/web/backend/settings/handlers.go create mode 100644 src/web/backend/settings/settings.go diff --git a/src/main/main.go b/src/main/main.go index 32ad062..01acc53 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -5,6 +5,7 @@ import ( "explo/src/logging" "explo/src/models" "explo/src/web/backend" + "explo/src/web/backend/playlist" "fmt" "log" "log/slog" @@ -222,7 +223,7 @@ func main() { for _, t := range tracks { added[t.CleanTitle+"|"+t.Artist] = true } - backend.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) + playlist.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) } if err := client.CreatePlaylist(tracks); err != nil { @@ -240,7 +241,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { if !strings.HasPrefix(cfg.Flags.Playlist, "custom-") { return } - cp := backend.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) + cp := playlist.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) if cp == nil || cp.ArtworkURL == "" || cp.ArtworkUploaded { return } @@ -248,7 +249,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { if !ok { return } - path := backend.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID) + path := playlist.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID) if _, err := os.Stat(path); err != nil { slog.Warn("custom-playlists: artwork not cached locally, skipping upload", "id", cp.ID, "path", path) return @@ -257,7 +258,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { slog.Warn("custom-playlists: failed to upload playlist artwork", "id", cp.ID, "err", err.Error()) return } - if err := backend.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil { + if err := playlist.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil { slog.Warn("custom-playlists: artwork upload succeeded but flag not persisted", "id", cp.ID, "err", err.Error()) return } diff --git a/src/util/backend.go b/src/util/backend.go new file mode 100644 index 0000000..0e5adbf --- /dev/null +++ b/src/util/backend.go @@ -0,0 +1,50 @@ +package util + +import ( + "strings" + "os/exec" + "os" + "log/slog" + + "explo/src/web/backend/app" +) + +// customEnvPrefix converts a playlist name like "Today's Hits" +// to an env-var prefix like "CUSTOM_TODAYS_HITS". +// Non-alphanumeric characters are collapsed into underscores. +func CustomEnvPrefix(name string) string { + var b strings.Builder + prevUnderscore := true // start true so leading separators are skipped + for _, r := range strings.ToUpper(name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevUnderscore = false + } else if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + } + return "CUSTOM_" + strings.TrimRight(b.String(), "_") +} + +// 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 TriggerLibraryRefresh(cfg app.Config) { + go func() { + cmd := exec.Command(cfg.ExploPath, "--refresh-only", "--config", 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") + }() +} \ No newline at end of file diff --git a/src/web/backend/jobs.go b/src/web/backend/jobs.go deleted file mode 100644 index 01901d4..0000000 --- a/src/web/backend/jobs.go +++ /dev/null @@ -1,150 +0,0 @@ -package backend - -// Jobs running on a schedule go here i.e cache cleanups (and playlist imports in the future) - -import ( - "path/filepath" - "log/slog" - "os" - "slices" - "time" - - "github.com/go-co-op/gocron/v2" -) - - -type Jobs struct { - scheduler gocron.Scheduler -} - -type fileInfo struct { - path string - size int64 - modTime time.Time -} - -func NewJobs() (*Jobs) { - scheduler, err := gocron.NewScheduler() - if err != nil { - slog.Error("failed creating cron scheduler") - } - - return &Jobs{ scheduler: scheduler} -} - -func (j *Jobs) Start() { - j.scheduler.Start() -} - -func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64) error { - _, err := j.scheduler.NewJob( - gocron.CronJob(schedule, false), - gocron.NewTask(func() { - slog.Info("running cache cleanup") - - trimCacheDir(coversDir, maxBytes) - }), - ) - - return err -} - -// RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist -// using its stored schedule. Falls back to daily at 4 AM if no schedule is set. -func (j *Jobs) RegisterCustomPlaylistRefresh(cfgDir, envPath string) error { - playlists := loadCustomPlaylists(cfgDir) - if len(playlists) == 0 { - return nil - } - - var envValues map[string]string - if data, err := os.ReadFile(envPath); err == nil { - envValues = parseEnvText(string(data)) - } else { - envValues = map[string]string{} - } - - for _, p := range playlists { - p := p - prefix := customEnvPrefix(p.Name) - flags := envValues[prefix+"_FLAGS"] - if flags == "" { - continue // disabled - } - schedule := envValues[prefix+"_SCHEDULE"] - if p.RefreshDays <= 0 && schedule == "" { - continue - } - if schedule == "" { - schedule = "0 4 * * *" - } - _, err := j.scheduler.NewJob( - gocron.CronJob(schedule, false), - gocron.NewTask(func() { - if time.Since(p.LastFetched) < time.Duration(p.RefreshDays)*24*time.Hour { - return - } - slog.Info("custom-playlists: refreshing", "id", p.ID, "name", p.Name, "source", p.Source) - result, err := fetchCustomPlaylistTracks(p) - if err != nil { - slog.Warn("custom-playlists: refresh fetch failed", "id", p.ID, "err", err) - return - } - writePrefetchCache(cfgDir, p.ID, result.Tracks) - playlists := loadCustomPlaylists(cfgDir) - for i, pl := range playlists { - if pl.ID == p.ID { - playlists[i].LastFetched = time.Now().UTC() - break - } - } - if err := saveCustomPlaylists(cfgDir, playlists); err != nil { - slog.Error("custom-playlists: failed to save after refresh", "err", err) - } - }), - ) - if err != nil { - slog.Warn("custom-playlists: failed to register refresh job", "id", p.ID, "err", err) - } - } - return nil -} - -func trimCacheDir(dataDir string, maxBytes int64) { - - var files []fileInfo - var total int64 - - err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - - files = append(files, fileInfo{ - path: path, - size: info.Size(), - modTime: info.ModTime(), - }) - - total += info.Size() - return nil - }) - - if err != nil || total <= maxBytes { - return - } - - slices.SortFunc(files, func(a, b fileInfo) int { - return a.modTime.Compare(b.modTime) - }) - - for _, f := range files { - if total <= maxBytes { - break - } - - if err := os.Remove(f.path); err == nil { - total -= f.size - } - } -} \ No newline at end of file diff --git a/src/web/backend/jobs/jobs.go b/src/web/backend/jobs/jobs.go new file mode 100644 index 0000000..dcbb302 --- /dev/null +++ b/src/web/backend/jobs/jobs.go @@ -0,0 +1,90 @@ +package jobs + +// Jobs running on a schedule go here i.e cache cleanups + +import ( + "path/filepath" + "log/slog" + "os" + "slices" + "time" + + "github.com/go-co-op/gocron/v2" +) + + +type Jobs struct { + Scheduler gocron.Scheduler +} + +type fileInfo struct { + path string + size int64 + modTime time.Time +} + +func NewJobs() (*Jobs) { + scheduler, err := gocron.NewScheduler() + if err != nil { + slog.Error("failed creating cron scheduler") + } + + return &Jobs{ Scheduler: scheduler} +} + +func (j *Jobs) Start() { + j.Scheduler.Start() +} + +func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64) error { + _, err := j.Scheduler.NewJob( + gocron.CronJob(schedule, false), + gocron.NewTask(func() { + slog.Info("running cache cleanup") + + trimCacheDir(coversDir, maxBytes) + }), + ) + + return err +} + + +func trimCacheDir(dataDir string, maxBytes int64) { + + var files []fileInfo + var total int64 + + err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + files = append(files, fileInfo{ + path: path, + size: info.Size(), + modTime: info.ModTime(), + }) + + total += info.Size() + return nil + }) + + if err != nil || total <= maxBytes { + return + } + + slices.SortFunc(files, func(a, b fileInfo) int { + return a.modTime.Compare(b.modTime) + }) + + for _, f := range files { + if total <= maxBytes { + break + } + + if err := os.Remove(f.path); err == nil { + total -= f.size + } + } +} \ No newline at end of file diff --git a/src/web/backend/apple_music.go b/src/web/backend/playlist/apple_music.go similarity index 99% rename from src/web/backend/apple_music.go rename to src/web/backend/playlist/apple_music.go index 7f7f59e..e928418 100644 --- a/src/web/backend/apple_music.go +++ b/src/web/backend/playlist/apple_music.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "encoding/json" diff --git a/src/web/backend/playlist/custom_playlists.go b/src/web/backend/playlist/custom_playlists.go new file mode 100644 index 0000000..da85ca0 --- /dev/null +++ b/src/web/backend/playlist/custom_playlists.go @@ -0,0 +1,211 @@ +package playlist + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "explo/src/discovery" + "explo/src/util" + +) + +// CustomPlaylist holds the metadata for a user-imported playlist. +type CustomPlaylist struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` // "listenbrainz" | "apple_music" | "spotify" + SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh + LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat) + ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music) + ArtworkUploaded bool `json:"artwork_uploaded,omitempty"` // true after artwork has been pushed to the music app + RefreshDays int `json:"refresh_days"` + ColorIndex int `json:"color_index"` + LastFetched time.Time `json:"last_fetched"` +} + +// CustomPlaylistArtworkPath returns the local file path where a playlist's +// artwork is cached (regardless of whether the file exists). +func CustomPlaylistArtworkPath(cfgDir, id string) string { + return filepath.Join(cfgDir, "cache", "playlist_artwork", id+".jpg") +} + +// GetCustomPlaylist looks up a custom playlist by ID. Returns nil if not found. +func GetCustomPlaylist(cfgDir, id string) *CustomPlaylist { + for _, p := range loadCustomPlaylists(cfgDir) { + if p.ID == id { + cp := p + return &cp + } + } + return nil +} + +// MarkCustomPlaylistArtworkUploaded sets ArtworkUploaded=true and persists. +func MarkCustomPlaylistArtworkUploaded(cfgDir, id string) error { + playlists := loadCustomPlaylists(cfgDir) + for i := range playlists { + if playlists[i].ID == id { + if playlists[i].ArtworkUploaded { + return nil + } + playlists[i].ArtworkUploaded = true + return saveCustomPlaylists(cfgDir, playlists) + } + } + return nil +} + +var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + +var appleMusicURLRe = regexp.MustCompile( + `^https?://music\.apple\.com/[a-z]{2}/playlist/[^/]+/(pl\.[a-zA-Z0-9-]+)`, +) + +// extractAppleMusicID pulls the playlist ID (pl.xxx) from an Apple Music URL. +func extractAppleMusicID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + m := appleMusicURLRe.FindStringSubmatch(raw) + if len(m) < 2 { + return "", fmt.Errorf("not a valid Apple Music playlist URL") + } + return m[1], nil +} + +// extractLBMBID pulls the playlist UUID out of a ListenBrainz playlist URL or bare MBID string. +func extractLBMBID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + m := lbMBIDRe.FindString(raw) + if m == "" { + return "", fmt.Errorf("no ListenBrainz playlist UUID found in %q", raw) + } + return m, nil +} + +func customPlaylistsPath(cfgDir string) string { + return filepath.Join(cfgDir, "custom-playlists.json") +} + +func loadCustomPlaylists(cfgDir string) []CustomPlaylist { + data, err := os.ReadFile(customPlaylistsPath(cfgDir)) + if err != nil { + return nil + } + var out []CustomPlaylist + if err := json.Unmarshal(data, &out); err != nil { + slog.Warn("custom-playlists: failed to parse metadata", "err", err) + return nil + } + return out +} + +func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error { + raw, err := json.MarshalIndent(playlists, "", " ") + if err != nil { + return err + } + return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644) +} + +// FetchResult is the uniform return type for fetching playlist data from any source. +type FetchResult struct { + Name string + ArtworkURL string + Tracks []PlaylistTrack +} + +// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher. +// This is the single point where source-specific logic lives for fetching. +func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) { + switch p.Source { + case "apple_music": + name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL) + return FetchResult{name, art, tracks}, err + case "spotify": + name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL) + return FetchResult{name, art, tracks}, err + default: // "listenbrainz" or legacy empty + mbid := p.LBMBID + if mbid == "" && p.SourceURL != "" { + var err error + mbid, err = extractLBMBID(p.SourceURL) + if err != nil { + return FetchResult{}, err + } + } + if mbid == "" { + return FetchResult{}, fmt.Errorf("no source data for playlist %s", p.ID) + } + httpClient := util.NewHttp(util.HttpClientConfig{Timeout: 30}) + name, modelTracks, err := discovery.FetchPlaylistByMBID(httpClient, mbid) + if err != nil { + return FetchResult{}, err + } + tracks := modelTracksToPlaylistTracks(modelTracks) + return FetchResult{Name: name, Tracks: tracks}, nil + } +} + +// extractSourceID validates a URL and returns the canonical ID for the given source. +func extractSourceID(source, url string) (string, error) { + switch source { + case "apple_music": + return extractAppleMusicID(url) + case "spotify": + return extractSpotifyID(url) + default: + return extractLBMBID(url) + } +} + +// isDuplicate checks whether a playlist with the same source and source ID already exists. +func isDuplicate(source, sourceID string, existing []CustomPlaylist) (string, bool) { + for _, p := range existing { + if p.Source != source && p.Source != "" { + continue + } + existID, _ := extractSourceID(p.Source, p.SourceURL) + if existID == "" && p.LBMBID != "" { + existID = p.LBMBID + } + if existID == "" { + continue + } + if existID == sourceID { + return p.ID, true + } + } + return "", false +} + +func (p *Playlist) PrefetchCovers() { + + coversDir := p.cfg.CoversDir() + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + fetchSitewideCovers(coversDir) + } +} + +// customPlaylistTrackCount reads the cached track count for a custom playlist without +// fully parsing the JSON. +func customPlaylistTrackCount(cfgDir, id string) int { + type mini struct { + Tracks []json.RawMessage `json:"tracks"` + } + data, err := os.ReadFile(filepath.Join(cfgDir, "cache", id+".json")) + if err != nil { + return 0 + } + var m mini + if err := json.Unmarshal(data, &m); err != nil { + return 0 + } + return len(m.Tracks) +} \ No newline at end of file diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/playlist/handlers.go similarity index 52% rename from src/web/backend/custom_playlists.go rename to src/web/backend/playlist/handlers.go index 947421d..b1af52e 100644 --- a/src/web/backend/custom_playlists.go +++ b/src/web/backend/playlist/handlers.go @@ -1,222 +1,33 @@ -package backend +package playlist import ( "encoding/json" - "fmt" - "log/slog" - "math/rand/v2" "net/http" + "fmt" "os" + "log/slog" "path/filepath" - "regexp" - "strings" + "math/rand/v2" "time" - "explo/src/discovery" "explo/src/util" - "explo/src/web" "explo/src/web/backend/defs" + "explo/src/web" "golang.org/x/text/cases" "golang.org/x/text/language" ) -// CustomPlaylist holds the metadata for a user-imported playlist. -type CustomPlaylist struct { - ID string `json:"id"` - Name string `json:"name"` - Source string `json:"source"` // "listenbrainz" | "apple_music" | "spotify" - SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh - LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat) - ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music) - ArtworkUploaded bool `json:"artwork_uploaded,omitempty"` // true after artwork has been pushed to the music app - RefreshDays int `json:"refresh_days"` - ColorIndex int `json:"color_index"` - LastFetched time.Time `json:"last_fetched"` -} - -// CustomPlaylistArtworkPath returns the local file path where a playlist's -// artwork is cached (regardless of whether the file exists). -func CustomPlaylistArtworkPath(cfgDir, id string) string { - return filepath.Join(cfgDir, "cache", "playlist_artwork", id+".jpg") -} - -// GetCustomPlaylist looks up a custom playlist by ID. Returns nil if not found. -func GetCustomPlaylist(cfgDir, id string) *CustomPlaylist { - for _, p := range loadCustomPlaylists(cfgDir) { - if p.ID == id { - cp := p - return &cp - } - } - return nil -} - -// MarkCustomPlaylistArtworkUploaded sets ArtworkUploaded=true and persists. -func MarkCustomPlaylistArtworkUploaded(cfgDir, id string) error { - playlists := loadCustomPlaylists(cfgDir) - for i := range playlists { - if playlists[i].ID == id { - if playlists[i].ArtworkUploaded { - return nil - } - playlists[i].ArtworkUploaded = true - return saveCustomPlaylists(cfgDir, playlists) - } - } - return nil -} - -var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) - -var appleMusicURLRe = regexp.MustCompile( - `^https?://music\.apple\.com/[a-z]{2}/playlist/[^/]+/(pl\.[a-zA-Z0-9-]+)`, -) - -// extractAppleMusicID pulls the playlist ID (pl.xxx) from an Apple Music URL. -func extractAppleMusicID(raw string) (string, error) { - raw = strings.TrimSpace(raw) - m := appleMusicURLRe.FindStringSubmatch(raw) - if len(m) < 2 { - return "", fmt.Errorf("not a valid Apple Music playlist URL") - } - return m[1], nil -} - -// extractLBMBID pulls the playlist UUID out of a ListenBrainz playlist URL or bare MBID string. -func extractLBMBID(raw string) (string, error) { - raw = strings.TrimSpace(raw) - m := lbMBIDRe.FindString(raw) - if m == "" { - return "", fmt.Errorf("no ListenBrainz playlist UUID found in %q", raw) - } - return m, nil -} - -func customPlaylistsPath(cfgDir string) string { - return filepath.Join(cfgDir, "custom-playlists.json") -} - -// customEnvPrefix converts a playlist name like "Today's Hits" -// to an env-var prefix like "CUSTOM_TODAYS_HITS". -// Non-alphanumeric characters are collapsed into underscores. -func customEnvPrefix(name string) string { - var b strings.Builder - prevUnderscore := true // start true so leading separators are skipped - for _, r := range strings.ToUpper(name) { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevUnderscore = false - } else if !prevUnderscore { - b.WriteRune('_') - prevUnderscore = true - } - } - return "CUSTOM_" + strings.TrimRight(b.String(), "_") -} - - -func loadCustomPlaylists(cfgDir string) []CustomPlaylist { - data, err := os.ReadFile(customPlaylistsPath(cfgDir)) - if err != nil { - return nil - } - var out []CustomPlaylist - if err := json.Unmarshal(data, &out); err != nil { - slog.Warn("custom-playlists: failed to parse metadata", "err", err) - return nil - } - return out -} - -func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error { - raw, err := json.MarshalIndent(playlists, "", " ") - if err != nil { - return err - } - return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644) -} - -// FetchResult is the uniform return type for fetching playlist data from any source. -type FetchResult struct { - Name string - ArtworkURL string - Tracks []PlaylistTrack -} - -// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher. -// This is the single point where source-specific logic lives for fetching. -func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) { - switch p.Source { - case "apple_music": - name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL) - return FetchResult{name, art, tracks}, err - case "spotify": - name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL) - return FetchResult{name, art, tracks}, err - default: // "listenbrainz" or legacy empty - mbid := p.LBMBID - if mbid == "" && p.SourceURL != "" { - var err error - mbid, err = extractLBMBID(p.SourceURL) - if err != nil { - return FetchResult{}, err - } - } - if mbid == "" { - return FetchResult{}, fmt.Errorf("no source data for playlist %s", p.ID) - } - httpClient := util.NewHttp(util.HttpClientConfig{Timeout: 30}) - name, modelTracks, err := discovery.FetchPlaylistByMBID(httpClient, mbid) - if err != nil { - return FetchResult{}, err - } - tracks := modelTracksToPlaylistTracks(modelTracks) - return FetchResult{Name: name, Tracks: tracks}, nil - } -} - -// extractSourceID validates a URL and returns the canonical ID for the given source. -func extractSourceID(source, url string) (string, error) { - switch source { - case "apple_music": - return extractAppleMusicID(url) - case "spotify": - return extractSpotifyID(url) - default: - return extractLBMBID(url) - } -} - -// isDuplicate checks whether a playlist with the same source and source ID already exists. -func isDuplicate(source, sourceID string, existing []CustomPlaylist) (string, bool) { - for _, p := range existing { - if p.Source != source && p.Source != "" { - continue - } - existID, _ := extractSourceID(p.Source, p.SourceURL) - if existID == "" && p.LBMBID != "" { - existID = p.LBMBID - } - if existID == "" { - continue - } - if existID == sourceID { - return p.ID, true - } - } - return "", false -} // handleGetCustomPlaylists returns all saved custom playlists with a track_count // derived from their cache file (if present) and the current sync schedule from .env. -func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) { - playlists := loadCustomPlaylists(s.cfg.WebDataDir) +func (p *Playlist) HandleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) { + playlists := loadCustomPlaylists(p.cfg.WebDataDir) // Read .env to look up schedule state for each custom playlist. var envValues map[string]string - if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { - envValues = parseEnvText(string(data)) + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + envValues = p.settings.ParseEnvText(string(data)) } else { envValues = map[string]string{} } @@ -228,12 +39,12 @@ func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request Flags string `json:"flags"` } items := make([]respItem, 0, len(playlists)) - for _, p := range playlists { - count := customPlaylistTrackCount(s.cfg.WebDataDir, p.ID) - prefix := customEnvPrefix(p.Name) + for _, plist := range playlists { + count := customPlaylistTrackCount(p.cfg.WebDataDir, plist.ID) + prefix := util.CustomEnvPrefix(plist.Name) sched := envValues[prefix+"_SCHEDULE"] flags := envValues[prefix+"_FLAGS"] - items = append(items, respItem{CustomPlaylist: p, TrackCount: count, Schedule: sched, Flags: flags}) + items = append(items, respItem{CustomPlaylist: plist, TrackCount: count, Schedule: sched, Flags: flags}) } w.Header().Set("Content-Type", "application/json") @@ -244,7 +55,7 @@ func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request // handleImportCustomPlaylist imports a playlist by URL (ListenBrainz or Apple Music), // writes a cache, and returns the playlist name/tracks to the frontend for the import animation. -func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleImportCustomPlaylist(w http.ResponseWriter, r *http.Request) { var body struct { URL string `json:"url"` Source string `json:"source"` // "listenbrainz" | "apple_music" @@ -255,7 +66,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque return } - existing := loadCustomPlaylists(s.cfg.WebDataDir) + existing := loadCustomPlaylists(p.cfg.WebDataDir) if body.Source == "" { body.Source = "listenbrainz" @@ -290,7 +101,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque slog.Info("custom-playlists: fetched", "source", body.Source, "name", name, "tracks", len(tracks)) // Ensure data directories exist before writing anything - if err := os.MkdirAll(filepath.Join(s.cfg.WebDataDir, "cache"), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(p.cfg.WebDataDir, "cache"), 0755); err != nil { slog.Error("custom-playlists: failed to create data dir", "err", err) http.Error(w, "server data directory unavailable: "+err.Error(), http.StatusInternalServerError) return @@ -302,18 +113,18 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // Write cache with remote cover URLs synchronously so the response is fast, // then download local copies of cover art in the background. slog.Info("custom-playlists: writing cache", "id", id) - if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) { + if !writePreliminaryCache(p.cfg.WebDataDir, id, tracks) { http.Error(w, "failed to write playlist cache", http.StatusInternalServerError) return } - go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks) + go downloadAndCacheCovers(p.cfg.WebDataDir, id, tracks) // Cache the playlist's own artwork locally so we can later push it to the // music app on first playlist creation. Apple Music imports have artwork; // ListenBrainz don't. if artworkURL != "" { go func() { - if _, err := util.DownloadFile(artworkURL, CustomPlaylistArtworkPath(s.cfg.WebDataDir, id)); err != nil { + if _, err := util.DownloadFile(artworkURL, CustomPlaylistArtworkPath(p.cfg.WebDataDir, id)); err != nil { slog.Warn("custom-playlists: artwork download failed", "id", id, "err", err.Error()) } }() @@ -338,7 +149,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque LastFetched: time.Now().UTC(), } existing = append(existing, cp) - if err := saveCustomPlaylists(s.cfg.WebDataDir, existing); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, existing); err != nil { slog.Error("custom-playlists: failed to save metadata", "err", err) http.Error(w, "failed to save playlist metadata: "+err.Error(), http.StatusInternalServerError) return @@ -348,14 +159,14 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // a daily poll SCHEDULE — RefreshDays in the JSON gates the actual refresh interval // inside the cron task body. "Never" imports get FLAGS only so the card is usable // for manual runs while the schedule editor pre-selects "Never". - prefix := customEnvPrefix(name) + prefix := util.CustomEnvPrefix(name) envUpdates := map[string]string{ prefix + "_FLAGS": "--playlist " + id, } if body.RefreshDays > 0 { envUpdates[prefix+"_SCHEDULE"] = "0 4 * * *" } - _ = updateEnvKeys(s.cfg.WebEnvPath, envUpdates, web.SampleEnv) + _ = p.settings.UpdateEnvKeys(envUpdates, web.SampleEnv) slog.Info("custom-playlists: import complete", "id", id, "name", name) @@ -387,14 +198,14 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // handleRefreshCustomPlaylist re-fetches a custom playlist and updates the cache. // Equivalent to manually triggering the nightly refresh cron job for a single playlist. -func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !defs.CustomIDRe.MatchString(id) { http.Error(w, "invalid playlist id", http.StatusBadRequest) return } - playlists := loadCustomPlaylists(s.cfg.WebDataDir) + playlists := loadCustomPlaylists(p.cfg.WebDataDir) idx := -1 for i, p := range playlists { if p.ID == id { @@ -407,10 +218,10 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ return } - p := playlists[idx] - slog.Info("custom-playlists: manual refresh", "id", id, "source", p.Source) + plist := playlists[idx] + slog.Info("custom-playlists: manual refresh", "id", id, "source", plist.Source) - result, err := fetchCustomPlaylistTracks(p) + result, err := fetchCustomPlaylistTracks(plist) if err != nil { slog.Error("custom-playlists: refresh fetch failed", "id", id, "err", err) http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway) @@ -418,14 +229,14 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ } tracks := result.Tracks - if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) { + if !writePreliminaryCache(p.cfg.WebDataDir, id, tracks) { http.Error(w, "failed to write playlist cache", http.StatusInternalServerError) return } - go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks) + go downloadAndCacheCovers(p.cfg.WebDataDir, id, tracks) playlists[idx].LastFetched = time.Now().UTC() - if err := saveCustomPlaylists(s.cfg.WebDataDir, playlists); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { slog.Warn("custom-playlists: failed to update last_fetched after refresh", "err", err) } @@ -439,7 +250,7 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ // handleDeleteCustomPlaylist removes a custom playlist's metadata and cache file. // If ?delete_tracks=true is set and USE_SUBDIRECTORY is on, also removes the // playlist's download subdirectories from DOWNLOAD_DIR. -func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !defs.CustomIDRe.MatchString(id) { slog.Warn("custom-playlists: invalid id in delete request", "id", id) @@ -449,7 +260,7 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque deleteTracks := r.URL.Query().Get("delete_tracks") == "true" slog.Info("custom-playlists: delete request", "id", id, "delete_tracks", deleteTracks) - existing := loadCustomPlaylists(s.cfg.WebDataDir) + existing := loadCustomPlaylists(p.cfg.WebDataDir) filtered := existing[:0] found := false var deletedName string @@ -466,25 +277,25 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque return } - if err := saveCustomPlaylists(s.cfg.WebDataDir, filtered); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, filtered); err != nil { http.Error(w, "failed to save: "+err.Error(), http.StatusInternalServerError) return } // Remove the cache file; ignore error if already gone - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", id+".json") + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", id+".json") _ = os.Remove(cachePath) // Remove schedule env vars from .env - prefix := customEnvPrefix(deletedName) - _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{ + prefix := util.CustomEnvPrefix(deletedName) + _ = p.settings.UpdateEnvKeys(map[string]string{ prefix + "_SCHEDULE": "", prefix + "_FLAGS": "", }, web.SampleEnv) if deleteTracks { - if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { - env := parseEnvText(string(data)) + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + env := p.settings.ParseEnvText(string(data)) if env["USE_SUBDIRECTORY"] == "true" && env["DOWNLOAD_DIR"] != "" { prefix := cases.Title(language.Und).String(id) // "custom-1234" -> "Custom-1234" removed, err := util.RemoveDirsByPrefix(env["DOWNLOAD_DIR"], prefix) @@ -493,7 +304,7 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque } else { slog.Info("custom-playlists: removed download dirs", "id", id, "count", removed) if removed > 0 { - s.triggerLibraryRefresh() + util.TriggerLibraryRefresh(p.cfg) } } } else { @@ -507,19 +318,152 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } -// customPlaylistTrackCount reads the cached track count for a custom playlist without -// fully parsing the JSON. -func customPlaylistTrackCount(cfgDir, id string) int { - type mini struct { - Tracks []json.RawMessage `json:"tracks"` +// handleSaveSchedule updates a single playlist's schedule in the .env file. +func (p *Playlist) 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"` } - data, err := os.ReadFile(filepath.Join(cfgDir, "cache", id+".json")) - if err != nil { - return 0 + 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 + } + + 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 := p.settings.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleGetPlaylist serves the tracklist cache written by explo during its last run. +// Returns an empty track list if no cache exists yet. +func (p *Playlist) HandleGetPlaylist(w http.ResponseWriter, r *http.Request) { + playlistType := r.URL.Query().Get("type") + if !isValidPlaylistID(playlistType) { + http.Error(w, "unknown playlist type", http.StatusBadRequest) + return + } + + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", playlistType+".json") + if raw, err := os.ReadFile(cachePath); err == nil { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(raw); err != nil { + slog.Error("failed to write playlist response", "msg", err.Error()) + } + return + } + + // No cache yet — return an empty response. Run explo or use the prefetch + // endpoint to populate the cache. + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"tracks":[]}`)); err != nil { + slog.Error("failed to write empty playlist response", "msg", err.Error()) } - var m mini - if err := json.Unmarshal(data, &m); err != nil { - return 0 +} + +// handlePrefetchCovers fetches the most recent LB playlists for the given user, +// writes a preliminary JSON cache for the web UI, then downloads cover art. +// Runs in the background — returns 202 immediately. +func (p *Playlist) HandlePrefetchCovers(w http.ResponseWriter, r *http.Request) { + var body struct { + User string `json:"user"` + Playlists []string `json:"playlists"` + Source string `json:"source"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.User == "" || len(body.Playlists) == 0 { + http.Error(w, "user and playlists are required", http.StatusBadRequest) + return } - return len(m.Tracks) + forceRefresh := body.Source == "wizard" + w.WriteHeader(http.StatusAccepted) + + slog.Info("prefetch: starting", "user", body.User, "playlists", body.Playlists, "source", body.Source, "force_refresh", forceRefresh) + go func() { + for _, pt := range body.Playlists { + if !validPlaylistTypes[pt] { + slog.Warn("prefetch: unknown playlist type", "type", pt) + continue + } + // Normal prefetch keeps an existing cache intact; wizard prefetch refreshes it + // after the user updates discovery settings. + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", pt+".json") + if _, err := os.Stat(cachePath); err == nil && !forceRefresh { + slog.Info("prefetch: cache already exists, skipping", "playlist", pt) + continue + } + var tracks []PlaylistTrack + var err error + if pt == "on-repeat" { + tracks, err = fetchOnRepeatTracks(body.User) + } else { + tracks, err = fetchMostRecentLBPlaylist(body.User, pt) + } + if err != nil { + slog.Warn("prefetch: failed to fetch LB playlist", "type", pt, "err", err) + continue + } + slog.Info("prefetch: fetched tracks", "playlist", pt, "count", len(tracks)) + writePrefetchCache(p.cfg.WebDataDir, pt, tracks) + } + }() } + +// handleBackgroundArt returns a single cover art URL for use as a login page backdrop. +// It picks a random local cover if any exist; otherwise it fetches the top global +// albums from ListenBrainz and downloads cover art for the first available one. +func (p *Playlist) HandleBackgroundArt(w http.ResponseWriter, r *http.Request) { + coversDir := filepath.Join(p.cfg.WebDataDir, "cache", "covers") + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + url = fetchSitewideCovers(coversDir) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { + slog.Error("background-art: failed to write response", "err", err.Error()) + } +} \ No newline at end of file diff --git a/src/web/backend/playlist/jobs.go b/src/web/backend/playlist/jobs.go new file mode 100644 index 0000000..b708f86 --- /dev/null +++ b/src/web/backend/playlist/jobs.go @@ -0,0 +1,79 @@ +package playlist + +import ( + "log/slog" + "os" + "time" + "explo/src/util" + "explo/src/web/backend/jobs" + + "github.com/go-co-op/gocron/v2" +) + +type fileInfo struct { + path string + size int64 + modTime time.Time +} + + +// RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist +// using its stored schedule. Falls back to daily at 4 AM if no schedule is set. +func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { + playlists := loadCustomPlaylists(p.cfg.WebDataDir) + if len(playlists) == 0 { + return nil + } + + var envValues map[string]string + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + envValues = p.settings.ParseEnvText(string(data)) + } else { + envValues = map[string]string{} + } + + for _, plist := range playlists { + plist := plist + prefix := util.CustomEnvPrefix(plist.Name) + flags := envValues[prefix+"_FLAGS"] + if flags == "" { + continue // disabled + } + schedule := envValues[prefix+"_SCHEDULE"] + if plist.RefreshDays <= 0 && schedule == "" { + continue + } + if schedule == "" { + schedule = "0 4 * * *" + } + _, err := j.Scheduler.NewJob( + gocron.CronJob(schedule, false), + gocron.NewTask(func() { + if time.Since(plist.LastFetched) < time.Duration(plist.RefreshDays)*24*time.Hour { + return + } + slog.Info("custom-playlists: refreshing", "id", plist.ID, "name", plist.Name, "source", plist.Source) + result, err := fetchCustomPlaylistTracks(plist) + if err != nil { + slog.Warn("custom-playlists: refresh fetch failed", "id", plist.ID, "err", err) + return + } + writePrefetchCache(p.cfg.WebDataDir, plist.ID, result.Tracks) + playlists := loadCustomPlaylists(p.cfg.WebDataDir) + for i, pl := range playlists { + if pl.ID == plist.ID { + playlists[i].LastFetched = time.Now().UTC() + break + } + } + if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { + slog.Error("custom-playlists: failed to save after refresh", "err", err) + } + }), + ) + if err != nil { + slog.Warn("custom-playlists: failed to register refresh job", "id", plist.ID, "err", err) + } + } + return nil +} \ No newline at end of file diff --git a/src/web/backend/playlists.go b/src/web/backend/playlist/playlist.go similarity index 74% rename from src/web/backend/playlists.go rename to src/web/backend/playlist/playlist.go index f46b594..1847f9d 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlist/playlist.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "bytes" @@ -6,7 +6,9 @@ import ( "explo/src/discovery" "explo/src/models" "explo/src/util" + "explo/src/web/backend/app" "explo/src/web/backend/defs" + "explo/src/web/backend/settings" "fmt" "image" _ "image/jpeg" @@ -32,6 +34,12 @@ type PlaylistTrack struct { CoverURL string } + +type Playlist struct { + settings *settings.Settings + cfg app.Config +} + // validPlaylistTypes is derived from playlistDefs — no manual sync needed. var validPlaylistTypes = func() map[string]bool { m := make(map[string]bool, len(defs.PlaylistDefs)) @@ -41,37 +49,15 @@ var validPlaylistTypes = func() map[string]bool { return m }() +func NewPlaylist(cfg app.Config, settings *settings.Settings) *Playlist { + return &Playlist{cfg: cfg} +} + // isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal). func isValidPlaylistID(t string) bool { return validPlaylistTypes[t] || defs.CustomIDRe.MatchString(t) } -// handleGetPlaylist serves the tracklist cache written by explo during its last run. -// Returns an empty track list if no cache exists yet. -func (s *Server) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { - playlistType := r.URL.Query().Get("type") - if !isValidPlaylistID(playlistType) { - http.Error(w, "unknown playlist type", http.StatusBadRequest) - return - } - - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", playlistType+".json") - if raw, err := os.ReadFile(cachePath); err == nil { - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(raw); err != nil { - slog.Error("failed to write playlist response", "msg", err.Error()) - } - return - } - - // No cache yet — return an empty response. Run explo or use the prefetch - // endpoint to populate the cache. - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write([]byte(`{"tracks":[]}`)); err != nil { - slog.Error("failed to write empty playlist response", "msg", err.Error()) - } -} - // ── LB fallback ────────────────────────────────────────────────────────────── func fetchOnRepeatTracks(username string) ([]PlaylistTrack, error) { @@ -174,57 +160,6 @@ func lbGet(url string) ([]byte, error) { return io.ReadAll(resp.Body) } -// handlePrefetchCovers fetches the most recent LB playlists for the given user, -// writes a preliminary JSON cache for the web UI, then downloads cover art. -// Runs in the background — returns 202 immediately. -func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) { - var body struct { - User string `json:"user"` - Playlists []string `json:"playlists"` - Source string `json:"source"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.User == "" || len(body.Playlists) == 0 { - http.Error(w, "user and playlists are required", http.StatusBadRequest) - return - } - forceRefresh := body.Source == "wizard" - w.WriteHeader(http.StatusAccepted) - - slog.Info("prefetch: starting", "user", body.User, "playlists", body.Playlists, "source", body.Source, "force_refresh", forceRefresh) - go func() { - for _, pt := range body.Playlists { - if !validPlaylistTypes[pt] { - slog.Warn("prefetch: unknown playlist type", "type", pt) - continue - } - // Normal prefetch keeps an existing cache intact; wizard prefetch refreshes it - // after the user updates discovery settings. - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", pt+".json") - if _, err := os.Stat(cachePath); err == nil && !forceRefresh { - slog.Info("prefetch: cache already exists, skipping", "playlist", pt) - continue - } - var tracks []PlaylistTrack - var err error - if pt == "on-repeat" { - tracks, err = fetchOnRepeatTracks(body.User) - } else { - tracks, err = fetchMostRecentLBPlaylist(body.User, pt) - } - if err != nil { - slog.Warn("prefetch: failed to fetch LB playlist", "type", pt, "err", err) - continue - } - slog.Info("prefetch: fetched tracks", "playlist", pt, "count", len(tracks)) - writePrefetchCache(s.cfg.WebDataDir, pt, tracks) - } - }() -} - type cachedPrefetchTrack struct { Rank int `json:"rank"` Title string `json:"title"` @@ -284,23 +219,6 @@ type sitewideReleasesResp struct { } `json:"payload"` } -// handleBackgroundArt returns a single cover art URL for use as a login page backdrop. -// It picks a random local cover if any exist; otherwise it fetches the top global -// albums from ListenBrainz and downloads cover art for the first available one. -func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) { - coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") - - url := randomLocalCoverHiRes(coversDir) - if url == "" { - url = fetchSitewideCovers(coversDir) - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { - slog.Error("background-art: failed to write response", "err", err.Error()) - } -} - // randomLocalCoverHiRes picks a random cover from the existing library, ensures a // 1200px background version is cached (as {mbid}-bg.jpg), and returns its API URL. // Playlist thumbnails are stored at 250px; this fetches full-res on demand from CAA. diff --git a/src/web/backend/spotify.go b/src/web/backend/playlist/spotify.go similarity index 99% rename from src/web/backend/spotify.go rename to src/web/backend/playlist/spotify.go index 202e902..9209f51 100644 --- a/src/web/backend/spotify.go +++ b/src/web/backend/playlist/spotify.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "bytes" @@ -808,4 +808,4 @@ func findPartHash(jsContent, name string) string { return "" } return rest[:end] -} +} \ No newline at end of file diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 1680ae4..612782d 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -28,8 +28,7 @@ func (s *Server) registerRoutes() { }) s.registerAuthRoutes() - s.registerConfigRoutes() - s.registerWizardRoutes() + s.registerSettingRoutes() s.registerPlaylistRoutes() s.registerRunRoutes() s.registerMiscRoutes() @@ -45,43 +44,43 @@ func (s *Server) registerAuthRoutes() { s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) } -func (s *Server) registerConfigRoutes() { - s.mux.Handle("GET /api/ui/config", s.auth(s.handleGetConfig)) - s.mux.Handle("POST /api/ui/config", s.auth(s.handleSaveConfig)) +func (s *Server) registerSettingRoutes() { + s.mux.Handle("GET /api/ui/config", s.auth(s.settings.HandleGetConfig)) + s.mux.Handle("POST /api/ui/config", s.auth(s.settings.HandleSaveConfig)) - s.mux.Handle("GET /api/ui/config/raw", s.auth(s.handleGetConfigRaw)) - s.mux.Handle("POST /api/ui/config/reset", s.auth(s.handleResetConfig)) - s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.handleSaveSchedule)) - s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.handleSavePathTemplate)) - s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.handleSaveEnrichMetadata)) + s.mux.Handle("GET /api/ui/config/raw", s.auth(s.settings.HandleGetConfigRaw)) + s.mux.Handle("POST /api/ui/config/reset", s.auth(s.settings.HandleResetConfig)) + 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)) // Path template presets: GET list, POST add; DELETE per name under prefix s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) -} - -func (s *Server) registerWizardRoutes() { // Wizard steps (POST) — require auth - s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.handleWizardStep1)) - s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.handleWizardStep2)) - s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.handleWizardStep3)) + s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.settings.HandleWizardStep1)) + s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.settings.HandleWizardStep2)) + s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.settings.HandleWizardStep3)) // Public - s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) + s.mux.HandleFunc("GET /api/ui/setup-status", s.settings.HandleSetupStatus) + } func (s *Server) registerPlaylistRoutes() { - s.mux.Handle("GET /api/ui/playlists", s.auth(s.handleGetPlaylist)) - s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.handlePrefetchCovers)) + s.mux.Handle("GET /api/ui/playlists", s.auth(s.customPlaylist.HandleGetPlaylist)) + s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.customPlaylist.HandlePrefetchCovers)) // custom playlists: GET list, POST import (same path); per-ID actions under prefix - s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.handleGetCustomPlaylists)) - s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.handleImportCustomPlaylist)) + s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.customPlaylist.HandleGetCustomPlaylists)) + s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.customPlaylist.HandleImportCustomPlaylist)) // ID-specific routes: DELETE /api/ui/custom-playlists/{id} and POST .../{id}/refresh - s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.handleRefreshCustomPlaylist)) - s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.handleDeleteCustomPlaylist)) + s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.customPlaylist.HandleRefreshCustomPlaylist)) + s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.customPlaylist.HandleDeleteCustomPlaylist)) + + s.mux.HandleFunc("GET /api/ui/background-art", s.customPlaylist.HandleBackgroundArt) } func (s *Server) registerRunRoutes() { @@ -94,7 +93,6 @@ func (s *Server) registerRunRoutes() { func (s *Server) registerMiscRoutes() { s.mux.Handle("GET /api/ui/logs", s.auth(s.handleGetLog)) s.mux.Handle("GET /api/ui/browse", s.auth(s.handleBrowse)) - s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") s.mux.Handle("GET /api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 2f898db..2ef864e 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -10,32 +10,17 @@ import ( "os" "path/filepath" "strings" - "syscall" "time" - "os/exec" "explo/src/config" "explo/src/web" - "explo/src/web/backend/run" "explo/src/web/backend/app" - "explo/src/web/backend/defs" + "explo/src/web/backend/run" + "explo/src/web/backend/playlist" + "explo/src/web/backend/jobs" + "explo/src/web/backend/settings" ) -// 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"` @@ -47,9 +32,11 @@ type Server struct { mux *http.ServeMux server *http.Server authStore *AuthStore - cronJobs *Jobs + settings *settings.Settings + cronJobs *jobs.Jobs sessionManager *SessionManager manualRun *run.ManualRun + customPlaylist *playlist.Playlist } func NewServer(cfg config.ServerConfig) *Server { @@ -65,15 +52,17 @@ func NewServer(cfg config.ServerConfig) *Server { cfg.Password, sessionManager, ) - - appCfg := app.Config{ + webCfg := app.Config{ WebEnvPath: cfg.WebEnvPath, WebDataDir: cfg.WebDataDir, ExploPath: cfg.ExploPath, } - cronJobs := NewJobs() - manualRun := run.NewManualRun(appCfg) + settings := settings.NewSettings(webCfg) + + cronJobs := jobs.NewJobs() + manualRun := run.NewManualRun(webCfg) + playlist := playlist.NewPlaylist(webCfg, settings) mux := http.NewServeMux() s := &Server{ @@ -84,9 +73,11 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, + settings: settings, cronJobs: cronJobs, sessionManager: sessionManager, manualRun: manualRun, + customPlaylist: playlist, } s.registerRoutes() @@ -98,7 +89,7 @@ func (s *Server) Start() error { 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() @@ -138,28 +129,6 @@ func checkForUpdate() { } } -// 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 parseVer(v string) [3]int { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 3) @@ -182,23 +151,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. @@ -240,18 +199,6 @@ func (s *Server) openRunLog() (*os.File, error) { 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) @@ -322,431 +269,6 @@ func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { } } -// ── 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(defs.AllConfigKeys)) - sources := make(map[string]string, len(defs.AllConfigKeys)) - for _, key := range defs.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 := defs.PlaylistDefs[body.Name]; ok { - envPrefix = def.EnvPrefix - defaultFlags = def.DefaultFlags - } else if defs.CustomIDRe.MatchString(body.Name) { - envPrefix = customEnvPrefix(body.Name) - defaultFlags = "--playlist " + body.Name - } else { - http.Error(w, "unknown playlist name", http.StatusBadRequest) - return - } - - 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) -} - -// 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 defs.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")) diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go new file mode 100644 index 0000000..26f6604 --- /dev/null +++ b/src/web/backend/settings/handlers.go @@ -0,0 +1,352 @@ +package settings + +import ( + "net/http" + "encoding/json" + "os" + "log/slog" + "io" + "syscall" + "time" + "fmt" + "strings" + + "explo/src/web" + "explo/src/web/backend/defs" + "explo/src/util" +) + +// 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 *Settings) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(s.cfg.WebEnvPath) + var fileValues map[string]string + if err == nil { + fileValues = s.ParseEnvText(string(data)) + } else { + fileValues = s.ParseEnvText(string(web.SampleEnv)) + } + + configKeys := defs.AllConfigKeys + values := make(map[string]string, len(configKeys)) + sources := make(map[string]string, len(configKeys)) + for _, key := range configKeys { + 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 *Settings) 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 *Settings) 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 *Settings) 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 *Settings) 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 := 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 + } + + 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 := s.UpdateEnvKeys(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 *Settings) 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 := s.UpdateEnvKeys(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 *Settings) 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 := s.UpdateEnvKeys(map[string]string{"ENRICH_TRACK_METADATA": val}, 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 { + 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 defs.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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep2 saves media system configuration. +func (s *Settings) 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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep3 saves downloader configuration. +func (s *Settings) 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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleSetupStatus returns {"wizard_complete": bool} for first time setups. Public — no auth required. +func (s *Settings) HandleSetupStatus(w http.ResponseWriter, r *http.Request) { + wizardComplete := false + if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { + wizardComplete = s.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()) + } +} \ No newline at end of file diff --git a/src/web/backend/settings/settings.go b/src/web/backend/settings/settings.go new file mode 100644 index 0000000..ace6e5c --- /dev/null +++ b/src/web/backend/settings/settings.go @@ -0,0 +1,122 @@ +package settings + +import ( + "strings" + "os" + "fmt" + + "explo/src/web/backend/app" +) + +// ConfigResponse is returned by GET /api/config. +type ConfigResponse struct { + Values map[string]string `json:"values"` + Sources map[string]string `json:"sources"` // "env" | "file" +} + +type Settings struct { + cfg app.Config +} + +func NewSettings(Config app.Config) *Settings { + return &Settings{cfg: Config} +} +// parseEnvText parses key=value lines, ignoring comments, blanks and unquotes variables +func (s *Settings) 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 +} + +// 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 (s *Settings) UpdateEnvKeys(updates map[string]string, fallback []byte) error { + path := s.cfg.WebEnvPath + 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 +} \ No newline at end of file From f012095c1044b7b4b9a9d87450a32faaea8bcaf6 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 23:23:41 +0300 Subject: [PATCH 12/35] move path template handling under settings --- src/web/backend/path_templates.go | 112 --------------------- src/web/backend/routes.go | 4 +- src/web/backend/settings/handlers.go | 71 +++++++++++++ src/web/backend/settings/path_templates.go | 39 +++++++ 4 files changed, 112 insertions(+), 114 deletions(-) delete mode 100644 src/web/backend/path_templates.go create mode 100644 src/web/backend/settings/path_templates.go diff --git a/src/web/backend/path_templates.go b/src/web/backend/path_templates.go deleted file mode 100644 index 0617a65..0000000 --- a/src/web/backend/path_templates.go +++ /dev/null @@ -1,112 +0,0 @@ -package backend - -import ( - "encoding/json" - "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" -) - -// PathTemplatePreset is a named folder-structure template saved by the user. -type PathTemplatePreset struct { - Name string `json:"name"` - Template string `json:"template"` -} - -func pathTemplatesFilePath(cfgDir string) string { - return filepath.Join(cfgDir, "path-templates.json") -} - -func loadPathTemplates(cfgDir string) []PathTemplatePreset { - data, err := os.ReadFile(pathTemplatesFilePath(cfgDir)) - if err != nil { - return nil - } - var out []PathTemplatePreset - if err := json.Unmarshal(data, &out); err != nil { - slog.Warn("path-templates: failed to parse", "err", err) - return nil - } - return out -} - -func savePathTemplates(cfgDir string, presets []PathTemplatePreset) error { - raw, err := json.MarshalIndent(presets, "", " ") - if err != nil { - return err - } - return os.WriteFile(pathTemplatesFilePath(cfgDir), raw, 0644) -} - -// handlePathTemplates handles GET and POST for /api/ui/path-templates. -func (s *Server) handlePathTemplates(w http.ResponseWriter, r *http.Request) { - cfgDir := s.cfg.WebDataDir - switch r.Method { - case http.MethodGet: - presets := loadPathTemplates(cfgDir) - if presets == nil { - presets = []PathTemplatePreset{} - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(presets); err != nil { - slog.Error("failed encoding path templates", "err", err.Error()) - } - case http.MethodPost: - var body PathTemplatePreset - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.Name == "" || body.Template == "" { - http.Error(w, "name and template are required", http.StatusBadRequest) - return - } - presets := loadPathTemplates(cfgDir) - presets = append(presets, body) - if err := savePathTemplates(cfgDir, presets); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(body); err != nil { - slog.Error("failed encoding path template", "err", err.Error()) - } - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleDeletePathTemplate handles DELETE /api/ui/path-templates/{name}. -func (s *Server) handleDeletePathTemplate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - raw := strings.TrimPrefix(r.URL.Path, "/api/ui/path-templates/") - name, err := url.PathUnescape(raw) - if err != nil || name == "" { - http.Error(w, "invalid name", http.StatusBadRequest) - return - } - cfgDir := s.cfg.WebDataDir - presets := loadPathTemplates(cfgDir) - filtered := presets[:0] - for _, p := range presets { - if p.Name != name { - filtered = append(filtered, p) - } - } - if len(filtered) == len(presets) { - http.Error(w, "not found", http.StatusNotFound) - return - } - if err := savePathTemplates(cfgDir, filtered); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 612782d..cfcbdb2 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -55,8 +55,8 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata)) // Path template presets: GET list, POST add; DELETE per name under prefix - s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) - s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) + s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) + s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.settings.HandleDeletePathTemplate)) // Wizard steps (POST) — require auth s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.settings.HandleWizardStep1)) diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 26f6604..ab0b3c4 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -10,6 +10,7 @@ import ( "time" "fmt" "strings" + "net/url" "explo/src/web" "explo/src/web/backend/defs" @@ -349,4 +350,74 @@ func (s *Settings) HandleSetupStatus(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(map[string]bool{"wizard_complete": wizardComplete}); err != nil { slog.Error("failed encoding setup status", "err", err.Error()) } +} + +// handlePathTemplates handles GET and POST for /api/ui/path-templates. +func (s *Settings) HandlePathTemplates(w http.ResponseWriter, r *http.Request) { + cfgDir := s.cfg.WebDataDir + switch r.Method { + case http.MethodGet: + presets := loadPathTemplates(cfgDir) + if presets == nil { + presets = []PathTemplatePreset{} + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(presets); err != nil { + slog.Error("failed encoding path templates", "err", err.Error()) + } + case http.MethodPost: + var body PathTemplatePreset + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" || body.Template == "" { + http.Error(w, "name and template are required", http.StatusBadRequest) + return + } + presets := loadPathTemplates(cfgDir) + presets = append(presets, body) + if err := savePathTemplates(cfgDir, presets); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Error("failed encoding path template", "err", err.Error()) + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleDeletePathTemplate handles DELETE /api/ui/path-templates/{name}. +func (s *Settings) HandleDeletePathTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + raw := strings.TrimPrefix(r.URL.Path, "/api/ui/path-templates/") + name, err := url.PathUnescape(raw) + if err != nil || name == "" { + http.Error(w, "invalid name", http.StatusBadRequest) + return + } + cfgDir := s.cfg.WebDataDir + presets := loadPathTemplates(cfgDir) + filtered := presets[:0] + for _, p := range presets { + if p.Name != name { + filtered = append(filtered, p) + } + } + if len(filtered) == len(presets) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := savePathTemplates(cfgDir, filtered); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) } \ No newline at end of file diff --git a/src/web/backend/settings/path_templates.go b/src/web/backend/settings/path_templates.go new file mode 100644 index 0000000..e84e7fe --- /dev/null +++ b/src/web/backend/settings/path_templates.go @@ -0,0 +1,39 @@ +package settings + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" +) + +// PathTemplatePreset is a named folder-structure template saved by the user. +type PathTemplatePreset struct { + Name string `json:"name"` + Template string `json:"template"` +} + +func pathTemplatesFilePath(cfgDir string) string { + return filepath.Join(cfgDir, "path-templates.json") +} + +func loadPathTemplates(cfgDir string) []PathTemplatePreset { + data, err := os.ReadFile(pathTemplatesFilePath(cfgDir)) + if err != nil { + return nil + } + var out []PathTemplatePreset + if err := json.Unmarshal(data, &out); err != nil { + slog.Warn("path-templates: failed to parse", "err", err) + return nil + } + return out +} + +func savePathTemplates(cfgDir string, presets []PathTemplatePreset) error { + raw, err := json.MarshalIndent(presets, "", " ") + if err != nil { + return err + } + return os.WriteFile(pathTemplatesFilePath(cfgDir), raw, 0644) +} From 894d2e3d8d4d1d97c2dfb1b701b44c72a06c50fa Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 24 Jun 2026 20:24:19 +0300 Subject: [PATCH 13/35] separate package for auth, fix session migration --- src/web/backend/{ => auth}/auth.go | 2 +- src/web/backend/auth/handlers.go | 70 ++++++++++++++++++++++++ src/web/backend/{ => auth}/middleware.go | 2 +- src/web/backend/{ => auth}/session.go | 2 +- src/web/backend/routes.go | 8 +-- src/web/backend/server.go | 70 ++---------------------- 6 files changed, 83 insertions(+), 71 deletions(-) rename src/web/backend/{ => auth}/auth.go (98%) create mode 100644 src/web/backend/auth/handlers.go rename src/web/backend/{ => auth}/middleware.go (99%) rename src/web/backend/{ => auth}/session.go (99%) diff --git a/src/web/backend/auth.go b/src/web/backend/auth/auth.go similarity index 98% rename from src/web/backend/auth.go rename to src/web/backend/auth/auth.go index 2cbfa04..b763fbc 100644 --- a/src/web/backend/auth.go +++ b/src/web/backend/auth/auth.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "net/http" diff --git a/src/web/backend/auth/handlers.go b/src/web/backend/auth/handlers.go new file mode 100644 index 0000000..83568fd --- /dev/null +++ b/src/web/backend/auth/handlers.go @@ -0,0 +1,70 @@ +package auth + +import ( + "net/http" + "log/slog" + "encoding/json" +) + +func (a *AuthStore) HandleAuthStatus(w http.ResponseWriter, r *http.Request) { + sess := a.sessionManager.GetSession(r) + auth, _ := sess.Get("authenticated").(bool) + if !auth { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *AuthStore) 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 !a.CompareCreds(username, password) { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + sess := a.sessionManager.GetSession(r) + sess.Put("authenticated", true) + sess.Put("username", username) + + if err := a.sessionManager.Migrate(sess); err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + slog.Info("successful login", "user", username) +} + +func (a *AuthStore) HandleLogout(w http.ResponseWriter, r *http.Request) { + sess := a.sessionManager.GetSession(r) + sess.Delete("authenticated") + sess.Delete("username") + w.WriteHeader(http.StatusOK) +} + +func (a *AuthStore) HandleCSRF(w http.ResponseWriter, r *http.Request) { + session := a.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()) + } +} diff --git a/src/web/backend/middleware.go b/src/web/backend/auth/middleware.go similarity index 99% rename from src/web/backend/middleware.go rename to src/web/backend/auth/middleware.go index 8400fa2..7f719a1 100644 --- a/src/web/backend/middleware.go +++ b/src/web/backend/auth/middleware.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "fmt" diff --git a/src/web/backend/session.go b/src/web/backend/auth/session.go similarity index 99% rename from src/web/backend/session.go rename to src/web/backend/auth/session.go index de9c48a..dc6c5ec 100644 --- a/src/web/backend/session.go +++ b/src/web/backend/auth/session.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "context" diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index cfcbdb2..3ae56be 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -36,12 +36,12 @@ func (s *Server) registerRoutes() { } func (s *Server) registerAuthRoutes() { - s.mux.Handle("POST /api/ui/logout", s.auth(s.handleLogout)) + s.mux.Handle("POST /api/ui/logout", s.auth(s.authStore.HandleLogout)) // Public routes - s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) - s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) - s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) + s.mux.HandleFunc("GET /api/ui/csrf", s.authStore.HandleCSRF) + s.mux.HandleFunc("POST /api/ui/login", s.authStore.HandleLogin) + s.mux.HandleFunc("GET /api/ui/auth/status", s.authStore.HandleAuthStatus) } func (s *Server) registerSettingRoutes() { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 2ef864e..99dfa3b 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -19,6 +19,7 @@ import ( "explo/src/web/backend/playlist" "explo/src/web/backend/jobs" "explo/src/web/backend/settings" + "explo/src/web/backend/auth" ) // ConfigResponse is returned by GET /api/config. @@ -31,23 +32,22 @@ type Server struct { cfg config.ServerConfig mux *http.ServeMux server *http.Server - authStore *AuthStore + authStore *auth.AuthStore settings *settings.Settings cronJobs *jobs.Jobs - sessionManager *SessionManager 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, @@ -73,9 +73,8 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, - settings: settings, + settings: settings, cronJobs: cronJobs, - sessionManager: sessionManager, manualRun: manualRun, customPlaylist: playlist, } @@ -199,49 +198,6 @@ func (s *Server) openRunLog() (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) } -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()) @@ -255,20 +211,6 @@ func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) { } } -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()) - } -} - // 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")) From 91a48d715fb7d96651ca54256c11cdd91c3e8252 Mon Sep 17 00:00:00 2001 From: Blake Alvarez Date: Wed, 24 Jun 2026 21:06:16 -0500 Subject: [PATCH 14/35] Ensure playlist schedules in .env are up to date in memory --- src/web/backend/server.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 90bf134..d640495 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -596,6 +596,15 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + + for k, v := range updates { + if v == "" { + os.Unsetenv(k) + } else { + os.Setenv(k, v) + } + } + w.WriteHeader(http.StatusOK) } From 68ebe09b35de397d5dc317443c99b18fa80caee1 Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 25 Jun 2026 19:25:55 +0300 Subject: [PATCH 15/35] separate handlers --- src/web/backend/handlers.go | 55 +++++++++++++++++++++++++++++++++++++ src/web/backend/server.go | 45 ------------------------------ 2 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 src/web/backend/handlers.go diff --git a/src/web/backend/handlers.go b/src/web/backend/handlers.go new file mode 100644 index 0000000..383ad05 --- /dev/null +++ b/src/web/backend/handlers.go @@ -0,0 +1,55 @@ +package backend + +import ( + "os" + "net/http" + "path/filepath" + "log/slog" + "encoding/json" + "strings" +) + +// 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()) + } +} + +// 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()) + } +} \ No newline at end of file diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 99dfa3b..c630d47 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -196,49 +196,4 @@ func (s *Server) openRunLog() (*os.File, error) { return nil, err } return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) -} - -// 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()) - } -} - -// 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()) - } } \ No newline at end of file From 70f080cf8c387b75dcb742e47d81638b46ddb09c Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 25 Jun 2026 22:58:06 +0300 Subject: [PATCH 16/35] replace package config with app.Config --- src/web/backend/run/events.go | 2 +- src/web/backend/run/handlers.go | 2 +- src/web/backend/run/manual_run.go | 16 +++------------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index e306fad..98fbe08 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -77,7 +77,7 @@ func (mr *ManualRun) unsubscribeRun(ch chan runEvent) { // logPath returns the path to the single rolling log file. func (mr *ManualRun) logPath() string { - return filepath.Join(mr.cfg.webDataDir, "logs", "explo.log") + return filepath.Join(mr.cfg.WebDataDir, "logs", "explo.log") } // initServerLog redirects the default slog handler so all server log output diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go index 8b48133..595090f 100644 --- a/src/web/backend/run/handlers.go +++ b/src/web/backend/run/handlers.go @@ -17,7 +17,7 @@ func (mr *ManualRun) HandleRun(w http.ResponseWriter, r *http.Request) { args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - mr.cfg.webEnvPath) + 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 0be2eb3..5999793 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -19,12 +19,6 @@ type RunStatus struct { ExitCode *int `json:"exit_code,omitempty"` } -type Config struct { - webDataDir string - webEnvPath string - exploPath string -} - type manualRunState struct { mu sync.Mutex running bool @@ -41,7 +35,7 @@ type runEvent struct { } type ManualRun struct { - cfg Config + cfg app.Config state manualRunState } @@ -49,11 +43,7 @@ var errRunAlreadyStarted = errors.New("run already in progress") func NewManualRun(cfg app.Config) *ManualRun { return &ManualRun{ - cfg: Config{ - webDataDir: cfg.WebDataDir, - webEnvPath: cfg.WebEnvPath, - exploPath: cfg.ExploPath, - }, + cfg: cfg, state: newManualRunState(), } } @@ -64,7 +54,7 @@ func newManualRunState() manualRunState { func (mr *ManualRun) startRun(args []string) error { ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, mr.cfg.exploPath, args...) + cmd := exec.CommandContext(ctx, mr.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() { From d6c2fee23f02ae358b4fa400d5971be2fd346988 Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 25 Jun 2026 23:02:29 +0300 Subject: [PATCH 17/35] linter fix --- src/web/backend/server.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index d640495..cced8e7 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -599,9 +599,13 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { for k, v := range updates { if v == "" { - os.Unsetenv(k) + if err := os.Unsetenv(k); err != nil { + slog.Warn("failed to unset env variable", "err", err.Error()) + } } else { - os.Setenv(k, v) + if err := os.Setenv(k, v); err != nil { + slog.Warn("failed to set env variable", "err", err.Error()) + } } } From 31a9a2ae9433127a513e648d95f1b6cf513b11bc Mon Sep 17 00:00:00 2001 From: LumePart Date: Sat, 13 Jun 2026 23:46:45 +0300 Subject: [PATCH 18/35] separate file for routes --- src/web/backend/routes.go | 106 +++++++++++++++++++++++++++++++++++++ src/web/backend/server.go | 107 -------------------------------------- 2 files changed, 106 insertions(+), 107 deletions(-) create mode 100644 src/web/backend/routes.go diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go new file mode 100644 index 0000000..88c09ec --- /dev/null +++ b/src/web/backend/routes.go @@ -0,0 +1,106 @@ +package backend + +import ( + "log/slog" + "net/http" + "strings" + "io/fs" + "path/filepath" +) + +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()) + } + }) + + s.registerAuthRoutes() + s.registerConfigRoutes() + s.registerWizardRoutes() + s.registerPlaylistRoutes() + s.registerRunRoutes() + s.registerMiscRoutes() + +} + +func (s *Server) registerAuthRoutes() { + s.mux.Handle("POST /api/ui/logout", s.auth(s.handleLogout)) + + // Public routes + s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) + s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) + s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) +} + +func (s *Server) registerConfigRoutes() { + s.mux.Handle("GET /api/ui/config", s.auth(s.handleGetConfig)) + s.mux.Handle("POST /api/ui/config", s.auth(s.handleSaveConfig)) + + s.mux.Handle("GET /api/ui/config/raw", s.auth(s.handleGetConfigRaw)) + s.mux.Handle("POST /api/ui/config/reset", s.auth(s.handleResetConfig)) + s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.handleSaveSchedule)) + s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.handleSavePathTemplate)) + s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.handleSaveEnrichMetadata)) + + // Path template presets: GET list, POST add; DELETE per name under prefix + s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) + s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) + +} + +func (s *Server) registerWizardRoutes() { + // Wizard steps (POST) — require auth + s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.handleWizardStep1)) + s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.handleWizardStep2)) + s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.handleWizardStep3)) + + // Public + s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) +} + +func (s *Server) registerPlaylistRoutes() { + s.mux.Handle("GET /api/ui/playlists", s.auth(s.handleGetPlaylist)) + s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.handlePrefetchCovers)) + + // custom playlists: GET list, POST import (same path); per-ID actions under prefix + s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.handleGetCustomPlaylists)) + s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.handleImportCustomPlaylist)) + + // ID-specific routes: DELETE /api/ui/custom-playlists/{id} and POST .../{id}/refresh + s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.handleRefreshCustomPlaylist)) + s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.handleDeleteCustomPlaylist)) +} + +func (s *Server) registerRunRoutes() { + s.mux.Handle("POST /api/ui/run", s.auth(s.handleRun)) + s.mux.Handle("GET /api/ui/run/events", s.auth(s.handleRunEvents)) + s.mux.Handle("POST /api/ui/run/stop", s.auth(s.handleStopRun)) + s.mux.Handle("GET /api/ui/run/status", s.auth(s.handleRunStatus)) +} + +func (s *Server) registerMiscRoutes() { + s.mux.Handle("GET /api/ui/logs", s.auth(s.handleGetLog)) + s.mux.Handle("GET /api/ui/browse", s.auth(s.handleBrowse)) + s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) + + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") + s.mux.Handle("GET /api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) +} + +// small helper func for auth routing +func (s *Server) auth(h http.HandlerFunc) http.Handler { + return s.authStore.RequireAuth(h) +} \ No newline at end of file diff --git a/src/web/backend/server.go b/src/web/backend/server.go index cced8e7..b7d8de2 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -210,112 +210,6 @@ func spaFS() (fs.FS, []byte) { 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/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. @@ -428,7 +322,6 @@ func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { // ── 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") { From efba3d5e497c7768d47ce9d0568b75cd57c50d4c Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:27:38 +0300 Subject: [PATCH 19/35] split run state, handlers and events --- src/web/backend/routes.go | 8 ++++---- src/web/backend/server.go | 42 +++++++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 88c09ec..1680ae4 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -85,10 +85,10 @@ func (s *Server) registerPlaylistRoutes() { } func (s *Server) registerRunRoutes() { - s.mux.Handle("POST /api/ui/run", s.auth(s.handleRun)) - s.mux.Handle("GET /api/ui/run/events", s.auth(s.handleRunEvents)) - s.mux.Handle("POST /api/ui/run/stop", s.auth(s.handleStopRun)) - s.mux.Handle("GET /api/ui/run/status", s.auth(s.handleRunStatus)) + s.mux.Handle("POST /api/ui/run", s.auth(s.manualRun.HandleRun)) + s.mux.Handle("GET /api/ui/run/events", s.auth(s.manualRun.HandleRunEvents)) + s.mux.Handle("POST /api/ui/run/stop", s.auth(s.manualRun.HandleStopRun)) + s.mux.Handle("GET /api/ui/run/status", s.auth(s.manualRun.HandleRunStatus)) } func (s *Server) registerMiscRoutes() { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index b7d8de2..f58c8ea 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -1,25 +1,22 @@ 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" + "os/exec" "explo/src/config" "explo/src/web" + "explo/src/web/backend/run" ) // Option is a value/label pair for select-type fields. @@ -43,7 +40,7 @@ type ConfigResponse struct { Sources map[string]string `json:"sources"` // "env" | "file" } -// runEvent is an SSE event sent to connected browser clients. +/* // runEvent is an SSE event sent to connected browser clients. type runEvent struct { typ string data string @@ -66,7 +63,7 @@ type manualRunState struct { func newManualRunState() manualRunState { return manualRunState{subscribers: make(map[chan runEvent]struct{})} -} +} */ type Server struct { cfg config.ServerConfig @@ -75,7 +72,7 @@ type Server struct { authStore *AuthStore cronJobs *Jobs sessionManager *SessionManager - manualRun manualRunState + manualRun *run.ManualRun } func NewServer(cfg config.ServerConfig) *Server { @@ -93,6 +90,7 @@ func NewServer(cfg config.ServerConfig) *Server { ) cronJobs := NewJobs() + manualRun := run.NewManualRun(cfg.WebDataDir, cfg.WebEnvPath, cfg.ExploPath) mux := http.NewServeMux() s := &Server{ @@ -105,7 +103,7 @@ func NewServer(cfg config.ServerConfig) *Server { authStore: authStore, cronJobs: cronJobs, sessionManager: sessionManager, - manualRun: newManualRunState(), + manualRun: manualRun, } s.registerRoutes() @@ -157,6 +155,28 @@ func checkForUpdate() { } } +// 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 parseVer(v string) [3]int { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 3) @@ -890,7 +910,7 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { // ── Manual run ───────────────────────────────────────────────────────────── -var errRunAlreadyStarted = errors.New("run already in progress") +/* 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) { @@ -1207,7 +1227,7 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { s.manualRun.mu.Lock() delete(s.manualRun.subscribers, ch) s.manualRun.mu.Unlock() -} +} */ // ── Helpers ──────────────────────────────────────────────────────────────── From 9fca132fd30c6ec5c18312f65359354f7342cc04 Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:28:02 +0300 Subject: [PATCH 20/35] split run state, handlers and events --- src/web/backend/run/events.go | 101 ++++++++++++++++ src/web/backend/run/handlers.go | 125 ++++++++++++++++++++ src/web/backend/run/manual_run.go | 190 ++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 src/web/backend/run/events.go create mode 100644 src/web/backend/run/handlers.go create mode 100644 src/web/backend/run/manual_run.go diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go new file mode 100644 index 0000000..e306fad --- /dev/null +++ b/src/web/backend/run/events.go @@ -0,0 +1,101 @@ +package run + +import ( + "fmt" + "log/slog" + "os" + "io" + "path/filepath" + "bufio" + "os/exec" +) + + +func (mr *ManualRun) appendRunLog(line string) { + event := runEvent{data: line} + + mr.state.mu.Lock() + mr.state.logs = append(mr.state.logs, line) + subscribers := make([]chan runEvent, 0, len(mr.state.subscribers)) + for ch := range mr.state.subscribers { + subscribers = append(subscribers, ch) + } + mr.state.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- event: + default: + } + } +} + +func (mr *ManualRun) 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 { + mr.appendRunLog("failed to write run output: " + err.Error()) + } + } + mr.appendRunLog(line) + } + if err := scanner.Err(); err != nil { + mr.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() + } + mr.finishRun(code) +} + +func (mr *ManualRun) unsubscribeRun(ch chan runEvent) { + mr.state.mu.Lock() + delete(mr.state.subscribers, ch) + mr.state.mu.Unlock() +} + +// logPath returns the path to the single rolling log file. +func (mr *ManualRun) logPath() string { + return filepath.Join(mr.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 (mr *ManualRun) initServerLog() { + lf, err := mr.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 (mr *ManualRun) openRunLog() (*os.File, error) { + p := mr.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) +} \ No newline at end of file diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go new file mode 100644 index 0000000..8b48133 --- /dev/null +++ b/src/web/backend/run/handlers.go @@ -0,0 +1,125 @@ +package run + +import ( + "net/http" + "errors" + "encoding/json" + "log/slog" + "fmt" +) + +// handleRun starts an explo run in the background. Clients follow output via /api/ui/run/events. +func (mr *ManualRun) 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"), + r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", + mr.cfg.webEnvPath) + + if err := mr.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(mr.currentRunStatus()); err != nil { + slog.Warn("failed to encode current run status", "msg", err.Error()) + } +} + +func (mr *ManualRun) HandleStopRun(w http.ResponseWriter, r *http.Request) { + mr.state.mu.Lock() + cancel := mr.state.cancel + running := mr.state.running + mr.state.mu.Unlock() + + if !running || cancel == nil { + http.Error(w, "no run is currently in progress", http.StatusConflict) + return + } + + cancel() + w.WriteHeader(http.StatusAccepted) +} + +func (mr *ManualRun) HandleRunStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mr.currentRunStatus()); err != nil { + slog.Warn("failed encoding current run status to response") + } +} + +// 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 (mr *ManualRun) 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) + mr.state.mu.Lock() + lines := append([]string(nil), mr.state.logs...) + running := mr.state.running + var exitCode *int + if mr.state.exitCode != nil { + code := *mr.state.exitCode + exitCode = &code + } + if running { + mr.state.subscribers[ch] = struct{}{} + } + mr.state.mu.Unlock() + + for _, line := range lines { + sendEvent("", line) + } + if !running { + if exitCode != nil { + sendEvent("done", fmt.Sprintf("%d", *exitCode)) + } + return + } + + defer mr.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 + } + } + } +} \ No newline at end of file diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go new file mode 100644 index 0000000..6628f61 --- /dev/null +++ b/src/web/backend/run/manual_run.go @@ -0,0 +1,190 @@ +package run + +import ( + "fmt" + "errors" + "log/slog" + "os" + "os/exec" + "strings" + "context" + "sync" +) + +// RunStatus is returned by GET /api/run/status. +type RunStatus struct { + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` +} + +type Config struct { + webDataDir string + webEnvPath string + exploPath string +} + +type manualRunState struct { + mu sync.Mutex + running bool + cancel context.CancelFunc + exitCode *int + logs []string + subscribers map[chan runEvent]struct{} +} + +// runEvent is an SSE event sent to connected browser clients. +type runEvent struct { + typ string + data string +} + +type ManualRun struct { + cfg Config + state manualRunState +} + +var errRunAlreadyStarted = errors.New("run already in progress") + +func NewManualRun(dataDir, envPath, exploPath string) *ManualRun { + return &ManualRun{ + cfg: Config{ + webDataDir: dataDir, + webEnvPath: envPath, + exploPath: exploPath, + }, + state: newManualRunState(), + } +} + +func newManualRunState() manualRunState { + return manualRunState{subscribers: make(map[chan runEvent]struct{})} +} + +func (mr *ManualRun) startRun(args []string) error { + ctx, cancel := context.WithCancel(context.Background()) + cmd := exec.CommandContext(ctx, mr.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 := mr.openRunLog() + if err != nil { + slog.Warn("failed to open run log", "err", err.Error()) + } + + mr.state.mu.Lock() + if mr.state.running { + mr.state.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 + } + mr.state.running = true + mr.state.cancel = cancel + mr.state.exitCode = nil + mr.state.logs = nil + mr.state.mu.Unlock() + + if err := cmd.Start(); err != nil { + mr.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 mr.collectRunOutput(cmd, pr, lf) + return nil +} + +func (mr *ManualRun) currentRunStatus() RunStatus { + mr.state.mu.Lock() + defer mr.state.mu.Unlock() + + var exitCode *int + if mr.state.exitCode != nil { + code := *mr.state.exitCode + exitCode = &code + } + return RunStatus{Running: mr.state.running, ExitCode: exitCode} +} + +func (mr *ManualRun) finishRun(code int) { + done := runEvent{typ: "done", data: fmt.Sprintf("%d", code)} + + mr.state.mu.Lock() + mr.state.running = false + mr.state.cancel = nil + mr.state.exitCode = &code + subscribers := make([]chan runEvent, 0, len(mr.state.subscribers)) + for ch := range mr.state.subscribers { + subscribers = append(subscribers, ch) + delete(mr.state.subscribers, ch) + } + mr.state.mu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- done: + default: + } + close(ch) + } +} + +// helper to build flag arguments +func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { + args := []string{"--config", WebEnvPath} + if playlist != "" { + args = append(args, "--playlist", playlist) + } + 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 From 7cd5eb05fa10bdcd60aeafd84bc474f5e186addf Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 17 Jun 2026 20:30:56 +0300 Subject: [PATCH 21/35] remove functions from server.go --- src/web/backend/server.go | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index f58c8ea..9c9f725 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -40,31 +40,6 @@ type ConfigResponse struct { 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 @@ -906,6 +881,7 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(dirs); err != nil { slog.Warn("failed to encode directories to response", "err", err.Error()) } +<<<<<<< HEAD } // ── Manual run ───────────────────────────────────────────────────────────── @@ -1247,3 +1223,6 @@ func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebE } return args } +======= +} +>>>>>>> e957089 (remove functions from server.go) From 97ab12ffbc734eaf8e5b156f79b76c3fca0ea639 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 17:33:32 +0300 Subject: [PATCH 22/35] create app package for backend configs --- src/web/backend/app/app.go | 8 ++++++++ src/web/backend/app/paths.go | 17 +++++++++++++++++ src/web/backend/run/manual_run.go | 10 ++++++---- src/web/backend/server.go | 9 ++++++++- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 src/web/backend/app/app.go create mode 100644 src/web/backend/app/paths.go diff --git a/src/web/backend/app/app.go b/src/web/backend/app/app.go new file mode 100644 index 0000000..a302987 --- /dev/null +++ b/src/web/backend/app/app.go @@ -0,0 +1,8 @@ +package app + + +type Config struct { + WebEnvPath string + WebDataDir string + ExploPath string +} \ No newline at end of file diff --git a/src/web/backend/app/paths.go b/src/web/backend/app/paths.go new file mode 100644 index 0000000..7f003cb --- /dev/null +++ b/src/web/backend/app/paths.go @@ -0,0 +1,17 @@ +package app + +import( + "path/filepath" +) + +func (c Config) CacheDir() string { + return filepath.Join(c.WebDataDir, "cache") +} + +func (c Config) CoversDir() string { + return filepath.Join(c.CacheDir(), "covers") +} + +func (c Config) LogsDir() string { + return filepath.Join(c.WebDataDir, "logs") +} \ No newline at end of file diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go index 6628f61..0be2eb3 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -9,6 +9,8 @@ import ( "strings" "context" "sync" + + "explo/src/web/backend/app" ) // RunStatus is returned by GET /api/run/status. @@ -45,12 +47,12 @@ type ManualRun struct { var errRunAlreadyStarted = errors.New("run already in progress") -func NewManualRun(dataDir, envPath, exploPath string) *ManualRun { +func NewManualRun(cfg app.Config) *ManualRun { return &ManualRun{ cfg: Config{ - webDataDir: dataDir, - webEnvPath: envPath, - exploPath: exploPath, + webDataDir: cfg.WebDataDir, + webEnvPath: cfg.WebEnvPath, + exploPath: cfg.ExploPath, }, state: newManualRunState(), } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 9c9f725..9fe2b68 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -17,6 +17,7 @@ import ( "explo/src/config" "explo/src/web" "explo/src/web/backend/run" + "explo/src/web/backend/app" ) // Option is a value/label pair for select-type fields. @@ -64,8 +65,14 @@ func NewServer(cfg config.ServerConfig) *Server { sessionManager, ) + appCfg := app.Config{ + WebEnvPath: cfg.WebEnvPath, + WebDataDir: cfg.WebDataDir, + ExploPath: cfg.ExploPath, + } + cronJobs := NewJobs() - manualRun := run.NewManualRun(cfg.WebDataDir, cfg.WebEnvPath, cfg.ExploPath) + manualRun := run.NewManualRun(appCfg) mux := http.NewServeMux() s := &Server{ From 9400a4c8ba5239d2120395d47ddd0c8256b05dab Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 19:17:47 +0300 Subject: [PATCH 23/35] move defs under separate package, move customID regex under defs --- src/web/backend/custom_playlists.go | 5 +- src/web/backend/defs/defs.go | 185 ++++++++++++++++++++++++++++ src/web/backend/playlists.go | 6 +- src/web/backend/server.go | 3 +- 4 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 src/web/backend/defs/defs.go diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/custom_playlists.go index f9c8a4d..947421d 100644 --- a/src/web/backend/custom_playlists.go +++ b/src/web/backend/custom_playlists.go @@ -15,6 +15,7 @@ import ( "explo/src/discovery" "explo/src/util" "explo/src/web" + "explo/src/web/backend/defs" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -388,7 +389,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // Equivalent to manually triggering the nightly refresh cron job for a single playlist. func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !customIDRe.MatchString(id) { + if !defs.CustomIDRe.MatchString(id) { http.Error(w, "invalid playlist id", http.StatusBadRequest) return } @@ -440,7 +441,7 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ // playlist's download subdirectories from DOWNLOAD_DIR. func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !customIDRe.MatchString(id) { + if !defs.CustomIDRe.MatchString(id) { slog.Warn("custom-playlists: invalid id in delete request", "id", id) http.Error(w, "invalid playlist id", http.StatusBadRequest) return diff --git a/src/web/backend/defs/defs.go b/src/web/backend/defs/defs.go new file mode 100644 index 0000000..84fe591 --- /dev/null +++ b/src/web/backend/defs/defs.go @@ -0,0 +1,185 @@ +package defs + +import ( + "regexp" +) +// place for confs/variables in use by the UI or backend + + +// Custom playlist regex validation +var CustomIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`) + +// configFields is the single source of truth for the settings this web UI +// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard +// uses bespoke HTML but references the same logical rules. + +/* var configFields = []FieldDef{ + // ── Discovery ────────────────────────────────────────────────── + { + Key: "LISTENBRAINZ_USER", Label: "ListenBrainz Username", + Type: "text", Section: "discovery", + Placeholder: "e.g. musiclover42", Required: true, + }, + + // ── Media System ─────────────────────────────────────────────── + { + Key: "EXPLO_SYSTEM", Label: "Media System", + Type: "select", Section: "system", Required: true, + Options: []Option{ + {Value: "jellyfin", Label: "Jellyfin"}, + {Value: "emby", Label: "Emby"}, + {Value: "plex", Label: "Plex"}, + {Value: "subsonic", Label: "Subsonic"}, + {Value: "mpd", Label: "MPD"}, + }, + }, + { + Key: "SYSTEM_URL", Label: "Server URL", + Type: "url", Section: "system", + Placeholder: "e.g. http://192.168.1.100:8096", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, + }, + { + Key: "API_KEY", Label: "API Key", + Type: "text", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "LIBRARY_NAME", Label: "Library Name", + Type: "text", Section: "system", + Placeholder: "e.g. Music", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "SYSTEM_USERNAME", Label: "Username", + Type: "text", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + { + Key: "SYSTEM_PASSWORD", Label: "Password", + Type: "password", Section: "system", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + { + Key: "PLAYLIST_DIR", Label: "Playlist Directory", + Type: "text", Section: "system", + Hint: "Explo writes .m3u files here — MPD reads them as playlists.", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, + RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, + }, + { + Key: "SLEEP", Label: "Library Scan Wait (minutes)", + Type: "text", Section: "system", + Placeholder: "2", + Hint: "How long to wait after triggering a library scan before creating playlists.", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, + }, + { + Key: "PUBLIC_PLAYLIST", Label: "Public Playlists", + Type: "text", Section: "system", + Hint: "Set to true to make playlists visible to all users (Subsonic).", + VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, + }, + + // ── Downloader ───────────────────────────────────────────────── + { + Key: "DOWNLOAD_DIR", Label: "Download directory", + Type: "text", Section: "downloader", + Placeholder: "e.g. /data/ or ./downloads/", + Required: true, + }, + { + Key: "USE_SUBDIRECTORY", Label: "Use playlist subfolders", + Type: "text", Section: "downloader", + Hint: "When enabled, Explo creates a subfolder per playlist inside the download directory.", + }, + { + Key: "YOUTUBE_API_KEY", Label: "YouTube API Key", + Type: "text", Section: "downloader", + Placeholder: "AIza…", + Hint: "Required when using YouTube. Enable the YouTube Data API v3.", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, + }, + { + Key: "SLSKD_URL", Label: "Slskd URL", + Type: "url", Section: "downloader", + Placeholder: "e.g. http://192.168.1.100:5030", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + }, + { + Key: "SLSKD_API_KEY", Label: "Slskd API Key", + Type: "text", Section: "downloader", + VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, + }, +} */ + +// 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 +} + +// FieldDef describes a single configurable env var. +// Injected into the page as window.__FIELDS__ for the settings UI to consume. +type FieldDef struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` // text | password | url | select + Section string `json:"section"` // discovery | system | downloader + Placeholder string `json:"placeholder,omitempty"` + Hint string `json:"hint,omitempty"` + Required bool `json:"required,omitempty"` + Options []Option `json:"options,omitempty"` // for type=select + VisibleWhen *Condition `json:"visibleWhen,omitempty"` // hide field when condition is false + RequiredWhen *Condition `json:"requiredWhen,omitempty"` // conditionally required +} + +/* var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"} +var apiKeySystems = []string{"jellyfin", "emby", "plex"} */ + +// playlistDef is the single source of truth for a supported playlist type. +// To add a new playlist: append one entry here and add the matching entry in +// PLAYLISTS in the frontend Settings.jsx. +type playlistDef struct { + EnvPrefix string // e.g. "WEEKLY_EXPLORATION" + DefaultSchedule string // cron expression + DefaultFlags string // CLI flags for the run +} + +var PlaylistDefs = map[string]playlistDef{ + "weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"}, + "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, + "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, + "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, +} + +// allConfigKeys is the complete set of env keys the web UI reads and writes. +var AllConfigKeys = []string{ + "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY", + "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS", + "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", + "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", + "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_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", + "SLSKD_URL", "SLSKD_API_KEY", + "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", +} \ No newline at end of file diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 8db584f..961e685 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -6,6 +6,7 @@ import ( "explo/src/discovery" "explo/src/models" "explo/src/util" + "explo/src/web/backend/defs" "fmt" "image" _ "image/jpeg" @@ -15,7 +16,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strings" ) @@ -41,11 +41,9 @@ var validPlaylistTypes = func() map[string]bool { return m }() -var customIDRe = regexp.MustCompile(`^custom-[a-z0-9]+$`) - // isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal). func isValidPlaylistID(t string) bool { - return validPlaylistTypes[t] || customIDRe.MatchString(t) + return validPlaylistTypes[t] || defs.CustomIDRe.MatchString(t) } // handleGetPlaylist serves the tracklist cache written by explo during its last run. diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 9fe2b68..a53da40 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -18,6 +18,7 @@ import ( "explo/src/web" "explo/src/web/backend/run" "explo/src/web/backend/app" + "explo/src/web/backend/defs" ) // Option is a value/label pair for select-type fields. @@ -444,7 +445,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { if def, ok := playlistDefs[body.Name]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags - } else if customIDRe.MatchString(body.Name) { + } else if defs.CustomIDRe.MatchString(body.Name) { envPrefix = customEnvPrefix(body.Name) defaultFlags = "--playlist " + body.Name } else { From d513ae77e35b669bb2026dc84d7c17dda494325e Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 19:19:29 +0300 Subject: [PATCH 24/35] remove defs from backend package --- src/web/backend/defs.go | 163 ----------------------------------- src/web/backend/playlists.go | 4 +- src/web/backend/server.go | 10 +-- 3 files changed, 7 insertions(+), 170 deletions(-) delete mode 100644 src/web/backend/defs.go diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go deleted file mode 100644 index 8a22dee..0000000 --- a/src/web/backend/defs.go +++ /dev/null @@ -1,163 +0,0 @@ -// place for confs/variables in use by the UI - -package backend - -// configFields is the single source of truth for the settings this web UI -// currently owns. VisibleWhen / RequiredWhen drive the settings UI; the wizard -// uses bespoke HTML but references the same logical rules. - -/* var configFields = []FieldDef{ - // ── Discovery ────────────────────────────────────────────────── - { - Key: "LISTENBRAINZ_USER", Label: "ListenBrainz Username", - Type: "text", Section: "discovery", - Placeholder: "e.g. musiclover42", Required: true, - }, - - // ── Media System ─────────────────────────────────────────────── - { - Key: "EXPLO_SYSTEM", Label: "Media System", - Type: "select", Section: "system", Required: true, - Options: []Option{ - {Value: "jellyfin", Label: "Jellyfin"}, - {Value: "emby", Label: "Emby"}, - {Value: "plex", Label: "Plex"}, - {Value: "subsonic", Label: "Subsonic"}, - {Value: "mpd", Label: "MPD"}, - }, - }, - { - Key: "SYSTEM_URL", Label: "Server URL", - Type: "url", Section: "system", - Placeholder: "e.g. http://192.168.1.100:8096", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: netSystems}, - }, - { - Key: "API_KEY", Label: "API Key", - Type: "text", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "LIBRARY_NAME", Label: "Library Name", - Type: "text", Section: "system", - Placeholder: "e.g. Music", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "SYSTEM_USERNAME", Label: "Username", - Type: "text", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - { - Key: "SYSTEM_PASSWORD", Label: "Password", - Type: "password", Section: "system", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - { - Key: "PLAYLIST_DIR", Label: "Playlist Directory", - Type: "text", Section: "system", - Hint: "Explo writes .m3u files here — MPD reads them as playlists.", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, - RequiredWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "mpd"}, - }, - { - Key: "SLEEP", Label: "Library Scan Wait (minutes)", - Type: "text", Section: "system", - Placeholder: "2", - Hint: "How long to wait after triggering a library scan before creating playlists.", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", In: apiKeySystems}, - }, - { - Key: "PUBLIC_PLAYLIST", Label: "Public Playlists", - Type: "text", Section: "system", - Hint: "Set to true to make playlists visible to all users (Subsonic).", - VisibleWhen: &Condition{Field: "EXPLO_SYSTEM", Eq: "subsonic"}, - }, - - // ── Downloader ───────────────────────────────────────────────── - { - Key: "DOWNLOAD_DIR", Label: "Download directory", - Type: "text", Section: "downloader", - Placeholder: "e.g. /data/ or ./downloads/", - Required: true, - }, - { - Key: "USE_SUBDIRECTORY", Label: "Use playlist subfolders", - Type: "text", Section: "downloader", - Hint: "When enabled, Explo creates a subfolder per playlist inside the download directory.", - }, - { - Key: "YOUTUBE_API_KEY", Label: "YouTube API Key", - Type: "text", Section: "downloader", - Placeholder: "AIza…", - Hint: "Required when using YouTube. Enable the YouTube Data API v3.", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "youtube"}, - }, - { - Key: "SLSKD_URL", Label: "Slskd URL", - Type: "url", Section: "downloader", - Placeholder: "e.g. http://192.168.1.100:5030", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - }, - { - Key: "SLSKD_API_KEY", Label: "Slskd API Key", - Type: "text", Section: "downloader", - VisibleWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - RequiredWhen: &Condition{Field: "DOWNLOAD_SERVICES", Contains: "slskd"}, - }, -} */ - -// FieldDef describes a single configurable env var. -// Injected into the page as window.__FIELDS__ for the settings UI to consume. -type FieldDef struct { - Key string `json:"key"` - Label string `json:"label"` - Type string `json:"type"` // text | password | url | select - Section string `json:"section"` // discovery | system | downloader - Placeholder string `json:"placeholder,omitempty"` - Hint string `json:"hint,omitempty"` - Required bool `json:"required,omitempty"` - Options []Option `json:"options,omitempty"` // for type=select - VisibleWhen *Condition `json:"visibleWhen,omitempty"` // hide field when condition is false - RequiredWhen *Condition `json:"requiredWhen,omitempty"` // conditionally required -} - -/* var netSystems = []string{"jellyfin", "emby", "plex", "subsonic"} -var apiKeySystems = []string{"jellyfin", "emby", "plex"} */ - -// playlistDef is the single source of truth for a supported playlist type. -// To add a new playlist: append one entry here and add the matching entry in -// PLAYLISTS in the frontend Settings.jsx. -type playlistDef struct { - EnvPrefix string // e.g. "WEEKLY_EXPLORATION" - DefaultSchedule string // cron expression - DefaultFlags string // CLI flags for the run -} - -var playlistDefs = map[string]playlistDef{ - "weekly-exploration": {"WEEKLY_EXPLORATION", "15 00 * * 2", "--playlist weekly-exploration"}, - "weekly-jams": {"WEEKLY_JAMS", "30 00 * * 1", "--playlist weekly-jams"}, - "daily-jams": {"DAILY_JAMS", "15 01 * * *", "--playlist daily-jams"}, - "on-repeat": {"ON_REPEAT", "0 12 1 * *", "--playlist on-repeat"}, -} - -// allConfigKeys is the complete set of env keys the web UI reads and writes. -var allConfigKeys = []string{ - "LISTENBRAINZ_USER", "LISTENBRAINZ_DISCOVERY", - "WEEKLY_EXPLORATION_SCHEDULE", "WEEKLY_EXPLORATION_FLAGS", - "WEEKLY_JAMS_SCHEDULE", "WEEKLY_JAMS_FLAGS", - "DAILY_JAMS_SCHEDULE", "DAILY_JAMS_FLAGS", - "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_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", - "SLSKD_URL", "SLSKD_API_KEY", - "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", -} diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 961e685..f46b594 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -34,8 +34,8 @@ type PlaylistTrack struct { // validPlaylistTypes is derived from playlistDefs — no manual sync needed. var validPlaylistTypes = func() map[string]bool { - m := make(map[string]bool, len(playlistDefs)) - for k := range playlistDefs { + m := make(map[string]bool, len(defs.PlaylistDefs)) + for k := range defs.PlaylistDefs { m[k] = true } return m diff --git a/src/web/backend/server.go b/src/web/backend/server.go index a53da40..9281e7d 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -365,9 +365,9 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { fileValues = parseEnvText(string(web.SampleEnv)) } - values := make(map[string]string, len(allConfigKeys)) - sources := make(map[string]string, len(allConfigKeys)) - for _, key := range allConfigKeys { + values := make(map[string]string, len(defs.AllConfigKeys)) + sources := make(map[string]string, len(defs.AllConfigKeys)) + for _, key := range defs.AllConfigKeys { if v, ok := fileValues[key]; ok && v != "" { values[key] = v sources[key] = "file" @@ -442,7 +442,7 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { var envPrefix string var defaultFlags string - if def, ok := playlistDefs[body.Name]; ok { + if def, ok := defs.PlaylistDefs[body.Name]; ok { envPrefix = def.EnvPrefix defaultFlags = def.DefaultFlags } else if defs.CustomIDRe.MatchString(body.Name) { @@ -735,7 +735,7 @@ func (s *Server) handleWizardStep1(w http.ResponseWriter, r *http.Request) { "LISTENBRAINZ_USER": body.User, "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, } - for name, def := range playlistDefs { + for name, def := range defs.PlaylistDefs { if enabled[name] { updates[def.EnvPrefix+"_SCHEDULE"] = def.DefaultSchedule updates[def.EnvPrefix+"_FLAGS"] = def.DefaultFlags From 07c6a8caca1dfcc5c3839368b8ff31a26a6a7967 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 23:15:38 +0300 Subject: [PATCH 25/35] move files under separate packages, add backend util functions --- src/main/main.go | 18 +- src/util/backend.go | 50 ++ src/web/backend/jobs.go | 150 ----- src/web/backend/jobs/jobs.go | 90 +++ src/web/backend/{ => playlist}/apple_music.go | 2 +- src/web/backend/playlist/custom_playlists.go | 211 ++++++ .../handlers.go} | 424 ++++++------ src/web/backend/playlist/jobs.go | 79 +++ .../{playlists.go => playlist/playlist.go} | 108 +-- src/web/backend/{ => playlist}/spotify.go | 4 +- src/web/backend/routes.go | 46 +- src/web/backend/server.go | 624 +----------------- src/web/backend/settings/handlers.go | 352 ++++++++++ src/web/backend/settings/settings.go | 122 ++++ 14 files changed, 1158 insertions(+), 1122 deletions(-) create mode 100644 src/util/backend.go delete mode 100644 src/web/backend/jobs.go create mode 100644 src/web/backend/jobs/jobs.go rename src/web/backend/{ => playlist}/apple_music.go (99%) create mode 100644 src/web/backend/playlist/custom_playlists.go rename src/web/backend/{custom_playlists.go => playlist/handlers.go} (52%) create mode 100644 src/web/backend/playlist/jobs.go rename src/web/backend/{playlists.go => playlist/playlist.go} (74%) rename src/web/backend/{ => playlist}/spotify.go (99%) create mode 100644 src/web/backend/settings/handlers.go create mode 100644 src/web/backend/settings/settings.go diff --git a/src/main/main.go b/src/main/main.go index 2c81834..efb6c18 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -5,6 +5,7 @@ import ( "explo/src/logging" "explo/src/models" "explo/src/web/backend" + "explo/src/web/backend/playlist" "fmt" "log" "log/slog" @@ -223,6 +224,17 @@ func main() { } } +<<<<<<< HEAD +======= + if cfg.ServerCfg.Enabled { + added := make(map[string]bool) + for _, t := range tracks { + added[t.CleanTitle+"|"+t.Artist] = true + } + playlist.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) + } + +>>>>>>> 9129feb (move files under separate packages, add backend util functions) if err := client.CreatePlaylist(tracks); err != nil { slog.Warn(err.Error()) } else { @@ -238,7 +250,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { if !strings.HasPrefix(cfg.Flags.Playlist, "custom-") { return } - cp := backend.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) + cp := playlist.GetCustomPlaylist(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist) if cp == nil || cp.ArtworkURL == "" || cp.ArtworkUploaded { return } @@ -246,7 +258,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { if !ok { return } - path := backend.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID) + path := playlist.CustomPlaylistArtworkPath(cfg.ServerCfg.WebDataDir, cp.ID) if _, err := os.Stat(path); err != nil { slog.Warn("custom-playlists: artwork not cached locally, skipping upload", "id", cp.ID, "path", path) return @@ -255,7 +267,7 @@ func uploadCustomPlaylistArtwork(cfg *config.Config, c *client.Client) { slog.Warn("custom-playlists: failed to upload playlist artwork", "id", cp.ID, "err", err.Error()) return } - if err := backend.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil { + if err := playlist.MarkCustomPlaylistArtworkUploaded(cfg.ServerCfg.WebDataDir, cp.ID); err != nil { slog.Warn("custom-playlists: artwork upload succeeded but flag not persisted", "id", cp.ID, "err", err.Error()) return } diff --git a/src/util/backend.go b/src/util/backend.go new file mode 100644 index 0000000..0e5adbf --- /dev/null +++ b/src/util/backend.go @@ -0,0 +1,50 @@ +package util + +import ( + "strings" + "os/exec" + "os" + "log/slog" + + "explo/src/web/backend/app" +) + +// customEnvPrefix converts a playlist name like "Today's Hits" +// to an env-var prefix like "CUSTOM_TODAYS_HITS". +// Non-alphanumeric characters are collapsed into underscores. +func CustomEnvPrefix(name string) string { + var b strings.Builder + prevUnderscore := true // start true so leading separators are skipped + for _, r := range strings.ToUpper(name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevUnderscore = false + } else if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + } + return "CUSTOM_" + strings.TrimRight(b.String(), "_") +} + +// 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 TriggerLibraryRefresh(cfg app.Config) { + go func() { + cmd := exec.Command(cfg.ExploPath, "--refresh-only", "--config", 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") + }() +} \ No newline at end of file diff --git a/src/web/backend/jobs.go b/src/web/backend/jobs.go deleted file mode 100644 index 01901d4..0000000 --- a/src/web/backend/jobs.go +++ /dev/null @@ -1,150 +0,0 @@ -package backend - -// Jobs running on a schedule go here i.e cache cleanups (and playlist imports in the future) - -import ( - "path/filepath" - "log/slog" - "os" - "slices" - "time" - - "github.com/go-co-op/gocron/v2" -) - - -type Jobs struct { - scheduler gocron.Scheduler -} - -type fileInfo struct { - path string - size int64 - modTime time.Time -} - -func NewJobs() (*Jobs) { - scheduler, err := gocron.NewScheduler() - if err != nil { - slog.Error("failed creating cron scheduler") - } - - return &Jobs{ scheduler: scheduler} -} - -func (j *Jobs) Start() { - j.scheduler.Start() -} - -func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64) error { - _, err := j.scheduler.NewJob( - gocron.CronJob(schedule, false), - gocron.NewTask(func() { - slog.Info("running cache cleanup") - - trimCacheDir(coversDir, maxBytes) - }), - ) - - return err -} - -// RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist -// using its stored schedule. Falls back to daily at 4 AM if no schedule is set. -func (j *Jobs) RegisterCustomPlaylistRefresh(cfgDir, envPath string) error { - playlists := loadCustomPlaylists(cfgDir) - if len(playlists) == 0 { - return nil - } - - var envValues map[string]string - if data, err := os.ReadFile(envPath); err == nil { - envValues = parseEnvText(string(data)) - } else { - envValues = map[string]string{} - } - - for _, p := range playlists { - p := p - prefix := customEnvPrefix(p.Name) - flags := envValues[prefix+"_FLAGS"] - if flags == "" { - continue // disabled - } - schedule := envValues[prefix+"_SCHEDULE"] - if p.RefreshDays <= 0 && schedule == "" { - continue - } - if schedule == "" { - schedule = "0 4 * * *" - } - _, err := j.scheduler.NewJob( - gocron.CronJob(schedule, false), - gocron.NewTask(func() { - if time.Since(p.LastFetched) < time.Duration(p.RefreshDays)*24*time.Hour { - return - } - slog.Info("custom-playlists: refreshing", "id", p.ID, "name", p.Name, "source", p.Source) - result, err := fetchCustomPlaylistTracks(p) - if err != nil { - slog.Warn("custom-playlists: refresh fetch failed", "id", p.ID, "err", err) - return - } - writePrefetchCache(cfgDir, p.ID, result.Tracks) - playlists := loadCustomPlaylists(cfgDir) - for i, pl := range playlists { - if pl.ID == p.ID { - playlists[i].LastFetched = time.Now().UTC() - break - } - } - if err := saveCustomPlaylists(cfgDir, playlists); err != nil { - slog.Error("custom-playlists: failed to save after refresh", "err", err) - } - }), - ) - if err != nil { - slog.Warn("custom-playlists: failed to register refresh job", "id", p.ID, "err", err) - } - } - return nil -} - -func trimCacheDir(dataDir string, maxBytes int64) { - - var files []fileInfo - var total int64 - - err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - - files = append(files, fileInfo{ - path: path, - size: info.Size(), - modTime: info.ModTime(), - }) - - total += info.Size() - return nil - }) - - if err != nil || total <= maxBytes { - return - } - - slices.SortFunc(files, func(a, b fileInfo) int { - return a.modTime.Compare(b.modTime) - }) - - for _, f := range files { - if total <= maxBytes { - break - } - - if err := os.Remove(f.path); err == nil { - total -= f.size - } - } -} \ No newline at end of file diff --git a/src/web/backend/jobs/jobs.go b/src/web/backend/jobs/jobs.go new file mode 100644 index 0000000..dcbb302 --- /dev/null +++ b/src/web/backend/jobs/jobs.go @@ -0,0 +1,90 @@ +package jobs + +// Jobs running on a schedule go here i.e cache cleanups + +import ( + "path/filepath" + "log/slog" + "os" + "slices" + "time" + + "github.com/go-co-op/gocron/v2" +) + + +type Jobs struct { + Scheduler gocron.Scheduler +} + +type fileInfo struct { + path string + size int64 + modTime time.Time +} + +func NewJobs() (*Jobs) { + scheduler, err := gocron.NewScheduler() + if err != nil { + slog.Error("failed creating cron scheduler") + } + + return &Jobs{ Scheduler: scheduler} +} + +func (j *Jobs) Start() { + j.Scheduler.Start() +} + +func (j *Jobs) RegisterCoverCleanup(schedule, coversDir string, maxBytes int64) error { + _, err := j.Scheduler.NewJob( + gocron.CronJob(schedule, false), + gocron.NewTask(func() { + slog.Info("running cache cleanup") + + trimCacheDir(coversDir, maxBytes) + }), + ) + + return err +} + + +func trimCacheDir(dataDir string, maxBytes int64) { + + var files []fileInfo + var total int64 + + err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + + files = append(files, fileInfo{ + path: path, + size: info.Size(), + modTime: info.ModTime(), + }) + + total += info.Size() + return nil + }) + + if err != nil || total <= maxBytes { + return + } + + slices.SortFunc(files, func(a, b fileInfo) int { + return a.modTime.Compare(b.modTime) + }) + + for _, f := range files { + if total <= maxBytes { + break + } + + if err := os.Remove(f.path); err == nil { + total -= f.size + } + } +} \ No newline at end of file diff --git a/src/web/backend/apple_music.go b/src/web/backend/playlist/apple_music.go similarity index 99% rename from src/web/backend/apple_music.go rename to src/web/backend/playlist/apple_music.go index 7f7f59e..e928418 100644 --- a/src/web/backend/apple_music.go +++ b/src/web/backend/playlist/apple_music.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "encoding/json" diff --git a/src/web/backend/playlist/custom_playlists.go b/src/web/backend/playlist/custom_playlists.go new file mode 100644 index 0000000..da85ca0 --- /dev/null +++ b/src/web/backend/playlist/custom_playlists.go @@ -0,0 +1,211 @@ +package playlist + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "explo/src/discovery" + "explo/src/util" + +) + +// CustomPlaylist holds the metadata for a user-imported playlist. +type CustomPlaylist struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` // "listenbrainz" | "apple_music" | "spotify" + SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh + LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat) + ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music) + ArtworkUploaded bool `json:"artwork_uploaded,omitempty"` // true after artwork has been pushed to the music app + RefreshDays int `json:"refresh_days"` + ColorIndex int `json:"color_index"` + LastFetched time.Time `json:"last_fetched"` +} + +// CustomPlaylistArtworkPath returns the local file path where a playlist's +// artwork is cached (regardless of whether the file exists). +func CustomPlaylistArtworkPath(cfgDir, id string) string { + return filepath.Join(cfgDir, "cache", "playlist_artwork", id+".jpg") +} + +// GetCustomPlaylist looks up a custom playlist by ID. Returns nil if not found. +func GetCustomPlaylist(cfgDir, id string) *CustomPlaylist { + for _, p := range loadCustomPlaylists(cfgDir) { + if p.ID == id { + cp := p + return &cp + } + } + return nil +} + +// MarkCustomPlaylistArtworkUploaded sets ArtworkUploaded=true and persists. +func MarkCustomPlaylistArtworkUploaded(cfgDir, id string) error { + playlists := loadCustomPlaylists(cfgDir) + for i := range playlists { + if playlists[i].ID == id { + if playlists[i].ArtworkUploaded { + return nil + } + playlists[i].ArtworkUploaded = true + return saveCustomPlaylists(cfgDir, playlists) + } + } + return nil +} + +var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + +var appleMusicURLRe = regexp.MustCompile( + `^https?://music\.apple\.com/[a-z]{2}/playlist/[^/]+/(pl\.[a-zA-Z0-9-]+)`, +) + +// extractAppleMusicID pulls the playlist ID (pl.xxx) from an Apple Music URL. +func extractAppleMusicID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + m := appleMusicURLRe.FindStringSubmatch(raw) + if len(m) < 2 { + return "", fmt.Errorf("not a valid Apple Music playlist URL") + } + return m[1], nil +} + +// extractLBMBID pulls the playlist UUID out of a ListenBrainz playlist URL or bare MBID string. +func extractLBMBID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + m := lbMBIDRe.FindString(raw) + if m == "" { + return "", fmt.Errorf("no ListenBrainz playlist UUID found in %q", raw) + } + return m, nil +} + +func customPlaylistsPath(cfgDir string) string { + return filepath.Join(cfgDir, "custom-playlists.json") +} + +func loadCustomPlaylists(cfgDir string) []CustomPlaylist { + data, err := os.ReadFile(customPlaylistsPath(cfgDir)) + if err != nil { + return nil + } + var out []CustomPlaylist + if err := json.Unmarshal(data, &out); err != nil { + slog.Warn("custom-playlists: failed to parse metadata", "err", err) + return nil + } + return out +} + +func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error { + raw, err := json.MarshalIndent(playlists, "", " ") + if err != nil { + return err + } + return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644) +} + +// FetchResult is the uniform return type for fetching playlist data from any source. +type FetchResult struct { + Name string + ArtworkURL string + Tracks []PlaylistTrack +} + +// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher. +// This is the single point where source-specific logic lives for fetching. +func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) { + switch p.Source { + case "apple_music": + name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL) + return FetchResult{name, art, tracks}, err + case "spotify": + name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL) + return FetchResult{name, art, tracks}, err + default: // "listenbrainz" or legacy empty + mbid := p.LBMBID + if mbid == "" && p.SourceURL != "" { + var err error + mbid, err = extractLBMBID(p.SourceURL) + if err != nil { + return FetchResult{}, err + } + } + if mbid == "" { + return FetchResult{}, fmt.Errorf("no source data for playlist %s", p.ID) + } + httpClient := util.NewHttp(util.HttpClientConfig{Timeout: 30}) + name, modelTracks, err := discovery.FetchPlaylistByMBID(httpClient, mbid) + if err != nil { + return FetchResult{}, err + } + tracks := modelTracksToPlaylistTracks(modelTracks) + return FetchResult{Name: name, Tracks: tracks}, nil + } +} + +// extractSourceID validates a URL and returns the canonical ID for the given source. +func extractSourceID(source, url string) (string, error) { + switch source { + case "apple_music": + return extractAppleMusicID(url) + case "spotify": + return extractSpotifyID(url) + default: + return extractLBMBID(url) + } +} + +// isDuplicate checks whether a playlist with the same source and source ID already exists. +func isDuplicate(source, sourceID string, existing []CustomPlaylist) (string, bool) { + for _, p := range existing { + if p.Source != source && p.Source != "" { + continue + } + existID, _ := extractSourceID(p.Source, p.SourceURL) + if existID == "" && p.LBMBID != "" { + existID = p.LBMBID + } + if existID == "" { + continue + } + if existID == sourceID { + return p.ID, true + } + } + return "", false +} + +func (p *Playlist) PrefetchCovers() { + + coversDir := p.cfg.CoversDir() + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + fetchSitewideCovers(coversDir) + } +} + +// customPlaylistTrackCount reads the cached track count for a custom playlist without +// fully parsing the JSON. +func customPlaylistTrackCount(cfgDir, id string) int { + type mini struct { + Tracks []json.RawMessage `json:"tracks"` + } + data, err := os.ReadFile(filepath.Join(cfgDir, "cache", id+".json")) + if err != nil { + return 0 + } + var m mini + if err := json.Unmarshal(data, &m); err != nil { + return 0 + } + return len(m.Tracks) +} \ No newline at end of file diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/playlist/handlers.go similarity index 52% rename from src/web/backend/custom_playlists.go rename to src/web/backend/playlist/handlers.go index 947421d..b1af52e 100644 --- a/src/web/backend/custom_playlists.go +++ b/src/web/backend/playlist/handlers.go @@ -1,222 +1,33 @@ -package backend +package playlist import ( "encoding/json" - "fmt" - "log/slog" - "math/rand/v2" "net/http" + "fmt" "os" + "log/slog" "path/filepath" - "regexp" - "strings" + "math/rand/v2" "time" - "explo/src/discovery" "explo/src/util" - "explo/src/web" "explo/src/web/backend/defs" + "explo/src/web" "golang.org/x/text/cases" "golang.org/x/text/language" ) -// CustomPlaylist holds the metadata for a user-imported playlist. -type CustomPlaylist struct { - ID string `json:"id"` - Name string `json:"name"` - Source string `json:"source"` // "listenbrainz" | "apple_music" | "spotify" - SourceURL string `json:"source_url,omitempty"` // original URL for dedup + refresh - LBMBID string `json:"lb_mbid,omitempty"` // ListenBrainz MBID (backward compat) - ArtworkURL string `json:"artwork_url,omitempty"` // playlist cover image (Apple Music) - ArtworkUploaded bool `json:"artwork_uploaded,omitempty"` // true after artwork has been pushed to the music app - RefreshDays int `json:"refresh_days"` - ColorIndex int `json:"color_index"` - LastFetched time.Time `json:"last_fetched"` -} - -// CustomPlaylistArtworkPath returns the local file path where a playlist's -// artwork is cached (regardless of whether the file exists). -func CustomPlaylistArtworkPath(cfgDir, id string) string { - return filepath.Join(cfgDir, "cache", "playlist_artwork", id+".jpg") -} - -// GetCustomPlaylist looks up a custom playlist by ID. Returns nil if not found. -func GetCustomPlaylist(cfgDir, id string) *CustomPlaylist { - for _, p := range loadCustomPlaylists(cfgDir) { - if p.ID == id { - cp := p - return &cp - } - } - return nil -} - -// MarkCustomPlaylistArtworkUploaded sets ArtworkUploaded=true and persists. -func MarkCustomPlaylistArtworkUploaded(cfgDir, id string) error { - playlists := loadCustomPlaylists(cfgDir) - for i := range playlists { - if playlists[i].ID == id { - if playlists[i].ArtworkUploaded { - return nil - } - playlists[i].ArtworkUploaded = true - return saveCustomPlaylists(cfgDir, playlists) - } - } - return nil -} - -var lbMBIDRe = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) - -var appleMusicURLRe = regexp.MustCompile( - `^https?://music\.apple\.com/[a-z]{2}/playlist/[^/]+/(pl\.[a-zA-Z0-9-]+)`, -) - -// extractAppleMusicID pulls the playlist ID (pl.xxx) from an Apple Music URL. -func extractAppleMusicID(raw string) (string, error) { - raw = strings.TrimSpace(raw) - m := appleMusicURLRe.FindStringSubmatch(raw) - if len(m) < 2 { - return "", fmt.Errorf("not a valid Apple Music playlist URL") - } - return m[1], nil -} - -// extractLBMBID pulls the playlist UUID out of a ListenBrainz playlist URL or bare MBID string. -func extractLBMBID(raw string) (string, error) { - raw = strings.TrimSpace(raw) - m := lbMBIDRe.FindString(raw) - if m == "" { - return "", fmt.Errorf("no ListenBrainz playlist UUID found in %q", raw) - } - return m, nil -} - -func customPlaylistsPath(cfgDir string) string { - return filepath.Join(cfgDir, "custom-playlists.json") -} - -// customEnvPrefix converts a playlist name like "Today's Hits" -// to an env-var prefix like "CUSTOM_TODAYS_HITS". -// Non-alphanumeric characters are collapsed into underscores. -func customEnvPrefix(name string) string { - var b strings.Builder - prevUnderscore := true // start true so leading separators are skipped - for _, r := range strings.ToUpper(name) { - if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - prevUnderscore = false - } else if !prevUnderscore { - b.WriteRune('_') - prevUnderscore = true - } - } - return "CUSTOM_" + strings.TrimRight(b.String(), "_") -} - - -func loadCustomPlaylists(cfgDir string) []CustomPlaylist { - data, err := os.ReadFile(customPlaylistsPath(cfgDir)) - if err != nil { - return nil - } - var out []CustomPlaylist - if err := json.Unmarshal(data, &out); err != nil { - slog.Warn("custom-playlists: failed to parse metadata", "err", err) - return nil - } - return out -} - -func saveCustomPlaylists(cfgDir string, playlists []CustomPlaylist) error { - raw, err := json.MarshalIndent(playlists, "", " ") - if err != nil { - return err - } - return os.WriteFile(customPlaylistsPath(cfgDir), raw, 0644) -} - -// FetchResult is the uniform return type for fetching playlist data from any source. -type FetchResult struct { - Name string - ArtworkURL string - Tracks []PlaylistTrack -} - -// fetchCustomPlaylistTracks dispatches to the appropriate source fetcher. -// This is the single point where source-specific logic lives for fetching. -func fetchCustomPlaylistTracks(p CustomPlaylist) (FetchResult, error) { - switch p.Source { - case "apple_music": - name, art, tracks, err := fetchAppleMusicPlaylist(p.SourceURL) - return FetchResult{name, art, tracks}, err - case "spotify": - name, art, tracks, err := fetchSpotifyPlaylist(p.SourceURL) - return FetchResult{name, art, tracks}, err - default: // "listenbrainz" or legacy empty - mbid := p.LBMBID - if mbid == "" && p.SourceURL != "" { - var err error - mbid, err = extractLBMBID(p.SourceURL) - if err != nil { - return FetchResult{}, err - } - } - if mbid == "" { - return FetchResult{}, fmt.Errorf("no source data for playlist %s", p.ID) - } - httpClient := util.NewHttp(util.HttpClientConfig{Timeout: 30}) - name, modelTracks, err := discovery.FetchPlaylistByMBID(httpClient, mbid) - if err != nil { - return FetchResult{}, err - } - tracks := modelTracksToPlaylistTracks(modelTracks) - return FetchResult{Name: name, Tracks: tracks}, nil - } -} - -// extractSourceID validates a URL and returns the canonical ID for the given source. -func extractSourceID(source, url string) (string, error) { - switch source { - case "apple_music": - return extractAppleMusicID(url) - case "spotify": - return extractSpotifyID(url) - default: - return extractLBMBID(url) - } -} - -// isDuplicate checks whether a playlist with the same source and source ID already exists. -func isDuplicate(source, sourceID string, existing []CustomPlaylist) (string, bool) { - for _, p := range existing { - if p.Source != source && p.Source != "" { - continue - } - existID, _ := extractSourceID(p.Source, p.SourceURL) - if existID == "" && p.LBMBID != "" { - existID = p.LBMBID - } - if existID == "" { - continue - } - if existID == sourceID { - return p.ID, true - } - } - return "", false -} // handleGetCustomPlaylists returns all saved custom playlists with a track_count // derived from their cache file (if present) and the current sync schedule from .env. -func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) { - playlists := loadCustomPlaylists(s.cfg.WebDataDir) +func (p *Playlist) HandleGetCustomPlaylists(w http.ResponseWriter, r *http.Request) { + playlists := loadCustomPlaylists(p.cfg.WebDataDir) // Read .env to look up schedule state for each custom playlist. var envValues map[string]string - if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { - envValues = parseEnvText(string(data)) + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + envValues = p.settings.ParseEnvText(string(data)) } else { envValues = map[string]string{} } @@ -228,12 +39,12 @@ func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request Flags string `json:"flags"` } items := make([]respItem, 0, len(playlists)) - for _, p := range playlists { - count := customPlaylistTrackCount(s.cfg.WebDataDir, p.ID) - prefix := customEnvPrefix(p.Name) + for _, plist := range playlists { + count := customPlaylistTrackCount(p.cfg.WebDataDir, plist.ID) + prefix := util.CustomEnvPrefix(plist.Name) sched := envValues[prefix+"_SCHEDULE"] flags := envValues[prefix+"_FLAGS"] - items = append(items, respItem{CustomPlaylist: p, TrackCount: count, Schedule: sched, Flags: flags}) + items = append(items, respItem{CustomPlaylist: plist, TrackCount: count, Schedule: sched, Flags: flags}) } w.Header().Set("Content-Type", "application/json") @@ -244,7 +55,7 @@ func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request // handleImportCustomPlaylist imports a playlist by URL (ListenBrainz or Apple Music), // writes a cache, and returns the playlist name/tracks to the frontend for the import animation. -func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleImportCustomPlaylist(w http.ResponseWriter, r *http.Request) { var body struct { URL string `json:"url"` Source string `json:"source"` // "listenbrainz" | "apple_music" @@ -255,7 +66,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque return } - existing := loadCustomPlaylists(s.cfg.WebDataDir) + existing := loadCustomPlaylists(p.cfg.WebDataDir) if body.Source == "" { body.Source = "listenbrainz" @@ -290,7 +101,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque slog.Info("custom-playlists: fetched", "source", body.Source, "name", name, "tracks", len(tracks)) // Ensure data directories exist before writing anything - if err := os.MkdirAll(filepath.Join(s.cfg.WebDataDir, "cache"), 0755); err != nil { + if err := os.MkdirAll(filepath.Join(p.cfg.WebDataDir, "cache"), 0755); err != nil { slog.Error("custom-playlists: failed to create data dir", "err", err) http.Error(w, "server data directory unavailable: "+err.Error(), http.StatusInternalServerError) return @@ -302,18 +113,18 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // Write cache with remote cover URLs synchronously so the response is fast, // then download local copies of cover art in the background. slog.Info("custom-playlists: writing cache", "id", id) - if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) { + if !writePreliminaryCache(p.cfg.WebDataDir, id, tracks) { http.Error(w, "failed to write playlist cache", http.StatusInternalServerError) return } - go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks) + go downloadAndCacheCovers(p.cfg.WebDataDir, id, tracks) // Cache the playlist's own artwork locally so we can later push it to the // music app on first playlist creation. Apple Music imports have artwork; // ListenBrainz don't. if artworkURL != "" { go func() { - if _, err := util.DownloadFile(artworkURL, CustomPlaylistArtworkPath(s.cfg.WebDataDir, id)); err != nil { + if _, err := util.DownloadFile(artworkURL, CustomPlaylistArtworkPath(p.cfg.WebDataDir, id)); err != nil { slog.Warn("custom-playlists: artwork download failed", "id", id, "err", err.Error()) } }() @@ -338,7 +149,7 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque LastFetched: time.Now().UTC(), } existing = append(existing, cp) - if err := saveCustomPlaylists(s.cfg.WebDataDir, existing); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, existing); err != nil { slog.Error("custom-playlists: failed to save metadata", "err", err) http.Error(w, "failed to save playlist metadata: "+err.Error(), http.StatusInternalServerError) return @@ -348,14 +159,14 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // a daily poll SCHEDULE — RefreshDays in the JSON gates the actual refresh interval // inside the cron task body. "Never" imports get FLAGS only so the card is usable // for manual runs while the schedule editor pre-selects "Never". - prefix := customEnvPrefix(name) + prefix := util.CustomEnvPrefix(name) envUpdates := map[string]string{ prefix + "_FLAGS": "--playlist " + id, } if body.RefreshDays > 0 { envUpdates[prefix+"_SCHEDULE"] = "0 4 * * *" } - _ = updateEnvKeys(s.cfg.WebEnvPath, envUpdates, web.SampleEnv) + _ = p.settings.UpdateEnvKeys(envUpdates, web.SampleEnv) slog.Info("custom-playlists: import complete", "id", id, "name", name) @@ -387,14 +198,14 @@ func (s *Server) handleImportCustomPlaylist(w http.ResponseWriter, r *http.Reque // handleRefreshCustomPlaylist re-fetches a custom playlist and updates the cache. // Equivalent to manually triggering the nightly refresh cron job for a single playlist. -func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !defs.CustomIDRe.MatchString(id) { http.Error(w, "invalid playlist id", http.StatusBadRequest) return } - playlists := loadCustomPlaylists(s.cfg.WebDataDir) + playlists := loadCustomPlaylists(p.cfg.WebDataDir) idx := -1 for i, p := range playlists { if p.ID == id { @@ -407,10 +218,10 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ return } - p := playlists[idx] - slog.Info("custom-playlists: manual refresh", "id", id, "source", p.Source) + plist := playlists[idx] + slog.Info("custom-playlists: manual refresh", "id", id, "source", plist.Source) - result, err := fetchCustomPlaylistTracks(p) + result, err := fetchCustomPlaylistTracks(plist) if err != nil { slog.Error("custom-playlists: refresh fetch failed", "id", id, "err", err) http.Error(w, "failed to fetch playlist: "+err.Error(), http.StatusBadGateway) @@ -418,14 +229,14 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ } tracks := result.Tracks - if !writePreliminaryCache(s.cfg.WebDataDir, id, tracks) { + if !writePreliminaryCache(p.cfg.WebDataDir, id, tracks) { http.Error(w, "failed to write playlist cache", http.StatusInternalServerError) return } - go downloadAndCacheCovers(s.cfg.WebDataDir, id, tracks) + go downloadAndCacheCovers(p.cfg.WebDataDir, id, tracks) playlists[idx].LastFetched = time.Now().UTC() - if err := saveCustomPlaylists(s.cfg.WebDataDir, playlists); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { slog.Warn("custom-playlists: failed to update last_fetched after refresh", "err", err) } @@ -439,7 +250,7 @@ func (s *Server) handleRefreshCustomPlaylist(w http.ResponseWriter, r *http.Requ // handleDeleteCustomPlaylist removes a custom playlist's metadata and cache file. // If ?delete_tracks=true is set and USE_SUBDIRECTORY is on, also removes the // playlist's download subdirectories from DOWNLOAD_DIR. -func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { +func (p *Playlist) HandleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if !defs.CustomIDRe.MatchString(id) { slog.Warn("custom-playlists: invalid id in delete request", "id", id) @@ -449,7 +260,7 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque deleteTracks := r.URL.Query().Get("delete_tracks") == "true" slog.Info("custom-playlists: delete request", "id", id, "delete_tracks", deleteTracks) - existing := loadCustomPlaylists(s.cfg.WebDataDir) + existing := loadCustomPlaylists(p.cfg.WebDataDir) filtered := existing[:0] found := false var deletedName string @@ -466,25 +277,25 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque return } - if err := saveCustomPlaylists(s.cfg.WebDataDir, filtered); err != nil { + if err := saveCustomPlaylists(p.cfg.WebDataDir, filtered); err != nil { http.Error(w, "failed to save: "+err.Error(), http.StatusInternalServerError) return } // Remove the cache file; ignore error if already gone - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", id+".json") + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", id+".json") _ = os.Remove(cachePath) // Remove schedule env vars from .env - prefix := customEnvPrefix(deletedName) - _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{ + prefix := util.CustomEnvPrefix(deletedName) + _ = p.settings.UpdateEnvKeys(map[string]string{ prefix + "_SCHEDULE": "", prefix + "_FLAGS": "", }, web.SampleEnv) if deleteTracks { - if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { - env := parseEnvText(string(data)) + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + env := p.settings.ParseEnvText(string(data)) if env["USE_SUBDIRECTORY"] == "true" && env["DOWNLOAD_DIR"] != "" { prefix := cases.Title(language.Und).String(id) // "custom-1234" -> "Custom-1234" removed, err := util.RemoveDirsByPrefix(env["DOWNLOAD_DIR"], prefix) @@ -493,7 +304,7 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque } else { slog.Info("custom-playlists: removed download dirs", "id", id, "count", removed) if removed > 0 { - s.triggerLibraryRefresh() + util.TriggerLibraryRefresh(p.cfg) } } } else { @@ -507,19 +318,152 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } -// customPlaylistTrackCount reads the cached track count for a custom playlist without -// fully parsing the JSON. -func customPlaylistTrackCount(cfgDir, id string) int { - type mini struct { - Tracks []json.RawMessage `json:"tracks"` +// handleSaveSchedule updates a single playlist's schedule in the .env file. +func (p *Playlist) 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"` } - data, err := os.ReadFile(filepath.Join(cfgDir, "cache", id+".json")) - if err != nil { - return 0 + 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 + } + + 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 := p.settings.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleGetPlaylist serves the tracklist cache written by explo during its last run. +// Returns an empty track list if no cache exists yet. +func (p *Playlist) HandleGetPlaylist(w http.ResponseWriter, r *http.Request) { + playlistType := r.URL.Query().Get("type") + if !isValidPlaylistID(playlistType) { + http.Error(w, "unknown playlist type", http.StatusBadRequest) + return + } + + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", playlistType+".json") + if raw, err := os.ReadFile(cachePath); err == nil { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(raw); err != nil { + slog.Error("failed to write playlist response", "msg", err.Error()) + } + return + } + + // No cache yet — return an empty response. Run explo or use the prefetch + // endpoint to populate the cache. + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"tracks":[]}`)); err != nil { + slog.Error("failed to write empty playlist response", "msg", err.Error()) } - var m mini - if err := json.Unmarshal(data, &m); err != nil { - return 0 +} + +// handlePrefetchCovers fetches the most recent LB playlists for the given user, +// writes a preliminary JSON cache for the web UI, then downloads cover art. +// Runs in the background — returns 202 immediately. +func (p *Playlist) HandlePrefetchCovers(w http.ResponseWriter, r *http.Request) { + var body struct { + User string `json:"user"` + Playlists []string `json:"playlists"` + Source string `json:"source"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.User == "" || len(body.Playlists) == 0 { + http.Error(w, "user and playlists are required", http.StatusBadRequest) + return } - return len(m.Tracks) + forceRefresh := body.Source == "wizard" + w.WriteHeader(http.StatusAccepted) + + slog.Info("prefetch: starting", "user", body.User, "playlists", body.Playlists, "source", body.Source, "force_refresh", forceRefresh) + go func() { + for _, pt := range body.Playlists { + if !validPlaylistTypes[pt] { + slog.Warn("prefetch: unknown playlist type", "type", pt) + continue + } + // Normal prefetch keeps an existing cache intact; wizard prefetch refreshes it + // after the user updates discovery settings. + cachePath := filepath.Join(p.cfg.WebDataDir, "cache", pt+".json") + if _, err := os.Stat(cachePath); err == nil && !forceRefresh { + slog.Info("prefetch: cache already exists, skipping", "playlist", pt) + continue + } + var tracks []PlaylistTrack + var err error + if pt == "on-repeat" { + tracks, err = fetchOnRepeatTracks(body.User) + } else { + tracks, err = fetchMostRecentLBPlaylist(body.User, pt) + } + if err != nil { + slog.Warn("prefetch: failed to fetch LB playlist", "type", pt, "err", err) + continue + } + slog.Info("prefetch: fetched tracks", "playlist", pt, "count", len(tracks)) + writePrefetchCache(p.cfg.WebDataDir, pt, tracks) + } + }() } + +// handleBackgroundArt returns a single cover art URL for use as a login page backdrop. +// It picks a random local cover if any exist; otherwise it fetches the top global +// albums from ListenBrainz and downloads cover art for the first available one. +func (p *Playlist) HandleBackgroundArt(w http.ResponseWriter, r *http.Request) { + coversDir := filepath.Join(p.cfg.WebDataDir, "cache", "covers") + + url := randomLocalCoverHiRes(coversDir) + if url == "" { + url = fetchSitewideCovers(coversDir) + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { + slog.Error("background-art: failed to write response", "err", err.Error()) + } +} \ No newline at end of file diff --git a/src/web/backend/playlist/jobs.go b/src/web/backend/playlist/jobs.go new file mode 100644 index 0000000..b708f86 --- /dev/null +++ b/src/web/backend/playlist/jobs.go @@ -0,0 +1,79 @@ +package playlist + +import ( + "log/slog" + "os" + "time" + "explo/src/util" + "explo/src/web/backend/jobs" + + "github.com/go-co-op/gocron/v2" +) + +type fileInfo struct { + path string + size int64 + modTime time.Time +} + + +// RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist +// using its stored schedule. Falls back to daily at 4 AM if no schedule is set. +func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { + playlists := loadCustomPlaylists(p.cfg.WebDataDir) + if len(playlists) == 0 { + return nil + } + + var envValues map[string]string + if data, err := os.ReadFile(p.cfg.WebEnvPath); err == nil { + envValues = p.settings.ParseEnvText(string(data)) + } else { + envValues = map[string]string{} + } + + for _, plist := range playlists { + plist := plist + prefix := util.CustomEnvPrefix(plist.Name) + flags := envValues[prefix+"_FLAGS"] + if flags == "" { + continue // disabled + } + schedule := envValues[prefix+"_SCHEDULE"] + if plist.RefreshDays <= 0 && schedule == "" { + continue + } + if schedule == "" { + schedule = "0 4 * * *" + } + _, err := j.Scheduler.NewJob( + gocron.CronJob(schedule, false), + gocron.NewTask(func() { + if time.Since(plist.LastFetched) < time.Duration(plist.RefreshDays)*24*time.Hour { + return + } + slog.Info("custom-playlists: refreshing", "id", plist.ID, "name", plist.Name, "source", plist.Source) + result, err := fetchCustomPlaylistTracks(plist) + if err != nil { + slog.Warn("custom-playlists: refresh fetch failed", "id", plist.ID, "err", err) + return + } + writePrefetchCache(p.cfg.WebDataDir, plist.ID, result.Tracks) + playlists := loadCustomPlaylists(p.cfg.WebDataDir) + for i, pl := range playlists { + if pl.ID == plist.ID { + playlists[i].LastFetched = time.Now().UTC() + break + } + } + if err := saveCustomPlaylists(p.cfg.WebDataDir, playlists); err != nil { + slog.Error("custom-playlists: failed to save after refresh", "err", err) + } + }), + ) + if err != nil { + slog.Warn("custom-playlists: failed to register refresh job", "id", plist.ID, "err", err) + } + } + return nil +} \ No newline at end of file diff --git a/src/web/backend/playlists.go b/src/web/backend/playlist/playlist.go similarity index 74% rename from src/web/backend/playlists.go rename to src/web/backend/playlist/playlist.go index f46b594..1847f9d 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlist/playlist.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "bytes" @@ -6,7 +6,9 @@ import ( "explo/src/discovery" "explo/src/models" "explo/src/util" + "explo/src/web/backend/app" "explo/src/web/backend/defs" + "explo/src/web/backend/settings" "fmt" "image" _ "image/jpeg" @@ -32,6 +34,12 @@ type PlaylistTrack struct { CoverURL string } + +type Playlist struct { + settings *settings.Settings + cfg app.Config +} + // validPlaylistTypes is derived from playlistDefs — no manual sync needed. var validPlaylistTypes = func() map[string]bool { m := make(map[string]bool, len(defs.PlaylistDefs)) @@ -41,37 +49,15 @@ var validPlaylistTypes = func() map[string]bool { return m }() +func NewPlaylist(cfg app.Config, settings *settings.Settings) *Playlist { + return &Playlist{cfg: cfg} +} + // isValidPlaylistID accepts built-in playlist types and custom-* IDs (blocks path traversal). func isValidPlaylistID(t string) bool { return validPlaylistTypes[t] || defs.CustomIDRe.MatchString(t) } -// handleGetPlaylist serves the tracklist cache written by explo during its last run. -// Returns an empty track list if no cache exists yet. -func (s *Server) handleGetPlaylist(w http.ResponseWriter, r *http.Request) { - playlistType := r.URL.Query().Get("type") - if !isValidPlaylistID(playlistType) { - http.Error(w, "unknown playlist type", http.StatusBadRequest) - return - } - - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", playlistType+".json") - if raw, err := os.ReadFile(cachePath); err == nil { - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write(raw); err != nil { - slog.Error("failed to write playlist response", "msg", err.Error()) - } - return - } - - // No cache yet — return an empty response. Run explo or use the prefetch - // endpoint to populate the cache. - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write([]byte(`{"tracks":[]}`)); err != nil { - slog.Error("failed to write empty playlist response", "msg", err.Error()) - } -} - // ── LB fallback ────────────────────────────────────────────────────────────── func fetchOnRepeatTracks(username string) ([]PlaylistTrack, error) { @@ -174,57 +160,6 @@ func lbGet(url string) ([]byte, error) { return io.ReadAll(resp.Body) } -// handlePrefetchCovers fetches the most recent LB playlists for the given user, -// writes a preliminary JSON cache for the web UI, then downloads cover art. -// Runs in the background — returns 202 immediately. -func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) { - var body struct { - User string `json:"user"` - Playlists []string `json:"playlists"` - Source string `json:"source"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.User == "" || len(body.Playlists) == 0 { - http.Error(w, "user and playlists are required", http.StatusBadRequest) - return - } - forceRefresh := body.Source == "wizard" - w.WriteHeader(http.StatusAccepted) - - slog.Info("prefetch: starting", "user", body.User, "playlists", body.Playlists, "source", body.Source, "force_refresh", forceRefresh) - go func() { - for _, pt := range body.Playlists { - if !validPlaylistTypes[pt] { - slog.Warn("prefetch: unknown playlist type", "type", pt) - continue - } - // Normal prefetch keeps an existing cache intact; wizard prefetch refreshes it - // after the user updates discovery settings. - cachePath := filepath.Join(s.cfg.WebDataDir, "cache", pt+".json") - if _, err := os.Stat(cachePath); err == nil && !forceRefresh { - slog.Info("prefetch: cache already exists, skipping", "playlist", pt) - continue - } - var tracks []PlaylistTrack - var err error - if pt == "on-repeat" { - tracks, err = fetchOnRepeatTracks(body.User) - } else { - tracks, err = fetchMostRecentLBPlaylist(body.User, pt) - } - if err != nil { - slog.Warn("prefetch: failed to fetch LB playlist", "type", pt, "err", err) - continue - } - slog.Info("prefetch: fetched tracks", "playlist", pt, "count", len(tracks)) - writePrefetchCache(s.cfg.WebDataDir, pt, tracks) - } - }() -} - type cachedPrefetchTrack struct { Rank int `json:"rank"` Title string `json:"title"` @@ -284,23 +219,6 @@ type sitewideReleasesResp struct { } `json:"payload"` } -// handleBackgroundArt returns a single cover art URL for use as a login page backdrop. -// It picks a random local cover if any exist; otherwise it fetches the top global -// albums from ListenBrainz and downloads cover art for the first available one. -func (s *Server) handleBackgroundArt(w http.ResponseWriter, r *http.Request) { - coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") - - url := randomLocalCoverHiRes(coversDir) - if url == "" { - url = fetchSitewideCovers(coversDir) - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { - slog.Error("background-art: failed to write response", "err", err.Error()) - } -} - // randomLocalCoverHiRes picks a random cover from the existing library, ensures a // 1200px background version is cached (as {mbid}-bg.jpg), and returns its API URL. // Playlist thumbnails are stored at 250px; this fetches full-res on demand from CAA. diff --git a/src/web/backend/spotify.go b/src/web/backend/playlist/spotify.go similarity index 99% rename from src/web/backend/spotify.go rename to src/web/backend/playlist/spotify.go index 202e902..9209f51 100644 --- a/src/web/backend/spotify.go +++ b/src/web/backend/playlist/spotify.go @@ -1,4 +1,4 @@ -package backend +package playlist import ( "bytes" @@ -808,4 +808,4 @@ func findPartHash(jsContent, name string) string { return "" } return rest[:end] -} +} \ No newline at end of file diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 1680ae4..612782d 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -28,8 +28,7 @@ func (s *Server) registerRoutes() { }) s.registerAuthRoutes() - s.registerConfigRoutes() - s.registerWizardRoutes() + s.registerSettingRoutes() s.registerPlaylistRoutes() s.registerRunRoutes() s.registerMiscRoutes() @@ -45,43 +44,43 @@ func (s *Server) registerAuthRoutes() { s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) } -func (s *Server) registerConfigRoutes() { - s.mux.Handle("GET /api/ui/config", s.auth(s.handleGetConfig)) - s.mux.Handle("POST /api/ui/config", s.auth(s.handleSaveConfig)) +func (s *Server) registerSettingRoutes() { + s.mux.Handle("GET /api/ui/config", s.auth(s.settings.HandleGetConfig)) + s.mux.Handle("POST /api/ui/config", s.auth(s.settings.HandleSaveConfig)) - s.mux.Handle("GET /api/ui/config/raw", s.auth(s.handleGetConfigRaw)) - s.mux.Handle("POST /api/ui/config/reset", s.auth(s.handleResetConfig)) - s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.handleSaveSchedule)) - s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.handleSavePathTemplate)) - s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.handleSaveEnrichMetadata)) + s.mux.Handle("GET /api/ui/config/raw", s.auth(s.settings.HandleGetConfigRaw)) + s.mux.Handle("POST /api/ui/config/reset", s.auth(s.settings.HandleResetConfig)) + 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)) // Path template presets: GET list, POST add; DELETE per name under prefix s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) -} - -func (s *Server) registerWizardRoutes() { // Wizard steps (POST) — require auth - s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.handleWizardStep1)) - s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.handleWizardStep2)) - s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.handleWizardStep3)) + s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.settings.HandleWizardStep1)) + s.mux.Handle("POST /api/ui/wizard/step2", s.auth(s.settings.HandleWizardStep2)) + s.mux.Handle("POST /api/ui/wizard/step3", s.auth(s.settings.HandleWizardStep3)) // Public - s.mux.HandleFunc("GET /api/ui/setup-status", s.handleSetupStatus) + s.mux.HandleFunc("GET /api/ui/setup-status", s.settings.HandleSetupStatus) + } func (s *Server) registerPlaylistRoutes() { - s.mux.Handle("GET /api/ui/playlists", s.auth(s.handleGetPlaylist)) - s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.handlePrefetchCovers)) + s.mux.Handle("GET /api/ui/playlists", s.auth(s.customPlaylist.HandleGetPlaylist)) + s.mux.Handle("POST /api/ui/playlists/prefetch", s.auth(s.customPlaylist.HandlePrefetchCovers)) // custom playlists: GET list, POST import (same path); per-ID actions under prefix - s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.handleGetCustomPlaylists)) - s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.handleImportCustomPlaylist)) + s.mux.Handle("GET /api/ui/custom-playlists", s.auth(s.customPlaylist.HandleGetCustomPlaylists)) + s.mux.Handle("POST /api/ui/custom-playlists", s.auth(s.customPlaylist.HandleImportCustomPlaylist)) // ID-specific routes: DELETE /api/ui/custom-playlists/{id} and POST .../{id}/refresh - s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.handleRefreshCustomPlaylist)) - s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.handleDeleteCustomPlaylist)) + s.mux.Handle("POST /api/ui/custom-playlists/{id}/refresh", s.auth(s.customPlaylist.HandleRefreshCustomPlaylist)) + s.mux.Handle("DELETE /api/ui/custom-playlists/{id}", s.auth(s.customPlaylist.HandleDeleteCustomPlaylist)) + + s.mux.HandleFunc("GET /api/ui/background-art", s.customPlaylist.HandleBackgroundArt) } func (s *Server) registerRunRoutes() { @@ -94,7 +93,6 @@ func (s *Server) registerRunRoutes() { func (s *Server) registerMiscRoutes() { s.mux.Handle("GET /api/ui/logs", s.auth(s.handleGetLog)) s.mux.Handle("GET /api/ui/browse", s.auth(s.handleBrowse)) - s.mux.HandleFunc("GET /api/ui/background-art", s.handleBackgroundArt) coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") s.mux.Handle("GET /api/covers/", http.StripPrefix("/api/covers/", http.FileServer(http.Dir(coversDir)))) diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 9281e7d..329bb27 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -10,32 +10,17 @@ import ( "os" "path/filepath" "strings" - "syscall" "time" - "os/exec" "explo/src/config" "explo/src/web" - "explo/src/web/backend/run" "explo/src/web/backend/app" - "explo/src/web/backend/defs" + "explo/src/web/backend/run" + "explo/src/web/backend/playlist" + "explo/src/web/backend/jobs" + "explo/src/web/backend/settings" ) -// 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"` @@ -47,9 +32,11 @@ type Server struct { mux *http.ServeMux server *http.Server authStore *AuthStore - cronJobs *Jobs + settings *settings.Settings + cronJobs *jobs.Jobs sessionManager *SessionManager manualRun *run.ManualRun + customPlaylist *playlist.Playlist } func NewServer(cfg config.ServerConfig) *Server { @@ -65,15 +52,17 @@ func NewServer(cfg config.ServerConfig) *Server { cfg.Password, sessionManager, ) - - appCfg := app.Config{ + webCfg := app.Config{ WebEnvPath: cfg.WebEnvPath, WebDataDir: cfg.WebDataDir, ExploPath: cfg.ExploPath, } - cronJobs := NewJobs() - manualRun := run.NewManualRun(appCfg) + settings := settings.NewSettings(webCfg) + + cronJobs := jobs.NewJobs() + manualRun := run.NewManualRun(webCfg) + playlist := playlist.NewPlaylist(webCfg, settings) mux := http.NewServeMux() s := &Server{ @@ -84,9 +73,11 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, + settings: settings, cronJobs: cronJobs, sessionManager: sessionManager, manualRun: manualRun, + customPlaylist: playlist, } s.registerRoutes() @@ -98,7 +89,7 @@ func (s *Server) Start() error { 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() @@ -138,28 +129,6 @@ func checkForUpdate() { } } -// 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 parseVer(v string) [3]int { v = strings.TrimPrefix(v, "v") parts := strings.SplitN(v, ".", 3) @@ -182,23 +151,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. @@ -240,18 +199,6 @@ func (s *Server) openRunLog() (*os.File, error) { 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) @@ -322,543 +269,6 @@ func (s *Server) csrfHandler(w http.ResponseWriter, r *http.Request) { } } -// ── 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(defs.AllConfigKeys)) - sources := make(map[string]string, len(defs.AllConfigKeys)) - for _, key := range defs.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 := defs.PlaylistDefs[body.Name]; ok { - envPrefix = def.EnvPrefix - defaultFlags = def.DefaultFlags - } else if defs.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 - } - - for k, v := range updates { - if v == "" { - if err := os.Unsetenv(k); err != nil { - slog.Warn("failed to unset env variable", "err", err.Error()) - } - } else { - if err := os.Setenv(k, v); err != nil { - slog.Warn("failed to set env variable", "err", err.Error()) - } - } - } - - 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 defs.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")) diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go new file mode 100644 index 0000000..26f6604 --- /dev/null +++ b/src/web/backend/settings/handlers.go @@ -0,0 +1,352 @@ +package settings + +import ( + "net/http" + "encoding/json" + "os" + "log/slog" + "io" + "syscall" + "time" + "fmt" + "strings" + + "explo/src/web" + "explo/src/web/backend/defs" + "explo/src/util" +) + +// 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 *Settings) HandleGetConfig(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(s.cfg.WebEnvPath) + var fileValues map[string]string + if err == nil { + fileValues = s.ParseEnvText(string(data)) + } else { + fileValues = s.ParseEnvText(string(web.SampleEnv)) + } + + configKeys := defs.AllConfigKeys + values := make(map[string]string, len(configKeys)) + sources := make(map[string]string, len(configKeys)) + for _, key := range configKeys { + 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 *Settings) 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 *Settings) 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 *Settings) 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 *Settings) 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 := 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 + } + + 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 := s.UpdateEnvKeys(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 *Settings) 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 := s.UpdateEnvKeys(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 *Settings) 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 := s.UpdateEnvKeys(map[string]string{"ENRICH_TRACK_METADATA": val}, 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 { + 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 defs.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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep2 saves media system configuration. +func (s *Settings) 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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleWizardStep3 saves downloader configuration. +func (s *Settings) 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 := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// handleSetupStatus returns {"wizard_complete": bool} for first time setups. Public — no auth required. +func (s *Settings) HandleSetupStatus(w http.ResponseWriter, r *http.Request) { + wizardComplete := false + if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { + wizardComplete = s.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()) + } +} \ No newline at end of file diff --git a/src/web/backend/settings/settings.go b/src/web/backend/settings/settings.go new file mode 100644 index 0000000..ace6e5c --- /dev/null +++ b/src/web/backend/settings/settings.go @@ -0,0 +1,122 @@ +package settings + +import ( + "strings" + "os" + "fmt" + + "explo/src/web/backend/app" +) + +// ConfigResponse is returned by GET /api/config. +type ConfigResponse struct { + Values map[string]string `json:"values"` + Sources map[string]string `json:"sources"` // "env" | "file" +} + +type Settings struct { + cfg app.Config +} + +func NewSettings(Config app.Config) *Settings { + return &Settings{cfg: Config} +} +// parseEnvText parses key=value lines, ignoring comments, blanks and unquotes variables +func (s *Settings) 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 +} + +// 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 (s *Settings) UpdateEnvKeys(updates map[string]string, fallback []byte) error { + path := s.cfg.WebEnvPath + 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 +} \ No newline at end of file From 3280c13f1c33f4fb6d54b1e4b643b9723c54a9f0 Mon Sep 17 00:00:00 2001 From: LumePart Date: Sun, 21 Jun 2026 23:23:41 +0300 Subject: [PATCH 26/35] move path template handling under settings --- src/web/backend/path_templates.go | 112 --------------------- src/web/backend/routes.go | 4 +- src/web/backend/settings/handlers.go | 71 +++++++++++++ src/web/backend/settings/path_templates.go | 39 +++++++ 4 files changed, 112 insertions(+), 114 deletions(-) delete mode 100644 src/web/backend/path_templates.go create mode 100644 src/web/backend/settings/path_templates.go diff --git a/src/web/backend/path_templates.go b/src/web/backend/path_templates.go deleted file mode 100644 index 0617a65..0000000 --- a/src/web/backend/path_templates.go +++ /dev/null @@ -1,112 +0,0 @@ -package backend - -import ( - "encoding/json" - "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" -) - -// PathTemplatePreset is a named folder-structure template saved by the user. -type PathTemplatePreset struct { - Name string `json:"name"` - Template string `json:"template"` -} - -func pathTemplatesFilePath(cfgDir string) string { - return filepath.Join(cfgDir, "path-templates.json") -} - -func loadPathTemplates(cfgDir string) []PathTemplatePreset { - data, err := os.ReadFile(pathTemplatesFilePath(cfgDir)) - if err != nil { - return nil - } - var out []PathTemplatePreset - if err := json.Unmarshal(data, &out); err != nil { - slog.Warn("path-templates: failed to parse", "err", err) - return nil - } - return out -} - -func savePathTemplates(cfgDir string, presets []PathTemplatePreset) error { - raw, err := json.MarshalIndent(presets, "", " ") - if err != nil { - return err - } - return os.WriteFile(pathTemplatesFilePath(cfgDir), raw, 0644) -} - -// handlePathTemplates handles GET and POST for /api/ui/path-templates. -func (s *Server) handlePathTemplates(w http.ResponseWriter, r *http.Request) { - cfgDir := s.cfg.WebDataDir - switch r.Method { - case http.MethodGet: - presets := loadPathTemplates(cfgDir) - if presets == nil { - presets = []PathTemplatePreset{} - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(presets); err != nil { - slog.Error("failed encoding path templates", "err", err.Error()) - } - case http.MethodPost: - var body PathTemplatePreset - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) - return - } - if body.Name == "" || body.Template == "" { - http.Error(w, "name and template are required", http.StatusBadRequest) - return - } - presets := loadPathTemplates(cfgDir) - presets = append(presets, body) - if err := savePathTemplates(cfgDir, presets); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(body); err != nil { - slog.Error("failed encoding path template", "err", err.Error()) - } - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleDeletePathTemplate handles DELETE /api/ui/path-templates/{name}. -func (s *Server) handleDeletePathTemplate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - raw := strings.TrimPrefix(r.URL.Path, "/api/ui/path-templates/") - name, err := url.PathUnescape(raw) - if err != nil || name == "" { - http.Error(w, "invalid name", http.StatusBadRequest) - return - } - cfgDir := s.cfg.WebDataDir - presets := loadPathTemplates(cfgDir) - filtered := presets[:0] - for _, p := range presets { - if p.Name != name { - filtered = append(filtered, p) - } - } - if len(filtered) == len(presets) { - http.Error(w, "not found", http.StatusNotFound) - return - } - if err := savePathTemplates(cfgDir, filtered); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 612782d..cfcbdb2 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -55,8 +55,8 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata)) // Path template presets: GET list, POST add; DELETE per name under prefix - s.mux.Handle("api/ui/path-templates", s.auth(s.handlePathTemplates)) - s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.handleDeletePathTemplate)) + s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) + s.mux.Handle("DELETE /api/ui/path-templates/", s.auth(s.settings.HandleDeletePathTemplate)) // Wizard steps (POST) — require auth s.mux.Handle("POST /api/ui/wizard/step1", s.auth(s.settings.HandleWizardStep1)) diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 26f6604..ab0b3c4 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -10,6 +10,7 @@ import ( "time" "fmt" "strings" + "net/url" "explo/src/web" "explo/src/web/backend/defs" @@ -349,4 +350,74 @@ func (s *Settings) HandleSetupStatus(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(map[string]bool{"wizard_complete": wizardComplete}); err != nil { slog.Error("failed encoding setup status", "err", err.Error()) } +} + +// handlePathTemplates handles GET and POST for /api/ui/path-templates. +func (s *Settings) HandlePathTemplates(w http.ResponseWriter, r *http.Request) { + cfgDir := s.cfg.WebDataDir + switch r.Method { + case http.MethodGet: + presets := loadPathTemplates(cfgDir) + if presets == nil { + presets = []PathTemplatePreset{} + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(presets); err != nil { + slog.Error("failed encoding path templates", "err", err.Error()) + } + case http.MethodPost: + var body PathTemplatePreset + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" || body.Template == "" { + http.Error(w, "name and template are required", http.StatusBadRequest) + return + } + presets := loadPathTemplates(cfgDir) + presets = append(presets, body) + if err := savePathTemplates(cfgDir, presets); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Error("failed encoding path template", "err", err.Error()) + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleDeletePathTemplate handles DELETE /api/ui/path-templates/{name}. +func (s *Settings) HandleDeletePathTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + raw := strings.TrimPrefix(r.URL.Path, "/api/ui/path-templates/") + name, err := url.PathUnescape(raw) + if err != nil || name == "" { + http.Error(w, "invalid name", http.StatusBadRequest) + return + } + cfgDir := s.cfg.WebDataDir + presets := loadPathTemplates(cfgDir) + filtered := presets[:0] + for _, p := range presets { + if p.Name != name { + filtered = append(filtered, p) + } + } + if len(filtered) == len(presets) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := savePathTemplates(cfgDir, filtered); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) } \ No newline at end of file diff --git a/src/web/backend/settings/path_templates.go b/src/web/backend/settings/path_templates.go new file mode 100644 index 0000000..e84e7fe --- /dev/null +++ b/src/web/backend/settings/path_templates.go @@ -0,0 +1,39 @@ +package settings + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" +) + +// PathTemplatePreset is a named folder-structure template saved by the user. +type PathTemplatePreset struct { + Name string `json:"name"` + Template string `json:"template"` +} + +func pathTemplatesFilePath(cfgDir string) string { + return filepath.Join(cfgDir, "path-templates.json") +} + +func loadPathTemplates(cfgDir string) []PathTemplatePreset { + data, err := os.ReadFile(pathTemplatesFilePath(cfgDir)) + if err != nil { + return nil + } + var out []PathTemplatePreset + if err := json.Unmarshal(data, &out); err != nil { + slog.Warn("path-templates: failed to parse", "err", err) + return nil + } + return out +} + +func savePathTemplates(cfgDir string, presets []PathTemplatePreset) error { + raw, err := json.MarshalIndent(presets, "", " ") + if err != nil { + return err + } + return os.WriteFile(pathTemplatesFilePath(cfgDir), raw, 0644) +} From eb0fc83e3e5362f7934d2ba21eee996c8996aaff Mon Sep 17 00:00:00 2001 From: LumePart Date: Wed, 24 Jun 2026 20:24:19 +0300 Subject: [PATCH 27/35] separate package for auth, fix session migration --- src/web/backend/{ => auth}/auth.go | 2 +- src/web/backend/auth/handlers.go | 70 ++++++++++++++++++++++++ src/web/backend/{ => auth}/middleware.go | 2 +- src/web/backend/{ => auth}/session.go | 2 +- src/web/backend/routes.go | 8 +-- src/web/backend/server.go | 70 ++---------------------- 6 files changed, 83 insertions(+), 71 deletions(-) rename src/web/backend/{ => auth}/auth.go (98%) create mode 100644 src/web/backend/auth/handlers.go rename src/web/backend/{ => auth}/middleware.go (99%) rename src/web/backend/{ => auth}/session.go (99%) diff --git a/src/web/backend/auth.go b/src/web/backend/auth/auth.go similarity index 98% rename from src/web/backend/auth.go rename to src/web/backend/auth/auth.go index 2cbfa04..b763fbc 100644 --- a/src/web/backend/auth.go +++ b/src/web/backend/auth/auth.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "net/http" diff --git a/src/web/backend/auth/handlers.go b/src/web/backend/auth/handlers.go new file mode 100644 index 0000000..83568fd --- /dev/null +++ b/src/web/backend/auth/handlers.go @@ -0,0 +1,70 @@ +package auth + +import ( + "net/http" + "log/slog" + "encoding/json" +) + +func (a *AuthStore) HandleAuthStatus(w http.ResponseWriter, r *http.Request) { + sess := a.sessionManager.GetSession(r) + auth, _ := sess.Get("authenticated").(bool) + if !auth { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) +} + +func (a *AuthStore) 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 !a.CompareCreds(username, password) { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + sess := a.sessionManager.GetSession(r) + sess.Put("authenticated", true) + sess.Put("username", username) + + if err := a.sessionManager.Migrate(sess); err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + slog.Info("successful login", "user", username) +} + +func (a *AuthStore) HandleLogout(w http.ResponseWriter, r *http.Request) { + sess := a.sessionManager.GetSession(r) + sess.Delete("authenticated") + sess.Delete("username") + w.WriteHeader(http.StatusOK) +} + +func (a *AuthStore) HandleCSRF(w http.ResponseWriter, r *http.Request) { + session := a.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()) + } +} diff --git a/src/web/backend/middleware.go b/src/web/backend/auth/middleware.go similarity index 99% rename from src/web/backend/middleware.go rename to src/web/backend/auth/middleware.go index 8400fa2..7f719a1 100644 --- a/src/web/backend/middleware.go +++ b/src/web/backend/auth/middleware.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "fmt" diff --git a/src/web/backend/session.go b/src/web/backend/auth/session.go similarity index 99% rename from src/web/backend/session.go rename to src/web/backend/auth/session.go index de9c48a..dc6c5ec 100644 --- a/src/web/backend/session.go +++ b/src/web/backend/auth/session.go @@ -1,4 +1,4 @@ -package backend +package auth import ( "context" diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index cfcbdb2..3ae56be 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -36,12 +36,12 @@ func (s *Server) registerRoutes() { } func (s *Server) registerAuthRoutes() { - s.mux.Handle("POST /api/ui/logout", s.auth(s.handleLogout)) + s.mux.Handle("POST /api/ui/logout", s.auth(s.authStore.HandleLogout)) // Public routes - s.mux.HandleFunc("GET /api/ui/csrf", s.csrfHandler) - s.mux.HandleFunc("POST /api/ui/login", s.handleLogin) - s.mux.HandleFunc("GET /api/ui/auth/status", s.handleAuthStatus) + s.mux.HandleFunc("GET /api/ui/csrf", s.authStore.HandleCSRF) + s.mux.HandleFunc("POST /api/ui/login", s.authStore.HandleLogin) + s.mux.HandleFunc("GET /api/ui/auth/status", s.authStore.HandleAuthStatus) } func (s *Server) registerSettingRoutes() { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 329bb27..c9db0db 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -19,6 +19,7 @@ import ( "explo/src/web/backend/playlist" "explo/src/web/backend/jobs" "explo/src/web/backend/settings" + "explo/src/web/backend/auth" ) // ConfigResponse is returned by GET /api/config. @@ -31,23 +32,22 @@ type Server struct { cfg config.ServerConfig mux *http.ServeMux server *http.Server - authStore *AuthStore + authStore *auth.AuthStore settings *settings.Settings cronJobs *jobs.Jobs - sessionManager *SessionManager 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, @@ -73,9 +73,8 @@ func NewServer(cfg config.ServerConfig) *Server { Handler: sessionManager.Handle(mux), }, authStore: authStore, - settings: settings, + settings: settings, cronJobs: cronJobs, - sessionManager: sessionManager, manualRun: manualRun, customPlaylist: playlist, } @@ -199,49 +198,6 @@ func (s *Server) openRunLog() (*os.File, error) { return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) } -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()) @@ -255,20 +211,6 @@ func (s *Server) handleGetLog(w http.ResponseWriter, r *http.Request) { } } -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()) - } -} - // 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")) From 4f27754d01fda2f94df1828877f37fa30a1042b2 Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 25 Jun 2026 19:25:55 +0300 Subject: [PATCH 28/35] separate handlers --- src/web/backend/handlers.go | 55 ++++++ src/web/backend/server.go | 346 +----------------------------------- 2 files changed, 56 insertions(+), 345 deletions(-) create mode 100644 src/web/backend/handlers.go diff --git a/src/web/backend/handlers.go b/src/web/backend/handlers.go new file mode 100644 index 0000000..383ad05 --- /dev/null +++ b/src/web/backend/handlers.go @@ -0,0 +1,55 @@ +package backend + +import ( + "os" + "net/http" + "path/filepath" + "log/slog" + "encoding/json" + "strings" +) + +// 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()) + } +} + +// 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()) + } +} \ No newline at end of file diff --git a/src/web/backend/server.go b/src/web/backend/server.go index c9db0db..99dfa3b 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -241,348 +241,4 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(dirs); err != nil { slog.Warn("failed to encode directories to response", "err", err.Error()) } -<<<<<<< HEAD -} - -// ── 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"), - r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - 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() - // Echo to stdout so runs show up in docker logs. - _, _ = fmt.Fprintln(os.Stdout, line) - 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 string, noPersist, excludeLocal bool, WebEnvPath string) []string { - args := []string{"--config", WebEnvPath} - if playlist != "" { - args = append(args, "--playlist", playlist) - } - if downloadMode != "" { - args = append(args, "--download-mode", downloadMode) - } - if noPersist { - args = append(args, "--persist=false") - } - if excludeLocal { - args = append(args, "--exclude-local") - } - return args -} -======= -} ->>>>>>> e957089 (remove functions from server.go) +} \ No newline at end of file From b8c9b51ddbdc883d956e46ddb3fa315de321355b Mon Sep 17 00:00:00 2001 From: LumePart Date: Thu, 25 Jun 2026 22:58:06 +0300 Subject: [PATCH 29/35] replace package config with app.Config --- src/web/backend/run/events.go | 2 +- src/web/backend/run/handlers.go | 2 +- src/web/backend/run/manual_run.go | 16 +++------------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index e306fad..98fbe08 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -77,7 +77,7 @@ func (mr *ManualRun) unsubscribeRun(ch chan runEvent) { // logPath returns the path to the single rolling log file. func (mr *ManualRun) logPath() string { - return filepath.Join(mr.cfg.webDataDir, "logs", "explo.log") + return filepath.Join(mr.cfg.WebDataDir, "logs", "explo.log") } // initServerLog redirects the default slog handler so all server log output diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go index 8b48133..595090f 100644 --- a/src/web/backend/run/handlers.go +++ b/src/web/backend/run/handlers.go @@ -17,7 +17,7 @@ func (mr *ManualRun) HandleRun(w http.ResponseWriter, r *http.Request) { args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - mr.cfg.webEnvPath) + 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 0be2eb3..5999793 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -19,12 +19,6 @@ type RunStatus struct { ExitCode *int `json:"exit_code,omitempty"` } -type Config struct { - webDataDir string - webEnvPath string - exploPath string -} - type manualRunState struct { mu sync.Mutex running bool @@ -41,7 +35,7 @@ type runEvent struct { } type ManualRun struct { - cfg Config + cfg app.Config state manualRunState } @@ -49,11 +43,7 @@ var errRunAlreadyStarted = errors.New("run already in progress") func NewManualRun(cfg app.Config) *ManualRun { return &ManualRun{ - cfg: Config{ - webDataDir: cfg.WebDataDir, - webEnvPath: cfg.WebEnvPath, - exploPath: cfg.ExploPath, - }, + cfg: cfg, state: newManualRunState(), } } @@ -64,7 +54,7 @@ func newManualRunState() manualRunState { func (mr *ManualRun) startRun(args []string) error { ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, mr.cfg.exploPath, args...) + cmd := exec.CommandContext(ctx, mr.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() { From 5c2ef1e14e2bac7a35f782bedc2e406d84c5b0b8 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:08:24 +0300 Subject: [PATCH 30/35] rebase --- src/main/main.go | 5 +--- src/web/backend/server.go | 45 ---------------------------- src/web/backend/settings/settings.go | 2 +- 3 files changed, 2 insertions(+), 50 deletions(-) diff --git a/src/main/main.go b/src/main/main.go index efb6c18..bc97b3b 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -187,7 +187,7 @@ func main() { } allTracks := append([]*models.Track(nil), tracks...) if cfg.ServerCfg.WebDataDir != "" { - backend.WritePlaylistCache(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist, allTracks, nil) + playlist.WritePlaylistCache(cfg.ServerCfg.WebDataDir, cfg.Flags.Playlist, allTracks, nil) slog.Info("Saved playlist", "playlist", cfg.Flags.Playlist, "tracks", len(allTracks)) } @@ -224,8 +224,6 @@ func main() { } } -<<<<<<< HEAD -======= if cfg.ServerCfg.Enabled { added := make(map[string]bool) for _, t := range tracks { @@ -234,7 +232,6 @@ func main() { playlist.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) } ->>>>>>> 9129feb (move files under separate packages, add backend util functions) if err := client.CreatePlaylist(tracks); err != nil { slog.Warn(err.Error()) } else { diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 99dfa3b..c630d47 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -196,49 +196,4 @@ func (s *Server) openRunLog() (*os.File, error) { return nil, err } return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) -} - -// 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()) - } -} - -// 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()) - } } \ No newline at end of file diff --git a/src/web/backend/settings/settings.go b/src/web/backend/settings/settings.go index ace6e5c..cebf45a 100644 --- a/src/web/backend/settings/settings.go +++ b/src/web/backend/settings/settings.go @@ -112,7 +112,7 @@ func formatEnvValue(v string) string { return v } - if strings.ContainsAny(v, `"$#?' `) { + if strings.ContainsAny(v, `"$#?'`) { // escape single quotes inside value v = strings.ReplaceAll(v, `'`, `'\''`) return fmt.Sprintf(`'%s'`, v) From de918938bfb24d7a61160073a46714ec89d634e0 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:17:00 +0300 Subject: [PATCH 31/35] fix linter --- src/web/backend/playlist/jobs.go | 7 ------- src/web/backend/run/events.go | 2 +- src/web/backend/server.go | 14 +------------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/web/backend/playlist/jobs.go b/src/web/backend/playlist/jobs.go index b708f86..abf444a 100644 --- a/src/web/backend/playlist/jobs.go +++ b/src/web/backend/playlist/jobs.go @@ -10,13 +10,6 @@ import ( "github.com/go-co-op/gocron/v2" ) -type fileInfo struct { - path string - size int64 - modTime time.Time -} - - // RegisterCustomPlaylistRefresh registers a cache-refresh job for each custom playlist // using its stored schedule. Falls back to daily at 4 AM if no schedule is set. func (p *Playlist) RegisterCustomPlaylistRefresh(j *jobs.Jobs) error { diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index 98fbe08..8e4c402 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -82,7 +82,7 @@ func (mr *ManualRun) logPath() string { // initServerLog redirects the default slog handler so all server log output // goes to both stderr and the rolling log file. -func (mr *ManualRun) initServerLog() { +func (mr *ManualRun) InitServerLog() { lf, err := mr.openRunLog() if err != nil { return diff --git a/src/web/backend/server.go b/src/web/backend/server.go index c630d47..3d58fb7 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -3,7 +3,6 @@ package backend import ( "encoding/json" "fmt" - "io" "io/fs" "log/slog" "net/http" @@ -84,7 +83,7 @@ func NewServer(cfg config.ServerConfig) *Server { } func (s *Server) Start() error { - s.initServerLog() + s.manualRun.InitServerLog() s.startJobs() coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") if _, err := os.Stat(coversDir); os.IsNotExist(err) { @@ -178,17 +177,6 @@ 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() From b5e049f71ce611ab45826d9c3688aa9f162afc22 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:20:09 +0300 Subject: [PATCH 32/35] remove log funcs from server.go --- src/web/backend/handlers.go | 2 +- src/web/backend/run/events.go | 4 ++-- src/web/backend/server.go | 16 ---------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/web/backend/handlers.go b/src/web/backend/handlers.go index 383ad05..aab35a0 100644 --- a/src/web/backend/handlers.go +++ b/src/web/backend/handlers.go @@ -11,7 +11,7 @@ import ( // 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()) + data, err := os.ReadFile(s.manualRun.LogPath()) if err != nil && !os.IsNotExist(err) { http.Error(w, "failed to read log", http.StatusInternalServerError) return diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index 8e4c402..9442cef 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -76,7 +76,7 @@ func (mr *ManualRun) unsubscribeRun(ch chan runEvent) { } // logPath returns the path to the single rolling log file. -func (mr *ManualRun) logPath() string { +func (mr *ManualRun) LogPath() string { return filepath.Join(mr.cfg.WebDataDir, "logs", "explo.log") } @@ -93,7 +93,7 @@ func (mr *ManualRun) InitServerLog() { // openRunLog opens the single rolling log file in append mode. func (mr *ManualRun) openRunLog() (*os.File, error) { - p := mr.logPath() + p := mr.LogPath() if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { return nil, err } diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 3d58fb7..bcd2c3c 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -168,20 +168,4 @@ func spaFS() (fs.FS, []byte) { embedded, _ := fs.Sub(web.DistFiles, "dist") index, _ := fs.ReadFile(embedded, "index.html") return embedded, index -} - -// ── 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") -} - -// 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) } \ No newline at end of file From b3806214302a2b0eae94c374bba803dfeed6d7ec Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:27:36 +0300 Subject: [PATCH 33/35] add context to log --- src/web/backend/settings/handlers.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index ab0b3c4..3225673 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -230,6 +230,19 @@ func (s *Settings) HandleWizardStep1(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + + for k, v := range updates { + if v == "" { + if err := os.Unsetenv(k); err != nil { + slog.Warn("failed to unset env variable", "key", k, "err", err) + } + } else { + if err := os.Setenv(k, v); err != nil { + slog.Warn("failed to set env variable", "key", k, "err", err) + } + } + } + w.WriteHeader(http.StatusOK) } From e4812c48ef55ac24e307af6284d5aba33460290e Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:31:07 +0300 Subject: [PATCH 34/35] remove redundant func --- src/main/main.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/main.go b/src/main/main.go index bc97b3b..25c196a 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -224,14 +224,6 @@ func main() { } } - if cfg.ServerCfg.Enabled { - added := make(map[string]bool) - for _, t := range tracks { - added[t.CleanTitle+"|"+t.Artist] = true - } - playlist.WritePlaylistCache(cfg.Flags.CfgPath, cfg.Flags.Playlist, allTracks, added) - } - if err := client.CreatePlaylist(tracks); err != nil { slog.Warn(err.Error()) } else { From 33746b7375afcd400468b6f3983aab4905f11fb5 Mon Sep 17 00:00:00 2001 From: LumePart Date: Mon, 29 Jun 2026 23:33:04 +0300 Subject: [PATCH 35/35] add fix lost with rebase --- src/web/backend/run/events.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index 9442cef..3f958bb 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -48,6 +48,8 @@ func (mr *ManualRun) collectRunOutput(cmd *exec.Cmd, pr *os.File, lf *os.File) { scanner := bufio.NewScanner(pr) for scanner.Scan() { line := scanner.Text() + // Echo to stdout so runs show up in docker logs. + _, _ = fmt.Fprintln(os.Stdout, line) if lf != nil { if _, err := fmt.Fprintln(lf, line); err != nil { mr.appendRunLog("failed to write run output: " + err.Error())