diff --git a/src/config/config.go b/src/config/config.go index 8eabc58..2f23b1e 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -23,8 +23,8 @@ type Config struct { NotifyCfg NotifyConfig ServerCfg ServerConfig Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool + ReplacePlaylistENV bool `env:"REPLACE_PLAYLIST" env-default:"true"` + ReplacePlaylist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` @@ -37,8 +37,8 @@ type Flags struct { PlaylistSet bool DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + ReplacePlaylist bool + ReplacePlaylistSet bool SearchMBID string RefreshOnly bool CleanDownloads bool @@ -257,8 +257,8 @@ func (cfg *Config) HandleDeprecation() { // slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" } - if cfg.Flags.PersistSet { - slog.Warn("--persist flag now only handles playlist deletion, use toggle in UI or --clean-downloads to delete tracks") + if cfg.Flags.ReplacePlaylistSet { + slog.Warn("--replace flag is deprecated, use the toggle in the UI instead") } if cfg.Flags.CleanDownloads && !cfg.DownloadCfg.UseSubDir { @@ -269,7 +269,7 @@ func (cfg *Config) HandleDeprecation() { // // Generate playlist name and description func (cfg *Config) GenPlaylistDetails() { - cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist) + cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.ReplacePlaylist) cfg.ClientCfg.PlaylistDescr = fmt.Sprintf( "Created for %s by Explo, using ListenBrainz recommendations.", cfg.DiscoveryCfg.Listenbrainz.User) @@ -282,14 +282,13 @@ func (cfg *Config) GenPlaylistDetails() { } } -func getPlaylistName(playlistType, format string, persist bool) string { - +func getPlaylistName(playlistType, format string, replace bool) string { toTitle := cases.Title(language.Und) base := toTitle.String(playlistType) - // Non-persistent or custom playlists always use base name - if !persist || strings.HasPrefix(playlistType, "custom-") { + // When replacing or for custom playlists, always use base name + if replace || strings.HasPrefix(playlistType, "custom-") { return base } diff --git a/src/config/flags.go b/src/config/flags.go index dfeac6e..e5a39d8 100644 --- a/src/config/flags.go +++ b/src/config/flags.go @@ -20,7 +20,7 @@ func (cfg *Config) GetFlags() error { var playlist string var downloadMode string var excludeLocal bool - var persist bool + var replace bool var showVersion bool var searchMBID string var refreshOnly bool @@ -30,7 +30,7 @@ func (cfg *Config) GetFlags() error { flag.StringVarP(&playlist, "playlist", "p", "weekly-exploration", "Playlist where to get tracks. Supported: weekly-exploration, weekly-jams, daily-jams, on-repeat") flag.StringVarP(&downloadMode, "download-mode", "d", "normal", "Download mode: 'normal' (download only when track is not found locally), 'skip' (skip downloading, only use tracks already found locally), 'force' (always download, don't check for local tracks)") flag.BoolVarP(&excludeLocal, "exclude-local", "e", false, "Exclude locally found tracks from the imported playlist") - flag.BoolVar(&persist, "persist", true, "Keep playlists between generations") + flag.BoolVar(&replace, "replace", true, "Replace existing playlist with the same name") flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") flag.StringVar(&searchMBID, "search-mbid", "", "Test Plex search for a single recording MBID (resolves via ListenBrainz, then searches your library)") flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") @@ -42,7 +42,7 @@ func (cfg *Config) GetFlags() error { fmt.Println(Version) os.Exit(0) } - persistSet := flag.Lookup("persist").Changed + replaceSet := flag.Lookup("replace").Changed cfgSet := flag.Lookup("config").Changed playlistSet := flag.Lookup("playlist").Changed @@ -65,13 +65,13 @@ func (cfg *Config) GetFlags() error { cfg.Flags.PlaylistSet = playlistSet cfg.Flags.DownloadMode = downloadMode cfg.Flags.ExcludeLocal = excludeLocal - cfg.Flags.Persist = persist + cfg.Flags.ReplacePlaylist = replace cfg.Flags.SearchMBID = searchMBID cfg.Flags.RefreshOnly = refreshOnly cfg.Flags.CleanDownloads = cleanDownloads // for deprecation purposes (can be removed at a later date) - cfg.Flags.PersistSet = persistSet + cfg.Flags.ReplacePlaylistSet = replaceSet return nil } @@ -84,10 +84,10 @@ func (cfg *Config) MergeFlags() { cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } - if cfg.Flags.PersistSet { - cfg.Persist = cfg.Flags.Persist + if cfg.Flags.ReplacePlaylistSet { + cfg.ReplacePlaylist = cfg.Flags.ReplacePlaylist } else { - cfg.Persist = cfg.PersistENV + cfg.ReplacePlaylist = cfg.ReplacePlaylistENV } } diff --git a/src/main/main.go b/src/main/main.go index 7fe3d0d..d131836 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -194,7 +194,7 @@ func main() { slog.Error(err.Error(), "notify", true) os.Exit(1) } - if !cfg.Persist { + if cfg.ReplacePlaylist { err := client.DeletePlaylist() if err != nil { slog.Warn(err.Error(), "notify", true) diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go index 8a22dee..16629ec 100644 --- a/src/web/backend/defs.go +++ b/src/web/backend/defs.go @@ -156,7 +156,7 @@ var allConfigKeys = []string{ "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", - "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", + "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "ENRICH_TRACK_METADATA", "REPLACE_PLAYLIST", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", diff --git a/src/web/backend/server.go b/src/web/backend/server.go index c319fe3..1fe64d2 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -112,7 +112,37 @@ func NewServer(cfg config.ServerConfig) *Server { return s } +// migratePersistEnv converts the old PERSIST env var to REPLACE_PLAYLIST (inverted). +// TODO: REMOVE THIS AFTER NEXT RELEASE — one-time migration for existing users. +func (s *Server) migratePersistEnv() { + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil { + return + } + env := parseEnvText(string(data)) + if _, hasPersist := env["PERSIST"]; !hasPersist { + return + } + if _, hasReplace := env["REPLACE_PLAYLIST"]; hasReplace { + // Already migrated, just clean up old key + _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PERSIST": ""}, web.SampleEnv) + return + } + // PERSIST=true (accumulate) → REPLACE_PLAYLIST=false + // PERSIST=false (replace) → REPLACE_PLAYLIST=true + val := "true" + if env["PERSIST"] == "true" { + val = "false" + } + _ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{ + "REPLACE_PLAYLIST": val, + "PERSIST": "", + }, web.SampleEnv) + slog.Info("migrated PERSIST env var to REPLACE_PLAYLIST", "value", val) +} + func (s *Server) Start() error { + s.migratePersistEnv() s.initServerLog() s.startJobs() coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") @@ -245,7 +275,7 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule))) s.mux.Handle("/api/ui/config/path-template", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePathTemplate))) s.mux.Handle("/api/ui/config/enrich-metadata", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveEnrichMetadata))) - s.mux.Handle("/api/ui/config/persist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePersist))) + s.mux.Handle("/api/ui/config/replace-playlist", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveReplacePlaylist))) s.mux.Handle("/api/ui/config/clean-downloads", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveCleanDownloads))) // Path template presets: GET list, POST add; DELETE per name under prefix @@ -557,13 +587,10 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { return } - // Carry over --persist=false / --clean-downloads if globally set + // Carry over --clean-downloads if globally set data, _ := os.ReadFile(s.cfg.WebEnvPath) for k, v := range parseEnvText(string(data)) { if strings.HasSuffix(k, "_FLAGS") && v != "" { - if strings.Contains(v, "--persist=false") { - defaultFlags = addFlag(defaultFlags, "--persist=false") - } if strings.Contains(v, "--clean-downloads") { defaultFlags = addFlag(defaultFlags, "--clean-downloads") } @@ -643,9 +670,8 @@ 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) { +// handleSaveReplacePlaylist writes REPLACE_PLAYLIST=true/false to the .env file. +func (s *Server) handleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -657,17 +683,14 @@ func (s *Server) handleSavePersist(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } - - if err := s.toggleFlagInEnv(!body.Enabled, "--persist=false"); err != nil { + val := "false" + if body.Enabled { + val = "true" + } + if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"REPLACE_PLAYLIST": val}, web.SampleEnv); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - - // 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) } @@ -753,7 +776,8 @@ func updateEnvKeys(path string, updates map[string]string, fallback []byte) erro key = strings.TrimSpace(key) if val, ok := updates[key]; ok { if val == "" { - lines[i] = "" // remove by blanking + lines[i] = "" // remove by blanking + _ = os.Unsetenv(key) // drop stale copy cleanenv set at startup } else { lines[i] = key + "=" + formatEnvValue(val) } @@ -993,9 +1017,7 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { return } - args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), - r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - s.cfg.WebEnvPath) + args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), s.cfg.WebEnvPath) if err := s.startRun(args); err != nil { if errors.Is(err, errRunAlreadyStarted) { @@ -1303,7 +1325,7 @@ func (s *Server) unsubscribeRun(ch chan runEvent) { // ── Helpers ──────────────────────────────────────────────────────────────── -func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { +func buildArgs(playlist, downloadMode, WebEnvPath string) []string { args := []string{"--config", WebEnvPath} if playlist != "" { args = append(args, "--playlist", playlist) @@ -1311,11 +1333,5 @@ func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebE if downloadMode != "" { args = append(args, "--download-mode", downloadMode) } - if noPersist { - args = append(args, "--persist=false") - } - if excludeLocal { - args = append(args, "--exclude-local") - } return args } diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 172b805..efddf57 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -15,7 +15,7 @@ import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata, - savePersist, saveCleanDownloads, + saveReplacePlaylist, saveCleanDownloads, fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' @@ -34,7 +34,7 @@ const tabBtnCls = active => // ── Home Tab ────────────────────────────────────────────────────────────────── // Manages scheduled playlists, manual runs, and live run output. -// Fetches its own config on mount to initialise schedule state and locked keys. +// Fetches its own config on mount to initialise schedule state. // Streams live run output from /api/ui/run/events function useSSE({ onLine, onDone }) { @@ -199,7 +199,6 @@ function CustomPlaylistsSection({ function HomeSection() { const [schedules, setSchedules] = useState(null) - const [envSources, setEnvSources] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) const [lbUser, setLbUser] = useState('') const [openTracklist, setOpenTracklist] = useState(null) @@ -208,8 +207,6 @@ function HomeSection() { const [playlist, setPlaylist] = useState('weekly-exploration') const [dlmode, setDlmode] = useState('normal') - const [noPersist, setNoPersist] = useState(false) - const [excludeLocal, setExcludeLocal] = useState(false) const [running, setRunning] = useState(false) const [status, setStatus] = useState('') @@ -221,10 +218,8 @@ function HomeSection() { Promise.all([ fetchConfig(), fetchCustomPlaylists().catch(() => []) - ]).then(([{ values, sources }, customList]) => { - setEnvSources(sources || {}) + ]).then(([{ values }, customList]) => { 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 = {} @@ -271,11 +266,6 @@ function HomeSection() { return () => disconnect() }, [connect, disconnect]) - const isScheduleLocked = id => { - const p = PLAYLISTS.find(p => p.value === id) - return p ? envSources[p.scheduleKey] === 'env' : false - } - const nextRunText = id => { const s = schedules[id] if (!s?.enabled) return 'Disabled' @@ -302,7 +292,6 @@ function HomeSection() { ...prev, [id]: { ...prev[id], editing: !prev[id].editing } })), onSave: () => { - if (isScheduleLocked(id)) return saveSchedule(id, s.enabled, s.day, s.hour, s.minute) .then(() => flashStatus(id, 'Saved.')) .catch(() => flashStatus(id, 'Error saving.')) @@ -328,7 +317,7 @@ function HomeSection() { setLogEntries([]) setStatus('running…') try { - await startRun(playlist, dlmode, !noPersist, excludeLocal) + await startRun(playlist, dlmode) connect() } catch (e) { if (e.conflict) { setStatus('already running'); setRunning(false); return } @@ -356,7 +345,6 @@ function HomeSection() { key={p.value} playlist={p} {...scheduleProps(p.value)} - locked={isScheduleLocked(p.value)} fixedSchedule={!!p.fixedSchedule} index={i} nextRunText={nextRunText(p.value)} @@ -370,7 +358,7 @@ function HomeSection() { lbUser={lbUser} playlist={openTracklist} onRun={async () => { - await startRun(openTracklist, 'normal', true, false) + await startRun(openTracklist, 'normal') setRunning(true) setStatus('running…') setLogEntries([]) @@ -410,7 +398,7 @@ function HomeSection() { setShowImportModal(false) }} onSync={async (id) => { - await startRun(id, 'normal', true, false) + await startRun(id, 'normal') setRunning(true) setStatus('running…') setLogEntries([]) @@ -440,7 +428,7 @@ function HomeSection() { @@ -528,8 +510,8 @@ function DownloadPathSection() { ...jsonPresets, ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') + setReplacePlaylist(values.REPLACE_PLAYLIST !== 'false') const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' - setReplacePlaylist(anyFlags.includes('--persist=false')) setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' if (t) { @@ -561,7 +543,7 @@ function DownloadPathSection() { const handleReplaceToggle = async () => { const next = !replacePlaylist setReplacePlaylist(next) - try { await savePersist(!next) } catch { setReplacePlaylist(!next) } + try { await saveReplacePlaylist(next) } catch { setReplacePlaylist(!next) } } const handleCleanToggle = async () => { diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index d53b3e9..5b9f011 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -358,7 +358,6 @@ const NOISE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' export function PlaylistCard({ playlist, schedule: s, - locked, fixedSchedule = false, index = 0, nextRunText, @@ -445,7 +444,7 @@ export function PlaylistCard({ const [copyLabel, setCopyLabel] = useState('Copy URL') const [cardHovered, setCardHovered] = useState(false) const menuBtnRef = useRef(null) - const canEdit = !locked && !fixedSchedule && !!onToggleEdit + const canEdit = !fixedSchedule && !!onToggleEdit const hasMenu = canEdit || !!onDelete || !!sourceUrl useEffect(() => { @@ -600,25 +599,16 @@ export function PlaylistCard({ {/* Toggle — bottom right */} {onToggle && ( - <> - - {locked && ( - ENV - )} - + )} @@ -747,7 +737,7 @@ export function PlaylistCard({ {/* Inline schedule editor */} - {!locked && !fixedSchedule && ( + {!fixedSchedule && (