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)