Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions src/web/backend/custom_playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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": "",
Expand Down
2 changes: 1 addition & 1 deletion src/web/backend/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 27 additions & 20 deletions src/web/frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,28 +151,25 @@ function CustomPlaylistsSection({
No custom playlists yet. Import one from ListenBrainz or Apple Music.
</p>
) : (
<div className="flex gap-3 mt-3 overflow-x-auto snap-x snap-mandatory pb-2">
<div className="grid grid-cols-1 min-[420px]:grid-cols-2 min-[720px]:grid-cols-4 gap-3 mt-3">
{customPlaylists.map((cp, i) => {
if (!schedules[cp.id]) return null
return (
<div
<PlaylistCard
key={cp.id}
className="shrink-0 snap-start w-full min-[420px]:w-[calc((100%-12px)/2)] min-[720px]:w-[calc((100%-36px)/4)]"
>
<PlaylistCard
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)}
onDelete={(opts) => onDelete(cp.id, opts)}
/>
</div>
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)}
/>
)
})}
</div>
Expand Down Expand Up @@ -367,7 +364,17 @@ function HomeSection() {
))}
</div>
<TracklistSlide show={openTracklist && PLAYLISTS.some(p => p.value === openTracklist)} slideKey={openTracklist}>
<TracklistDropdown lbUser={lbUser} playlist={openTracklist} />
<TracklistDropdown
lbUser={lbUser}
playlist={openTracklist}
onRun={async () => {
await startRun(openTracklist, 'normal', true, false)
setRunning(true)
setStatus('running…')
setLogEntries([])
connect()
}}
/>
</TracklistSlide>
<p className="text-[12px] text-muted mt-3">Schedule changes take effect after restarting the container.</p>
</div>
Expand Down Expand Up @@ -617,7 +624,7 @@ function DownloadPathSection() {
<div className="flex items-start justify-between mt-3 mb-1 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[13px] text-white">Auto-tag songs</span>
<span className="text-[11px] text-muted">Looks up track numbers, year, genre & more from MusicBrainz and writes them to downloaded files.</span>
<span className="text-[11px] text-muted">Looks up track numbers, year, genre & more from MusicBrainz and writes them to downloaded files. Applies to scheduled playlists only — not custom imports.</span>
</div>
<button
role="switch"
Expand Down
2 changes: 1 addition & 1 deletion src/web/frontend/src/components/ui/ImportModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
43 changes: 35 additions & 8 deletions src/web/frontend/src/components/ui/PlaylistCard.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -552,7 +558,7 @@ export function PlaylistCard({

{hasMenu && (
<button
onMouseDown={e => e.stopPropagation()}
ref={menuBtnRef}
onClick={e => { e.stopPropagation(); setMenuOpen(o => !o) }}
style={{
position: 'absolute', top: 6, right: 8,
Expand Down Expand Up @@ -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',
Expand All @@ -639,6 +645,27 @@ export function PlaylistCard({
boxShadow: '0 8px 24px #00000088',
}}
>
{sourceUrl && (
<button
onClick={e => {
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}
</button>
)}
{canEdit && (
<button
onClick={e => { e.stopPropagation(); setMenuOpen(false); onToggleEdit() }}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -705,7 +732,7 @@ export function PlaylistCard({
<button
onClick={e => { 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',
}}
Expand Down
Loading