diff --git a/go_backend/ac4_config.go b/go_backend/ac4_config.go index edd2a452..d05c9f95 100644 --- a/go_backend/ac4_config.go +++ b/go_backend/ac4_config.go @@ -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) } diff --git a/go_backend/ac4_metadata.go b/go_backend/ac4_metadata.go index d87b5087..cd8543a7 100644 --- a/go_backend/ac4_metadata.go +++ b/go_backend/ac4_metadata.go @@ -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 diff --git a/go_backend/flac_save.go b/go_backend/flac_save.go new file mode 100644 index 00000000..fbc779d3 --- /dev/null +++ b/go_backend/flac_save.go @@ -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() +} diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 36162cee..d809238c 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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 diff --git a/go_backend/wav_aiff.go b/go_backend/wav_aiff.go index ef825a36..0cd3d042 100644 --- a/go_backend/wav_aiff.go +++ b/go_backend/wav_aiff.go @@ -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) {