mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-13 05:19:04 +02:00
fix(download): resolve missing album folders before publishing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -863,6 +863,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '',
|
||||
coverMaxDimension: settings.embeddedCoverMaxDimension,
|
||||
outputDir: outputDir,
|
||||
albumFolderTemplate: _unresolvedAlbumFolderTemplate(
|
||||
track,
|
||||
item,
|
||||
settings,
|
||||
),
|
||||
filenameFormat: filenameFormat,
|
||||
quality: quality,
|
||||
embedMetadata: settings.embedMetadata,
|
||||
|
||||
@@ -25,6 +25,23 @@ class _NativeWorkerRequestContext {
|
||||
this.safFileName,
|
||||
this.qualityVariantCollisionOnly = false,
|
||||
});
|
||||
|
||||
_NativeWorkerRequestContext withResolvedFolder(Map<String, dynamic> 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<String, dynamic> result,
|
||||
AppSettings settings,
|
||||
) async {
|
||||
context = context.withResolvedFolder(result);
|
||||
final item = context.item;
|
||||
var filePath = result['file_path'] as String?;
|
||||
if (filePath == null || filePath.isEmpty) {
|
||||
|
||||
@@ -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<String> _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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user