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()}> + + {openMenuIdx === i && ( +
+ +
+ )} +
+ )} +
+
+
+
+ +
+
+
+ ) + })} + + {/* 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.'} + + + + + )} + {!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 && ( + )} {canEdit && (