mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
fix(metadata): write FLAC, M4A, and AC-4 tags atomically with fsync
go-flac's Save(samePath) rewrites the file in place by shifting the audio body within the same inode, so a process kill or power loss mid-save destroyed the file with no recovery copy. All tag writers now stream to a sibling temp, fsync, and rename over the target, so an interruption leaves either the old intact file or the new complete one. The M4A freeform and AC-4 whole-file rewrites get the same treatment, and the WAV/AIFF writer gains the missing fsync before its rename.
This commit is contained in:
@@ -288,7 +288,7 @@ func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
childStart := loc.entry.body() + hdrLen
|
||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||
// Already has dac4; still persist any normalization changes.
|
||||
return os.WriteFile(decryptedPath, dst, 0o644)
|
||||
return writeFileAtomic(decryptedPath, dst, 0o644)
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(sourcePath)
|
||||
@@ -319,5 +319,5 @@ func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
out = append(out, dac4...)
|
||||
out = append(out, dst[insertPos:]...)
|
||||
|
||||
return os.WriteFile(decryptedPath, out, 0o644)
|
||||
return writeFileAtomic(decryptedPath, out, 0o644)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string)
|
||||
}
|
||||
|
||||
out := writeMP4iTunesMetadata(data, md, cover)
|
||||
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
||||
if err := writeFileAtomic(decryptedPath, out, 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
flac "github.com/go-flac/go-flac/v2"
|
||||
)
|
||||
|
||||
// saveFlacFile persists an edited FLAC crash-safely. go-flac's Save(samePath)
|
||||
// rewrites the file in place by shifting the audio body within the same inode,
|
||||
// so a process kill or power loss mid-save destroys the file with no recovery
|
||||
// copy. Instead, stream the whole edited file to a sibling temp, fsync it, and
|
||||
// rename it over the target: an interruption at any point leaves either the
|
||||
// old intact file or the new complete one.
|
||||
//
|
||||
// fd-backed targets (/proc/self/fd/N) have no directory entry to rename over,
|
||||
// so those keep the library's in-place path.
|
||||
func saveFlacFile(f *flac.File, filePath string) error {
|
||||
if strings.HasPrefix(filePath, "/proc/self/fd/") {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// The ".partial" suffix keeps the temp invisible to library scans and
|
||||
// extension duplicate checks; the extra ".tag" avoids colliding with the
|
||||
// download staging sibling of the same final path.
|
||||
tmpPath := filePath + ".tag.partial"
|
||||
os.Remove(tmpPath)
|
||||
tmp, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp tag file: %w", err)
|
||||
}
|
||||
// WriteTo streams the audio frames from the still-open source handle and
|
||||
// closes it when done, so the rename below can replace the source even on
|
||||
// Windows.
|
||||
if _, err := f.WriteTo(tmp); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to sync temp tag file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, filePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to publish tagged file: %w", err)
|
||||
}
|
||||
syncDir(filepath.Dir(filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces filePath's contents via temp+fsync+rename so an
|
||||
// interrupted write never leaves a truncated file under the final name.
|
||||
func writeFileAtomic(filePath string, data []byte, perm os.FileMode) error {
|
||||
tmpPath := filePath + ".tag.partial"
|
||||
os.Remove(tmpPath)
|
||||
tmp, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, filePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
syncDir(filepath.Dir(filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDir best-effort fsyncs a directory so a just-renamed entry survives
|
||||
// power loss. Unsupported on some platforms/filesystems; errors are ignored.
|
||||
func syncDir(dir string) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||
@@ -330,7 +330,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
}
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
@@ -574,7 +574,7 @@ func EditFlacFields(filePath string, fields map[string]string) error {
|
||||
}
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
// writeVorbisMetadata writes all metadata fields to a Vorbis Comment block.
|
||||
@@ -742,7 +742,7 @@ func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
|
||||
f.Meta = append(f.Meta, &cmtMeta)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
|
||||
@@ -912,7 +912,7 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
@@ -958,7 +958,7 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
return saveFlacFile(f, filePath)
|
||||
}
|
||||
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
@@ -1676,8 +1676,9 @@ func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4
|
||||
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, updated, 0o644)
|
||||
// Release the read handle before replacing the file (required on Windows).
|
||||
f.Close()
|
||||
return writeFileAtomic(filePath, updated, 0o644)
|
||||
}
|
||||
|
||||
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
|
||||
|
||||
+11
-1
@@ -725,13 +725,23 @@ func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) e
|
||||
return err
|
||||
}
|
||||
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
in.Close()
|
||||
|
||||
return os.Rename(tmpPath, filePath)
|
||||
if err := os.Rename(tmpPath, filePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
syncDir(filepath.Dir(filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadCoverForTag(fields map[string]string) ([]byte, string) {
|
||||
|
||||
Reference in New Issue
Block a user