diff --git a/sample.env b/sample.env
index 29273b8..4128b65 100644
--- a/sample.env
+++ b/sample.env
@@ -33,8 +33,14 @@ LIBRARY_NAME=
# Directory to store downloaded tracks. It's recommended to make a separate directory (under the music library) for Explo
# PS! This is only needed when running the binary version, in docker it's set through volume mapping
# DOWNLOAD_DIR=/path/to/musiclibrary/explo/
-# Download/move tracks to a subdirectory named after the playlist
+# Download/move tracks to a formatted subdirectory under DOWNLOAD_DIR
# USE_SUBDIRECTORY=true
+# Format for the subdirectory path under DOWNLOAD_DIR when USE_SUBDIRECTORY=true.
+# Ignored when USE_SUBDIRECTORY=false.
+# Available tokens: {playlist}, {artist}, {album}. Default: {playlist}.
+# Example: {artist}/{album} stores tracks by artist and album.
+# With --persist=false, formats without a {playlist} root use playlist manifests for cleanup.
+# DOWNLOAD_SUBDIRECTORY_FORMAT={playlist}
# Keep original file permissions when moving files (set to false on Synology devices)
# KEEP_PERMISSIONS=true
# Comma-separated list (no spaces) of download services, in priority order (default: youtube)
diff --git a/src/client/jellyfin.go b/src/client/jellyfin.go
index f70fd01..3037f9e 100644
--- a/src/client/jellyfin.go
+++ b/src/client/jellyfin.go
@@ -122,7 +122,7 @@ func (c *Jellyfin) AddLibrary() error {
}
func (c *Jellyfin) RefreshLibrary() error {
- reqParam := fmt.Sprintf("/Items/%s/Refresh?metadataRefreshMode=FullRefresh&Recursive=true", c.LibraryID)
+ reqParam := fmt.Sprintf("/Items/%s/Refresh?metadataRefreshMode=FullRefresh&imageRefreshMode=FullRefresh&replaceAllMetadata=false&replaceAllImages=false&Recursive=true", c.LibraryID)
if _, err := c.HttpClient.MakeRequest("POST", c.Cfg.URL+reqParam, nil, c.Cfg.Creds.Headers); err != nil {
return err
diff --git a/src/config/config.go b/src/config/config.go
index 002cd93..3ef5727 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -89,16 +89,20 @@ type SubsonicConfig struct {
}
type DownloadConfig struct {
- DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
- Youtube Youtube
- YoutubeMusic YoutubeMusic
- Slskd Slskd
- ExcludeLocal bool
- KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
- RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format
- UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"`
- Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
- Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
+ DownloadDir string `env:"DOWNLOAD_DIR" env-default:"/data/"`
+ Youtube Youtube
+ YoutubeMusic YoutubeMusic
+ Slskd Slskd
+ ExcludeLocal bool
+ KeepPermissions bool `env:"KEEP_PERMISSIONS" env-default:"true"` // keep original file permissions when migrating download
+ RenameTrack bool `env:"RENAME_TRACK" env-default:"false"` // Rename track in {title}-{artist} format
+ UseSubDir bool `env:"USE_SUBDIRECTORY" env-default:"true"`
+ DownloadSubdirectoryFormat string `env:"DOWNLOAD_SUBDIRECTORY_FORMAT" env-default:"{playlist}"`
+ PlaylistName string
+ PlaylistType string
+ PlaylistManifestDir string
+ Discovery string `env:"LISTENBRAINZ_DISCOVERY" env-default:"playlist"`
+ Services []string `env:"DOWNLOAD_SERVICES" env-default:"youtube"`
}
type Filters struct {
@@ -246,6 +250,14 @@ func (cfg *Config) HandleDeprecation() { //
slog.Warn("'PERSIST' variable is deprecated, use --persist flag instead")
}
+ if !cfg.DownloadCfg.UseSubDir && downloadSubdirectoryFormatConfigured(cfg.DownloadCfg.DownloadSubdirectoryFormat) {
+ slog.Warn("'DOWNLOAD_SUBDIRECTORY_FORMAT' is ignored because 'USE_SUBDIRECTORY' is false")
+ }
+
+ if tokens := unsupportedSubdirectoryFormatTokens(cfg.DownloadCfg.DownloadSubdirectoryFormat); len(tokens) > 0 {
+ slog.Warn("'DOWNLOAD_SUBDIRECTORY_FORMAT' contains unsupported token(s)", "tokens", strings.Join(tokens, ","), "supported", "{playlist},{artist},{album}")
+ }
+
if !cfg.Persist && !cfg.DownloadCfg.UseSubDir {
slog.Warn("Deleting tracks requires 'USE_SUBDIRECTORY' to be true")
}
@@ -258,11 +270,47 @@ func (cfg *Config) GenPlaylistName() { // Generate playlist name and description
"Created for %s by Explo, using ListenBrainz recommendations.",
cfg.DiscoveryCfg.Listenbrainz.User)
- if cfg.DownloadCfg.UseSubDir {
- // add playlist name to downloadDir so all songs get downloaded to a single sub directory.
- cfg.DownloadCfg.DownloadDir = filepath.Join(
- cfg.DownloadCfg.DownloadDir,
- cfg.ClientCfg.PlaylistName)
+ cfg.DownloadCfg.PlaylistName = cfg.ClientCfg.PlaylistName
+ cfg.DownloadCfg.PlaylistType = cfg.Flags.Playlist
+ cfg.DownloadCfg.PlaylistManifestDir = filepath.Join(filepath.Dir(cfg.Flags.CfgPath), "playlist-manifests")
+}
+
+func downloadSubdirectoryFormatConfigured(format string) bool {
+ format = strings.TrimSpace(format)
+ return format != "" && format != "{playlist}"
+}
+
+func unsupportedSubdirectoryFormatTokens(format string) []string {
+ supported := map[string]bool{
+ "{playlist}": true,
+ "{artist}": true,
+ "{album}": true,
+ }
+
+ var tokens []string
+ seen := make(map[string]bool)
+ for {
+ start := strings.Index(format, "{")
+ if start == -1 {
+ return tokens
+ }
+
+ end := strings.Index(format[start:], "}")
+ if end == -1 {
+ token := format[start:]
+ if !seen[token] {
+ tokens = append(tokens, token)
+ seen[token] = true
+ }
+ return tokens
+ }
+
+ token := format[start : start+end+1]
+ if !supported[token] && !seen[token] {
+ tokens = append(tokens, token)
+ seen[token] = true
+ }
+ format = format[start+end+1:]
}
}
diff --git a/src/downloader/downloader.go b/src/downloader/downloader.go
index 0235c16..56f5f5b 100644
--- a/src/downloader/downloader.go
+++ b/src/downloader/downloader.go
@@ -5,7 +5,6 @@ import (
"io"
"log/slog"
"os"
- "path"
"path/filepath"
"strings"
@@ -33,7 +32,7 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL
for _, service := range cfg.Services {
switch service {
case "youtube":
- downloader = append(downloader, NewYoutube(cfg.Youtube, cfg.Discovery, cfg.DownloadDir, httpClient))
+ downloader = append(downloader, NewYoutube(cfg.Youtube, cfg, httpClient))
case "slskd":
slskdClient := NewSlskd(cfg.Slskd, cfg.DownloadDir)
slskdClient.AddHeader()
@@ -49,6 +48,8 @@ func NewDownloader(cfg *cfg.DownloadConfig, httpClient *util.HttpClient, filterL
}
func (c *DownloadClient) StartDownload(tracks *[]*models.Track) {
+ var filesBeforeDownload map[string]struct{}
+
if c.Cfg.ExcludeLocal { // remove locally found tracks, so they can't be added to playlist
filterLocalTracks(tracks, true)
}
@@ -58,6 +59,13 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) {
return
}
}
+ if c.needsDownloadDir() && playlistManifestCleanupRequired(c.Cfg) {
+ var err error
+ filesBeforeDownload, err = snapshotDownloadFiles(c.Cfg.DownloadDir)
+ if err != nil {
+ slog.Warn("failed to snapshot download directory before run", "context", err.Error())
+ }
+ }
for _, d := range c.Downloaders {
var g errgroup.Group
@@ -92,6 +100,12 @@ func (c *DownloadClient) StartDownload(tracks *[]*models.Track) {
}
}
}
+
+ if c.needsDownloadDir() && playlistManifestCleanupRequired(c.Cfg) {
+ if err := c.writePlaylistManifest(*tracks, filesBeforeDownload); err != nil {
+ slog.Warn("failed to write playlist manifest", "context", err.Error())
+ }
+ }
filterLocalTracks(tracks, false)
}
@@ -105,17 +119,34 @@ func (c *DownloadClient) needsDownloadDir() bool {
}
func (c *DownloadClient) DeleteSongs() {
- entries, err := os.ReadDir(c.Cfg.DownloadDir)
+ if playlistManifestCleanupRequired(c.Cfg) {
+ if err := c.deletePlaylistManifestFiles(c.Cfg); err != nil {
+ slog.Warn("failed to clean playlist manifest downloads", "context", err.Error())
+ }
+ result, err := cleanupOrphanDownloads(c.Cfg)
+ if err != nil {
+ slog.Warn("failed to clean orphan downloads", "context", err.Error())
+ } else {
+ slog.Info("orphan cleanup finished", "scanned", result.Scanned, "removed", result.Removed, "referenced", result.Referenced, "skipped", result.Skipped)
+ }
+ return
+ }
+
+ downloadDir := cleanupDownloadDir(c.Cfg)
+ entries, err := os.ReadDir(downloadDir)
if err != nil {
slog.Error("failed to read directory", "context", err.Error())
}
for _, entry := range entries {
- if !(entry.IsDir()) {
- err = os.Remove(path.Join(c.Cfg.DownloadDir, entry.Name()))
+ entryPath := filepath.Join(downloadDir, entry.Name())
+ if entry.IsDir() {
+ err = os.RemoveAll(entryPath)
+ } else {
+ err = os.Remove(entryPath)
+ }
- if err != nil {
- slog.Error("failed to remove file", "context", err.Error())
- }
+ if err != nil {
+ slog.Error("failed to remove downloaded track", "context", err.Error())
}
}
}
@@ -201,11 +232,14 @@ func (c *DownloadClient) MoveDownload(srcDir, destDir, trackPath string, track *
}
}()
- if err = os.MkdirAll(destDir, os.ModePerm); err != nil {
+ moveCfg := *c.Cfg
+ moveCfg.DownloadDir = destDir
+ targetDir := trackDownloadDir(&moveCfg, track)
+ if err = os.MkdirAll(targetDir, os.ModePerm); err != nil {
return fmt.Errorf("couldn't make download directory: %s", err.Error())
}
- dstFile := filepath.Join(destDir, track.File)
+ dstFile := filepath.Join(targetDir, track.File)
out, err := os.Create(dstFile)
if err != nil {
return fmt.Errorf("couldn't create destination file: %s", err.Error())
diff --git a/src/downloader/manifest.go b/src/downloader/manifest.go
new file mode 100644
index 0000000..d4e9bb6
--- /dev/null
+++ b/src/downloader/manifest.go
@@ -0,0 +1,331 @@
+package downloader
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ cfg "explo/src/config"
+ "explo/src/models"
+)
+
+type playlistManifest struct {
+ Version int `json:"version"`
+ Playlist string `json:"playlist"`
+ Files []string `json:"files"`
+}
+
+type orphanCleanupResult struct {
+ Scanned int
+ Referenced int
+ Removed int
+ Skipped int
+}
+
+func playlistManifestCleanupRequired(downloadCfg *cfg.DownloadConfig) bool {
+ return downloadCfg.UseSubDir && !downloadSubdirectoryFormatHasPlaylistRoot(downloadCfg.DownloadSubdirectoryFormat)
+}
+
+func cleanupOrphanDownloads(downloadCfg *cfg.DownloadConfig) (orphanCleanupResult, error) {
+ var result orphanCleanupResult
+
+ if strings.TrimSpace(downloadCfg.DownloadDir) == "" {
+ return result, fmt.Errorf("DOWNLOAD_DIR is empty")
+ }
+
+ referencedFiles, err := referencedManifestFiles(downloadCfg)
+ if err != nil {
+ return result, err
+ }
+ result.Referenced = len(referencedFiles)
+
+ downloadRoot, err := filepath.Abs(downloadCfg.DownloadDir)
+ if err != nil {
+ return result, err
+ }
+ manifestDir, err := filepath.Abs(downloadCfg.PlaylistManifestDir)
+ if err != nil {
+ return result, err
+ }
+ skipManifestDir := strings.TrimSpace(downloadCfg.PlaylistManifestDir) != ""
+
+ err = filepath.WalkDir(downloadCfg.DownloadDir, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ if skipManifestDir && samePathOrInside(path, manifestDir) {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !entry.Type().IsRegular() {
+ result.Skipped++
+ return nil
+ }
+
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(downloadRoot, absPath)
+ if err != nil {
+ return err
+ }
+ if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." {
+ return fmt.Errorf("refusing to remove path outside download directory: %s", path)
+ }
+
+ rel = filepath.ToSlash(rel)
+ if _, ok := referencedFiles[rel]; ok {
+ result.Scanned++
+ return nil
+ }
+
+ fullPath, err := safeDownloadPath(downloadCfg.DownloadDir, rel)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ removeEmptyParents(filepath.Dir(fullPath), downloadCfg.DownloadDir)
+ result.Scanned++
+ result.Removed++
+ return nil
+ })
+ if os.IsNotExist(err) {
+ return result, nil
+ }
+ return result, err
+}
+
+func (c *DownloadClient) writePlaylistManifest(tracks []*models.Track, filesBeforeDownload map[string]struct{}) error {
+ files := make([]string, 0)
+
+ for _, track := range tracks {
+ if !track.Present || track.File == "" {
+ continue
+ }
+
+ rel, err := downloadedTrackRelPath(c.Cfg, track)
+ if err != nil {
+ return err
+ }
+ if _, existedBefore := filesBeforeDownload[rel]; existedBefore || filesBeforeDownload == nil {
+ referenced, err := fileReferencedByAnyPlaylistManifest(c.Cfg, rel)
+ if err != nil {
+ return err
+ }
+ if !referenced {
+ continue
+ }
+ }
+ files = append(files, rel)
+ }
+
+ manifest := playlistManifest{
+ Version: 1,
+ Playlist: c.Cfg.PlaylistName,
+ Files: files,
+ }
+
+ path := playlistManifestPath(c.Cfg)
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(data, '\n'), 0644)
+}
+
+func snapshotDownloadFiles(downloadDir string) (map[string]struct{}, error) {
+ files := make(map[string]struct{})
+
+ err := filepath.WalkDir(downloadDir, func(path string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ rel, err := filepath.Rel(downloadDir, path)
+ if err != nil {
+ return err
+ }
+ files[filepath.ToSlash(rel)] = struct{}{}
+ return nil
+ })
+ if os.IsNotExist(err) {
+ return files, nil
+ }
+ return files, err
+}
+
+func (c *DownloadClient) deletePlaylistManifestFiles(downloadCfg *cfg.DownloadConfig) error {
+ path := playlistManifestPath(downloadCfg)
+ manifest, err := readPlaylistManifest(path)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+
+ for _, relPath := range manifest.Files {
+ referenced, err := fileReferencedByOtherPlaylistManifests(downloadCfg, path, relPath)
+ if err != nil {
+ slog.Warn("skipping playlist manifest cleanup because references could not be checked", "file", relPath, "context", err.Error())
+ continue
+ }
+ if referenced {
+ continue
+ }
+
+ fullPath, err := safeDownloadPath(downloadCfg.DownloadDir, relPath)
+ if err != nil {
+ slog.Warn("skipping invalid playlist manifest path", "file", relPath, "context", err.Error())
+ continue
+ }
+ if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
+ slog.Warn("failed to remove playlist manifest download", "file", relPath, "context", err.Error())
+ continue
+ }
+ removeEmptyParents(filepath.Dir(fullPath), downloadCfg.DownloadDir)
+ }
+
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
+
+func playlistManifestPath(downloadCfg *cfg.DownloadConfig) string {
+ manifestName := downloadCfg.PlaylistType
+ if strings.TrimSpace(manifestName) == "" {
+ manifestName = downloadCfg.PlaylistName
+ }
+ return filepath.Join(downloadCfg.PlaylistManifestDir, folderPart(manifestName, "playlist")+".json")
+}
+
+func readPlaylistManifest(path string) (playlistManifest, error) {
+ var manifest playlistManifest
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return manifest, err
+ }
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return manifest, err
+ }
+ return manifest, nil
+}
+
+func fileReferencedByOtherPlaylistManifests(downloadCfg *cfg.DownloadConfig, currentManifestPath, relPath string) (bool, error) {
+ return fileReferencedByPlaylistManifests(downloadCfg, currentManifestPath, relPath)
+}
+
+func fileReferencedByAnyPlaylistManifest(downloadCfg *cfg.DownloadConfig, relPath string) (bool, error) {
+ return fileReferencedByPlaylistManifests(downloadCfg, "", relPath)
+}
+
+func fileReferencedByPlaylistManifests(downloadCfg *cfg.DownloadConfig, currentManifestPath, relPath string) (bool, error) {
+ manifestDir := filepath.Dir(playlistManifestPath(downloadCfg))
+ entries, err := os.ReadDir(manifestDir)
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+
+ cleanCurrent := ""
+ if currentManifestPath != "" {
+ cleanCurrent, err = filepath.Abs(currentManifestPath)
+ if err != nil {
+ return false, err
+ }
+ }
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
+ continue
+ }
+
+ path := filepath.Join(manifestDir, entry.Name())
+ cleanPath, err := filepath.Abs(path)
+ if err != nil {
+ return false, err
+ }
+ if cleanCurrent != "" && cleanPath == cleanCurrent {
+ continue
+ }
+
+ manifest, err := readPlaylistManifest(path)
+ if err != nil {
+ return false, err
+ }
+ for _, file := range manifest.Files {
+ if filepath.ToSlash(filepath.Clean(filepath.FromSlash(file))) == filepath.ToSlash(filepath.Clean(filepath.FromSlash(relPath))) {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+func referencedManifestFiles(downloadCfg *cfg.DownloadConfig) (map[string]struct{}, error) {
+ files := make(map[string]struct{})
+ if strings.TrimSpace(downloadCfg.PlaylistManifestDir) == "" {
+ return files, nil
+ }
+
+ manifestDir := filepath.Dir(playlistManifestPath(downloadCfg))
+ entries, err := os.ReadDir(manifestDir)
+ if os.IsNotExist(err) {
+ return files, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
+ continue
+ }
+
+ manifest, err := readPlaylistManifest(filepath.Join(manifestDir, entry.Name()))
+ if err != nil {
+ return nil, err
+ }
+ for _, file := range manifest.Files {
+ relPath := filepath.ToSlash(filepath.Clean(filepath.FromSlash(file)))
+ if _, err := safeDownloadPath(downloadCfg.DownloadDir, relPath); err != nil {
+ slog.Warn("skipping invalid playlist manifest path", "file", file, "context", err.Error())
+ continue
+ }
+ files[relPath] = struct{}{}
+ }
+ }
+ return files, nil
+}
+
+func samePathOrInside(path, dir string) bool {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return false
+ }
+ absDir, err := filepath.Abs(dir)
+ if err != nil {
+ return false
+ }
+ rel, err := filepath.Rel(absDir, absPath)
+ if err != nil {
+ return false
+ }
+ return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)))
+}
diff --git a/src/downloader/paths.go b/src/downloader/paths.go
new file mode 100644
index 0000000..01a30d7
--- /dev/null
+++ b/src/downloader/paths.go
@@ -0,0 +1,197 @@
+package downloader
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ cfg "explo/src/config"
+ "explo/src/models"
+ "explo/src/util"
+)
+
+func cleanupDownloadDir(downloadCfg *cfg.DownloadConfig) string {
+ if !downloadCfg.UseSubDir {
+ return downloadCfg.DownloadDir
+ }
+
+ if downloadSubdirectoryFormatHasPlaylistRoot(downloadCfg.DownloadSubdirectoryFormat) {
+ root := renderDownloadSubdirectoryPart(firstSubdirectoryFormatPart(downloadCfg.DownloadSubdirectoryFormat), downloadCfg.PlaylistName, nil)
+ root = folderPart(root, "")
+ if root != "" {
+ return filepath.Join(downloadCfg.DownloadDir, root)
+ }
+ }
+
+ if !subdirectoryFormatUsesTrackFields(downloadCfg.DownloadSubdirectoryFormat) {
+ return trackDownloadDir(downloadCfg, nil)
+ }
+
+ return downloadCfg.DownloadDir
+}
+
+func subdirectoryFormatUsesTrackFields(format string) bool {
+ return strings.Contains(format, "{artist}") ||
+ strings.Contains(format, "{album}")
+}
+
+func downloadSubdirectoryFormatHasPlaylistRoot(format string) bool {
+ root := firstSubdirectoryFormatPart(format)
+ return strings.Contains(root, "{playlist}") &&
+ !subdirectoryFormatUsesTrackFields(root)
+}
+
+func firstSubdirectoryFormatPart(format string) string {
+ format = strings.TrimSpace(format)
+ if format == "" {
+ format = "{playlist}"
+ }
+
+ parts := strings.FieldsFunc(format, func(r rune) bool {
+ return r == '/' || r == '\\'
+ })
+ for _, part := range parts {
+ if strings.TrimSpace(part) != "" {
+ return part
+ }
+ }
+ return "{playlist}"
+}
+
+func trackDownloadDir(downloadCfg *cfg.DownloadConfig, track *models.Track) string {
+ if !downloadCfg.UseSubDir {
+ return downloadCfg.DownloadDir
+ }
+
+ subdir := renderDownloadSubdirectoryFormat(downloadCfg.DownloadSubdirectoryFormat, downloadCfg.PlaylistName, track)
+ if subdir == "" {
+ return downloadCfg.DownloadDir
+ }
+
+ return filepath.Join(downloadCfg.DownloadDir, subdir)
+}
+
+func downloadedTrackRelPath(downloadCfg *cfg.DownloadConfig, track *models.Track) (string, error) {
+ fullPath := track.File
+ if !filepath.IsAbs(fullPath) {
+ fullPath = filepath.Join(trackDownloadDir(downloadCfg, track), track.File)
+ }
+ relPath, err := filepath.Rel(downloadCfg.DownloadDir, fullPath)
+ if err != nil {
+ return "", err
+ }
+ if _, err := safeDownloadPath(downloadCfg.DownloadDir, relPath); err != nil {
+ return "", err
+ }
+ return filepath.ToSlash(relPath), nil
+}
+
+func renderDownloadSubdirectoryFormat(format, playlist string, track *models.Track) string {
+ format = strings.TrimSpace(format)
+ if format == "" {
+ format = "{playlist}"
+ }
+
+ parts := strings.FieldsFunc(format, func(r rune) bool {
+ return r == '/' || r == '\\'
+ })
+
+ safeParts := make([]string, 0, len(parts))
+ for _, part := range parts {
+ rendered := renderDownloadSubdirectoryPart(part, playlist, track)
+ safePart := folderPart(rendered, "")
+ if safePart != "" {
+ safeParts = append(safeParts, safePart)
+ }
+ }
+
+ return filepath.Join(safeParts...)
+}
+
+func renderDownloadSubdirectoryPart(part, playlist string, track *models.Track) string {
+ replacer := strings.NewReplacer(
+ "{playlist}", folderValue(playlist, "Playlist"),
+ "{artist}", trackFolderValue(track, "artist"),
+ "{album}", trackFolderValue(track, "album"),
+ )
+ return replacer.Replace(part)
+}
+
+func trackFolderValue(track *models.Track, field string) string {
+ if track == nil {
+ return ""
+ }
+
+ switch field {
+ case "artist":
+ if track.MainArtist != "" {
+ return track.MainArtist
+ }
+ return folderValue(track.Artist, "Unknown Artist")
+ case "album":
+ return folderValue(track.Album, "Unknown Album")
+ default:
+ return ""
+ }
+}
+
+func folderValue(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func folderPart(value, fallback string) string {
+ part := strings.Trim(util.FilenameSafe(strings.TrimSpace(value)), " ._")
+ if part == "" {
+ return fallback
+ }
+ return part
+}
+
+func safeDownloadPath(downloadDir, relPath string) (string, error) {
+ cleanRel := filepath.Clean(filepath.FromSlash(relPath))
+ if cleanRel == "." || filepath.IsAbs(cleanRel) || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("path escapes download directory")
+ }
+
+ fullPath := filepath.Join(downloadDir, cleanRel)
+ absRoot, err := filepath.Abs(downloadDir)
+ if err != nil {
+ return "", err
+ }
+ absPath, err := filepath.Abs(fullPath)
+ if err != nil {
+ return "", err
+ }
+ rel, err := filepath.Rel(absRoot, absPath)
+ if err != nil {
+ return "", err
+ }
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("path escapes download directory")
+ }
+ return fullPath, nil
+}
+
+func removeEmptyParents(dir, stopDir string) {
+ absStop, err := filepath.Abs(stopDir)
+ if err != nil {
+ return
+ }
+ for {
+ absDir, err := filepath.Abs(dir)
+ if err != nil || absDir == absStop {
+ return
+ }
+ if rel, err := filepath.Rel(absStop, absDir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return
+ }
+ if err := os.Remove(dir); err != nil {
+ return
+ }
+ dir = filepath.Dir(dir)
+ }
+}
diff --git a/src/downloader/youtube.go b/src/downloader/youtube.go
index 2db1643..cc00ff5 100644
--- a/src/downloader/youtube.go
+++ b/src/downloader/youtube.go
@@ -45,13 +45,13 @@ type YTMusicSearchResult struct {
}
type Youtube struct {
- DownloadDir string
+ DownloadCfg *cfg.DownloadConfig
HttpClient *util.HttpClient
Cfg cfg.Youtube
gouTubeOpts goutubedl.Options
}
-func NewYoutube(cfg cfg.Youtube, discovery, downloadDir string, httpClient *util.HttpClient) *Youtube { // init downloader cfg for youtube
+func NewYoutube(cfg cfg.Youtube, downloadCfg *cfg.DownloadConfig, httpClient *util.HttpClient) *Youtube { // init downloader cfg for youtube
// check for custom ytdlp options
if cfg.YtdlpPath != "" {
goutubedl.Path = cfg.YtdlpPath
@@ -63,7 +63,7 @@ func NewYoutube(cfg cfg.Youtube, discovery, downloadDir string, httpClient *util
}
return &Youtube{
- DownloadDir: downloadDir,
+ DownloadCfg: downloadCfg,
Cfg: cfg,
HttpClient: httpClient,
gouTubeOpts: opts}
@@ -131,7 +131,7 @@ func queryYTMusic(track *models.Track, query string) error {
func (c *Youtube) GetTrack(track *models.Track) error {
ctx := context.Background() // ctx for yt-dlp
- track.File = fmt.Sprintf("%s.%s",getFilename(track.Title, track.Artist), c.Cfg.FileExtension)
+ track.File = fmt.Sprintf("%s.%s", getFilename(track.Title, track.Artist), c.Cfg.FileExtension)
track.Present = fetchAndSaveVideo(ctx, *c, *track)
if track.Present {
@@ -182,7 +182,13 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult)
}
}()
- input := filepath.Join(c.DownloadDir, track.File+".tmp")
+ downloadDir := trackDownloadDir(c.DownloadCfg, &track)
+ if err := os.MkdirAll(downloadDir, 0755); err != nil {
+ slog.Error("failed to create track directory", "context", err.Error())
+ return false
+ }
+
+ input := filepath.Join(downloadDir, track.File+".tmp")
file, err := os.Create(input)
if err != nil {
slog.Error("failed to create song file", "context", err.Error())
@@ -203,9 +209,9 @@ func saveVideo(c Youtube, track models.Track, stream *goutubedl.DownloadResult)
return false
}
- cmd := ffmpeg.Input(input).Output(filepath.Join(c.DownloadDir, track.File), ffmpeg.KwArgs{
+ cmd := ffmpeg.Input(input).Output(filepath.Join(downloadDir, track.File), ffmpeg.KwArgs{
"map": "0:a",
- "metadata": []string{"artist=" + track.Artist, "title=" + track.Title, "album=" + track.Album},
+ "metadata": []string{"artist=" + track.Artist, "album_artist=" + track.MainArtist, "title=" + track.Title, "album=" + track.Album},
"loglevel": "error",
}).OverWriteOutput().ErrorToStdOut()
@@ -247,7 +253,7 @@ func (c *Youtube) gatherVideo(cfg cfg.Youtube, videos Videos, track models.Track
func fetchAndSaveVideo(ctx context.Context, cfg Youtube, track models.Track) bool {
stream, err := getVideo(ctx, cfg, track.ID)
if err != nil {
- slog.Error("failed getting stream for video", "trackID",track.ID, "context", err.Error())
+ slog.Error("failed getting stream for video", "trackID", track.ID, "context", err.Error())
return false
}
diff --git a/src/web/backend/defs.go b/src/web/backend/defs.go
index 957cef6..00ccc4c 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", "DOWNLOAD_SUBDIRECTORY_FORMAT",
"DOWNLOAD_SERVICES", "YOUTUBE_API_KEY", "TRACK_EXTENSION", "FILTER_LIST",
"SLSKD_URL", "SLSKD_API_KEY",
"WIZARD_COMPLETE",
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index 1495ec1..32b34e0 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -593,16 +593,17 @@ func (s *Server) handleWizardStep2(w http.ResponseWriter, r *http.Request) {
// handleWizardStep3 saves downloader configuration.
func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
var body struct {
- DownloadDir string `json:"download_dir"`
- UseSubdirectory bool `json:"use_subdirectory"`
- MigrateDownloads bool `json:"migrate_downloads"`
- DownloadServices []string `json:"download_services"`
- YoutubeAPIKey string `json:"youtube_api_key"`
- TrackExtension string `json:"track_extension"` // yt-dlp
- FilterList string `json:"filter_list"`
- SlskdURL string `json:"slskd_url"`
- SlskdAPIKey string `json:"slskd_api_key"`
- Extensions string `json:"extensions"` // slskd
+ DownloadDir string `json:"download_dir"`
+ UseSubdirectory bool `json:"use_subdirectory"`
+ DownloadSubdirectoryFormat string `json:"download_subdirectory_format"`
+ MigrateDownloads bool `json:"migrate_downloads"`
+ DownloadServices []string `json:"download_services"`
+ YoutubeAPIKey string `json:"youtube_api_key"`
+ TrackExtension string `json:"track_extension"` // yt-dlp
+ FilterList string `json:"filter_list"`
+ SlskdURL string `json:"slskd_url"`
+ SlskdAPIKey string `json:"slskd_api_key"`
+ Extensions string `json:"extensions"` // slskd
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
@@ -623,17 +624,18 @@ func (s *Server) handleWizardStep3(w http.ResponseWriter, r *http.Request) {
migrateDL = "true"
}
updates := map[string]string{
- "DOWNLOAD_DIR": body.DownloadDir,
- "USE_SUBDIRECTORY": useSubdir,
- "MIGRATE_DOWNLOADS": migrateDL,
- "DOWNLOAD_SERVICES": joined,
- "YOUTUBE_API_KEY": body.YoutubeAPIKey,
- "TRACK_EXTENSION": body.TrackExtension, // yt-dlp
- "FILTER_LIST": body.FilterList,
- "SLSKD_URL": body.SlskdURL,
- "SLSKD_API_KEY": body.SlskdAPIKey,
- "EXTENSIONS": body.Extensions, // slskd
- "WIZARD_COMPLETE": "true",
+ "DOWNLOAD_DIR": body.DownloadDir,
+ "USE_SUBDIRECTORY": useSubdir,
+ "DOWNLOAD_SUBDIRECTORY_FORMAT": body.DownloadSubdirectoryFormat,
+ "MIGRATE_DOWNLOADS": migrateDL,
+ "DOWNLOAD_SERVICES": joined,
+ "YOUTUBE_API_KEY": body.YoutubeAPIKey,
+ "TRACK_EXTENSION": body.TrackExtension, // yt-dlp
+ "FILTER_LIST": body.FilterList,
+ "SLSKD_URL": body.SlskdURL,
+ "SLSKD_API_KEY": body.SlskdAPIKey,
+ "EXTENSIONS": body.Extensions, // slskd
+ "WIZARD_COMPLETE": "true",
}
if err := updateEnvKeys(s.cfg.WebEnvPath, updates, web.SampleEnv); err != nil {
diff --git a/src/web/frontend/src/components/Wizard.jsx b/src/web/frontend/src/components/Wizard.jsx
index bddacb5..32e9b7e 100644
--- a/src/web/frontend/src/components/Wizard.jsx
+++ b/src/web/frontend/src/components/Wizard.jsx
@@ -256,7 +256,7 @@ function Collapse({ open, children }) {
function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
const { downloadDir, useSubdirectory, migrateDownloads, dlServices,
- youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, extensions } = fields
+ downloadSubdirectoryFormat, youtubeApiKey, trackExtension, filterList, slskdUrl, slskdApiKey, extensions } = fields
const isLocked = key => envSources[key] === 'env'
const valid = () => {
@@ -309,9 +309,16 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
checked={useSubdirectory}
onChange={v => setField('useSubdirectory', v)}
disabled={isLocked('USE_SUBDIRECTORY')}
- name="Use playlist subfolders"
- desc="Create a subfolder per playlist inside the download directory"
+ name="Use download subdirectories"
+ desc="Store downloads under a formatted subdirectory inside the download directory"
/>
+
+ setField('downloadSubdirectoryFormat', e.target.value)}
+ placeholder="{playlist}" autoComplete="off" spellCheck={false}
+ disabled={!useSubdirectory || isLocked('DOWNLOAD_SUBDIRECTORY_FORMAT')} />
+
@@ -370,9 +377,16 @@ function Step3({ fields, setField, envSources, onBack, onFinish, saving }) {
checked={useSubdirectory}
onChange={v => setField('useSubdirectory', v)}
disabled={isLocked('USE_SUBDIRECTORY')}
- name="Use playlist subfolders"
- desc="Create a subfolder per playlist inside the download directory"
+ name="Use download subdirectories"
+ desc="Store downloads under a formatted subdirectory inside the download directory"
/>
+
+ setField('downloadSubdirectoryFormat', e.target.value)}
+ placeholder="{playlist}" autoComplete="off" spellCheck={false}
+ disabled={!useSubdirectory || isLocked('DOWNLOAD_SUBDIRECTORY_FORMAT')} />
+
@@ -420,6 +434,7 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad,
// Step 3
downloadDir: config.DOWNLOAD_DIR || '',
useSubdirectory: config.USE_SUBDIRECTORY !== 'false',
+ downloadSubdirectoryFormat: config.DOWNLOAD_SUBDIRECTORY_FORMAT || '{playlist}',
migrateDownloads: config.MIGRATE_DOWNLOADS === 'true',
dlServices: { youtube: s.includes('youtube'), slskd: s.includes('slskd') },
youtubeApiKey: config.YOUTUBE_API_KEY || '',
@@ -476,6 +491,7 @@ export default function Wizard({ config, envSources, bgUrl, bgLoaded, onBgLoad,
const services = Object.entries(fields.dlServices).filter(([, v]) => v).map(([k]) => k)
await wizardStep3({
download_dir: fields.downloadDir, use_subdirectory: fields.useSubdirectory,
+ download_subdirectory_format: fields.downloadSubdirectoryFormat,
migrate_downloads: fields.migrateDownloads, download_services: services,
youtube_api_key: fields.youtubeApiKey, track_extension: fields.trackExtension,
filter_list: fields.filterList, slskd_url: fields.slskdUrl, slskd_api_key: fields.slskdApiKey,
diff --git a/src/web/sample.env b/src/web/sample.env
index 29273b8..4128b65 100644
--- a/src/web/sample.env
+++ b/src/web/sample.env
@@ -33,8 +33,14 @@ LIBRARY_NAME=
# Directory to store downloaded tracks. It's recommended to make a separate directory (under the music library) for Explo
# PS! This is only needed when running the binary version, in docker it's set through volume mapping
# DOWNLOAD_DIR=/path/to/musiclibrary/explo/
-# Download/move tracks to a subdirectory named after the playlist
+# Download/move tracks to a formatted subdirectory under DOWNLOAD_DIR
# USE_SUBDIRECTORY=true
+# Format for the subdirectory path under DOWNLOAD_DIR when USE_SUBDIRECTORY=true.
+# Ignored when USE_SUBDIRECTORY=false.
+# Available tokens: {playlist}, {artist}, {album}. Default: {playlist}.
+# Example: {artist}/{album} stores tracks by artist and album.
+# With --persist=false, formats without a {playlist} root use playlist manifests for cleanup.
+# DOWNLOAD_SUBDIRECTORY_FORMAT={playlist}
# Keep original file permissions when moving files (set to false on Synology devices)
# KEEP_PERMISSIONS=true
# Comma-separated list (no spaces) of download services, in priority order (default: youtube)