fix(download): resolve missing album folders before publishing

This commit is contained in:
zarzet
2026-09-07 16:13:41 +07:00
parent fed88fc3a9
commit c48406f5d0
15 changed files with 383 additions and 13 deletions
@@ -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(
+114
View File
@@ -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
}
+136
View File
@@ -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)
}
}
+2
View File
@@ -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"`
+4 -1
View File
@@ -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)
}
}
+4 -1
View File
@@ -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,
+3
View File
@@ -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);