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
+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
}