From d531fac8ebce2341b780a484b4a8e9be29841b32 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:04:36 -0700
Subject: [PATCH 1/4] adjusted playlist flags to be more readable in env
---
src/web/backend/custom_playlists.go | 29 +-
src/web/backend/jobs.go | 2 +-
src/web/frontend/src/components/Settings.jsx | 281 +++++++++++++++++++
3 files changed, 304 insertions(+), 8 deletions(-)
diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/custom_playlists.go
index 205f2dfd..f9c8a4d3 100644
--- a/src/web/backend/custom_playlists.go
+++ b/src/web/backend/custom_playlists.go
@@ -96,12 +96,25 @@ func customPlaylistsPath(cfgDir string) string {
return filepath.Join(cfgDir, "custom-playlists.json")
}
-// customEnvPrefix converts a custom playlist ID like "custom-a1b2c3d4"
-// to an env-var prefix like "CUSTOM_A1B2C3D4".
-func customEnvPrefix(id string) string {
- return strings.ToUpper(strings.ReplaceAll(id, "-", "_"))
+// 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 {
@@ -216,7 +229,7 @@ func (s *Server) handleGetCustomPlaylists(w http.ResponseWriter, r *http.Request
items := make([]respItem, 0, len(playlists))
for _, p := range playlists {
count := customPlaylistTrackCount(s.cfg.WebDataDir, p.ID)
- prefix := customEnvPrefix(p.ID)
+ prefix := customEnvPrefix(p.Name)
sched := envValues[prefix+"_SCHEDULE"]
flags := envValues[prefix+"_FLAGS"]
items = append(items, respItem{CustomPlaylist: p, TrackCount: count, Schedule: sched, Flags: flags})
@@ -334,7 +347,7 @@ 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(id)
+ prefix := customEnvPrefix(name)
envUpdates := map[string]string{
prefix + "_FLAGS": "--playlist " + id,
}
@@ -438,9 +451,11 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque
existing := loadCustomPlaylists(s.cfg.WebDataDir)
filtered := existing[:0]
found := false
+ var deletedName string
for _, p := range existing {
if p.ID == id {
found = true
+ deletedName = p.Name
} else {
filtered = append(filtered, p)
}
@@ -460,7 +475,7 @@ func (s *Server) handleDeleteCustomPlaylist(w http.ResponseWriter, r *http.Reque
_ = os.Remove(cachePath)
// Remove schedule env vars from .env
- prefix := customEnvPrefix(id)
+ prefix := customEnvPrefix(deletedName)
_ = updateEnvKeys(s.cfg.WebEnvPath, map[string]string{
prefix + "_SCHEDULE": "",
prefix + "_FLAGS": "",
diff --git a/src/web/backend/jobs.go b/src/web/backend/jobs.go
index 33c0795c..01901d45 100644
--- a/src/web/backend/jobs.go
+++ b/src/web/backend/jobs.go
@@ -66,7 +66,7 @@ func (j *Jobs) RegisterCustomPlaylistRefresh(cfgDir, envPath string) error {
for _, p := range playlists {
p := p
- prefix := customEnvPrefix(p.ID)
+ prefix := customEnvPrefix(p.Name)
flags := envValues[prefix+"_FLAGS"]
if flags == "" {
continue // disabled
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index 1e803ad1..9e58751f 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -488,6 +488,287 @@ function HomeSection() {
)
}
+// ── Download Path Tab ─────────────────────────────────────────────────────────
+// Profile-picker for the PATH_TEMPLATE env key. Users select a preset folder
+// structure (or author their own via the modal) and apply it. Pending changes
+// are previewed inline before being written to .env via the confirm bar.
+
+
+function DownloadPathSection() {
+ const [profiles, setProfiles] = useState(() => SEED_PRESETS.map(p => ({ ...p, seed: true })))
+ const [appliedIdx, setAppliedIdx] = useState(null)
+ const [selectedIdx, setSelectedIdx] = useState(null)
+ const [loaded, setLoaded] = useState(false)
+ const [saveStatus, setSaveStatus] = useState('')
+ const [showModal, setShowModal] = useState(false)
+ const [openMenuIdx, setOpenMenuIdx] = useState(null)
+ const [enrichEnabled, setEnrichEnabled] = useState(false)
+ const [templateEnabled, setTemplateEnabled] = useState(false)
+
+ useEffect(() => {
+ Promise.all([
+ fetchConfig(),
+ fetchPathTemplatePresets().catch(() => []),
+ ]).then(([{ values }, jsonPresets]) => {
+ const allProfiles = [
+ ...SEED_PRESETS.map(p => ({ ...p, seed: true })),
+ ...jsonPresets,
+ ]
+ setEnrichEnabled(values.ENRICH_TRACK_METADATA === 'true')
+ const t = values.PATH_TEMPLATE || ''
+ if (t) {
+ setTemplateEnabled(true)
+ const idx = allProfiles.findIndex(p => p.template === t)
+ if (idx >= 0) {
+ setProfiles(allProfiles)
+ setAppliedIdx(idx)
+ setSelectedIdx(idx)
+ } else {
+ const customIdx = allProfiles.length
+ setProfiles([...allProfiles, { name: 'Custom', template: t }])
+ setAppliedIdx(customIdx)
+ setSelectedIdx(customIdx)
+ }
+ } else {
+ setProfiles(allProfiles)
+ }
+ setLoaded(true)
+ })
+ }, [])
+
+ const handleEnrichToggle = async () => {
+ const next = !enrichEnabled
+ setEnrichEnabled(next)
+ try { await saveEnrichMetadata(next) } catch { setEnrichEnabled(!next) }
+ }
+
+ const handleTemplateToggle = async () => {
+ if (templateEnabled) {
+ setTemplateEnabled(false)
+ setAppliedIdx(null)
+ setSelectedIdx(null)
+ try { await savePathTemplate('') } catch { setTemplateEnabled(true) }
+ } else {
+ setTemplateEnabled(true)
+ if (selectedIdx === null) setSelectedIdx(0)
+ }
+ }
+
+ useEffect(() => {
+ if (!showModal) return
+ const handle = e => { e.preventDefault(); e.returnValue = '' }
+ window.addEventListener('beforeunload', handle)
+ return () => window.removeEventListener('beforeunload', handle)
+ }, [showModal])
+
+ useEffect(() => {
+ if (openMenuIdx === null) return
+ const handle = () => setOpenMenuIdx(null)
+ document.addEventListener('click', handle)
+ return () => document.removeEventListener('click', handle)
+ }, [openMenuIdx])
+
+ const dirty = selectedIdx !== appliedIdx
+
+ if (!loaded) return null
+
+ const handleDeleteProfile = async (i) => {
+ const profile = profiles[i]
+ if (!profile.seed) {
+ try { await deletePathTemplatePreset(profile.name) } catch {}
+ }
+ const newApplied = appliedIdx === i ? null : appliedIdx !== null && appliedIdx > i ? appliedIdx - 1 : appliedIdx
+ const newSelected = selectedIdx === i ? newApplied : selectedIdx !== null && selectedIdx > i ? selectedIdx - 1 : selectedIdx
+ setProfiles(prev => prev.filter((_, j) => j !== i))
+ setAppliedIdx(newApplied)
+ setSelectedIdx(newSelected)
+ setOpenMenuIdx(null)
+ }
+ const previewTemplate = selectedIdx !== null
+ ? (profiles[selectedIdx]?.template ?? SEED_PRESETS[0].template)
+ : SEED_PRESETS[0].template
+
+ const handleSave = async () => {
+ const t = selectedIdx !== null ? profiles[selectedIdx].template : ''
+ try {
+ await savePathTemplate(t)
+ setAppliedIdx(selectedIdx)
+ setSaveStatus('Saved.')
+ setTimeout(() => setSaveStatus(''), 2500)
+ } catch {
+ setSaveStatus('Error saving.')
+ }
+ }
+
+ const handleSavePreset = async ({ name, template }) => {
+ try { await addPathTemplatePreset(name, template) } catch {}
+ const newIdx = profiles.length
+ setProfiles(prev => [...prev, { name, template }])
+ setSelectedIdx(newIdx)
+ setShowModal(false)
+ }
+
+ return (
+
+
Folder Structure
+ {/* ENRICH_METADATA toggle */}
+
+
+ Auto-tag songs
+ Looks up track numbers, year, genre & more from MusicBrainz and writes them to downloaded files. Applies to scheduled playlists only — not custom imports.
+
+
+
+
+
+
+ {/* Organize into folders toggle */}
+
+
+ Organize into folders
+ Sort downloads into subfolders by artist, album, etc.
+
+
+
+
+
+
+ {templateEnabled && (<>
+ {/* Current / pending path readout */}
+
+
+ {dirty ? 'Preview:' : 'Active:'}
+
+
+
+
+ {/* Profile card grid */}
+
+ {profiles.map((profile, i) => {
+ const isSelected = i === selectedIdx
+ return (
+
setSelectedIdx(i)}
+ className={`group relative flex flex-col gap-2.5 p-4 bg-transparent rounded-[8px] cursor-pointer select-none transition-all duration-[120ms] border
+ ${isSelected
+ ? 'border-accent'
+ : 'border-ui-border hover:border-[#404040] hover:-translate-y-px'}`}
+ style={{ minHeight: 112, ...(isSelected ? { boxShadow: '0 0 0 3px rgba(30,215,96,0.14)' } : {}) }}
+ >
+
+
{profile.name}
+
+ {!profile.seed && (
+
e.stopPropagation()}>
+
{ e.stopPropagation(); setOpenMenuIdx(openMenuIdx === i ? null : i) }}
+ className="opacity-0 group-hover:opacity-100 transition-opacity bg-transparent border-none text-muted hover:text-white text-[15px] leading-none cursor-pointer px-1 py-0"
+ title="Options"
+ >
+ ···
+
+ {openMenuIdx === i && (
+
+ handleDeleteProfile(i)}
+ className="w-full text-left px-3 py-1.5 text-[12px] text-danger hover:bg-well transition-colors cursor-pointer bg-transparent border-none whitespace-nowrap"
+ >
+ Delete
+
+
+ )}
+
+ )}
+
+
+
+
+ )
+ })}
+
+ {/* New template card */}
+
setShowModal(true)}
+ className="flex flex-col items-center justify-center gap-2 p-4 bg-transparent rounded-[8px] border border-dashed border-ui-border cursor-pointer transition-colors text-muted hover:text-accent hover:border-accent"
+ style={{ minHeight: 112 }}
+ >
+ +
+ New template
+
+
+
+ {/* Confirm bar */}
+
+ {dirty && (
+
+
+ {saveStatus || 'Preview only — not applied yet.'}
+
+ setSelectedIdx(appliedIdx)}
+ className="bg-transparent border-none text-muted text-[13px] cursor-pointer p-0 hover:text-white transition-colors"
+ >
+ Cancel
+
+
+ Save folder structure
+
+
+ )}
+ {!dirty && saveStatus && (
+
+ {saveStatus}
+
+ )}
+
+
+ >)}
+
+
+ {showModal && (
+ setShowModal(false)}
+ onSave={handleSavePreset}
+ enrichEnabled={enrichEnabled}
+ />
+ )}
+
+
+ )
+}
+
// ── Config Tab ────────────────────────────────────────────────────────────────
// Raw .env file viewer/editor, plus wizard re-run and full reset actions.
// Fetches its own raw config text from the API.
From 3d8bd3b3e9274f36a14ad13f903434d6dddbc1be Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:09:52 -0700
Subject: [PATCH 2/4] Add "Copy URL" button to PlaylistCard.jsx, pulls
sourceUrl
---
src/web/frontend/src/components/Settings.jsx | 1 +
.../src/components/ui/PlaylistCard.jsx | 43 +++++++++++++++----
2 files changed, 36 insertions(+), 8 deletions(-)
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index 9e58751f..4c8161de 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -168,6 +168,7 @@ function CustomPlaylistsSection({
: 'Disabled'}
tracklistOpen={openTracklist === cp.id}
onTracklistToggle={() => setOpenTracklist(v => v === cp.id ? null : cp.id)}
+ sourceUrl={cp.source_url || undefined}
onDelete={(opts) => onDelete(cp.id, opts)}
/>
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index 06fb2b10..d53b3e97 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Toggle } from './Toggle'
import { Button } from './common'
@@ -375,6 +375,7 @@ export function PlaylistCard({
onDelete,
trackId,
artworkUrl,
+ sourceUrl,
}) {
const { value, name } = playlist
// trackFetchId: use real playlist ID (custom playlists) if provided, else fall back to value
@@ -441,13 +442,18 @@ export function PlaylistCard({
const [menuOpen, setMenuOpen] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleteTracksChecked, setDeleteTracksChecked] = useState(false)
+ const [copyLabel, setCopyLabel] = useState('Copy URL')
const [cardHovered, setCardHovered] = useState(false)
+ const menuBtnRef = useRef(null)
const canEdit = !locked && !fixedSchedule && !!onToggleEdit
- const hasMenu = canEdit || !!onDelete
+ const hasMenu = canEdit || !!onDelete || !!sourceUrl
useEffect(() => {
if (!menuOpen) { setConfirmDelete(false); setDeleteTracksChecked(false); return }
- const close = () => setMenuOpen(false)
+ const close = e => {
+ if (menuBtnRef.current?.contains(e.target)) return
+ setMenuOpen(false)
+ }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [menuOpen])
@@ -552,7 +558,7 @@ export function PlaylistCard({
{hasMenu && (
e.stopPropagation()}
+ ref={menuBtnRef}
onClick={e => { e.stopPropagation(); setMenuOpen(o => !o) }}
style={{
position: 'absolute', top: 6, right: 8,
@@ -629,7 +635,7 @@ export function PlaylistCard({
position: 'absolute', top: 4, right: 4,
zIndex: 50,
transformOrigin: 'top right',
- background: '#1a1a1ae6',
+ background: '#0d0d0df0',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
border: '1px solid #282828',
@@ -639,6 +645,27 @@ export function PlaylistCard({
boxShadow: '0 8px 24px #00000088',
}}
>
+ {sourceUrl && (
+ {
+ e.stopPropagation()
+ navigator.clipboard.writeText(sourceUrl).then(() => {
+ setCopyLabel('Copied!')
+ setTimeout(() => setCopyLabel('Copy URL'), 2000)
+ })
+ }}
+ style={{
+ display: 'block', width: '100%', textAlign: 'left',
+ background: 'none', border: 'none',
+ padding: '8px 14px', fontSize: 13, color: '#c0c0c0',
+ cursor: 'pointer',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.background = '#1a1a1a' }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
+ >
+ {copyLabel}
+
+ )}
{canEdit && (
{ e.stopPropagation(); setMenuOpen(false); onToggleEdit() }}
@@ -648,7 +675,7 @@ export function PlaylistCard({
padding: '8px 14px', fontSize: 13, color: '#c0c0c0',
cursor: 'pointer',
}}
- onMouseEnter={e => { e.currentTarget.style.background = '#2a2a2a' }}
+ onMouseEnter={e => { e.currentTarget.style.background = '#1a1a1a' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
Edit Schedule
@@ -663,7 +690,7 @@ export function PlaylistCard({
padding: '8px 14px', fontSize: 13, color: '#e05050',
cursor: 'pointer',
}}
- onMouseEnter={e => { e.currentTarget.style.background = '#2a2a2a' }}
+ onMouseEnter={e => { e.currentTarget.style.background = '#1a1a1a' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none' }}
>
Delete Playlist
@@ -705,7 +732,7 @@ export function PlaylistCard({
{ e.stopPropagation(); setConfirmDelete(false); setDeleteTracksChecked(false) }}
style={{
- flex: 1, background: '#242424', border: '1px solid #333',
+ flex: 1, background: '#161616', border: '1px solid #2a2a2a',
borderRadius: 5, padding: '5px 0', fontSize: 12,
color: '#888', cursor: 'pointer',
}}
From fb5e82c51fa2ae1b219e425dbc162ae459df5349 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:22:21 -0700
Subject: [PATCH 3/4] Fix broken delete playlist action, moved playlist grid to
rows
---
src/web/backend/server.go | 16 +++++----
src/web/frontend/src/components/Settings.jsx | 34 ++++++++-----------
.../src/components/ui/ImportModal.jsx | 2 +-
3 files changed, 25 insertions(+), 27 deletions(-)
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index 27ef6db0..a9bbd874 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -271,17 +271,19 @@ func (s *Server) registerRoutes() {
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/", func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodDelete {
- s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist)).ServeHTTP(w, r)
+ 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
}
- if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/refresh") {
- s.authStore.RequireAuth(http.HandlerFunc(s.handleRefreshCustomPlaylist)).ServeHTTP(w, r)
+ 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
}
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ s.authStore.RequireAuth(http.HandlerFunc(s.handleDeleteCustomPlaylist)).ServeHTTP(w, r)
})
s.mux.Handle("/api/ui/logout", s.authStore.RequireAuth(http.HandlerFunc(s.handleLogout)))
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index 4c8161de..9a6eb9a4 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -149,29 +149,25 @@ function CustomPlaylistsSection({
No custom playlists yet. Import one from ListenBrainz or Apple Music.
) : (
-
+
{customPlaylists.map((cp, i) => {
if (!schedules[cp.id]) return null
return (
-
-
d.value === schedules[cp.id].day)?.summary ?? 'Every day'
- : 'Disabled'}
- tracklistOpen={openTracklist === cp.id}
- onTracklistToggle={() => setOpenTracklist(v => v === cp.id ? null : cp.id)}
- sourceUrl={cp.source_url || undefined}
- onDelete={(opts) => onDelete(cp.id, opts)}
- />
-
+ playlist={{ value: `custom-${cp.color_index ?? i}`, name: cp.name }}
+ trackId={cp.id}
+ artworkUrl={cp.artwork_url || undefined}
+ {...scheduleProps(cp.id)}
+ index={i}
+ nextRunText={schedules[cp.id]?.enabled
+ ? SCHEDULE_DAYS.find(d => d.value === schedules[cp.id].day)?.summary ?? 'Every day'
+ : 'Disabled'}
+ tracklistOpen={openTracklist === cp.id}
+ onTracklistToggle={() => setOpenTracklist(v => v === cp.id ? null : cp.id)}
+ sourceUrl={cp.source_url || undefined}
+ onDelete={(opts) => onDelete(cp.id, opts)}
+ />
)
})}
diff --git a/src/web/frontend/src/components/ui/ImportModal.jsx b/src/web/frontend/src/components/ui/ImportModal.jsx
index a8aa382c..f5146c3a 100644
--- a/src/web/frontend/src/components/ui/ImportModal.jsx
+++ b/src/web/frontend/src/components/ui/ImportModal.jsx
@@ -167,7 +167,7 @@ export function ImportModal({ onClose, onImported, onSync }) {
onClick={e => e.stopPropagation()}
className="w-full max-w-[420px] mx-4 border border-ui-border rounded-lg overflow-hidden"
style={{
- background: '#1a1a1ae6',
+ background: '#0d0d0df0',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
boxShadow: '0 24px 64px #00000099',
From fe529a03d1a7a8fb4eec10fe3f9d11df99265da5 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Fri, 12 Jun 2026 12:27:04 -0700
Subject: [PATCH 4/4] Added Run button to Scheduled musicbrainz playlists to
match custom playlists
---
src/web/frontend/src/components/Settings.jsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx
index 9a6eb9a4..b4b0af7e 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -362,7 +362,17 @@ function HomeSection() {
))}
p.value === openTracklist)} slideKey={openTracklist}>
-
+ {
+ await startRun(openTracklist, 'normal', true, false)
+ setRunning(true)
+ setStatus('running…')
+ setLogEntries([])
+ connect()
+ }}
+ />
Schedule changes take effect after restarting the container.