From f37e4704a63beeb8439f3b93182c6225c8610e64 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 2 Apr 2026 03:15:01 +0700 Subject: [PATCH] feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs ReplayGain (track + album): - Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference) - Duration-weighted power-mean for album gain computation - Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A - Album RG auto-finalizes when all album tracks complete - Retryable gate: blocks finalization while failed/skipped items exist - SAF support: lossy album RG writes via temp file + writeTempToSaf - New embedReplayGain setting (off by default) with UI toggle APEv2 tag support: - Full APEv2 reader/writer with header+items+footer format - Merge-based editing with override keys for explicit deletions - Binary cover art embedding (Cover Art (Front) item) - Library scanner support for .ape/.wv/.mpc files - ReplayGain fields in APE read/write/edit pipeline Bug fixes (26): - setArtistComments wiping fields on empty string value - APEv2 rewrite corrupting files with ID3v1 trailer - APE edit replacing entire tag instead of merging - ReplayGain lost on manual MP3/Opus/M4A metadata edit - Editor metadata save losing custom tags (preserveMetadata) - Album RG accumulator not cleaned on queue mutation - Album gain using unweighted mean instead of power-mean - writeAlbumReplayGainTags return value silently ignored - SAF album RG writing to deleted temp path - Cancelled tracks polluting album gain computation - APE ReplayGain not wired end-to-end - APE field deletion not working in merge - APE cover edit was a no-op - Album RG duplicate entries on retry - APE apeKeysFromFields missing track/disc/lyrics mappings - Album RG entries purged by removeItem before computation - FFmpeg converters discarding empty metadata values - _appendVorbisArtistEntries skipping empty value (null vs empty) - Album RG write-back fails for SAF lossy files - Album RG partial finalization on failed tracks - FLAC ClearEmpty flag destroying tags on partial callers - clearCompleted not retriggering album RG checks - ReadFileMetadata MP3/Ogg missing label and copyright - Cover embed on CUE split destroying split artist tags - Album RG gain format inconsistent (missing + prefix) - FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.) - dart:math log shadowed by logger.dart export --- .github/ISSUE_TEMPLATE/config.yml | 2 +- go_backend/ape_tags.go | 631 ++++++++++++++++++ go_backend/audio_metadata.go | 24 + go_backend/exports.go | 151 ++++- go_backend/library_scan.go | 36 + go_backend/metadata.go | 473 ++++++++----- lib/l10n/app_localizations.dart | 18 + lib/l10n/app_localizations_de.dart | 11 + lib/l10n/app_localizations_en.dart | 11 + lib/l10n/app_localizations_es.dart | 11 + lib/l10n/app_localizations_fr.dart | 11 + lib/l10n/app_localizations_hi.dart | 11 + lib/l10n/app_localizations_id.dart | 11 + lib/l10n/app_localizations_ja.dart | 11 + lib/l10n/app_localizations_ko.dart | 11 + lib/l10n/app_localizations_nl.dart | 11 + lib/l10n/app_localizations_pt.dart | 11 + lib/l10n/app_localizations_ru.dart | 11 + lib/l10n/app_localizations_tr.dart | 11 + lib/l10n/app_localizations_zh.dart | 11 + lib/l10n/arb/app_en.arb | 12 + lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 489 +++++++++++++- lib/providers/settings_provider.dart | 5 + .../settings/options_settings_page.dart | 12 + lib/screens/track_metadata_screen.dart | 109 ++- lib/services/ffmpeg_service.dart | 231 ++++++- 28 files changed, 2110 insertions(+), 232 deletions(-) create mode 100644 go_backend/ape_tags.go diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 6cb50478..62e1fb4b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/zarzet/SpotiFLAC-Mobile#readme about: Check the README for setup instructions and FAQ - name: Extension Development Guide - url: https://zarz.moe/docs + url: https://spotiflac.zarz.moe/docs about: Documentation for building SpotiFLAC extensions diff --git a/go_backend/ape_tags.go b/go_backend/ape_tags.go new file mode 100644 index 00000000..d0b9975a --- /dev/null +++ b/go_backend/ape_tags.go @@ -0,0 +1,631 @@ +package gobackend + +import ( + "encoding/binary" + "fmt" + "io" + "os" + "strconv" + "strings" +) + +// APEv2 tag format constants. +const ( + apeTagPreamble = "APETAGEX" + apeTagHeaderSize = 32 + apeTagVersion2 = 2000 + apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer + apeTagFlagReadOnly = 1 << 0 + // Item flags: bits 1-2 encode content type + apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text + apeItemFlagBinary = 1 << 1 // 01: binary data + apeItemFlagLink = 2 << 1 // 10: external link +) + +// APETagItem represents a single key-value item in an APEv2 tag. +type APETagItem struct { + Key string + Value string + Flags uint32 +} + +// APETag represents a complete APEv2 tag block. +type APETag struct { + Version uint32 + Items []APETagItem + ReadOnly bool +} + +// ReadAPETags reads APEv2 tags from a file. +// APEv2 tags are typically appended at the end of the file. +// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer] +// We locate the footer first (last 32 bytes), then read the tag block. +func ReadAPETags(filePath string) (*APETag, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + fileSize := fi.Size() + + if fileSize < apeTagHeaderSize { + return nil, fmt.Errorf("file too small for APE tag") + } + + // Try to find APE tag footer at the end of file. + // The footer is the last 32 bytes before any ID3v1 tag (128 bytes). + tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize) + if err == nil { + return tag, nil + } + + // Retry: skip ID3v1 tag (128 bytes) if present + if fileSize > apeTagHeaderSize+128 { + tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128) + if err == nil { + return tag, nil + } + } + + return nil, fmt.Errorf("no APEv2 tag found") +} + +func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) { + if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize { + return nil, fmt.Errorf("invalid footer offset") + } + + // Read the 32-byte footer/header + footer := make([]byte, apeTagHeaderSize) + if _, err := f.ReadAt(footer, footerOffset); err != nil { + return nil, fmt.Errorf("failed to read APE footer: %w", err) + } + + // Verify preamble + if string(footer[0:8]) != apeTagPreamble { + return nil, fmt.Errorf("APE preamble not found") + } + + version := binary.LittleEndian.Uint32(footer[8:12]) + tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes) + itemCount := binary.LittleEndian.Uint32(footer[16:20]) + flags := binary.LittleEndian.Uint32(footer[20:24]) + + // Sanity checks + if version != apeTagVersion2 && version != 1000 { + return nil, fmt.Errorf("unsupported APE tag version: %d", version) + } + if tagSize < apeTagHeaderSize { + return nil, fmt.Errorf("APE tag size too small: %d", tagSize) + } + if itemCount > 1000 { + return nil, fmt.Errorf("APE tag item count too large: %d", itemCount) + } + + // This should be the footer (bit 29 clear) + isHeader := (flags & apeTagFlagHeader) != 0 + if isHeader { + return nil, fmt.Errorf("expected APE footer but found header") + } + + // Calculate where the items data starts. + // tagSize includes items + footer (32 bytes), but NOT the header. + itemsSize := int64(tagSize) - apeTagHeaderSize + if itemsSize < 0 { + return nil, fmt.Errorf("invalid APE tag: items size negative") + } + + itemsOffset := footerOffset - itemsSize + if itemsOffset < 0 { + return nil, fmt.Errorf("APE tag items extend before file start") + } + + // Read all items data + itemsData := make([]byte, itemsSize) + if _, err := f.ReadAt(itemsData, itemsOffset); err != nil { + return nil, fmt.Errorf("failed to read APE items: %w", err) + } + + // Parse individual items + items, err := parseAPEItems(itemsData, int(itemCount)) + if err != nil { + return nil, fmt.Errorf("failed to parse APE items: %w", err) + } + + return &APETag{ + Version: version, + Items: items, + ReadOnly: (flags & apeTagFlagReadOnly) != 0, + }, nil +} + +func parseAPEItems(data []byte, count int) ([]APETagItem, error) { + items := make([]APETagItem, 0, count) + pos := 0 + + for i := 0; i < count && pos < len(data); i++ { + if pos+8 > len(data) { + break + } + + valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8]) + pos += 8 + + // Key is null-terminated ASCII (2-255 bytes, case-insensitive) + keyEnd := pos + for keyEnd < len(data) && data[keyEnd] != 0 { + keyEnd++ + } + if keyEnd >= len(data) { + break + } + + key := string(data[pos:keyEnd]) + pos = keyEnd + 1 // skip null terminator + + // Read value + if pos+valueSize > len(data) { + break + } + value := string(data[pos : pos+valueSize]) + pos += valueSize + + items = append(items, APETagItem{ + Key: key, + Value: value, + Flags: itemFlags, + }) + } + + return items, nil +} + +// WriteAPETags writes APEv2 tags to the end of a file. +// If the file already has APEv2 tags, they are replaced. +// The tag is written with both header and footer. +func WriteAPETags(filePath string, tag *APETag) error { + // First, read existing file to find and strip any existing APE tag + existingSize, err := findExistingAPETagSize(filePath) + if err != nil { + return fmt.Errorf("failed to check existing APE tag: %w", err) + } + + // Build the new tag data + tagData, err := marshalAPETag(tag) + if err != nil { + return fmt.Errorf("failed to marshal APE tag: %w", err) + } + + // If there's an existing tag, we need to truncate the file first + if existingSize > 0 { + fi, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + newSize := fi.Size() - int64(existingSize) + if err := os.Truncate(filePath, newSize); err != nil { + return fmt.Errorf("failed to truncate existing APE tag: %w", err) + } + } + + // Append the new tag + f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open file for writing: %w", err) + } + defer f.Close() + + if _, err := f.Write(tagData); err != nil { + return fmt.Errorf("failed to write APE tag: %w", err) + } + + return nil +} + +// findExistingAPETagSize returns the total size of an existing APE tag +// (header + items + footer) at the end of the file, or 0 if none exists. +func findExistingAPETagSize(filePath string) (int64, error) { + f, err := os.Open(filePath) + if err != nil { + return 0, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return 0, err + } + fileSize := fi.Size() + + // Try to read footer + offsets := []int64{fileSize - apeTagHeaderSize} + if fileSize > apeTagHeaderSize+128 { + offsets = append(offsets, fileSize-apeTagHeaderSize-128) + } + + for _, offset := range offsets { + if offset < 0 { + continue + } + footer := make([]byte, apeTagHeaderSize) + if _, err := f.ReadAt(footer, offset); err != nil { + continue + } + if string(footer[0:8]) != apeTagPreamble { + continue + } + + flags := binary.LittleEndian.Uint32(footer[20:24]) + if (flags & apeTagFlagHeader) != 0 { + continue // This is a header, not footer + } + + tagSize := int64(binary.LittleEndian.Uint32(footer[12:16])) + + // Check if there's also a header (tagSize only covers items + footer) + hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header + totalSize := tagSize + if hasHeader { + totalSize += apeTagHeaderSize + } + + // Include any trailing data after the footer (e.g. ID3v1 128-byte tag). + // When truncating, we must remove the APE tag AND everything after it. + trailingBytes := fileSize - (offset + apeTagHeaderSize) + totalSize += trailingBytes + + return totalSize, nil + } + + return 0, nil +} + +// marshalAPETag serializes an APETag into bytes (header + items + footer). +func marshalAPETag(tag *APETag) ([]byte, error) { + if tag == nil || len(tag.Items) == 0 { + return nil, fmt.Errorf("empty APE tag") + } + + // Build items data + var itemsData []byte + for _, item := range tag.Items { + keyBytes := []byte(item.Key) + valueBytes := []byte(item.Value) + + // 4 bytes: value size (LE) + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes))) + + // 4 bytes: item flags (LE) + flagsBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(flagsBuf, item.Flags) + + itemsData = append(itemsData, sizeBuf...) + itemsData = append(itemsData, flagsBuf...) + itemsData = append(itemsData, keyBytes...) + itemsData = append(itemsData, 0) // null terminator + itemsData = append(itemsData, valueBytes...) + } + + // tagSize = items data + footer (32 bytes) + tagSize := uint32(len(itemsData) + apeTagHeaderSize) + itemCount := uint32(len(tag.Items)) + + version := uint32(apeTagVersion2) + if tag.Version != 0 { + version = tag.Version + } + + // Build header + // flags: bit 29 = 1 (is header), bit 31 = 1 (contains header) + headerFlags := uint32(apeTagFlagHeader | (1 << 31)) + header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags) + + // Build footer + // flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header) + footerFlags := uint32(1 << 31) + footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags) + + // Final layout: header + items + footer + result := make([]byte, 0, len(header)+len(itemsData)+len(footer)) + result = append(result, header...) + result = append(result, itemsData...) + result = append(result, footer...) + + return result, nil +} + +func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte { + buf := make([]byte, apeTagHeaderSize) + copy(buf[0:8], apeTagPreamble) + binary.LittleEndian.PutUint32(buf[8:12], version) + binary.LittleEndian.PutUint32(buf[12:16], tagSize) + binary.LittleEndian.PutUint32(buf[16:20], itemCount) + binary.LittleEndian.PutUint32(buf[20:24], flags) + // bytes 24-31 are reserved (zeros) + return buf +} + +// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct. +func APETagToAudioMetadata(tag *APETag) *AudioMetadata { + if tag == nil { + return nil + } + + metadata := &AudioMetadata{} + for _, item := range tag.Items { + key := strings.ToUpper(strings.TrimSpace(item.Key)) + value := strings.TrimSpace(item.Value) + if value == "" { + continue + } + + switch key { + case "TITLE": + metadata.Title = value + case "ARTIST": + metadata.Artist = value + case "ALBUM": + metadata.Album = value + case "ALBUMARTIST", "ALBUM ARTIST": + metadata.AlbumArtist = value + case "GENRE": + metadata.Genre = value + case "YEAR": + metadata.Year = value + case "DATE": + metadata.Date = value + case "TRACK", "TRACKNUMBER": + // APE track format can be "3" or "3/12" + trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0]) + metadata.TrackNumber = trackNum + case "DISC", "DISCNUMBER": + discNum, _ := strconv.Atoi(strings.Split(value, "/")[0]) + metadata.DiscNumber = discNum + case "ISRC": + metadata.ISRC = value + case "LYRICS", "UNSYNCEDLYRICS": + if metadata.Lyrics == "" { + metadata.Lyrics = value + } + case "LABEL", "PUBLISHER": + metadata.Label = value + case "COPYRIGHT": + metadata.Copyright = value + case "COMPOSER": + metadata.Composer = value + case "COMMENT": + metadata.Comment = value + case "REPLAYGAIN_TRACK_GAIN": + metadata.ReplayGainTrackGain = value + case "REPLAYGAIN_TRACK_PEAK": + metadata.ReplayGainTrackPeak = value + case "REPLAYGAIN_ALBUM_GAIN": + metadata.ReplayGainAlbumGain = value + case "REPLAYGAIN_ALBUM_PEAK": + metadata.ReplayGainAlbumPeak = value + } + } + + return metadata +} + +// AudioMetadataToAPEItems converts metadata fields to APE tag items. +func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem { + if metadata == nil { + return nil + } + + var items []APETagItem + addItem := func(key, value string) { + if value != "" { + items = append(items, APETagItem{Key: key, Value: value}) + } + } + + addItem("Title", metadata.Title) + addItem("Artist", metadata.Artist) + addItem("Album", metadata.Album) + addItem("Album Artist", metadata.AlbumArtist) + addItem("Genre", metadata.Genre) + if metadata.Date != "" { + addItem("Year", metadata.Date) + } else if metadata.Year != "" { + addItem("Year", metadata.Year) + } + if metadata.TrackNumber > 0 { + addItem("Track", strconv.Itoa(metadata.TrackNumber)) + } + if metadata.DiscNumber > 0 { + addItem("Disc", strconv.Itoa(metadata.DiscNumber)) + } + addItem("ISRC", metadata.ISRC) + addItem("Lyrics", metadata.Lyrics) + addItem("Label", metadata.Label) + addItem("Copyright", metadata.Copyright) + addItem("Composer", metadata.Composer) + addItem("Comment", metadata.Comment) + addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain) + addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak) + addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain) + addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak) + + return items +} + +// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to +// the metadata fields map sent by the editor. This is used during merge to +// ensure that even empty (cleared) fields override old values. +func apeKeysFromFields(fields map[string]string) map[string]struct{} { + // Map from fields-map key → APE tag key. + mapping := map[string]string{ + "title": "TITLE", + "artist": "ARTIST", + "album": "ALBUM", + "album_artist": "ALBUM ARTIST", + "date": "YEAR", + "genre": "GENRE", + "track_number": "TRACK", + "disc_number": "DISC", + "isrc": "ISRC", + "lyrics": "LYRICS", + "label": "LABEL", + "copyright": "COPYRIGHT", + "composer": "COMPOSER", + "comment": "COMMENT", + "replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN", + "replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK", + "replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN", + "replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK", + } + result := make(map[string]struct{}) + for fk, apeKey := range mapping { + if _, present := fields[fk]; present { + result[strings.ToUpper(apeKey)] = struct{}{} + } + } + // The reader accepts both YEAR and DATE for the date field; the writer + // always emits "Year". Ensure both variants are overridden so that an + // old "DATE" tag from another tagger is removed when the user edits date. + if _, present := fields["date"]; present { + result["DATE"] = struct{}{} + } + // Similarly, DISCNUMBER is an alias for DISC in the reader. + if _, present := fields["disc_number"]; present { + result["DISCNUMBER"] = struct{}{} + } + // TRACKNUMBER is an alias for TRACK in the reader. + if _, present := fields["track_number"]; present { + result["TRACKNUMBER"] = struct{}{} + } + // ALBUMARTIST is an alias for ALBUM ARTIST in the reader. + if _, present := fields["album_artist"]; present { + result["ALBUMARTIST"] = struct{}{} + } + // PUBLISHER is an alias for LABEL in the reader. + if _, present := fields["label"]; present { + result["PUBLISHER"] = struct{}{} + } + // UNSYNCEDLYRICS is an alias for LYRICS in the reader. + if _, present := fields["lyrics"]; present { + result["UNSYNCEDLYRICS"] = struct{}{} + } + return result +} + +// MergeAPEItems overlays newItems on top of existing items. +// For each new item, if a matching key exists (case-insensitive) in existing, +// it is replaced. New keys are appended. Existing items whose keys are NOT +// in newItems are preserved (cover art, ReplayGain, custom tags, etc.). +// +// overrideKeys is an optional set of upper-case keys that should be removed +// from existing even if they do not appear in newItems. This handles field +// deletion: the caller sends an empty value which is not serialized into +// newItems, but the old value must still be dropped. +func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem { + // Build a set of keys being updated (upper-case for case-insensitive match) + combined := make(map[string]struct{}, len(newItems)+len(overrideKeys)) + for k := range overrideKeys { + combined[strings.ToUpper(k)] = struct{}{} + } + for _, item := range newItems { + combined[strings.ToUpper(item.Key)] = struct{}{} + } + + // Start with existing items whose keys are NOT in the combined set + var merged []APETagItem + for _, item := range existing { + if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten { + merged = append(merged, item) + } + } + + // Append all new items + merged = append(merged, newItems...) + + return merged +} + +// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size. +// This is useful for reading APE tags from files opened via SAF or other abstractions. +func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) { + if fileSize < apeTagHeaderSize { + return nil, fmt.Errorf("file too small for APE tag") + } + + // Try footer at end of file + footer := make([]byte, apeTagHeaderSize) + if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil { + return nil, fmt.Errorf("failed to read APE footer: %w", err) + } + + if string(footer[0:8]) == apeTagPreamble { + tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer) + if err == nil { + return tag, nil + } + } + + // Retry: skip ID3v1 tag (128 bytes) + if fileSize > apeTagHeaderSize+128 { + offset := fileSize - apeTagHeaderSize - 128 + if _, err := r.ReadAt(footer, offset); err == nil { + if string(footer[0:8]) == apeTagPreamble { + tag, err := parseAPETagFromFooter(r, fileSize, offset, footer) + if err == nil { + return tag, nil + } + } + } + } + + return nil, fmt.Errorf("no APEv2 tag found") +} + +func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) { + version := binary.LittleEndian.Uint32(footer[8:12]) + tagSize := binary.LittleEndian.Uint32(footer[12:16]) + itemCount := binary.LittleEndian.Uint32(footer[16:20]) + flags := binary.LittleEndian.Uint32(footer[20:24]) + + if version != apeTagVersion2 && version != 1000 { + return nil, fmt.Errorf("unsupported APE tag version: %d", version) + } + if tagSize < apeTagHeaderSize { + return nil, fmt.Errorf("APE tag size too small: %d", tagSize) + } + if itemCount > 1000 { + return nil, fmt.Errorf("APE tag item count too large: %d", itemCount) + } + if (flags & apeTagFlagHeader) != 0 { + return nil, fmt.Errorf("expected footer, found header") + } + + itemsSize := int64(tagSize) - apeTagHeaderSize + itemsOffset := footerOffset - itemsSize + if itemsOffset < 0 { + return nil, fmt.Errorf("APE items extend before file start") + } + + itemsData := make([]byte, itemsSize) + if _, err := r.ReadAt(itemsData, itemsOffset); err != nil { + return nil, fmt.Errorf("failed to read APE items: %w", err) + } + + items, err := parseAPEItems(itemsData, int(itemCount)) + if err != nil { + return nil, fmt.Errorf("failed to parse APE items: %w", err) + } + + return &APETag{ + Version: version, + Items: items, + ReadOnly: (flags & apeTagFlagReadOnly) != 0, + }, nil +} diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 8532ddaf..b4515480 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -28,6 +28,11 @@ type AudioMetadata struct { Copyright string Composer string Comment string + // ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831") + ReplayGainTrackGain string + ReplayGainTrackPeak string + ReplayGainAlbumGain string + ReplayGainAlbumPeak string } type MP3Quality struct { @@ -311,6 +316,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" { metadata.Lyrics = userValue } + upperDesc := strings.ToUpper(desc) + switch upperDesc { + case "REPLAYGAIN_TRACK_GAIN": + metadata.ReplayGainTrackGain = userValue + case "REPLAYGAIN_TRACK_PEAK": + metadata.ReplayGainTrackPeak = userValue + case "REPLAYGAIN_ALBUM_GAIN": + metadata.ReplayGainAlbumGain = userValue + case "REPLAYGAIN_ALBUM_PEAK": + metadata.ReplayGainAlbumPeak = userValue + } } pos += 10 + frameSize @@ -1038,6 +1054,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { metadata.Label = value case "COPYRIGHT": metadata.Copyright = value + case "REPLAYGAIN_TRACK_GAIN": + metadata.ReplayGainTrackGain = value + case "REPLAYGAIN_TRACK_PEAK": + metadata.ReplayGainTrackPeak = value + case "REPLAYGAIN_ALBUM_GAIN": + metadata.ReplayGainAlbumGain = value + case "REPLAYGAIN_ALBUM_PEAK": + metadata.ReplayGainAlbumPeak = value } } diff --git a/go_backend/exports.go b/go_backend/exports.go index cf61bebe..9222eaa4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -999,6 +999,9 @@ func ReadFileMetadata(filePath string) (string, error) { isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") isMp3 := strings.HasSuffix(lower, ".mp3") isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") + isApe := strings.HasSuffix(lower, ".ape") + isWv := strings.HasSuffix(lower, ".wv") + isMpc := strings.HasSuffix(lower, ".mpc") result := map[string]interface{}{ "title": "", @@ -1064,6 +1067,11 @@ func ReadFileMetadata(filePath string) (string, error) { result["copyright"] = metadata.Copyright result["composer"] = metadata.Composer result["comment"] = metadata.Comment + // ReplayGain fields + result["replaygain_track_gain"] = metadata.ReplayGainTrackGain + result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak + result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain + result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak quality, qualityErr := GetAudioQuality(filePath) if qualityErr == nil { @@ -1094,6 +1102,10 @@ func ReadFileMetadata(filePath string) (string, error) { result["copyright"] = meta.Copyright result["composer"] = meta.Composer result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak } quality, qualityErr := GetM4AQuality(filePath) if qualityErr == nil { @@ -1116,8 +1128,14 @@ func ReadFileMetadata(filePath string) (string, error) { result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright result["composer"] = meta.Composer result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak } quality, qualityErr := GetMP3Quality(filePath) if qualityErr == nil { @@ -1141,14 +1159,49 @@ func ReadFileMetadata(filePath string) (string, error) { result["isrc"] = meta.ISRC result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright result["composer"] = meta.Composer result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak } quality, qualityErr := GetOggQuality(filePath) if qualityErr == nil { result["sample_rate"] = quality.SampleRate result["duration"] = quality.Duration } + } else if isApe || isWv || isMpc { + // APE, WavPack, Musepack: read APEv2 tags + apeTag, apeErr := ReadAPETags(filePath) + if apeErr == nil && apeTag != nil { + meta := APETagToAudioMetadata(apeTag) + if meta != nil { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["disc_number"] = meta.DiscNumber + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + } } else { return "", fmt.Errorf("unsupported file format: %s", filePath) } @@ -1218,9 +1271,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { lower := strings.ToLower(filePath) isFlac := strings.HasSuffix(lower, ".flac") + isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc") coverPath := strings.TrimSpace(fields["cover_path"]) if isFlac { + if err := EditFlacFields(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write FLAC metadata: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + // APE/WV/MPC: write APEv2 tags natively + if isApeFile { trackNum := 0 discNum := 0 if v, ok := fields["track_number"]; ok && v != "" { @@ -1230,30 +1298,77 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { fmt.Sscanf(v, "%d", &discNum) } - meta := Metadata{ - Title: fields["title"], - Artist: fields["artist"], - Album: fields["album"], - AlbumArtist: fields["album_artist"], - ArtistTagMode: fields["artist_tag_mode"], - Date: fields["date"], - TrackNumber: trackNum, - DiscNumber: discNum, - ISRC: fields["isrc"], - Genre: fields["genre"], - Label: fields["label"], - Copyright: fields["copyright"], - Composer: fields["composer"], - Comment: fields["comment"], + meta := &AudioMetadata{ + Title: fields["title"], + Artist: fields["artist"], + Album: fields["album"], + AlbumArtist: fields["album_artist"], + Date: fields["date"], + TrackNumber: trackNum, + DiscNumber: discNum, + ISRC: fields["isrc"], + Genre: fields["genre"], + Label: fields["label"], + Copyright: fields["copyright"], + Composer: fields["composer"], + Comment: fields["comment"], + // ReplayGain fields + ReplayGainTrackGain: fields["replaygain_track_gain"], + ReplayGainTrackPeak: fields["replaygain_track_peak"], + ReplayGainAlbumGain: fields["replaygain_album_gain"], + ReplayGainAlbumPeak: fields["replaygain_album_peak"], } - if err := EmbedMetadata(filePath, meta, coverPath); err != nil { - return "", fmt.Errorf("failed to write FLAC metadata: %w", err) + newItems := AudioMetadataToAPEItems(meta) + + // If a cover image was provided, embed it as a binary APE item. + // APEv2 cover format: "cover.jpg\0", flagged binary. + if coverPath != "" { + coverData, coverErr := os.ReadFile(coverPath) + if coverErr == nil && len(coverData) > 0 { + // The value is "filename\0" + raw bytes. We store the + // description as the Value field, but since the item is + // flagged binary, the writer serializes it verbatim. + desc := "cover.jpg\x00" + binaryValue := desc + string(coverData) + newItems = append(newItems, APETagItem{ + Key: "Cover Art (Front)", + Value: binaryValue, + Flags: apeItemFlagBinary, + }) + } + } + + // Build the set of APE keys that the edit explicitly controls. + // Even if the value is empty (user cleared the field), the old + // value must be removed during merge. + overrideKeys := apeKeysFromFields(fields) + if coverPath != "" { + overrideKeys["COVER ART (FRONT)"] = struct{}{} + } + + // Read existing tags so we can merge rather than replace. + // This preserves cover art and custom items not in the edit set. + existingTag, _ := ReadAPETags(filePath) + var finalItems []APETagItem + if existingTag != nil && len(existingTag.Items) > 0 { + finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys) + } else { + finalItems = newItems + } + + tag := &APETag{ + Version: apeTagVersion2, + Items: finalItems, + } + + if err := WriteAPETags(filePath, tag); err != nil { + return "", fmt.Errorf("failed to write APE tags: %w", err) } resp := map[string]any{ "success": true, - "method": "native", + "method": "native_ape", } jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index dbb3d828..dc818ddd 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -66,6 +66,9 @@ var supportedAudioFormats = map[string]bool{ ".mp3": true, ".opus": true, ".ogg": true, + ".ape": true, + ".wv": true, + ".mpc": true, ".cue": true, } @@ -315,6 +318,8 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ return scanMP3File(filePath, result, displayNameHint) case ".opus", ".ogg": return scanOggFile(filePath, result, displayNameHint) + case ".ape", ".wv", ".mpc": + return scanAPEFile(filePath, result, displayNameHint) default: return scanFromFilename(filePath, displayNameHint, result) } @@ -478,6 +483,37 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str return result, nil } +func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) { + tag, err := ReadAPETags(filePath) + if err != nil { + GoLog("[LibraryScan] APE tag read error for %s: %v\n", filePath, err) + return scanFromFilename(filePath, displayNameHint, result) + } + + metadata := APETagToAudioMetadata(tag) + if metadata == nil { + return scanFromFilename(filePath, displayNameHint, result) + } + + result.TrackName = metadata.Title + result.ArtistName = metadata.Artist + result.AlbumName = metadata.Album + result.AlbumArtist = metadata.AlbumArtist + result.ISRC = metadata.ISRC + result.TrackNumber = metadata.TrackNumber + result.DiscNumber = metadata.DiscNumber + result.Genre = metadata.Genre + if metadata.Date != "" { + result.ReleaseDate = metadata.Date + } else { + result.ReleaseDate = metadata.Year + } + + applyDefaultLibraryMetadata(filePath, displayNameHint, result) + + return result, nil +} + func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) { result.MetadataFromFilename = true nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index ef8f25d0..1aee724b 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -118,6 +118,12 @@ type Metadata struct { Copyright string Composer string Comment string + + // ReplayGain fields (stored as Vorbis Comments in FLAC) + ReplayGainTrackGain string // e.g. "-6.50 dB" + ReplayGainTrackPeak string // e.g. "0.988831" + ReplayGainAlbumGain string // e.g. "-7.20 dB" + ReplayGainAlbumPeak string // e.g. "1.000000" } func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { @@ -144,61 +150,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { cmt = flacvorbis.New() } - setComment(cmt, "TITLE", metadata.Title) - setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode) - setComment(cmt, "ALBUM", metadata.Album) - setArtistComments( - cmt, - "ALBUMARTIST", - metadata.AlbumArtist, - metadata.ArtistTagMode, - ) - setComment(cmt, "DATE", metadata.Date) - - if metadata.TrackNumber > 0 { - if metadata.TotalTracks > 0 { - setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)) - } else { - setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber)) - } - } - - if metadata.DiscNumber > 0 { - setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) - } - - if metadata.ISRC != "" { - setComment(cmt, "ISRC", metadata.ISRC) - } - - if metadata.Description != "" { - setComment(cmt, "DESCRIPTION", metadata.Description) - } - - if metadata.Lyrics != "" { - setComment(cmt, "LYRICS", metadata.Lyrics) - setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) - } - - if metadata.Genre != "" { - setComment(cmt, "GENRE", metadata.Genre) - } - - if metadata.Label != "" { - setComment(cmt, "ORGANIZATION", metadata.Label) - } - - if metadata.Copyright != "" { - setComment(cmt, "COPYRIGHT", metadata.Copyright) - } - - if metadata.Composer != "" { - setComment(cmt, "COMPOSER", metadata.Composer) - } - - if metadata.Comment != "" { - setComment(cmt, "COMMENT", metadata.Comment) - } + writeVorbisMetadata(cmt, metadata) cmtBlock := cmt.Marshal() if cmtIdx >= 0 { @@ -258,15 +210,274 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] cmt = flacvorbis.New() } + writeVorbisMetadata(cmt, metadata) + + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + if len(coverData) > 0 { + for i := len(f.Meta) - 1; i >= 0; i-- { + if f.Meta[i].Type == flac.Picture { + f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) + } + } + + picBlock, err := buildPictureBlock("", coverData) + if err != nil { + return fmt.Errorf("failed to create picture block: %w", err) + } + f.Meta = append(f.Meta, &picBlock) + fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + } + + return f.Save(filePath) +} + +func ReadMetadata(filePath string) (*Metadata, error) { + f, err := flac.ParseFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to parse FLAC file: %w", err) + } + + metadata := &Metadata{} + + for _, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } + + metadata.Title = getComment(cmt, "TITLE") + metadata.Artist = getJoinedComment(cmt, "ARTIST") + metadata.Album = getComment(cmt, "ALBUM") + metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST") + if metadata.AlbumArtist == "" { + metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM ARTIST") + } + if metadata.AlbumArtist == "" { + metadata.AlbumArtist = getJoinedComment(cmt, "ALBUM_ARTIST") + } + metadata.Date = getComment(cmt, "DATE") + metadata.ISRC = getComment(cmt, "ISRC") + metadata.Description = getComment(cmt, "DESCRIPTION") + + metadata.Lyrics = getComment(cmt, "LYRICS") + if metadata.Lyrics == "" { + metadata.Lyrics = getComment(cmt, "UNSYNCEDLYRICS") + } + + trackNum := getComment(cmt, "TRACKNUMBER") + if trackNum != "" { + fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + } + if metadata.TrackNumber == 0 { + trackNum = getComment(cmt, "TRACK") + if trackNum != "" { + fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) + } + } + + discNum := getComment(cmt, "DISCNUMBER") + if discNum != "" { + fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + } + if metadata.DiscNumber == 0 { + discNum = getComment(cmt, "DISC") + if discNum != "" { + fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) + } + } + + if metadata.Date == "" { + metadata.Date = getComment(cmt, "YEAR") + } + + metadata.Genre = getComment(cmt, "GENRE") + metadata.Label = getComment(cmt, "ORGANIZATION") + if metadata.Label == "" { + metadata.Label = getComment(cmt, "LABEL") + } + if metadata.Label == "" { + metadata.Label = getComment(cmt, "PUBLISHER") + } + metadata.Copyright = getComment(cmt, "COPYRIGHT") + metadata.Composer = getComment(cmt, "COMPOSER") + metadata.Comment = getComment(cmt, "COMMENT") + + // ReplayGain tags + metadata.ReplayGainTrackGain = getComment(cmt, "REPLAYGAIN_TRACK_GAIN") + metadata.ReplayGainTrackPeak = getComment(cmt, "REPLAYGAIN_TRACK_PEAK") + metadata.ReplayGainAlbumGain = getComment(cmt, "REPLAYGAIN_ALBUM_GAIN") + metadata.ReplayGainAlbumPeak = getComment(cmt, "REPLAYGAIN_ALBUM_PEAK") + + break + } + } + + return metadata, nil +} + +// EditFlacFields opens a FLAC file and updates only the Vorbis Comment keys +// that are explicitly present in the fields map. Keys present with a non-empty +// value are set; keys present with an empty value are removed (cleared). Keys +// absent from the map are left untouched. This is the correct function for +// partial edits (e.g. writing only ReplayGain tags) and full editor saves alike. +func EditFlacFields(filePath string, fields map[string]string) error { + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + if cmt == nil { + cmt = flacvorbis.New() + } + + artistMode := fields["artist_tag_mode"] // may be "" + + // Mapping from fields-map key → one or more Vorbis Comment keys. + // Each entry is handled with set-or-clear semantics. + simpleKeys := map[string]string{ + "title": "TITLE", + "album": "ALBUM", + "date": "DATE", + "isrc": "ISRC", + "genre": "GENRE", + "label": "ORGANIZATION", + "copyright": "COPYRIGHT", + "composer": "COMPOSER", + "comment": "COMMENT", + "replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN", + "replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK", + "replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN", + "replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK", + } + + for fieldKey, vorbisKey := range simpleKeys { + if v, ok := fields[fieldKey]; ok { + setOrClearComment(cmt, vorbisKey, v) + } + } + + // Remove known aliases for fields that were just written/cleared, so that + // tags from other taggers (e.g. LABEL, PUBLISHER, ALBUM ARTIST) don't + // conflict with the canonical keys we use. + aliasCleanup := map[string][]string{ + "label": {"LABEL", "PUBLISHER"}, // canonical: ORGANIZATION + "date": {"YEAR"}, // canonical: DATE + "genre": {}, // no common aliases + "copyright": {}, + } + for fieldKey, aliases := range aliasCleanup { + if _, ok := fields[fieldKey]; ok { + for _, alias := range aliases { + removeCommentKey(cmt, alias) + } + } + } + + // Artist fields: use split-artist logic when mode is set. + if v, ok := fields["artist"]; ok { + setOrClearArtistComments(cmt, "ARTIST", v, artistMode) + } + if v, ok := fields["album_artist"]; ok { + setOrClearArtistComments(cmt, "ALBUMARTIST", v, artistMode) + // Remove aliases from other taggers. + removeCommentKey(cmt, "ALBUM ARTIST") + removeCommentKey(cmt, "ALBUM_ARTIST") + } + + // Track/disc numbers: present + empty → clear; present + "0" → clear. + if v, ok := fields["track_number"]; ok { + trackNum := 0 + if v != "" { + fmt.Sscanf(v, "%d", &trackNum) + } + if trackNum > 0 { + setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum)) + } else { + removeCommentKey(cmt, "TRACKNUMBER") + } + removeCommentKey(cmt, "TRACK") // alias + } + if v, ok := fields["disc_number"]; ok { + discNum := 0 + if v != "" { + fmt.Sscanf(v, "%d", &discNum) + } + if discNum > 0 { + setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum)) + } else { + removeCommentKey(cmt, "DISCNUMBER") + } + removeCommentKey(cmt, "DISC") // alias + } + + // Lyrics: set both LYRICS + UNSYNCEDLYRICS, or clear both. + if v, ok := fields["lyrics"]; ok { + if v != "" { + setOrClearComment(cmt, "LYRICS", v) + setOrClearComment(cmt, "UNSYNCEDLYRICS", v) + } else { + removeCommentKey(cmt, "LYRICS") + removeCommentKey(cmt, "UNSYNCEDLYRICS") + } + } + + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + // Cover art + coverPath := strings.TrimSpace(fields["cover_path"]) + if coverPath != "" && fileExists(coverPath) { + coverData, err := os.ReadFile(coverPath) + if err == nil && len(coverData) > 0 { + // Remove existing pictures + for i := len(f.Meta) - 1; i >= 0; i-- { + if f.Meta[i].Type == flac.Picture { + f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) + } + } + picBlock, err := buildPictureBlock("", coverData) + if err == nil { + f.Meta = append(f.Meta, &picBlock) + } + } + } + + return f.Save(filePath) +} + +// writeVorbisMetadata writes all metadata fields to a Vorbis Comment block. +// Empty/zero values are simply skipped (not written, not cleared). This is +// used by the download embedding path where absent fields should preserve any +// existing values. The editor path uses EditFlacFields() instead. +func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Metadata) { setComment(cmt, "TITLE", metadata.Title) setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode) setComment(cmt, "ALBUM", metadata.Album) - setArtistComments( - cmt, - "ALBUMARTIST", - metadata.AlbumArtist, - metadata.ArtistTagMode, - ) + setArtistComments(cmt, "ALBUMARTIST", metadata.AlbumArtist, metadata.ArtistTagMode) setComment(cmt, "DATE", metadata.Date) if metadata.TrackNumber > 0 { @@ -314,96 +525,10 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "COMMENT", metadata.Comment) } - cmtBlock := cmt.Marshal() - if cmtIdx >= 0 { - f.Meta[cmtIdx] = &cmtBlock - } else { - f.Meta = append(f.Meta, &cmtBlock) - } - - if len(coverData) > 0 { - for i := len(f.Meta) - 1; i >= 0; i-- { - if f.Meta[i].Type == flac.Picture { - f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) - } - } - - picBlock, err := buildPictureBlock("", coverData) - if err != nil { - return fmt.Errorf("failed to create picture block: %w", err) - } - f.Meta = append(f.Meta, &picBlock) - fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) - } - - return f.Save(filePath) -} - -func ReadMetadata(filePath string) (*Metadata, error) { - f, err := flac.ParseFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to parse FLAC file: %w", err) - } - - metadata := &Metadata{} - - for _, meta := range f.Meta { - if meta.Type == flac.VorbisComment { - cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) - if err != nil { - continue - } - - metadata.Title = getComment(cmt, "TITLE") - metadata.Artist = getJoinedComment(cmt, "ARTIST") - metadata.Album = getComment(cmt, "ALBUM") - metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST") - metadata.Date = getComment(cmt, "DATE") - metadata.ISRC = getComment(cmt, "ISRC") - metadata.Description = getComment(cmt, "DESCRIPTION") - - metadata.Lyrics = getComment(cmt, "LYRICS") - if metadata.Lyrics == "" { - metadata.Lyrics = getComment(cmt, "UNSYNCEDLYRICS") - } - - trackNum := getComment(cmt, "TRACKNUMBER") - if trackNum != "" { - fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) - } - if metadata.TrackNumber == 0 { - trackNum = getComment(cmt, "TRACK") - if trackNum != "" { - fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber) - } - } - - discNum := getComment(cmt, "DISCNUMBER") - if discNum != "" { - fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) - } - if metadata.DiscNumber == 0 { - discNum = getComment(cmt, "DISC") - if discNum != "" { - fmt.Sscanf(discNum, "%d", &metadata.DiscNumber) - } - } - - if metadata.Date == "" { - metadata.Date = getComment(cmt, "YEAR") - } - - metadata.Genre = getComment(cmt, "GENRE") - metadata.Label = getComment(cmt, "ORGANIZATION") - metadata.Copyright = getComment(cmt, "COPYRIGHT") - metadata.Composer = getComment(cmt, "COMPOSER") - metadata.Comment = getComment(cmt, "COMMENT") - - break - } - } - - return metadata, nil + setComment(cmt, "REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain) + setComment(cmt, "REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak) + setComment(cmt, "REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain) + setComment(cmt, "REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak) } func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { @@ -414,7 +539,21 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { cmt.Comments = append(cmt.Comments, key+"="+value) } +// setOrClearComment writes a Vorbis Comment, or removes the key if value is +// empty. Used by the metadata editor path where empty means "delete this tag". +func setOrClearComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) { + if value == "" { + removeCommentKey(cmt, key) + return + } + removeCommentKey(cmt, key) + cmt.Comments = append(cmt.Comments, key+"="+value) +} + func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) { + if value == "" { + return + } values := []string{value} if shouldSplitVorbisArtistTags(mode) { values = splitArtistTagValues(value) @@ -431,6 +570,30 @@ func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, m } } +// setOrClearArtistComments writes artist Vorbis Comments, or removes the key +// if value is empty. Used by the metadata editor path. +func setOrClearArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) { + if value == "" { + removeCommentKey(cmt, key) + return + } + values := []string{value} + if shouldSplitVorbisArtistTags(mode) { + values = splitArtistTagValues(value) + } + if len(values) == 0 { + removeCommentKey(cmt, key) + return + } + removeCommentKey(cmt, key) + for _, artist := range values { + if strings.TrimSpace(artist) == "" { + continue + } + cmt.Comments = append(cmt.Comments, key+"="+artist) + } +} + // RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and // ALBUMARTIST Vorbis comments as multiple separate entries (one per artist). // This is needed because FFmpeg's -metadata flag deduplicates keys, so only @@ -820,6 +983,14 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) { if metadata.Lyrics == "" { metadata.Lyrics = value } + case "REPLAYGAIN_TRACK_GAIN": + metadata.ReplayGainTrackGain = value + case "REPLAYGAIN_TRACK_PEAK": + metadata.ReplayGainTrackPeak = value + case "REPLAYGAIN_ALBUM_GAIN": + metadata.ReplayGainAlbumGain = value + case "REPLAYGAIN_ALBUM_PEAK": + metadata.ReplayGainAlbumPeak = value } } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 48b6462f..de4b5f10 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -412,6 +412,24 @@ abstract class AppLocalizations { /// **'Download highest resolution cover art'** String get optionsMaxQualityCoverSubtitle; + /// Title for ReplayGain setting toggle + /// + /// In en, this message translates to: + /// **'ReplayGain'** + String get optionsReplayGain; + + /// Subtitle when ReplayGain is enabled + /// + /// In en, this message translates to: + /// **'Scan loudness and embed ReplayGain tags (EBU R128)'** + String get optionsReplayGainSubtitleOn; + + /// Subtitle when ReplayGain is disabled + /// + /// In en, this message translates to: + /// **'Disabled: no loudness normalization tags'** + String get optionsReplayGainSubtitleOff; + /// Setting title for how artist metadata is written into files /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 39838c14..5adcf1b9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -165,6 +165,17 @@ class AppLocalizationsDe extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Cover in höchster Auflösung herunterladen'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 658f9916..656e2304 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -161,6 +161,17 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index dfbfd2c0..de6e5ce9 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -161,6 +161,17 @@ class AppLocalizationsEs extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index aa1f278c..f61302b8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -163,6 +163,17 @@ class AppLocalizationsFr extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index f8063b57..79202053 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -161,6 +161,17 @@ class AppLocalizationsHi extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 23fc4a35..01182d71 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -165,6 +165,17 @@ class AppLocalizationsId extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Unduh cover art resolusi tertinggi'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 12ad54f6..1f8bb5a8 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -159,6 +159,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 03017d26..36065943 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -155,6 +155,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9ce0ffd9..a4c30bf7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -161,6 +161,17 @@ class AppLocalizationsNl extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a523bad1..54f46b2b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -161,6 +161,17 @@ class AppLocalizationsPt extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ad558078..14a6a7c5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -166,6 +166,17 @@ class AppLocalizationsRu extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Скачивать обложку в макс. разрешении'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 53958ef4..39b9f84e 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -164,6 +164,17 @@ class AppLocalizationsTr extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'En yüksek kalitedeki albüm kapaklarını indir'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index fe7ffc93..7b3a1ce8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -161,6 +161,17 @@ class AppLocalizationsZh extends AppLocalizations { String get optionsMaxQualityCoverSubtitle => 'Download highest resolution cover art'; + @override + String get optionsReplayGain => 'ReplayGain'; + + @override + String get optionsReplayGainSubtitleOn => + 'Scan loudness and embed ReplayGain tags (EBU R128)'; + + @override + String get optionsReplayGainSubtitleOff => + 'Disabled: no loudness normalization tags'; + @override String get optionsArtistTagMode => 'Artist Tag Mode'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 206d0319..e067b198 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -198,6 +198,18 @@ "@optionsMaxQualityCoverSubtitle": { "description": "Subtitle for max quality cover" }, + "optionsReplayGain": "ReplayGain", + "@optionsReplayGain": { + "description": "Title for ReplayGain setting toggle" + }, + "optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)", + "@optionsReplayGainSubtitleOn": { + "description": "Subtitle when ReplayGain is enabled" + }, + "optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags", + "@optionsReplayGainSubtitleOff": { + "description": "Subtitle when ReplayGain is disabled" + }, "optionsArtistTagMode": "Artist Tag Mode", "@optionsArtistTagMode": { "description": "Setting title for how artist metadata is written into files" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 40cc57ea..fe172cb1 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -16,6 +16,7 @@ class AppSettings { final String artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats final bool embedLyrics; + final bool embedReplayGain; // Calculate and embed ReplayGain tags final bool maxQualityCover; final bool isFirstLaunch; final int concurrentDownloads; @@ -90,6 +91,7 @@ class AppSettings { this.embedMetadata = true, this.artistTagMode = artistTagModeJoined, this.embedLyrics = true, + this.embedReplayGain = false, this.maxQualityCover = true, this.isFirstLaunch = true, this.concurrentDownloads = 1, @@ -151,6 +153,7 @@ class AppSettings { bool? embedMetadata, String? artistTagMode, bool? embedLyrics, + bool? embedReplayGain, bool? maxQualityCover, bool? isFirstLaunch, int? concurrentDownloads, @@ -207,6 +210,7 @@ class AppSettings { embedMetadata: embedMetadata ?? this.embedMetadata, artistTagMode: artistTagMode ?? this.artistTagMode, embedLyrics: embedLyrics ?? this.embedLyrics, + embedReplayGain: embedReplayGain ?? this.embedReplayGain, maxQualityCover: maxQualityCover ?? this.maxQualityCover, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 3f9c5952..70c5f9f9 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedMetadata: json['embedMetadata'] as bool? ?? true, artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined, embedLyrics: json['embedLyrics'] as bool? ?? true, + embedReplayGain: json['embedReplayGain'] as bool? ?? false, maxQualityCover: json['maxQualityCover'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1, @@ -86,6 +87,7 @@ Map _$AppSettingsToJson( 'embedMetadata': instance.embedMetadata, 'artistTagMode': instance.artistTagMode, 'embedLyrics': instance.embedLyrics, + 'embedReplayGain': instance.embedReplayGain, 'maxQualityCover': instance.maxQualityCover, 'isFirstLaunch': instance.isFirstLaunch, 'concurrentDownloads': instance.concurrentDownloads, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ae892af9..9ea4c7ef 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -17,7 +17,7 @@ import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; -import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/logger.dart' hide log; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; @@ -27,6 +27,9 @@ final _historyLog = AppLogger('DownloadHistory'); final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); final _trailingDotsRegex = RegExp(r'\.+$'); + +/// log10 helper using dart:math's natural log. +double _log10(num x) => log(x) / ln10; final _yearRegex = RegExp(r'^(\d{4})'); const _defaultOutputFolderName = 'SpotiFLAC'; const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName'; @@ -1222,6 +1225,11 @@ class DownloadQueueNotifier extends Notifier { final Set _locallyCancelledItemIds = {}; final Set _pausePendingItemIds = {}; + // Album ReplayGain accumulator: keyed by album identifier. + // Stores per-track loudness data until all album tracks are done, + // then computes and writes album gain/peak to every track in the album. + final Map _albumRgData = {}; + double _normalizeProgressForUi(double value) { final clamped = value.clamp(0.0, 1.0).toDouble(); if (clamped <= 0) return 0; @@ -2453,6 +2461,9 @@ class DownloadQueueNotifier extends Notifier { item.status == DownloadStatus.queued || item.status == DownloadStatus.downloading || item.status == DownloadStatus.finalizing; + final wasFailed = + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped; if (isActive) { _pausePendingItemIds.remove(id); @@ -2462,15 +2473,55 @@ class DownloadQueueNotifier extends Notifier { _locallyCancelledItemIds.remove(id); } + // Clean accumulator entry for non-completed items. + if (item.status != DownloadStatus.completed) { + final key = _albumRgKey(item.track); + final accumulator = _albumRgData[key]; + if (accumulator != null) { + accumulator.entries.removeWhere((e) => e.trackId == item.track.id); + if (accumulator.entries.isEmpty) { + _albumRgData.remove(key); + } + } + } + final items = state.items.where((entry) => entry.id != id).toList(); final currentDownload = state.currentDownload?.id == id ? null : state.currentDownload; state = state.copyWith(items: items, currentDownload: currentDownload); _saveQueueToStorage(); + + // Dismissing a failed/skipped item may unblock album RG. + if (wasFailed) { + _retriggerAlbumRgChecks(); + } } void clearCompleted() { + // Purge accumulator entries for failed/skipped items being removed. + final removedItems = state.items.where( + (item) => + item.status == DownloadStatus.completed || + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped, + ); + bool hadFailedOrSkipped = false; + for (final item in removedItems) { + if (item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped) { + hadFailedOrSkipped = true; + final key = _albumRgKey(item.track); + final accumulator = _albumRgData[key]; + if (accumulator != null) { + accumulator.entries.removeWhere((e) => e.trackId == item.track.id); + if (accumulator.entries.isEmpty) { + _albumRgData.remove(key); + } + } + } + } + final items = state.items .where( (item) => @@ -2482,6 +2533,10 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(items: items); _saveQueueToStorage(); + + if (hadFailedOrSkipped) { + _retriggerAlbumRgChecks(); + } } void clearAll() { @@ -2507,6 +2562,7 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(items: [], isPaused: false, currentDownload: null); _notificationService.cancelDownloadNotification(); _saveQueueToStorage(); + _albumRgData.clear(); if (!wasProcessing) { _locallyCancelledItemIds.clear(); } @@ -2572,6 +2628,17 @@ class DownloadQueueNotifier extends Notifier { _log.i('Retrying item: ${item.track.name} (id: $id)'); _locallyCancelledItemIds.remove(id); + // Purge stale ReplayGain entry for this track so a re-scan doesn't + // produce duplicate entries that bias album gain. + final rgKey = _albumRgKey(item.track); + final rgAcc = _albumRgData[rgKey]; + if (rgAcc != null) { + rgAcc.entries.removeWhere((e) => e.trackId == item.track.id); + if (rgAcc.entries.isEmpty) { + _albumRgData.remove(rgKey); + } + } + final items = state.items.map((i) { if (i.id == id) { return i.copyWith( @@ -2594,10 +2661,30 @@ class DownloadQueueNotifier extends Notifier { } void removeItem(String id) { + final removedItem = state.items.where((item) => item.id == id).firstOrNull; _locallyCancelledItemIds.remove(id); final items = state.items.where((item) => item.id != id).toList(); state = state.copyWith(items: items); _saveQueueToStorage(); + + // Clean stale album RG entries when a track is removed from the queue. + // Only purge for items that were NOT completed — completed items' RG data + // must survive removal because album gain is computed after the last track + // finishes, by which time earlier completed tracks have been removed. + if (removedItem != null && removedItem.status != DownloadStatus.completed) { + final key = _albumRgKey(removedItem.track); + final accumulator = _albumRgData[key]; + if (accumulator != null) { + accumulator.entries.removeWhere( + (e) => e.trackId == removedItem.track.id, + ); + if (accumulator.entries.isEmpty) { + _albumRgData.remove(key); + } + } + // Removing a failed/skipped item may unblock album RG for the album. + _retriggerAlbumRgChecks(); + } } Future exportFailedDownloads() async { @@ -2673,12 +2760,32 @@ class DownloadQueueNotifier extends Notifier { } void clearFailedDownloads() { + // Purge accumulator entries for failed items before removing them. + final failedItems = state.items + .where((item) => item.status == DownloadStatus.failed) + .toList(); + for (final item in failedItems) { + final key = _albumRgKey(item.track); + final accumulator = _albumRgData[key]; + if (accumulator != null) { + accumulator.entries.removeWhere((e) => e.trackId == item.track.id); + if (accumulator.entries.isEmpty) { + _albumRgData.remove(key); + } + } + } + final items = state.items .where((item) => item.status != DownloadStatus.failed) .toList(); state = state.copyWith(items: items); _saveQueueToStorage(); _log.d('Cleared failed downloads from queue'); + + // Removing failed items may unblock album RG for affected albums. + if (failedItems.isNotEmpty) { + _retriggerAlbumRgChecks(); + } } Future _runPostProcessingHooks(String filePath, Track track) async { @@ -2734,6 +2841,287 @@ class DownloadQueueNotifier extends Notifier { } } + // --------------------------------------------------------------------------- + // Album ReplayGain: accumulate per-track data, compute & write album gain + // --------------------------------------------------------------------------- + + /// Build a stable key for grouping tracks by album. + String _albumRgKey(Track track) { + if (track.albumId != null && track.albumId!.isNotEmpty) { + return 'id:${track.albumId}'; + } + return 'name:${track.albumName}|${track.albumArtist ?? ''}'; + } + + /// Store a track's ReplayGain scan result for later album gain computation. + void _storeTrackReplayGainForAlbum( + Track track, + String filePath, + ReplayGainResult rg, + ) { + final key = _albumRgKey(track); + _albumRgData.putIfAbsent(key, () => _AlbumRgAccumulator()); + // Remove any stale entry for this track (e.g. from a previous failed + // attempt that was retried). Without this, the same track can accumulate + // multiple entries and bias the album loudness calculation. + _albumRgData[key]!.entries.removeWhere((e) => e.trackId == track.id); + _albumRgData[key]!.entries.add( + _AlbumRgTrackEntry( + filePath: filePath, + trackId: track.id, + integratedLufs: rg.integratedLufs, + truePeakLinear: rg.truePeakLinear, + durationSecs: track.duration.toDouble(), + ), + ); + } + + /// Replace the temp path stored in the accumulator with the final output + /// path. For SAF downloads the embed happens on a temp file which is later + /// deleted — this ensures the album-gain writer targets the real file. + void _updateAlbumRgFilePath(Track track, String finalPath) { + final key = _albumRgKey(track); + final accumulator = _albumRgData[key]; + if (accumulator == null) return; + // Find the entry for this track and update its file path in-place. + for (final entry in accumulator.entries) { + if (entry.trackId == track.id) { + entry.filePath = finalPath; + break; + } + } + } + + /// After a track completes, check whether all tracks from the same album + /// in the current queue are done. If so, compute album gain and write it + /// to every track's file. + Future _checkAndWriteAlbumReplayGain(Track track) async { + final settings = ref.read(settingsProvider); + if (!settings.embedReplayGain) return; + + final key = _albumRgKey(track); + final accumulator = _albumRgData[key]; + if (accumulator == null || accumulator.entries.isEmpty) return; + + // Find queue items for this album that are STILL in the queue. + // Completed tracks may have already been removed by removeItem(), so + // their absence means they finished successfully (not that they're + // still pending). + final albumItemsInQueue = state.items + .where((item) => _albumRgKey(item.track) == key) + .toList(); + + // If any item is still in-flight, the album isn't complete yet. + final pending = albumItemsInQueue.where( + (item) => + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); + if (pending.isNotEmpty) return; // still in progress + + // If any item is failed/skipped, the user might retry it later. + // Don't finalize album RG with partial data — wait until all album + // tracks are either completed (and possibly removed) or retried. + final retryable = albumItemsInQueue.where( + (item) => + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped, + ); + if (retryable.isNotEmpty) return; // still retryable + + // The accumulator entries represent successfully scanned tracks. Entries + // are only added after a successful ReplayGain scan, removed on retry or + // when a non-completed item is removed from the queue, so every entry + // here corresponds to a track that completed (or is about to complete) + // its download. + final validEntries = accumulator.entries.toList(); + + // Single-track albums: album gain == track gain, no extra write needed. + if (validEntries.length <= 1) { + _albumRgData.remove(key); + return; + } + + // Compute album gain using duration-weighted power-mean of LUFS values. + // album_loudness = 10 * log10( Σ(10^(Li/10) * di) / Σ(di) ) + // This weights longer tracks more, matching "whole program" loudness. + double sumWeightedPower = 0; + double sumDuration = 0; + double maxPeak = 0; + for (final entry in validEntries) { + final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; + sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; + sumDuration += weight; + if (entry.truePeakLinear > maxPeak) { + maxPeak = entry.truePeakLinear; + } + } + final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); + const replayGainReferenceLufs = -18.0; + final albumGainDb = replayGainReferenceLufs - albumLufs; + + final albumGain = + '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; + final albumPeak = maxPeak.toStringAsFixed(6); + + _log.i( + 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', + ); + + // Write album gain to every completed track file. + for (final entry in validEntries) { + try { + await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); + } catch (e) { + _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); + } + } + + _albumRgData.remove(key); + } + + /// Write album ReplayGain tags to a single file. + Future _writeAlbumReplayGain( + String filePath, + String albumGain, + String albumPeak, + ) async { + final lower = filePath.toLowerCase(); + if (lower.endsWith('.flac') || + lower.endsWith('.ape') || + lower.endsWith('.wv') || + lower.endsWith('.mpc')) { + // Native writer — only touches the provided fields, preserves the rest. + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_album_gain': albumGain, + 'replaygain_album_peak': albumPeak, + }); + } else if (isContentUri(filePath)) { + // SAF content:// URI — FFmpeg can read it but can't write back directly. + // Get the temp output from FFmpeg, then copy it to the SAF URI. + String? tempPath; + final ok = await FFmpegService.writeAlbumReplayGainTags( + filePath, + albumGain, + albumPeak, + returnTempPath: true, + onTempReady: (path) => tempPath = path, + ); + if (ok && tempPath != null) { + try { + final safOk = await PlatformBridge.writeTempToSaf( + tempPath!, + filePath, + ); + if (!safOk) { + _log.w('SAF write-back failed for album RG: $filePath'); + } + } finally { + // Clean up temp file regardless of SAF result. + try { + final tmp = File(tempPath!); + if (await tmp.exists()) await tmp.delete(); + } catch (_) {} + } + } else { + _log.w('FFmpeg album ReplayGain write failed for SAF: $filePath'); + } + } else { + // Local MP3 / Opus — use FFmpeg copy-with-metadata approach. + final ok = await FFmpegService.writeAlbumReplayGainTags( + filePath, + albumGain, + albumPeak, + ); + if (!ok) { + _log.w('FFmpeg album ReplayGain write failed for: $filePath'); + } + } + } + + /// Re-check album ReplayGain for all albums that still have accumulator data. + /// Called after removing/dismissing a failed or skipped item, which may + /// unblock an album that was waiting for retryable items to be resolved. + void _retriggerAlbumRgChecks() { + if (_albumRgData.isEmpty) return; + final settings = ref.read(settingsProvider); + if (!settings.embedReplayGain) return; + + // Snapshot the keys — _checkAndWriteAlbumReplayGain may mutate the map. + final keys = _albumRgData.keys.toList(); + for (final key in keys) { + final acc = _albumRgData[key]; + if (acc == null || acc.entries.isEmpty) continue; + // Use the first entry's trackId to find a representative track. + // _checkAndWriteAlbumReplayGain only needs it for _albumRgKey(), so any + // track from the album works. + final albumItems = state.items + .where((item) => _albumRgKey(item.track) == key) + .toList(); + // If there are no items left in queue for this album but we have + // accumulator data, all items were completed and removed. Use a + // synthetic call — we need a Track to call the check, but the items + // are gone. For this case, directly check conditions inline. + if (albumItems.isEmpty) { + // All items removed → no pending/retryable. Trigger computation. + if (acc.entries.length > 1) { + _computeAndWriteAlbumRg(key, acc); + } + continue; + } + // If any representative item is available, use its track. + final representative = albumItems.first; + _checkAndWriteAlbumReplayGain(representative.track); + } + } + + /// Compute album RG and write it — extracted from _checkAndWriteAlbumReplayGain + /// for use when no queue items remain (all completed and removed). + Future _computeAndWriteAlbumRg( + String key, + _AlbumRgAccumulator accumulator, + ) async { + final validEntries = accumulator.entries.toList(); + if (validEntries.length <= 1) { + _albumRgData.remove(key); + return; + } + + double sumWeightedPower = 0; + double sumDuration = 0; + double maxPeak = 0; + for (final entry in validEntries) { + final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; + sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; + sumDuration += weight; + if (entry.truePeakLinear > maxPeak) { + maxPeak = entry.truePeakLinear; + } + } + final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); + const replayGainReferenceLufs = -18.0; + final albumGainDb = replayGainReferenceLufs - albumLufs; + + final albumGain = + '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; + final albumPeak = maxPeak.toStringAsFixed(6); + + _log.i( + 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', + ); + + for (final entry in validEntries) { + try { + await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); + } catch (e) { + _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); + } + } + + _albumRgData.remove(key); + } + /// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); @@ -3027,6 +3415,28 @@ class DownloadQueueNotifier extends Notifier { } } + // ReplayGain: scan loudness and embed tags via native FLAC writer. + if (settings.embedReplayGain) { + try { + final rgResult = await FFmpegService.scanReplayGain(flacPath); + if (rgResult != null) { + await PlatformBridge.editFileMetadata(flacPath, { + 'replaygain_track_gain': rgResult.trackGain, + 'replaygain_track_peak': rgResult.trackPeak, + }); + _log.d( + 'ReplayGain tags embedded: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + // Store for album gain computation after all album tracks complete. + _storeTrackReplayGainForAlbum(track, flacPath, rgResult); + } else { + _log.w('ReplayGain scan returned no result'); + } + } catch (e) { + _log.w('Failed to scan/embed ReplayGain: $e'); + } + } + if (coverPath != null) { try { final coverFile = File(coverPath); @@ -3181,6 +3591,24 @@ class DownloadQueueNotifier extends Notifier { _log.d('Embedding tags to MP3: $metadata'); + // ReplayGain: scan loudness and add to metadata before FFmpeg embed. + if (settings.embedReplayGain) { + try { + final rgResult = await FFmpegService.scanReplayGain(mp3Path); + if (rgResult != null) { + metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; + metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; + _log.d( + 'ReplayGain added to MP3 metadata: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + // Store for album gain computation after all album tracks complete. + _storeTrackReplayGainForAlbum(track, mp3Path, rgResult); + } + } catch (e) { + _log.w('Failed to scan ReplayGain for MP3: $e'); + } + } + final result = await FFmpegService.embedMetadataToMp3( mp3Path: mp3Path, coverPath: coverPath != null && await File(coverPath).exists() @@ -3345,6 +3773,24 @@ class DownloadQueueNotifier extends Notifier { _log.d('Embedding tags to Opus: $metadata'); + // ReplayGain: scan loudness and add to metadata before FFmpeg embed. + if (settings.embedReplayGain) { + try { + final rgResult = await FFmpegService.scanReplayGain(opusPath); + if (rgResult != null) { + metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; + metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; + _log.d( + 'ReplayGain added to Opus metadata: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + // Store for album gain computation after all album tracks complete. + _storeTrackReplayGainForAlbum(track, opusPath, rgResult); + } + } catch (e) { + _log.w('Failed to scan ReplayGain for Opus: $e'); + } + } + final result = await FFmpegService.embedMetadataToOpus( opusPath: opusPath, coverPath: coverPath != null && await File(coverPath).exists() @@ -5098,6 +5544,23 @@ class DownloadQueueNotifier extends Notifier { await _runPostProcessingHooks(filePath, trackToDownload); } + // Album ReplayGain: update the accumulator path to the final file + // location. For SAF downloads the metadata was embedded on a temp + // copy, so the stored path still points there. Replace it with the + // actual output path (SAF content URI or local path) so the later + // album-gain writer targets the correct file. + if (filePath != null) { + _updateAlbumRgFilePath(trackToDownload, filePath); + } + + // Album ReplayGain: check if all album tracks are now complete and, + // if so, compute and write album gain/peak to every track file. + try { + await _checkAndWriteAlbumReplayGain(trackToDownload); + } catch (e) { + _log.w('Album ReplayGain check failed: $e'); + } + _completedInSession++; final historyNotifier = ref.read(downloadHistoryProvider.notifier); @@ -5426,3 +5889,27 @@ final downloadQueueLookupProvider = Provider((ref) { final items = ref.watch(downloadQueueProvider.select((s) => s.items)); return DownloadQueueLookup.fromItems(items); }); + +// --------------------------------------------------------------------------- +// Album ReplayGain helpers +// --------------------------------------------------------------------------- + +class _AlbumRgTrackEntry { + String filePath; + final String trackId; + final double integratedLufs; + final double truePeakLinear; + final double durationSecs; + + _AlbumRgTrackEntry({ + required this.filePath, + required this.trackId, + required this.integratedLufs, + required this.truePeakLinear, + required this.durationSecs, + }); +} + +class _AlbumRgAccumulator { + final List<_AlbumRgTrackEntry> entries = []; +} diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index c2a6627d..bd54b188 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -232,6 +232,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setEmbedReplayGain(bool enabled) { + state = state.copyWith(embedReplayGain: enabled); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index b46b6c27..940fef74 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -135,6 +135,18 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref .read(settingsProvider.notifier) .setMaxQualityCover(v), + ), + SettingsSwitchItem( + icon: Icons.graphic_eq, + title: context.l10n.optionsReplayGain, + subtitle: settings.embedReplayGain + ? context.l10n.optionsReplayGainSubtitleOn + : context.l10n.optionsReplayGainSubtitleOff, + value: settings.embedReplayGain, + enabled: settings.embedMetadata, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setEmbedReplayGain(v), showDivider: false, ), ], diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 056fe859..f9e5a754 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3401,18 +3401,13 @@ class _TrackMetadataScreenState extends ConsumerState { for (final path in finalOutputPaths) { if (path.toLowerCase().endsWith('.flac')) { try { - final metadata = await PlatformBridge.readFileMetadata(path); - if (metadata['error'] == null) { - final fields = {'cover_path': coverPath}; - for (final entry in metadata.entries) { - if (entry.key == 'error' || entry.value == null) continue; - final v = entry.value.toString().trim(); - if (v.isNotEmpty) { - fields[entry.key] = v; - } - } - await PlatformBridge.editFileMetadata(path, fields); - } + // Only send the cover_path field — EditFlacFields uses + // field-presence semantics, so omitting artist/album_artist + // means those keys won't be rewritten. This preserves any + // existing split artist Vorbis Comments. + await PlatformBridge.editFileMetadata(path, { + 'cover_path': coverPath, + }); } catch (e) { _log.w('Failed to embed cover to split track: $e'); } @@ -4950,48 +4945,32 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac'); - final vorbisMap = {}; - if (metadata['title']?.isNotEmpty == true) { - vorbisMap['TITLE'] = metadata['title']!; - } - if (metadata['artist']?.isNotEmpty == true) { - vorbisMap['ARTIST'] = metadata['artist']!; - } - if (metadata['album']?.isNotEmpty == true) { - vorbisMap['ALBUM'] = metadata['album']!; - } - if (metadata['album_artist']?.isNotEmpty == true) { - vorbisMap['ALBUMARTIST'] = metadata['album_artist']!; - } - if (metadata['date']?.isNotEmpty == true) { - vorbisMap['DATE'] = metadata['date']!; - } - if (metadata['track_number']?.isNotEmpty == true && - metadata['track_number'] != '0') { - vorbisMap['TRACKNUMBER'] = metadata['track_number']!; - } - if (metadata['disc_number']?.isNotEmpty == true && - metadata['disc_number'] != '0') { - vorbisMap['DISCNUMBER'] = metadata['disc_number']!; - } - if (metadata['genre']?.isNotEmpty == true) { - vorbisMap['GENRE'] = metadata['genre']!; - } - if (metadata['isrc']?.isNotEmpty == true) { - vorbisMap['ISRC'] = metadata['isrc']!; - } - if (metadata['label']?.isNotEmpty == true) { - vorbisMap['ORGANIZATION'] = metadata['label']!; - } - if (metadata['copyright']?.isNotEmpty == true) { - vorbisMap['COPYRIGHT'] = metadata['copyright']!; - } - if (metadata['composer']?.isNotEmpty == true) { - vorbisMap['COMPOSER'] = metadata['composer']!; - } - if (metadata['comment']?.isNotEmpty == true) { - vorbisMap['COMMENT'] = metadata['comment']!; - } + // Always include all known fields so -map_metadata 0 + explicit + // -metadata flags can both preserve custom tags AND clear fields + // the user emptied. + final vorbisMap = { + 'TITLE': metadata['title'] ?? '', + 'ARTIST': metadata['artist'] ?? '', + 'ALBUM': metadata['album'] ?? '', + 'ALBUMARTIST': metadata['album_artist'] ?? '', + 'DATE': metadata['date'] ?? '', + 'TRACKNUMBER': + (metadata['track_number']?.isNotEmpty == true && + metadata['track_number'] != '0') + ? metadata['track_number']! + : '', + 'DISCNUMBER': + (metadata['disc_number']?.isNotEmpty == true && + metadata['disc_number'] != '0') + ? metadata['disc_number']! + : '', + 'GENRE': metadata['genre'] ?? '', + 'ISRC': metadata['isrc'] ?? '', + 'ORGANIZATION': metadata['label'] ?? '', + 'COPYRIGHT': metadata['copyright'] ?? '', + 'COMPOSER': metadata['composer'] ?? '', + 'COMMENT': metadata['comment'] ?? '', + }; try { final existingMetadata = await PlatformBridge.readFileMetadata( ffmpegTarget, @@ -5001,8 +4980,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vorbisMap['LYRICS'] = existingLyrics; vorbisMap['UNSYNCEDLYRICS'] = existingLyrics; } + // Preserve ReplayGain tags if present — these are computed once + // during download and should survive manual metadata edits. + final rgFields = { + 'REPLAYGAIN_TRACK_GAIN': + existingMetadata['replaygain_track_gain']?.toString() ?? '', + 'REPLAYGAIN_TRACK_PEAK': + existingMetadata['replaygain_track_peak']?.toString() ?? '', + 'REPLAYGAIN_ALBUM_GAIN': + existingMetadata['replaygain_album_gain']?.toString() ?? '', + 'REPLAYGAIN_ALBUM_PEAK': + existingMetadata['replaygain_album_peak']?.toString() ?? '', + }; + rgFields.forEach((key, value) { + if (value.isNotEmpty) { + vorbisMap[key] = value; + } + }); } catch (_) { - // Lyrics preservation is best-effort. + // Lyrics/ReplayGain preservation is best-effort. } String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; @@ -5036,12 +5032,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { mp3Path: ffmpegTarget, coverPath: existingCoverPath, metadata: vorbisMap, + preserveMetadata: true, ); } else if (isM4A) { ffmpegResult = await FFmpegService.embedMetadataToM4a( m4aPath: ffmpegTarget, coverPath: existingCoverPath, metadata: vorbisMap, + preserveMetadata: true, ); } else if (isOpus) { ffmpegResult = await FFmpegService.embedMetadataToOpus( @@ -5049,6 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { coverPath: existingCoverPath, metadata: vorbisMap, artistTagMode: widget.artistTagMode, + preserveMetadata: true, ); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 52e11ea0..134d3e08 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; @@ -884,6 +885,159 @@ class FFmpegService { } } + /// Scan an audio file for EBU R128 loudness and compute ReplayGain values. + /// + /// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS) + /// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL). + /// + /// Returns a [ReplayGainResult] on success, or null if the scan fails. + static Future scanReplayGain(String filePath) async { + // Run FFmpeg with ebur128 filter + astats for true peak. + // -nostats suppresses the interactive progress line. + // ebur128=peak=true prints integrated loudness + true peak. + final command = + '-nostats -i "$filePath" -filter_complex ebur128=peak=true -f null -'; + + _log.d( + 'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}', + ); + final result = await _execute(command); + + // FFmpeg writes ebur128 stats to stderr, which ends up in the output. + // Even on "failure" return code, the output may contain valid data + // because -f null always "fails" on some FFmpeg builds. + final output = result.output; + + // Parse integrated loudness: "I: -14.0 LUFS" + final integratedMatch = RegExp( + r'I:\s+(-?\d+\.?\d*)\s+LUFS', + ).allMatches(output); + if (integratedMatch.isEmpty) { + _log.w('ReplayGain scan: could not parse integrated loudness'); + return null; + } + // Take the last match (the summary, not per-segment values) + final integratedLufs = double.tryParse(integratedMatch.last.group(1) ?? ''); + if (integratedLufs == null) { + _log.w('ReplayGain scan: invalid integrated loudness value'); + return null; + } + + // Parse true peak: "Peak: 0.9 dBFS" or "True peak:\n Peak: -0.3 dBFS" + // The ebur128 filter with peak=true outputs per-channel true peak. + // We want the highest (maximum) true peak across all channels. + double? truePeakDbfs; + final peakMatches = RegExp( + r'Peak:\s+(-?\d+\.?\d*)\s+dBFS', + ).allMatches(output); + for (final m in peakMatches) { + final val = double.tryParse(m.group(1) ?? ''); + if (val != null) { + if (truePeakDbfs == null || val > truePeakDbfs) { + truePeakDbfs = val; + } + } + } + + // ReplayGain reference level: -18 LUFS + const replayGainReferenceLufs = -18.0; + final gainDb = replayGainReferenceLufs - integratedLufs; + + // Convert true peak from dBFS to linear ratio. + // If no true peak was found, fall back to 1.0 (0 dBFS). + double peakLinear; + if (truePeakDbfs != null) { + // 10^(dBFS/20) converts dBFS to linear amplitude + peakLinear = math.pow(10, truePeakDbfs / 20.0).toDouble(); + } else { + peakLinear = 1.0; + } + + // Format to standard ReplayGain precision + final trackGain = + '${gainDb >= 0 ? "+" : ""}${gainDb.toStringAsFixed(2)} dB'; + final trackPeak = peakLinear.toStringAsFixed(6); + + _log.i( + 'ReplayGain scan result: gain=$trackGain, peak=$trackPeak (integrated=${integratedLufs.toStringAsFixed(1)} LUFS)', + ); + + return ReplayGainResult( + trackGain: trackGain, + trackPeak: trackPeak, + integratedLufs: integratedLufs, + truePeakLinear: peakLinear, + ); + } + + /// Write album ReplayGain tags to a non-FLAC file (MP3/Opus) using FFmpeg. + /// Preserves all existing metadata and adds/overwrites album gain fields. + /// Write album ReplayGain tags to a file via FFmpeg. + /// + /// For local files, replaces the file in-place and returns `true`. + /// When [returnTempPath] is `true` (for SAF content:// URIs), the method + /// skips the file replacement and returns the temp output path as a String + /// via [tempOutputPath]. The caller is responsible for writing the temp + /// file to the SAF URI and cleaning it up. + static Future writeAlbumReplayGainTags( + String filePath, + String albumGain, + String albumPeak, { + bool returnTempPath = false, + void Function(String tempPath)? onTempReady, + }) async { + final ext = filePath.contains('.') + ? '.${filePath.split('.').last}' + : '.tmp'; + final tempDir = await getTemporaryDirectory(); + final tempOutput = _nextTempEmbedPath(tempDir.path, ext); + + final sanitizedGain = albumGain.replaceAll('"', '\\"'); + final sanitizedPeak = albumPeak.replaceAll('"', '\\"'); + + // -map_metadata 0 preserves all existing metadata from the input. + // -metadata flags add/overwrite only the specified keys. + final command = + '-i "$filePath" -map 0 -c copy -map_metadata 0 ' + '-metadata REPLAYGAIN_ALBUM_GAIN="$sanitizedGain" ' + '-metadata REPLAYGAIN_ALBUM_PEAK="$sanitizedPeak" ' + '"$tempOutput" -y'; + + _log.d('Writing album ReplayGain tags via FFmpeg'); + final result = await _execute(command); + + if (result.success) { + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) { + if (returnTempPath) { + // Caller will handle SAF write-back and cleanup. + onTempReady?.call(tempOutput); + return true; + } + final originalFile = File(filePath); + if (await originalFile.exists()) { + await originalFile.delete(); + } + await tempFile.copy(filePath); + await tempFile.delete(); + _log.d('Album ReplayGain tags written successfully'); + return true; + } + } catch (e) { + _log.w('Failed to replace file with album ReplayGain: $e'); + } + } + + // Cleanup temp file on failure + try { + final tempFile = File(tempOutput); + if (await tempFile.exists()) await tempFile.delete(); + } catch (_) {} + + return false; + } + static Future embedMetadata({ required String flacPath, String? coverPath, @@ -1574,7 +1728,6 @@ class FFmpegService { for (final entry in metadata.entries) { final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - if (value.trim().isEmpty) continue; switch (key) { case 'TITLE': @@ -1628,6 +1781,19 @@ class FFmpegService { vorbis['LYRICS'] = value; vorbis['UNSYNCEDLYRICS'] = value; break; + // ReplayGain fields + case 'REPLAYGAINTRACKGAIN': + vorbis['REPLAYGAIN_TRACK_GAIN'] = value; + break; + case 'REPLAYGAINTRACKPEAK': + vorbis['REPLAYGAIN_TRACK_PEAK'] = value; + break; + case 'REPLAYGAINALBUMGAIN': + vorbis['REPLAYGAIN_ALBUM_GAIN'] = value; + break; + case 'REPLAYGAINALBUMPEAK': + vorbis['REPLAYGAIN_ALBUM_PEAK'] = value; + break; } } @@ -1699,8 +1865,12 @@ class FFmpegService { String? rawValue, { String artistTagMode = artistTagModeJoined, }) { - final value = rawValue?.trim() ?? ''; + if (rawValue == null) return; + final value = rawValue.trim(); if (value.isEmpty) { + // Emit an empty entry so that with preserveMetadata the old tag is + // overridden (cleared) by FFmpeg's `-metadata key=""`. + entries.add(MapEntry(key, '')); return; } @@ -1721,7 +1891,6 @@ class FFmpegService { for (final entry in metadata.entries) { final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - if (value.trim().isEmpty) continue; switch (key) { case 'TITLE': @@ -1773,6 +1942,19 @@ class FFmpegService { case 'UNSYNCEDLYRICS': m4aMap['lyrics'] = value; break; + // ReplayGain as iTunes freeform atoms (com.apple.iTunes:replaygain_*) + case 'REPLAYGAINTRACKGAIN': + m4aMap['REPLAYGAIN_TRACK_GAIN'] = value; + break; + case 'REPLAYGAINTRACKPEAK': + m4aMap['REPLAYGAIN_TRACK_PEAK'] = value; + break; + case 'REPLAYGAINALBUMGAIN': + m4aMap['REPLAYGAIN_ALBUM_GAIN'] = value; + break; + case 'REPLAYGAINALBUMPEAK': + m4aMap['REPLAYGAIN_ALBUM_PEAK'] = value; + break; } } @@ -1788,9 +1970,6 @@ class FFmpegService { final key = entry.key.toUpperCase(); final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final value = entry.value; - if (value.trim().isEmpty) { - continue; - } switch (normalizedKey) { case 'TITLE': @@ -1836,6 +2015,20 @@ class FFmpegService { case 'COMMENT': id3Map['comment'] = value; break; + // ReplayGain as TXXX user-defined frames + // FFmpeg writes these as TXXX frames automatically with uppercase keys + case 'REPLAYGAINTRACKGAIN': + id3Map['REPLAYGAIN_TRACK_GAIN'] = value; + break; + case 'REPLAYGAINTRACKPEAK': + id3Map['REPLAYGAIN_TRACK_PEAK'] = value; + break; + case 'REPLAYGAINALBUMGAIN': + id3Map['REPLAYGAIN_ALBUM_GAIN'] = value; + break; + case 'REPLAYGAINALBUMPEAK': + id3Map['REPLAYGAIN_ALBUM_PEAK'] = value; + break; default: id3Map[key.toLowerCase()] = value; } @@ -2021,3 +2214,29 @@ class LiveDecryptedStreamResult { required this.session, }); } + +/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags. +class ReplayGainResult { + /// Track gain in dB, e.g. "-6.50 dB" + final String trackGain; + + /// Track peak as a linear ratio, e.g. "0.988831" + final String trackPeak; + + /// Raw integrated loudness in LUFS (needed for album gain computation) + final double integratedLufs; + + /// Raw true peak as linear ratio (needed for album peak computation) + final double truePeakLinear; + + const ReplayGainResult({ + required this.trackGain, + required this.trackPeak, + required this.integratedLufs, + required this.truePeakLinear, + }); + + @override + String toString() => + 'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)'; +}