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
8 changes: 7 additions & 1 deletion src/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/web/backend/defs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 112 additions & 0 deletions src/web/backend/path_templates.go
Original file line number Diff line number Diff line change
@@ -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)
}
73 changes: 67 additions & 6 deletions src/web/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading