diff --git a/src/config/config.go b/src/config/config.go index 512c6a9..568a10d 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -23,8 +23,7 @@ type Config struct { NotifyCfg NotifyConfig ServerCfg ServerConfig Flags Flags - PersistENV bool `env:"PERSIST" env-default:"true"` - Persist bool + ReplacePlaylist bool System string `env:"EXPLO_SYSTEM"` Debug bool `env:"DEBUG" env-default:"false"` LogLevel string `env:"LOG_LEVEL" env-default:"INFO"` @@ -37,8 +36,7 @@ type Flags struct { PlaylistSet bool DownloadMode string ExcludeLocal bool - Persist bool - PersistSet bool + ReplacePlaylist bool SearchMBID string RefreshOnly bool CleanDownloads bool @@ -258,11 +256,7 @@ func (cfg *Config) HandleDeprecation() { // slog.Warn("'DEBUG' variable is deprecated, please use LOG_LEVEL=DEBUG instead") cfg.LogLevel = "DEBUG" } - if cfg.Flags.PersistSet { - slog.Warn("--persist flag now only handles playlist deletion, use toggle in UI or --clean-downloads to delete tracks") - } - - if cfg.DiscoveryCfg.Listenbrainz.UserToken == "" { +if cfg.DiscoveryCfg.Listenbrainz.UserToken == "" { slog.Warn("Provide a ListenBrainz user token via Wizard or env (LISTENBRAINZ_USER_TOKEN)") } @@ -274,7 +268,7 @@ func (cfg *Config) HandleDeprecation() { // // Generate playlist name and description func (cfg *Config) GenPlaylistDetails() { - cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.Persist) + cfg.ClientCfg.PlaylistName = getPlaylistName(cfg.Flags.Playlist, cfg.ClientCfg.PlaylistNFormat, cfg.ReplacePlaylist) cfg.ClientCfg.PlaylistDescr = fmt.Sprintf( "Created for %s by Explo, using ListenBrainz recommendations.", cfg.DiscoveryCfg.Listenbrainz.User) @@ -287,14 +281,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..5213c4d 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-playlist", true, "Replace existing playlist with the same name") flag.BoolVarP(&showVersion, "version", "v", false, "Print version and exit") flag.StringVar(&searchMBID, "search-mbid", "", "Test Plex search for a single recording MBID (resolves via ListenBrainz, then searches your library)") flag.BoolVar(&refreshOnly, "refresh-only", false, "Trigger alibrary rescan and exit; skips discovery and downloads") @@ -42,7 +42,6 @@ func (cfg *Config) GetFlags() error { fmt.Println(Version) os.Exit(0) } - persistSet := flag.Lookup("persist").Changed cfgSet := flag.Lookup("config").Changed playlistSet := flag.Lookup("playlist").Changed @@ -65,14 +64,11 @@ 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 - return nil } @@ -84,11 +80,7 @@ func (cfg *Config) MergeFlags() { cfg.ServerCfg.WebEnvPath = cfg.Flags.CfgPath } - if cfg.Flags.PersistSet { - cfg.Persist = cfg.Flags.Persist - } else { - cfg.Persist = cfg.PersistENV - } + cfg.ReplacePlaylist = cfg.Flags.ReplacePlaylist } func contains(valid []string, val string) bool { diff --git a/src/main/main.go b/src/main/main.go index 25c196a..8382230 100644 --- a/src/main/main.go +++ b/src/main/main.go @@ -201,7 +201,7 @@ func main() { slog.Error(err.Error(), "notify", true) os.Exit(1) } - if !cfg.Persist { + if cfg.ReplacePlaylist { err := client.DeletePlaylist() if err != nil { slog.Warn(err.Error(), "notify", true) diff --git a/src/web/backend/routes.go b/src/web/backend/routes.go index 3ae56be..ec75b4b 100644 --- a/src/web/backend/routes.go +++ b/src/web/backend/routes.go @@ -53,6 +53,8 @@ func (s *Server) registerSettingRoutes() { s.mux.Handle("POST /api/ui/config/schedules", s.auth(s.settings.HandleSaveSchedule)) s.mux.Handle("POST /api/ui/config/path-template", s.auth(s.settings.HandleSavePathTemplate)) s.mux.Handle("POST /api/ui/config/enrich-metadata", s.auth(s.settings.HandleSaveEnrichMetadata)) + s.mux.Handle("POST /api/ui/config/replace-playlist", s.auth(s.settings.HandleSaveReplacePlaylist)) + s.mux.Handle("POST /api/ui/config/clean-downloads", s.auth(s.settings.HandleSaveCleanDownloads)) // Path template presets: GET list, POST add; DELETE per name under prefix s.mux.Handle("api/ui/path-templates", s.auth(s.settings.HandlePathTemplates)) diff --git a/src/web/backend/run/handlers.go b/src/web/backend/run/handlers.go index 595090f..7633aa4 100644 --- a/src/web/backend/run/handlers.go +++ b/src/web/backend/run/handlers.go @@ -15,9 +15,8 @@ func (mr *ManualRun) HandleRun(w http.ResponseWriter, r *http.Request) { return } - args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), - r.FormValue("persist") == "false", r.FormValue("exclude_local") == "true", - mr.cfg.WebEnvPath) + replacePlaylist := r.FormValue("replace_playlist") != "false" + args := buildArgs(r.FormValue("playlist"), r.FormValue("download_mode"), mr.cfg.WebEnvPath, replacePlaylist) if err := mr.startRun(args); err != nil { if errors.Is(err, errRunAlreadyStarted) { diff --git a/src/web/backend/run/manual_run.go b/src/web/backend/run/manual_run.go index 5999793..7dacdc3 100644 --- a/src/web/backend/run/manual_run.go +++ b/src/web/backend/run/manual_run.go @@ -164,7 +164,7 @@ func (mr *ManualRun) finishRun(code int) { } // helper to build flag arguments -func buildArgs(playlist, downloadMode string, noPersist, excludeLocal bool, WebEnvPath string) []string { +func buildArgs(playlist, downloadMode, WebEnvPath string, replacePlaylist bool) []string { args := []string{"--config", WebEnvPath} if playlist != "" { args = append(args, "--playlist", playlist) @@ -172,11 +172,8 @@ 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") + if !replacePlaylist { + args = append(args, "--replace-playlist=false") } return args } \ No newline at end of file diff --git a/src/web/backend/settings/handlers.go b/src/web/backend/settings/handlers.go index 6ca56f9..f7c3627 100644 --- a/src/web/backend/settings/handlers.go +++ b/src/web/backend/settings/handlers.go @@ -1,20 +1,20 @@ package settings import ( - "net/http" "encoding/json" - "os" - "log/slog" + "fmt" "io" + "log/slog" + "net/http" + "net/url" + "os" + "strings" "syscall" "time" - "fmt" - "strings" - "net/url" + "explo/src/util" "explo/src/web" "explo/src/web/backend/defs" - "explo/src/util" ) // handleGetConfig returns resolved config as JSON: { values, sources }. @@ -118,6 +118,23 @@ func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { http.Error(w, "unknown playlist name", http.StatusBadRequest) return } + var currentFlags string + if data, err := os.ReadFile(s.cfg.WebEnvPath); err == nil { + currentFlags = s.ParseEnvText(string(data))[envPrefix+"_FLAGS"] + } + mergeFlags := func(base string) string { + injected := []string{"--replace-playlist=false", "--clean-downloads"} + var extras []string + for _, f := range injected { + if strings.Contains(currentFlags, f) { + extras = append(extras, f) + } + } + if len(extras) == 0 { + return base + } + return base + " " + strings.Join(extras, " ") + } updates := map[string]string{} if !body.Enabled { @@ -127,7 +144,7 @@ func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { } else if body.Day == -2 { // "Never" — keep playlist active for manual runs but remove auto-schedule updates[envPrefix+"_SCHEDULE"] = "" - updates[envPrefix+"_FLAGS"] = defaultFlags + updates[envPrefix+"_FLAGS"] = mergeFlags(defaultFlags) } else { dom := "*" dow := "*" @@ -137,7 +154,7 @@ func (s *Settings) HandleSaveSchedule(w http.ResponseWriter, r *http.Request) { 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 + updates[envPrefix+"_FLAGS"] = mergeFlags(defaultFlags) } if err := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { @@ -191,6 +208,107 @@ func (s *Settings) HandleSaveEnrichMetadata(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusOK) } +// HandleSaveReplacePlaylist injects or removes --replace-playlist=false from a playlist's FLAGS env var. +func (s *Settings) HandleSaveReplacePlaylist(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Replace bool `json:"replace"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + var envPrefix string + var defaultFlags string + if def, ok := defs.PlaylistDefs[body.Name]; ok { + envPrefix = def.EnvPrefix + defaultFlags = def.DefaultFlags + } else if defs.CustomIDRe.MatchString(body.Name) { + envPrefix = util.CustomEnvPrefix(body.Name) + defaultFlags = "--playlist " + body.Name + } else { + http.Error(w, "unknown playlist name", http.StatusBadRequest) + return + } + + flagsKey := envPrefix + "_FLAGS" + const replaceFlag = "--replace-playlist=false" + + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil && !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + current := s.ParseEnvText(string(data)) + currentFlags := current[flagsKey] + if currentFlags == "" { + currentFlags = defaultFlags + } + + hasFlag := strings.Contains(currentFlags, replaceFlag) + newFlags := currentFlags + if !body.Replace && !hasFlag { + newFlags = strings.TrimSpace(currentFlags + " " + replaceFlag) + } else if body.Replace && hasFlag { + newFlags = strings.TrimSpace(strings.ReplaceAll(currentFlags, replaceFlag, "")) + for strings.Contains(newFlags, " ") { + newFlags = strings.ReplaceAll(newFlags, " ", " ") + } + } + + if err := s.UpdateEnvKeys(map[string]string{flagsKey: newFlags}, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// HandleSaveCleanDownloads injects or removes --clean-downloads from every playlist's FLAGS env var. +func (s *Settings) HandleSaveCleanDownloads(w http.ResponseWriter, r *http.Request) { + 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 + } + + const cleanFlag = "--clean-downloads" + + data, err := os.ReadFile(s.cfg.WebEnvPath) + if err != nil && !os.IsNotExist(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + current := s.ParseEnvText(string(data)) + + updates := map[string]string{} + for _, def := range defs.PlaylistDefs { + flagsKey := def.EnvPrefix + "_FLAGS" + flags := current[flagsKey] + if flags == "" { + flags = def.DefaultFlags + } + hasFlag := strings.Contains(flags, cleanFlag) + if body.Enabled && !hasFlag { + flags = strings.TrimSpace(flags + " " + cleanFlag) + } else if !body.Enabled && hasFlag { + flags = strings.TrimSpace(strings.ReplaceAll(flags, cleanFlag, "")) + for strings.Contains(flags, " ") { + flags = strings.ReplaceAll(flags, " ", " ") + } + } + updates[flagsKey] = flags + } + + if err := s.UpdateEnvKeys(updates, 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 { @@ -214,9 +332,9 @@ func (s *Settings) HandleWizardStep1(w http.ResponseWriter, r *http.Request) { } updates := map[string]string{ - "LISTENBRAINZ_USER": body.User, + "LISTENBRAINZ_USER": body.User, "LISTENBRAINZ_USER_TOKEN": body.UserToken, - "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, + "LISTENBRAINZ_DISCOVERY": body.DiscoveryMode, } for name, def := range defs.PlaylistDefs { if enabled[name] { @@ -251,19 +369,19 @@ func (s *Settings) HandleWizardStep1(w http.ResponseWriter, r *http.Request) { // 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"` + 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"` + PublicPlaylist bool `json:"public_playlist"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -281,18 +399,18 @@ func (s *Settings) HandleWizardStep2(w http.ResponseWriter, r *http.Request) { 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, + "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, + "ADMIN_SYSTEM_APIKEY": body.AdminAPIKey, } if err := s.UpdateEnvKeys(updates, web.SampleEnv); err != nil { @@ -435,4 +553,4 @@ func (s *Settings) HandleDeletePathTemplate(w http.ResponseWriter, r *http.Reque return } w.WriteHeader(http.StatusOK) -} \ No newline at end of file +} diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index ec4fb7e..35b1181 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 }) { @@ -82,10 +82,10 @@ function useSSE({ onLine, onDone }) { } const PLAYLISTS = [ - { value: 'weekly-exploration', name: 'Weekly Exploration', scheduleKey: 'WEEKLY_EXPLORATION_SCHEDULE', defaultDay: 2, defaultHour: 0, defaultMinute: 15 }, - { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, - { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, - { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, + { value: 'weekly-exploration', name: 'Weekly Exploration', scheduleKey: 'WEEKLY_EXPLORATION_SCHEDULE', flagsKey: 'WEEKLY_EXPLORATION_FLAGS', defaultDay: 2, defaultHour: 0, defaultMinute: 15 }, + { value: 'weekly-jams', name: 'Weekly Jams', scheduleKey: 'WEEKLY_JAMS_SCHEDULE', flagsKey: 'WEEKLY_JAMS_FLAGS', defaultDay: 1, defaultHour: 0, defaultMinute: 30 }, + { value: 'daily-jams', name: 'Daily Jams', scheduleKey: 'DAILY_JAMS_SCHEDULE', flagsKey: 'DAILY_JAMS_FLAGS', defaultDay: -1, defaultHour: 1, defaultMinute: 15 }, + { value: 'on-repeat', name: 'On Repeat', scheduleKey: 'ON_REPEAT_SCHEDULE', flagsKey: 'ON_REPEAT_FLAGS', defaultDay: 100, defaultHour: 12, defaultMinute: 0, fixedSchedule: true }, ] const SCHEDULE_DAYS = [ @@ -202,7 +202,7 @@ function CustomPlaylistsSection({ function HomeSection() { const [schedules, setSchedules] = useState(null) - const [envSources, setEnvSources] = useState({}) + const [replacePlaylists, setReplacePlaylists] = useState({}) const [scheduleSaveStatus, setScheduleSaveStatus] = useState({}) const [lbUser, setLbUser] = useState('') const [openTracklist, setOpenTracklist] = useState(null) @@ -211,8 +211,6 @@ function HomeSection() { const [playlist, setPlaylist] = useState('weekly-exploration') const [dlmode, setDlmode] = useState('normal') - const [noPersist, setNoPersist] = useState(false) - const [excludeLocal, setExcludeLocal] = useState(false) const [running, setRunning] = useState(false) const [status, setStatus] = useState('') @@ -226,12 +224,16 @@ 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 rp = {} + for (const p of PLAYLISTS) { + rp[p.value] = !(values[p.flagsKey] || '').includes('--replace-playlist=false') + } + setReplacePlaylists(rp) + const s = {} for (const p of PLAYLISTS) { const cron = values[p.scheduleKey] @@ -280,11 +282,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' @@ -311,7 +308,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.')) @@ -320,6 +316,14 @@ function HomeSection() { onCancelEdit: () => setSchedules(prev => ({ ...prev, [id]: { ...prev[id], editing: false } })), + replacePlaylist: replacePlaylists[id] ?? true, + onReplaceToggle: () => { + const next = !(replacePlaylists[id] ?? true) + setReplacePlaylists(prev => ({ ...prev, [id]: next })) + saveReplacePlaylist(id, next).catch(() => + setReplacePlaylists(prev => ({ ...prev, [id]: !next })) + ) + }, onDayChange: day => setSchedules(prev => ({ ...prev, [id]: { ...prev[id], day } })), @@ -337,7 +341,7 @@ function HomeSection() { setLogEntries([]) setStatus('running…') try { - await startRun(playlist, dlmode, !noPersist, excludeLocal) + await startRun(playlist, dlmode, replacePlaylists[playlist] ?? true) connect() } catch (e) { if (e.conflict) { setStatus('already running'); setRunning(false); return } @@ -365,7 +369,6 @@ function HomeSection() { key={p.value} playlist={p} {...scheduleProps(p.value)} - locked={isScheduleLocked(p.value)} fixedSchedule={!!p.fixedSchedule} index={i} nextRunText={nextRunText(p.value)} @@ -381,7 +384,7 @@ function HomeSection() { playlist={openTracklist} refreshTick={refreshTick} onRun={async () => { - await startRun(openTracklist, 'normal', true, false) + await startRun(openTracklist, 'normal') setRunning(true) setStatus('running…') setLogEntries([]) @@ -422,7 +425,7 @@ function HomeSection() { setShowImportModal(false) }} onSync={async (id) => { - await startRun(id, 'normal', true, false) + await startRun(id, 'normal') setRunning(true) setStatus('running…') setLogEntries([]) @@ -452,7 +455,7 @@ function HomeSection() { @@ -526,7 +523,6 @@ function DownloadPathSection() { const [showModal, setShowModal] = useState(false) const [openMenuIdx, setOpenMenuIdx] = useState(null) const [enrichEnabled, setEnrichEnabled] = useState(false) - const [replacePlaylist, setReplacePlaylist] = useState(true) const [cleanDownloads, setCleanDownloads] = useState(false) const [templateEnabled, setTemplateEnabled] = useState(false) @@ -541,7 +537,6 @@ function DownloadPathSection() { ] setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true') const anyFlags = values.WEEKLY_EXPLORATION_FLAGS || values.WEEKLY_JAMS_FLAGS || values.DAILY_JAMS_FLAGS || values.ON_REPEAT_FLAGS || '' - setReplacePlaylist(anyFlags.includes('--persist=false')) setCleanDownloads(anyFlags.includes('--clean-downloads')) const t = values.PATH_TEMPLATE || '' if (t) { @@ -570,12 +565,6 @@ 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) @@ -667,22 +656,6 @@ function DownloadPathSection() { - {/* Replace playlist toggle */} -
-
- Update playlist in place - Keep a single playlist per type and refresh it with new recommendations each run. When off, a new playlist is created every time and previous ones are kept. -
- -
- {/* Clean old downloads toggle */}
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index d5e1570..35e2e63 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, @@ -373,6 +372,8 @@ export function PlaylistCard({ tracklistOpen, onTracklistToggle, onDelete, + replacePlaylist = true, + onReplaceToggle, trackId, artworkUrl, sourceUrl, @@ -445,9 +446,10 @@ export function PlaylistCard({ const [deleteTracksChecked, setDeleteTracksChecked] = useState(false) const [copyLabel, setCopyLabel] = useState('Copy URL') const [cardHovered, setCardHovered] = useState(false) + const [replaceInfoOpen, setReplaceInfoOpen] = useState(false) const menuBtnRef = useRef(null) - const canEdit = !locked && !fixedSchedule && !!onToggleEdit - const hasMenu = canEdit || !!onDelete || !!sourceUrl + const canEdit = !fixedSchedule && !!onToggleEdit + const hasMenu = canEdit || !!onDelete || !!sourceUrl || !!onReplaceToggle useEffect(() => { if (!menuOpen) { setConfirmDelete(false); setDeleteTracksChecked(false); return } @@ -601,25 +603,16 @@ export function PlaylistCard({ {/* Toggle — bottom right */} {onToggle && ( - <> - - {locked && ( - ENV - )} - + )}
@@ -682,6 +675,38 @@ export function PlaylistCard({ Edit Schedule )} + {onReplaceToggle && ( +
+ + {replaceInfoOpen && ( + + When on, creates a new dated playlist each run instead of overwriting. + + )} +
+ )} {onDelete && !confirmDelete && (