From c48406f5d0a8a3782eec3e63e2b9bc563224898f Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:13:41 +0700 Subject: [PATCH] fix(download): resolve missing album folders before publishing --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 3 + .../spotiflac/NativeFinalizationPolicy.kt | 18 +++ .../com/zarz/spotiflac/SafDownloadHandler.kt | 17 ++- .../spotiflac/NativeFinalizationPolicyTest.kt | 24 ++++ go_backend/download_album_folder.go | 114 +++++++++++++++ go_backend/download_album_folder_test.go | 136 ++++++++++++++++++ go_backend/exports_download.go | 2 + go_backend/extension_fallback.go | 5 +- go_backend/extension_fallback_output.go | 5 +- lib/providers/download_queue_provider.dart | 5 + ...download_queue_provider_native_worker.dart | 20 ++- .../download_queue_provider_paths.dart | 31 +++- .../download_queue_provider_single_item.dart | 8 +- lib/services/download_request_payload.dart | 5 + test/models_and_utils_test.dart | 3 + 15 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 go_backend/download_album_folder.go create mode 100644 go_backend/download_album_folder_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index f940dcbf..8809e0cb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -186,6 +186,9 @@ object NativeDownloadFinalizer { val itemObject = parseObject(itemJson) val requestObject = parseObject(requestJson) validateRequestContract(requestObject) + if (result.optBoolean("saf_deferred_publish", false) && result.has("saf_relative_dir")) { + requestObject.put("saf_relative_dir", result.getString("saf_relative_dir")) + } val input = FinalizeInput( itemId = itemId, request = requestObject, diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index 16ddf015..b7f4668c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -11,6 +11,24 @@ import kotlin.math.roundToInt * finalizer's I/O-heavy orchestration. */ internal object NativeFinalizationPolicy { + fun resolvedAlbumRelativeDirectory( + relativeDirectory: String, + albumFolderTemplate: String, + resolvedAlbumFolder: String, + ): String { + val albumFolder = resolvedAlbumFolder.trim() + if (!albumFolderTemplate.contains("{album}") || albumFolder.isEmpty()) { + return relativeDirectory + } + // The backend supplies one sanitized leaf, never a relative path. + if (albumFolder == "." || albumFolder == ".." || + albumFolder.contains('/') || albumFolder.contains('\\')) { + return relativeDirectory + } + val parent = relativeDirectory.substringBeforeLast('/', "") + return if (parent.isEmpty()) albumFolder else "$parent/$albumFolder" + } + private val lyricsMetadataLinePattern = Regex( "^\\[[a-z][a-z0-9_]*:.*]$", RegexOption.IGNORE_CASE, diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index efcffb01..701db325 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -95,7 +95,7 @@ object SafDownloadHandler { val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType val existingDir = findDocumentDir(context, treeUri, relativeDir) - if (existingDir != null) { + if (existingDir != null && req.optString("album_folder_template", "").isBlank()) { val existing = existingDir.findFile(fileName) if (existing != null && existing.isFile && existing.length() > 0) { deleteStaleStagedFiles(existingDir, fileName, outputExt) @@ -109,11 +109,8 @@ object SafDownloadHandler { } } - val targetDir = ensureDocumentDir(context, treeUri, relativeDir) - ?: return errorJson("Failed to access SAF directory") - if (deferSafPublish) { - deleteStaleStagedFiles(targetDir, fileName, outputExt) + existingDir?.let { deleteStaleStagedFiles(it, fileName, outputExt) } val workingExt = outputExt.ifBlank { ".tmp" } val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir) return try { @@ -135,7 +132,12 @@ object SafDownloadHandler { respObj.put("file_name", resolvedFileName) respObj.put("saf_deferred_publish", true) respObj.put("saf_final_file_name", resolvedFileName) - respObj.put("saf_relative_dir", relativeDir) + val resolvedDir = NativeFinalizationPolicy.resolvedAlbumRelativeDirectory( + relativeDirectory = relativeDir, + albumFolderTemplate = req.optString("album_folder_template", ""), + resolvedAlbumFolder = respObj.optString("resolved_album_folder", ""), + ) + respObj.put("saf_relative_dir", resolvedDir) respObj.put("saf_tree_uri", treeUriStr) respObj.put("saf_output_ext", outputExt) respObj.put("saf_final_mime_type", mimeType) @@ -149,6 +151,9 @@ object SafDownloadHandler { } } + val targetDir = ensureDocumentDir(context, treeUri, relativeDir) + ?: return errorJson("Failed to access SAF directory") + // Remove any stale partial from a previous killed attempt before // creating the staged document: reusing it would let a shorter new // write leave the old tail bytes in place (fd truncation is diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index 799b367c..dd1664db 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -7,6 +7,30 @@ import org.junit.Assert.assertTrue import org.junit.Test class NativeFinalizationPolicyTest { + @Test + fun lateAlbumMetadataResolvesOnlyThePendingFolderLeaf() { + assertEquals( + "Playlist/Artist/[2024] Album", + NativeFinalizationPolicy.resolvedAlbumRelativeDirectory( + "Playlist/Artist/[2024] Unknown", "[2024] {album}", "[2024] Album", + ), + ) + assertEquals( + "Album", + NativeFinalizationPolicy.resolvedAlbumRelativeDirectory("Unknown", "{album}", "Album"), + ) + for (album in listOf("", "..", "../Album", "Album/Part", "Album\\Part")) { + assertEquals( + "Artist/Unknown", + NativeFinalizationPolicy.resolvedAlbumRelativeDirectory("Artist/Unknown", "{album}", album), + ) + } + assertEquals( + "Unknown", + NativeFinalizationPolicy.resolvedAlbumRelativeDirectory("Unknown", "", "Album"), + ) + } + @Test fun matchesSharedLyricUsabilityCases() { val stream = checkNotNull( diff --git a/go_backend/download_album_folder.go b/go_backend/download_album_folder.go new file mode 100644 index 00000000..2036bba1 --- /dev/null +++ b/go_backend/download_album_folder.go @@ -0,0 +1,114 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode/utf8" +) + +// Only an album component that was missing when the queue was created may be +// resolved later. The caller supplies its leaf template, preserving artist, +// playlist and year folder choices without replacing literal "Unknown" paths. +func resolvedAlbumFolder(req DownloadRequest, album string) string { + if !strings.Contains(req.AlbumFolderTemplate, "{album}") || strings.TrimSpace(album) == "" { + return "" + } + name := strings.ReplaceAll(req.AlbumFolderTemplate, "{album}", album) + name = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + if strings.ContainsRune(`<>:"/\|?*`, r) { + return ' ' + } + return r + }, name) + name = strings.Join(strings.Fields(strings.Trim(name, ". ")), " ") + for strings.Contains(name, "__") { + name = strings.ReplaceAll(name, "__", "_") + } + name = strings.Trim(name, "_ ") + // Match the app's SAF segment limit without splitting a UTF-8 character. + if len(name) > 120 { + name = name[:120] + for !utf8.ValidString(name) { + name = name[:len(name)-1] + } + } + return strings.Trim(name, "._ ") +} + +func resolvedAlbumOutputDirectory(req DownloadRequest, album string) string { + folder := resolvedAlbumFolder(req, album) + if folder == "" || strings.TrimSpace(req.OutputDir) == "" { + return req.OutputDir + } + return filepath.Join(filepath.Dir(filepath.Clean(req.OutputDir)), folder) +} + +func finalizeDownloadAlbumFolder(req DownloadRequest, result *DownloadResponse) error { + album := firstNonEmptyTrimmed(req.AlbumName, result.Album) + if album == "" && strings.Contains(req.AlbumFolderTemplate, "{album}") && result.FilePath != "" { + // Container tags can remain readable even when the audio payload needs + // host-side decryption. Do not require optional provider enrichment. + if payload, err := ReadFileMetadata(result.FilePath); err == nil { + var metadata struct { + Album string `json:"album"` + } + if json.Unmarshal([]byte(payload), &metadata) == nil { + album = strings.TrimSpace(metadata.Album) + result.Album = album + } + } + } + result.ResolvedAlbumFolder = resolvedAlbumFolder(req, album) + // The Android SAF host publishes its temporary file using the resolved + // leaf. A supplied output path/FD remains owned by that host. + if result.ResolvedAlbumFolder == "" || req.OutputPath != "" || isFDOutput(req.OutputFD) || result.AlreadyExists { + return nil + } + dir := resolvedAlbumOutputDirectory(req, album) + if dir == "" || filepath.Clean(dir) == filepath.Dir(result.FilePath) { + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + destination := filepath.Join(dir, filepath.Base(result.FilePath)) + source, err := os.Open(result.FilePath) + if err != nil { + return err + } + defer source.Close() + // Exclusive creation preserves any existing download at the final path, + // including when another queue item resolves the same album concurrently. + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return fmt.Errorf("resolve album folder: %w", err) + } + _, copyErr := io.Copy(output, source) + closeErr := output.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(destination) + if copyErr != nil { + return copyErr + } + return closeErr + } + if err := source.Close(); err != nil { + _ = os.Remove(destination) + return err + } + if err := os.Remove(result.FilePath); err != nil { + _ = os.Remove(destination) + return err + } + AddAllowedDownloadDir(dir) + GoLog("[Download] Resolved album folder: %s\n", dir) + result.FilePath = destination + return nil +} diff --git a/go_backend/download_album_folder_test.go b/go_backend/download_album_folder_test.go new file mode 100644 index 00000000..f8b9d923 --- /dev/null +++ b/go_backend/download_album_folder_test.go @@ -0,0 +1,136 @@ +package gobackend + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" +) + +func TestResolvedAlbumFolder(t *testing.T) { + for _, tc := range []struct { + name, template, album, want string + }{ + {"missing metadata", "{album}", " ", ""}, + {"no pending folder", "", "Album", ""}, + {"invalid template", "Unknown", "Album", ""}, + {"album", "{album}", "Album", "Album"}, + {"year prefix", "[2024] {album}", "Album", "[2024] Album"}, + {"unsafe characters", "{album}", " ../Album: \"Deluxe\"/Part\\Two\x00 ", "Album Deluxe Part Two"}, + {"literal unknown album", "{album}", "Unknown", "Unknown"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := resolvedAlbumFolder(DownloadRequest{AlbumFolderTemplate: tc.template}, tc.album) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } + got := resolvedAlbumFolder(DownloadRequest{AlbumFolderTemplate: "[2024] {album}"}, strings.Repeat("音楽", 40)) + if len(got) > 120 || !utf8.ValidString(got) { + t.Fatalf("invalid bounded UTF-8 folder: %q", got) + } +} + +func TestFinalizeDownloadAlbumFolder(t *testing.T) { + for _, ext := range []string{".mp4", ".m4a", ".opus", ".flac"} { + t.Run(ext, func(t *testing.T) { + root := t.TempDir() + originalDir := filepath.Join(root, "Playlist", "Artist", "Unknown") + if err := os.MkdirAll(originalDir, 0755); err != nil { + t.Fatal(err) + } + original := filepath.Join(originalDir, "Track"+ext) + data := []byte("unchanged audio and metadata") + if err := os.WriteFile(original, data, 0644); err != nil { + t.Fatal(err) + } + req := DownloadRequest{OutputDir: originalDir, AlbumFolderTemplate: "{album}"} + result := DownloadResponse{Success: true, FilePath: original, Album: "Resolved Album"} + if err := finalizeDownloadAlbumFolder(req, &result); err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "Playlist", "Artist", "Resolved Album", "Track"+ext) + if result.FilePath != want || result.ResolvedAlbumFolder != "Resolved Album" { + t.Fatalf("unexpected resolved output: %+v", result) + } + got, err := os.ReadFile(want) + if err != nil || string(got) != string(data) { + t.Fatalf("output changed: %q, %v", got, err) + } + if _, err := os.Stat(original); !os.IsNotExist(err) { + t.Fatalf("old output remains: %v", err) + } + }) + } +} + +func TestFinalizeAlbumFolderPreservesHostOutputAndSourceAlbum(t *testing.T) { + req := DownloadRequest{ + OutputDir: filepath.Join(t.TempDir(), "Unknown"), OutputPath: "/host/cache/track.mp4", + AlbumFolderTemplate: "{album}", AlbumName: "Source Album", + } + result := DownloadResponse{FilePath: req.OutputPath, Album: "Provider Compilation"} + if err := finalizeDownloadAlbumFolder(req, &result); err != nil { + t.Fatal(err) + } + if result.FilePath != req.OutputPath || result.ResolvedAlbumFolder != "Source Album" { + t.Fatalf("host output or source album replaced: %+v", result) + } + req.OutputPath = "" + if got := filepath.Dir(buildOutputPath(req)); filepath.Base(got) != "Source Album" { + t.Fatalf("enriched metadata did not resolve the path before transfer: %q", got) + } +} + +func TestFinalizeAlbumFolderDoesNotOverwriteExistingFile(t *testing.T) { + root := t.TempDir() + original := filepath.Join(root, "track.mp4") + dir := filepath.Join(root, "Album") + if err := os.Mkdir(dir, 0755); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "track.mp4") + for path, value := range map[string]string{original: "new audio", destination: "existing audio"} { + if err := os.WriteFile(path, []byte(value), 0644); err != nil { + t.Fatal(err) + } + } + req := DownloadRequest{OutputDir: filepath.Join(root, "Unknown"), AlbumFolderTemplate: "{album}"} + result := DownloadResponse{FilePath: original, Album: "Album"} + if err := finalizeDownloadAlbumFolder(req, &result); err == nil { + t.Fatal("expected destination collision") + } + for path, want := range map[string]string{original: "new audio", destination: "existing audio"} { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("file damaged: %q, %v", got, err) + } + } +} + +func TestFinalizeAlbumFolderReadsContainerTagsWithoutAudioDecoding(t *testing.T) { + albumData := buildM4AAtom("data", append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, []byte("Embedded Album")...)) + ilst := buildM4AAtom("ilst", buildM4AAtom("\xa9alb", albumData)) + meta := buildM4AAtom("meta", append(make([]byte, 4), ilst...)) + data := buildM4AAtom("moov", buildM4AAtom("udta", meta)) + // The album atom is independent of the encrypted/undecoded audio bytes. + data = append(data, buildM4AAtom("mdat", []byte("undecoded audio payload"))...) + path := filepath.Join(t.TempDir(), "encrypted.mp4") + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + req := DownloadRequest{OutputPath: path, AlbumFolderTemplate: "{album}"} + result := DownloadResponse{FilePath: path} + if err := finalizeDownloadAlbumFolder(req, &result); err != nil { + t.Fatal(err) + } + if result.ResolvedAlbumFolder != "Embedded Album" || result.Album != "Embedded Album" { + t.Fatalf("missing container album: %+v", result) + } + got, err := os.ReadFile(path) + if err != nil || string(got) != string(data) { + t.Fatalf("host-owned audio changed: %v", err) + } +} diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index d80efd9b..cffd6fc4 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -21,6 +21,7 @@ type DownloadRequest struct { CoverURL string `json:"cover_url"` CoverMaxDimension int `json:"cover_max_dimension,omitempty"` OutputDir string `json:"output_dir"` + AlbumFolderTemplate string `json:"album_folder_template,omitempty"` OutputPath string `json:"output_path,omitempty"` OutputFD int `json:"output_fd,omitempty"` OutputExt string `json:"output_ext,omitempty"` @@ -65,6 +66,7 @@ type DownloadResponse struct { Success bool `json:"success"` Message string `json:"message"` FilePath string `json:"file_path,omitempty"` + ResolvedAlbumFolder string `json:"resolved_album_folder,omitempty"` ResolvedFileName string `json:"resolved_file_name,omitempty"` ProviderTrackID string `json:"provider_track_id,omitempty"` Error string `json:"error,omitempty"` diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index f9d4f30c..0e5db510 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -150,6 +150,9 @@ func attemptExtensionDownload( } } + if folderErr := finalizeDownloadAlbumFolder(req, &built); folderErr != nil { + return &DownloadResponse{Success: false, Error: folderErr.Error(), ErrorType: "file_error", Service: providerLabel}, false + } embedExtensionDownloadMetadata(built, req, alreadyExists) if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { @@ -158,7 +161,7 @@ func attemptExtensionDownload( indexISRC = strings.TrimSpace(req.ISRC) } if indexISRC != "" && strings.TrimSpace(built.FilePath) != "" { - AddToISRCIndex(req.OutputDir, indexISRC, built.FilePath) + AddToISRCIndex(resolvedAlbumOutputDirectory(req, firstNonEmptyTrimmed(req.AlbumName, built.Album)), indexISRC, built.FilePath) } } diff --git a/go_backend/extension_fallback_output.go b/go_backend/extension_fallback_output.go index bc6b0635..ae2b2643 100644 --- a/go_backend/extension_fallback_output.go +++ b/go_backend/extension_fallback_output.go @@ -72,7 +72,7 @@ func buildOutputPath(req DownloadRequest) string { return strings.TrimSpace(req.OutputPath) } - outputDir := req.OutputDir + outputDir := resolvedAlbumOutputDirectory(req, req.AlbumName) if strings.TrimSpace(outputDir) == "" { outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") } @@ -104,6 +104,9 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri } func shouldReuseExistingOutput(req DownloadRequest, outputPath string) bool { + if req.AlbumFolderTemplate != "" && resolvedAlbumFolder(req, req.AlbumName) == "" { + return false + } if req.AllowQualityVariant || isFDOutput(req.OutputFD) { return false } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ff1f8bcf..3f16cf9b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -863,6 +863,11 @@ class DownloadQueueNotifier extends Notifier { coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '', coverMaxDimension: settings.embeddedCoverMaxDimension, outputDir: outputDir, + albumFolderTemplate: _unresolvedAlbumFolderTemplate( + track, + item, + settings, + ), filenameFormat: filenameFormat, quality: quality, embedMetadata: settings.embedMetadata, diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 08dc0932..27fd437b 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -25,6 +25,23 @@ class _NativeWorkerRequestContext { this.safFileName, this.qualityVariantCollisionOnly = false, }); + + _NativeWorkerRequestContext withResolvedFolder(Map result) { + final directory = result['saf_relative_dir']; + if (storageMode != 'saf' || directory is! String) return this; + return _NativeWorkerRequestContext( + item: item, + requestJson: requestJson, + outputDir: directory, + quality: quality, + storageMode: storageMode, + outputExt: outputExt, + downloadTreeUri: downloadTreeUri, + safRelativeDir: directory, + safFileName: safFileName, + qualityVariantCollisionOnly: qualityVariantCollisionOnly, + ); + } } class _NativeWorkerStartupTimeout implements Exception { @@ -854,7 +871,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { final isSafMode = _isSafMode(settings); final rawOutputDir = isSafMode - ? await _buildRelativeOutputDir( + ? _buildRelativeOutputDir( item.track, settings.folderOrganization, separateSingles: settings.separateSingles, @@ -1212,6 +1229,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { Map result, AppSettings settings, ) async { + context = context.withResolvedFolder(result); final item = context.item; var filePath = result['file_path'] as String?; if (filePath == null || filePath.isEmpty) { diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart index 76744556..f0862f7c 100644 --- a/lib/providers/download_queue_provider_paths.dart +++ b/lib/providers/download_queue_provider_paths.dart @@ -234,7 +234,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { } } - final relativeDir = await _buildRelativeOutputDir( + final relativeDir = _buildRelativeOutputDir( track, folderOrganization, separateSingles: separateSingles, @@ -391,7 +391,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { return artist; } - Future _buildRelativeOutputDir( + String _buildRelativeOutputDir( Track track, String folderOrganization, { bool separateSingles = false, @@ -401,7 +401,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { bool usePrimaryArtistOnly = false, bool filterContributingArtistsInAlbumArtist = false, String? playlistName, - }) async { + }) { final playlistPrefix = createPlaylistFolder && folderOrganization != 'playlist' && @@ -503,6 +503,31 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { return '$prefix/$suffix'; } + String _unresolvedAlbumFolderTemplate( + Track track, + DownloadItem item, + AppSettings settings, + ) { + if (track.albumName.trim().isNotEmpty) return ''; + String folderFor(Track value) => _buildRelativeOutputDir( + value, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, + ); + final planned = folderFor(track); + final marked = folderFor(track.copyWith(albumName: '{album}')); + if (planned == marked) return ''; + final leaf = marked.split('/').last; + return leaf.contains('{album}') ? leaf : ''; + } + String? _extensionPreferredOutputExt(String service) { final normalizedService = service.trim().toLowerCase(); if (normalizedService.isEmpty) return null; diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 9df34862..cc5a84bb 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -233,6 +233,12 @@ class _DownloadRun { } if (result['success'] == true) { + if (effectiveSafMode && result['saf_relative_dir'] is String) { + effectiveOutputDir = n._sanitizeSafRelativeDir( + result['saf_relative_dir'] as String, + ); + _log.d('Resolved output dir: $effectiveOutputDir'); + } if (!await _handleDownloadSuccess()) return; } else { if (!await _handleBackendFailure()) return; @@ -394,7 +400,7 @@ class _DownloadRun { if (quality == 'DEFAULT') quality = n.state.audioQuality; final isSafMode = n._isSafMode(settings); final relativeOutputDir = isSafMode - ? await n._buildRelativeOutputDir( + ? n._buildRelativeOutputDir( trackToDownload, settings.folderOrganization, separateSingles: settings.separateSingles, diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 18391b87..0d3fe05e 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -14,6 +14,7 @@ class DownloadRequestPayload { final String coverUrl; final int coverMaxDimension; final String outputDir; + final String albumFolderTemplate; final String filenameFormat; final String quality; final bool embedMetadata; @@ -76,6 +77,7 @@ class DownloadRequestPayload { this.coverUrl = '', this.coverMaxDimension = 0, required this.outputDir, + this.albumFolderTemplate = '', required this.filenameFormat, this.quality = 'LOSSLESS', this.embedMetadata = true, @@ -140,6 +142,8 @@ class DownloadRequestPayload { 'cover_url': coverUrl, 'cover_max_dimension': coverMaxDimension, 'output_dir': outputDir, + if (albumFolderTemplate.isNotEmpty) + 'album_folder_template': albumFolderTemplate, 'filename_format': filenameFormat, 'quality': quality, 'embed_metadata': embedMetadata, @@ -208,6 +212,7 @@ class DownloadRequestPayload { coverUrl: coverUrl, coverMaxDimension: coverMaxDimension, outputDir: outputDir, + albumFolderTemplate: albumFolderTemplate, filenameFormat: filenameFormat, quality: quality, embedMetadata: embedMetadata, diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 66086047..83ac3bb6 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1245,6 +1245,7 @@ void main() { artistName: 'Artist', albumName: 'Album', outputDir: '/downloads', + albumFolderTemplate: '[2024] {album}', filenameFormat: '{title}', useExtensions: false, useFallback: true, @@ -1255,6 +1256,8 @@ void main() { expect(updated.useExtensions, isTrue); expect(updated.useFallback, isTrue); expect(updated.trackName, payload.trackName); + expect(updated.albumFolderTemplate, payload.albumFolderTemplate); + expect(updated.toJson()['album_folder_template'], '[2024] {album}'); expect(updated.filenameFormat, payload.filenameFormat); expect(updated.downloadProvider, payload.downloadProvider); expect(updated.providerTrackId, payload.providerTrackId);