From 60ddbc3fa351f21887844860dca009153c07fc4a Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:08:43 -0700 Subject: [PATCH 1/5] Add path template functionality to settings and API --- src/web/backend/defs.go | 2 +- src/web/backend/server.go | 21 ++ src/web/frontend/src/components/Settings.jsx | 214 +++++++++++++++++- .../src/components/ui/PathTemplateModal.jsx | 197 ++++++++++++++++ src/web/frontend/src/lib/api.js | 9 + 5 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 src/web/frontend/src/components/ui/PathTemplateModal.jsx diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go index b5b7b97e..50b2292e 100644 --- a/src/web/backend/defs.go +++ b/src/web/backend/defs.go @@ -156,7 +156,7 @@ var allConfigKeys = []string{ "ON_REPEAT_SCHEDULE", "ON_REPEAT_FLAGS", "EXPLO_SYSTEM", "SYSTEM_URL", "API_KEY", "LIBRARY_NAME", "SYSTEM_USERNAME", "SYSTEM_PASSWORD", "PLAYLIST_DIR", "SLEEP", "PUBLIC_PLAYLIST", - "DOWNLOAD_DIR", "USE_SUBDIRECTORY", + "DOWNLOAD_DIR", "USE_SUBDIRECTORY", "PATH_TEMPLATE", "DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST", "SLSKD_URL", "SLSKD_API_KEY", "WIZARD_COMPLETE", "MIGRATE_DOWNLOADS", "EXTENSIONS", diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 5b808e8c..9ee548e5 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -243,6 +243,7 @@ func (s *Server) registerRoutes() { s.mux.Handle("/api/ui/config/raw", s.authStore.RequireAuth(http.HandlerFunc(s.handleGetConfigRaw))) s.mux.Handle("/api/ui/config/reset", s.authStore.RequireAuth(http.HandlerFunc(s.handleResetConfig))) s.mux.Handle("/api/ui/config/schedules", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveSchedule))) + s.mux.Handle("/api/ui/config/path-template", s.authStore.RequireAuth(http.HandlerFunc(s.handleSavePathTemplate))) // Wizard steps (POST) — require auth s.mux.Handle("/api/ui/wizard/step1", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep1))) @@ -566,6 +567,26 @@ func (s *Server) handleSaveSchedule(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// handleSavePathTemplate writes the PATH_TEMPLATE key to the .env file. +func (s *Server) handleSavePathTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Template string `json:"template"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"PATH_TEMPLATE": body.Template}, web.SampleEnv); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // updateEnvKeys reads the env file (falling back to fallback if missing), updates the // given key=value pairs in-place preserving comments, and writes the result back. func updateEnvKeys(path string, updates map[string]string, fallback []byte) error { diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 1e803ad1..f03b00c8 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -14,7 +14,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, - fetchCustomPlaylists, deleteCustomPlaylist, + fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' import { fetchPlaylistTracks } from '../lib/listenbrainz' @@ -23,6 +23,7 @@ import { Toggle } from './ui/Toggle' import { Button, SectionLabel, Panel, LogRow } from './ui/common' import { PlaylistCard, TracklistDropdown } from './ui/PlaylistCard' import { ImportModal } from './ui/ImportModal' +import { SEED_PRESETS, PathLine, PathTemplateModal } from './ui/PathTemplateModal' import { UpdateNotification } from './ui/UpdateNotification' const tabBtnCls = active => @@ -488,6 +489,215 @@ 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) + + useEffect(() => { + fetchConfig().then(({ values }) => { + const t = values.PATH_TEMPLATE || '' + if (t) { + const idx = SEED_PRESETS.findIndex(p => p.template === t) + if (idx >= 0) { + setAppliedIdx(idx) + setSelectedIdx(idx) + } else { + const customIdx = SEED_PRESETS.length + setProfiles([...SEED_PRESETS.map(p => ({ ...p, seed: true })), { name: 'Custom', template: t }]) + setAppliedIdx(customIdx) + setSelectedIdx(customIdx) + } + } + setLoaded(true) + }) + }, []) + + useEffect(() => { + if (openMenuIdx === null) return + const handle = () => setOpenMenuIdx(null) + document.addEventListener('click', handle) + return () => document.removeEventListener('click', handle) + }, [openMenuIdx]) + + if (!loaded) return null + + const handleDeleteProfile = i => { + 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 dirty = selectedIdx !== appliedIdx + 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 = ({ name, template }) => { + const newIdx = profiles.length + setProfiles(prev => [...prev, { name, template }]) + setSelectedIdx(newIdx) + setShowModal(false) + } + + return ( +
+ Folder Structure + + {/* Current / pending path readout */} +

+ {dirty ? 'New folder structure' : 'Current folder structure'} +

+
+ +
+ +
+
+ + {/* Profile card grid */} +
+ {profiles.map((profile, i) => { + const isApplied = i === appliedIdx + 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} +
+ {isApplied && ( + + In use + + )} + {!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} + /> + )} + +
+ ) +} + // ── Config Tab ──────────────────────────────────────────────────────────────── // Raw .env file viewer/editor, plus wizard re-run and full reset actions. // Fetches its own raw config text from the API. @@ -560,6 +770,8 @@ function ConfigSection({ onWizard }) { )} + +
Setup
diff --git a/src/web/frontend/src/components/ui/PathTemplateModal.jsx b/src/web/frontend/src/components/ui/PathTemplateModal.jsx new file mode 100644 index 00000000..650a05b3 --- /dev/null +++ b/src/web/frontend/src/components/ui/PathTemplateModal.jsx @@ -0,0 +1,197 @@ +import { useState, useEffect, useRef } from 'react' +import { motion, AnimatePresence } from 'motion/react' +import { Button } from './common' + +export const SEED_PRESETS = [ + { name: 'Artist / Album / Track', template: '{{Artist}}/{{Album}}/{{TrackNumber}} - {{TrackName}}.{{ext}}' }, + { name: 'Year-tagged albums', template: '{{Artist}}/{{Year}} - {{Album}}/{{TrackNumber}} - {{TrackName}}.{{ext}}' }, + { name: 'Disc-aware', template: '{{Artist}}/{{Album}}/{{DiscNumber}}-{{TrackNumber}} {{TrackName}}.{{ext}}' }, + { name: 'Flat', template: '{{Artist}} - {{TrackName}}.{{ext}}' }, +] + +const TEMPLATE_VARS = [ + ['Artist', 'Radiohead'], + ['Album', 'OK Computer'], + ['TrackName', 'Karma Police'], + ['TrackNumber', '03'], + ['DiscNumber', '01'], + ['Year', '1997'], + ['File', 'filename'], + ['ext', 'flac'], +] + +const SAMPLE_META = { + Artist: 'Radiohead', Album: 'OK Computer', AlbumName: 'OK Computer', + TrackName: 'Karma Police', TrackNumber: '03', DiscNumber: '01', + Year: '1997', File: 'karma_police', ext: 'flac', +} + +function sanitizeSegment(v) { + return String(v).replace(/[/\\:*?"<>|]/g, '') +} + +function resolveTemplate(tpl) { + return tpl.replace(/\{\{\s*([A-Za-z]+)\s*\}\}/g, (_, name) => { + const key = Object.keys(SAMPLE_META).find(k => k.toLowerCase() === name.toLowerCase()) + return key ? sanitizeSegment(SAMPLE_META[key]) : `{{${name}}}` + }) +} + +export function PathLine({ template }) { + const parts = resolveTemplate(template).split('/') + return parts.map((part, i) => { + const isFile = i === parts.length - 1 && part.includes('.') + return ( + + {i > 0 && /} + {part || '·'} + + ) + }) +} + +// Props: +// onClose — called on cancel / backdrop / Escape +// onSave — called with { name, template } when user saves the preset +export function PathTemplateModal({ onClose, onSave }) { + const [name, setName] = useState('') + const [template, setTemplate] = useState(SEED_PRESETS[0].template) + const nameInputRef = useRef(null) + const templateInputRef = useRef(null) + + useEffect(() => { + const handle = e => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', handle) + setTimeout(() => nameInputRef.current?.focus(), 60) + return () => window.removeEventListener('keydown', handle) + }, [onClose]) + + const insertVariable = varName => { + const input = templateInputRef.current + if (!input) return + const token = `{{${varName}}}` + const start = input.selectionStart ?? template.length + const end = input.selectionEnd ?? template.length + const next = template.slice(0, start) + token + template.slice(end) + setTemplate(next) + const pos = start + token.length + setTimeout(() => { input.focus(); input.setSelectionRange(pos, pos) }, 0) + } + + const handleSave = () => { + onSave({ name: name.trim() || 'Custom template', template }) + } + + return ( + { if (e.target === e.currentTarget) onClose() }} + style={{ + position: 'fixed', inset: 0, zIndex: 50, + background: 'rgba(0,0,0,0.72)', + backdropFilter: 'blur(6px)', + WebkitBackdropFilter: 'blur(6px)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 24, + }} + > + + {/* Header */} +
+ New folder template + +
+ + {/* Body */} +
+

Template name

+ setName(e.target.value)} + spellCheck={false} + /> + +

Folder structure

+ setTemplate(e.target.value)} + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + +

Preview

+
+ +
+ +
+
+ +

Insert variable

+
+ {TEMPLATE_VARS.map(([varName, example]) => ( + + ))} +
+ +

+ Click to insert at cursor. Illegal characters{' '} + {['/', '\\', ':', '*', '?', '"', '<', '>', '|'].map(c => ( + {c} + ))}{' '} + in a value are stripped automatically. +

+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/src/web/frontend/src/lib/api.js b/src/web/frontend/src/lib/api.js index dc2eb493..632f58a4 100644 --- a/src/web/frontend/src/lib/api.js +++ b/src/web/frontend/src/lib/api.js @@ -192,6 +192,15 @@ export async function refreshCustomPlaylist(id) { return res.json() } +export async function savePathTemplate(template) { + const res = await apiFetch('/api/ui/config/path-template', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template }), + }) + if (!res.ok) throw new Error(await res.text()) +} + export async function fetchBackgroundArt() { try { const res = await fetch('/api/ui/background-art') From 93e3af60116b3a58effb00c347e5c79b092460c5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 11 Jun 2026 13:51:30 -0500 Subject: [PATCH 2/5] Update downloader.go (#3) * Update downloader.go Make it impossible to mess up track.File * Update downloader.go --- src/downloader/downloader.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go index 8dc4d65b..dd3cd173 100644 --- a/src/downloader/downloader.go +++ b/src/downloader/downloader.go @@ -264,9 +264,15 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track * }() var dstFile string - + if c.Cfg.PathTemplate != "" { relativePath := buildTrackPath(c.Cfg.PathTemplate, track) + track.File = filepath.Base(relativePath) + if track.File == "." || track.File == string(filepath.Separator) { + track.File = getFilename(track.CleanTitle, track.MainArtist) + filepath.Ext(track.File) + relativePath = filepath.Dir(relativePath) + string(filepath.Separator) + track.File + slog.Warn(fmt.Sprintf("invalid path template result for track '%s' by '%s', using filename '%s' instead", track.Title, track.Artist, track.File)) + } dstFile = filepath.Join(destDir, relativePath) } else { if err = os.MkdirAll(destDir, os.ModePerm); err != nil { From 0cac2bdecd43508d49905e5a3ffbeee293104dd9 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:52:01 -0700 Subject: [PATCH 3/5] added sensitive key masking, reduced templates to better reflect whats possible with paths --- src/web/frontend/src/components/Settings.jsx | 19 ++-- .../src/components/ui/PathTemplateModal.jsx | 100 ++++++++---------- src/web/frontend/src/lib/utils.js | 7 +- 3 files changed, 58 insertions(+), 68 deletions(-) diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index f03b00c8..0785521c 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -530,6 +530,8 @@ function DownloadPathSection() { return () => document.removeEventListener('click', handle) }, [openMenuIdx]) + const dirty = selectedIdx !== appliedIdx + if (!loaded) return null const handleDeleteProfile = i => { @@ -540,8 +542,6 @@ function DownloadPathSection() { setSelectedIdx(newSelected) setOpenMenuIdx(null) } - - const dirty = selectedIdx !== appliedIdx const previewTemplate = selectedIdx !== null ? (profiles[selectedIdx]?.template ?? SEED_PRESETS[0].template) : SEED_PRESETS[0].template @@ -570,11 +570,10 @@ function DownloadPathSection() { Folder Structure {/* Current / pending path readout */} -

- {dirty ? 'New folder structure' : 'Current folder structure'} -

- + + {dirty ? 'Preview:' : 'Active:'} +
@@ -583,7 +582,6 @@ function DownloadPathSection() { {/* Profile card grid */}
{profiles.map((profile, i) => { - const isApplied = i === appliedIdx const isSelected = i === selectedIdx return (
{profile.name}
- {isApplied && ( - - In use - - )} {!profile.seed && (
e.stopPropagation()}>