mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-21 00:50:58 +02:00
fix(download): resolve missing album folders before publishing
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user