diff --git a/src/web/backend/custom_playlists.go b/src/web/backend/custom_playlists.go
index 205f2df..f9c8a4d 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 33c0795..01901d4 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 61c1757..5c9d166 100644
--- a/src/web/frontend/src/components/Settings.jsx
+++ b/src/web/frontend/src/components/Settings.jsx
@@ -151,28 +151,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)}
- 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)}
+ />
)
})}
@@ -367,7 +364,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.
@@ -617,7 +624,7 @@ function DownloadPathSection() {
Auto-tag songs
- Looks up track numbers, year, genre & more from MusicBrainz and writes them to downloaded files.
+ Looks up track numbers, year, genre & more from MusicBrainz and writes them to downloaded files. Applies to scheduled playlists only — not custom imports.
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',
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index 06fb2b1..d53b3e9 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',
}}