diff --git a/src/config/config.go b/src/config/config.go index a68b6ce..db5c77d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -38,8 +38,9 @@ type Flags struct { ExcludeLocal bool Persist bool PersistSet bool - SearchMBID string - RefreshOnly bool + SearchMBID string + RefreshOnly bool + CleanDownloads bool } type ServerConfig struct { diff --git a/src/config/flags.go b/src/config/flags.go index 7059331..5c77f83 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -24,6 +24,7 @@ func (cfg *Config) GetFlags() error { var showVersion bool var searchMBID string var refreshOnly bool + var cleanDownloads bool // Long flags flag.StringVarP(&configPath, "config", "c", ".env", "Path of the configuration file") flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") @@ -33,6 +34,7 @@ func (cfg *Config) GetFlags() error { flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") flag.StringVar(&searchMBID, "search-mbid", "", "Test Plex search for a single recording MBID (resolves via ListenBrainz, then searches your library)") flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") + flag.BoolVar(&cleanDownloads, "clean-downloads", false, "Delete previously downloaded tracks before downloading new ones (requires USE_SUBDIRECTORY)") flag.Parse() @@ -64,6 +66,7 @@ func (cfg *Config) GetFlags() error { cfg.Flags.Persist = persist cfg.Flags.SearchMBID = searchMBID cfg.Flags.RefreshOnly = refreshOnly + cfg.Flags.CleanDownloads = cleanDownloads // for deprecation purposes (can be removed at a later date) cfg.Flags.PersistSet = persistSet diff --git a/src/main/main.go b/src/main/main.go index 32ad062..31e613d 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -199,9 +199,9 @@ func main() { if err != nil { slog.Warn(err.Error(), "notify", true) } - if cfg.DownloadCfg.UseSubDir { - downloader.DeleteSongs() - } + } + if cfg.Flags.CleanDownloads && cfg.DownloadCfg.UseSubDir { + downloader.DeleteSongs() } if cfg.Flags.DownloadMode != "force" { if err := client.CheckTracks(tracks); err != nil { // Check if tracks exist on system before downloading diff --git a/src/web/backend/server.go b/src/web/backend/server.go index ffa1403..499c2c6 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -245,6 +245,8 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule))) s.mux.Handle("/api/ui/config/path-template", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePathTemplate))) s.mux.Handle("/api/ui/config/enrich-metadata", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveEnrichMetadata))) + s.mux.Handle("/api/ui/config/persist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePersist))) + s.mux.Handle("/api/ui/config/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) { @@ -555,6 +557,20 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { 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 @@ -627,6 +643,91 @@ func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request 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 { diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index ffd72cc..172b805 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -15,6 +15,7 @@ import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata, + savePersist, saveCleanDownloads, fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' @@ -223,6 +224,7 @@ function HomeSection() { ]).then(([{ values, sources }, customList]) => { setEnvSources(sources || {}) setLbUser(values.LISTENBRAINZ_USER || '') + setNoPersist((values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '').includes('--persist=false')) setCustomPlaylists(customList) const s = {} @@ -512,6 +514,8 @@ function DownloadPathSection() { const [showModal, setShowModal] = useState(false) const [openMenuIdx, setOpenMenuIdx] = useState(null) const [enrichEnabled, setEnrichEnabled] = useState(false) + const [replacePlaylist, setReplacePlaylist] = useState(true) + const [cleanDownloads, setCleanDownloads] = useState(false) const [templateEnabled, setTemplateEnabled] = useState(false) useEffect(() => { @@ -524,6 +528,9 @@ function DownloadPathSection() { ...jsonPresets, ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') + const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' + setReplacePlaylist(anyFlags.includes('--persist=false')) + setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' if (t) { setTemplateEnabled(true) @@ -551,6 +558,18 @@ function DownloadPathSection() { try { await saveEnrichMetadata(next) } catch { setEnrichEnabled(!next) } } + const handleReplaceToggle = async () => { + const next = !replacePlaylist + setReplacePlaylist(next) + try { await savePersist(!next) } catch { setReplacePlaylist(!next) } + } + + const handleCleanToggle = async () => { + const next = !cleanDownloads + setCleanDownloads(next) + try { await saveCleanDownloads(next) } catch { setCleanDownloads(!next) } + } + const handleTemplateToggle = async () => { if (templateEnabled) { setTemplateEnabled(false) @@ -636,6 +655,38 @@ function DownloadPathSection() { + {/* Replace playlist toggle */} +
+
+ Update playlist in place + Keep a single playlist per type and refresh it with new recommendations each run. When off, a new playlist is created every time and previous ones are kept. +
+ +
+ + {/* Clean old downloads toggle */} +
+
+ Clean old downloads + Remove previously downloaded tracks before each run. Only affects Explo's download folder, not your main library. +
+ +
+ {/* Organize into folders toggle */}
diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index 4920bf2..7b53bcc 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -231,6 +231,24 @@ export async function saveEnrichMetadata(enabled) { if (!res.ok) throw new Error(await res.text()) } +export async function savePersist(enabled) { + const res = await apiFetch('/api/ui/config/persist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }) + if (!res.ok) throw new Error(await res.text()) +} + +export async function saveCleanDownloads(enabled) { + const res = await apiFetch('/api/ui/config/clean-downloads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }) + if (!res.ok) throw new Error(await res.text()) +} + export async function fetchBackgroundArt() { try { const res = await fetch('/api/ui/background-art')