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 { diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go index b5b7b97e..8a22deea 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", "ENRICH_TRACK_METADATA", "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/path_templates.go b/src/web/backend/path_templates.go new file mode 100644 index 00000000..0617a65b --- /dev/null +++ b/src/web/backend/path_templates.go @@ -0,0 +1,112 @@ +package backend + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +// PathTemplatePreset is a named folder-structure template saved by the user. +type PathTemplatePreset struct { + Name string `json:"name"` + Template string `json:"template"` +} + +func pathTemplatesFilePath(cfgDir string) string { + return filepath.Join(cfgDir, "path-templates.json") +} + +func loadPathTemplates(cfgDir string) []PathTemplatePreset { + data, err := os.ReadFile(pathTemplatesFilePath(cfgDir)) + if err != nil { + return nil + } + var out []PathTemplatePreset + if err := json.Unmarshal(data, &out); err != nil { + slog.Warn("path-templates: failed to parse", "err", err) + return nil + } + return out +} + +func savePathTemplates(cfgDir string, presets []PathTemplatePreset) error { + raw, err := json.MarshalIndent(presets, "", " ") + if err != nil { + return err + } + return os.WriteFile(pathTemplatesFilePath(cfgDir), raw, 0644) +} + +// handlePathTemplates handles GET and POST for /api/ui/path-templates. +func (s *Server) handlePathTemplates(w http.ResponseWriter, r *http.Request) { + cfgDir := s.cfg.WebDataDir + switch r.Method { + case http.MethodGet: + presets := loadPathTemplates(cfgDir) + if presets == nil { + presets = []PathTemplatePreset{} + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(presets); err != nil { + slog.Error("failed encoding path templates", "err", err.Error()) + } + case http.MethodPost: + var body PathTemplatePreset + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if body.Name == "" || body.Template == "" { + http.Error(w, "name and template are required", http.StatusBadRequest) + return + } + presets := loadPathTemplates(cfgDir) + presets = append(presets, body) + if err := savePathTemplates(cfgDir, presets); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(body); err != nil { + slog.Error("failed encoding path template", "err", err.Error()) + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleDeletePathTemplate handles DELETE /api/ui/path-templates/{name}. +func (s *Server) handleDeletePathTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + raw := strings.TrimPrefix(r.URL.Path, "/api/ui/path-templates/") + name, err := url.PathUnescape(raw) + if err != nil || name == "" { + http.Error(w, "invalid name", http.StatusBadRequest) + return + } + cfgDir := s.cfg.WebDataDir + presets := loadPathTemplates(cfgDir) + filtered := presets[:0] + for _, p := range presets { + if p.Name != name { + filtered = append(filtered, p) + } + } + if len(filtered) == len(presets) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := savePathTemplates(cfgDir, filtered); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 5b808e8c..b00431e2 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -243,6 +243,20 @@ 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))) + s.mux.Handle("/api/ui/config/enrich-metadata", s.authStore.RequireAuth(http.HandlerFunc(s.handleSaveEnrichMetadata))) + + // Path template presets: GET list, POST add; DELETE per name under prefix + s.mux.HandleFunc("/api/ui/path-templates", func(w http.ResponseWriter, r *http.Request) { + s.authStore.RequireAuth(http.HandlerFunc(s.handlePathTemplates)).ServeHTTP(w, r) + }) + s.mux.HandleFunc("/api/ui/path-templates/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + s.authStore.RequireAuth(http.HandlerFunc(s.handleDeletePathTemplate)).ServeHTTP(w, r) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + }) // Wizard steps (POST) — require auth s.mux.Handle("/api/ui/wizard/step1", s.authStore.RequireAuth(http.HandlerFunc(s.handleWizardStep1))) @@ -272,16 +286,19 @@ func (s *Server) registerRoutes() { } }) // 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))) @@ -566,6 +583,50 @@ 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) +} + +// handleSaveEnrichMetadata writes ENRICH_METADATA=true/false to the .env file. +func (s *Server) handleSaveEnrichMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + val := "false" + if body.Enabled { + val = "true" + } + if err := updateEnvKeys(s.cfg.WebEnvPath, map[string]string{"ENRICH_TRACK_METADATA": val}, 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..61c1757f 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -14,7 +14,8 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { fetchConfig, fetchConfigRaw, saveConfig, resetConfig, saveSchedule, startRun, stopRun, fetchRunStatus, fetchLogs, - fetchCustomPlaylists, deleteCustomPlaylist, + fetchCustomPlaylists, deleteCustomPlaylist, savePathTemplate, saveEnrichMetadata, + fetchPathTemplatePresets, addPathTemplatePreset, deletePathTemplatePreset, } from '../lib/api' import { parseSlogLine, cronToFields, highlightEnv } from '../lib/utils' import { fetchPlaylistTracks } from '../lib/listenbrainz' @@ -23,6 +24,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 +490,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. +
+ +
+ + {/* 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. @@ -545,7 +828,7 @@ function ConfigSection({ onWizard }) { {!editing ? (
         ) : (