mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
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
This commit is contained in:
parent
65dbd5c8e4
commit
f37e4704a6
28 changed files with 2110 additions and 232 deletions
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -4,5 +4,5 @@ contact_links:
|
||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC extensions
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|
|
||||||
631
go_backend/ape_tags.go
Normal file
631
go_backend/ape_tags.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,11 @@ type AudioMetadata struct {
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
Comment 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 {
|
type MP3Quality struct {
|
||||||
|
|
@ -311,6 +316,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = userValue
|
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
|
pos += 10 + frameSize
|
||||||
|
|
@ -1038,6 +1054,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||||
metadata.Label = value
|
metadata.Label = value
|
||||||
case "COPYRIGHT":
|
case "COPYRIGHT":
|
||||||
metadata.Copyright = value
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -999,6 +999,9 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
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{}{
|
result := map[string]interface{}{
|
||||||
"title": "",
|
"title": "",
|
||||||
|
|
@ -1064,6 +1067,11 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["copyright"] = metadata.Copyright
|
result["copyright"] = metadata.Copyright
|
||||||
result["composer"] = metadata.Composer
|
result["composer"] = metadata.Composer
|
||||||
result["comment"] = metadata.Comment
|
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)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
|
|
@ -1094,6 +1102,10 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["copyright"] = meta.Copyright
|
result["copyright"] = meta.Copyright
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
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)
|
quality, qualityErr := GetM4AQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
|
|
@ -1116,8 +1128,14 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
|
result["label"] = meta.Label
|
||||||
|
result["copyright"] = meta.Copyright
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
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)
|
quality, qualityErr := GetMP3Quality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
|
|
@ -1141,14 +1159,49 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||||
result["isrc"] = meta.ISRC
|
result["isrc"] = meta.ISRC
|
||||||
result["lyrics"] = meta.Lyrics
|
result["lyrics"] = meta.Lyrics
|
||||||
result["genre"] = meta.Genre
|
result["genre"] = meta.Genre
|
||||||
|
result["label"] = meta.Label
|
||||||
|
result["copyright"] = meta.Copyright
|
||||||
result["composer"] = meta.Composer
|
result["composer"] = meta.Composer
|
||||||
result["comment"] = meta.Comment
|
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)
|
quality, qualityErr := GetOggQuality(filePath)
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
result["duration"] = quality.Duration
|
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 {
|
} else {
|
||||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||||
}
|
}
|
||||||
|
|
@ -1218,9 +1271,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||||
|
|
||||||
lower := strings.ToLower(filePath)
|
lower := strings.ToLower(filePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||||
|
|
||||||
if isFlac {
|
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
|
trackNum := 0
|
||||||
discNum := 0
|
discNum := 0
|
||||||
if v, ok := fields["track_number"]; ok && v != "" {
|
if v, ok := fields["track_number"]; ok && v != "" {
|
||||||
|
|
@ -1230,30 +1298,77 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||||
fmt.Sscanf(v, "%d", &discNum)
|
fmt.Sscanf(v, "%d", &discNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := Metadata{
|
meta := &AudioMetadata{
|
||||||
Title: fields["title"],
|
Title: fields["title"],
|
||||||
Artist: fields["artist"],
|
Artist: fields["artist"],
|
||||||
Album: fields["album"],
|
Album: fields["album"],
|
||||||
AlbumArtist: fields["album_artist"],
|
AlbumArtist: fields["album_artist"],
|
||||||
ArtistTagMode: fields["artist_tag_mode"],
|
Date: fields["date"],
|
||||||
Date: fields["date"],
|
TrackNumber: trackNum,
|
||||||
TrackNumber: trackNum,
|
DiscNumber: discNum,
|
||||||
DiscNumber: discNum,
|
ISRC: fields["isrc"],
|
||||||
ISRC: fields["isrc"],
|
Genre: fields["genre"],
|
||||||
Genre: fields["genre"],
|
Label: fields["label"],
|
||||||
Label: fields["label"],
|
Copyright: fields["copyright"],
|
||||||
Copyright: fields["copyright"],
|
Composer: fields["composer"],
|
||||||
Composer: fields["composer"],
|
Comment: fields["comment"],
|
||||||
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 {
|
newItems := AudioMetadataToAPEItems(meta)
|
||||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
|
||||||
|
// If a cover image was provided, embed it as a binary APE item.
|
||||||
|
// APEv2 cover format: "cover.jpg\0<binary image data>", 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{
|
resp := map[string]any{
|
||||||
"success": true,
|
"success": true,
|
||||||
"method": "native",
|
"method": "native_ape",
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(resp)
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ var supportedAudioFormats = map[string]bool{
|
||||||
".mp3": true,
|
".mp3": true,
|
||||||
".opus": true,
|
".opus": true,
|
||||||
".ogg": true,
|
".ogg": true,
|
||||||
|
".ape": true,
|
||||||
|
".wv": true,
|
||||||
|
".mpc": true,
|
||||||
".cue": true,
|
".cue": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -315,6 +318,8 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||||
return scanMP3File(filePath, result, displayNameHint)
|
return scanMP3File(filePath, result, displayNameHint)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result, displayNameHint)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
|
case ".ape", ".wv", ".mpc":
|
||||||
|
return scanAPEFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
|
|
@ -478,6 +483,37 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||||
return result, nil
|
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) {
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
result.MetadataFromFilename = true
|
result.MetadataFromFilename = true
|
||||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,12 @@ type Metadata struct {
|
||||||
Copyright string
|
Copyright string
|
||||||
Composer string
|
Composer string
|
||||||
Comment 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 {
|
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()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
writeVorbisMetadata(cmt, metadata)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
|
|
@ -258,15 +210,274 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||||
cmt = flacvorbis.New()
|
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)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setArtistComments(
|
setArtistComments(cmt, "ALBUMARTIST", metadata.AlbumArtist, metadata.ArtistTagMode)
|
||||||
cmt,
|
|
||||||
"ALBUMARTIST",
|
|
||||||
metadata.AlbumArtist,
|
|
||||||
metadata.ArtistTagMode,
|
|
||||||
)
|
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
|
|
@ -314,96 +525,10 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||||
setComment(cmt, "COMMENT", metadata.Comment)
|
setComment(cmt, "COMMENT", metadata.Comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
setComment(cmt, "REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||||
if cmtIdx >= 0 {
|
setComment(cmt, "REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
setComment(cmt, "REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||||
} else {
|
setComment(cmt, "REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
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)
|
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) {
|
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
values := []string{value}
|
values := []string{value}
|
||||||
if shouldSplitVorbisArtistTags(mode) {
|
if shouldSplitVorbisArtistTags(mode) {
|
||||||
values = splitArtistTagValues(value)
|
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
|
// RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and
|
||||||
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
|
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
|
||||||
// This is needed because FFmpeg's -metadata flag deduplicates keys, so only
|
// 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 == "" {
|
if metadata.Lyrics == "" {
|
||||||
metadata.Lyrics = value
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Download highest resolution cover art'**
|
/// **'Download highest resolution cover art'**
|
||||||
String get optionsMaxQualityCoverSubtitle;
|
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
|
/// Setting title for how artist metadata is written into files
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Cover in höchster Auflösung herunterladen';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Unduh cover art resolusi tertinggi';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'En yüksek kalitedeki albüm kapaklarını indir';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||||
String get optionsMaxQualityCoverSubtitle =>
|
String get optionsMaxQualityCoverSubtitle =>
|
||||||
'Download highest resolution cover art';
|
'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
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,18 @@
|
||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"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": "Artist Tag Mode",
|
||||||
"@optionsArtistTagMode": {
|
"@optionsArtistTagMode": {
|
||||||
"description": "Setting title for how artist metadata is written into files"
|
"description": "Setting title for how artist metadata is written into files"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class AppSettings {
|
||||||
final String
|
final String
|
||||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||||
final bool embedLyrics;
|
final bool embedLyrics;
|
||||||
|
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
||||||
final bool maxQualityCover;
|
final bool maxQualityCover;
|
||||||
final bool isFirstLaunch;
|
final bool isFirstLaunch;
|
||||||
final int concurrentDownloads;
|
final int concurrentDownloads;
|
||||||
|
|
@ -90,6 +91,7 @@ class AppSettings {
|
||||||
this.embedMetadata = true,
|
this.embedMetadata = true,
|
||||||
this.artistTagMode = artistTagModeJoined,
|
this.artistTagMode = artistTagModeJoined,
|
||||||
this.embedLyrics = true,
|
this.embedLyrics = true,
|
||||||
|
this.embedReplayGain = false,
|
||||||
this.maxQualityCover = true,
|
this.maxQualityCover = true,
|
||||||
this.isFirstLaunch = true,
|
this.isFirstLaunch = true,
|
||||||
this.concurrentDownloads = 1,
|
this.concurrentDownloads = 1,
|
||||||
|
|
@ -151,6 +153,7 @@ class AppSettings {
|
||||||
bool? embedMetadata,
|
bool? embedMetadata,
|
||||||
String? artistTagMode,
|
String? artistTagMode,
|
||||||
bool? embedLyrics,
|
bool? embedLyrics,
|
||||||
|
bool? embedReplayGain,
|
||||||
bool? maxQualityCover,
|
bool? maxQualityCover,
|
||||||
bool? isFirstLaunch,
|
bool? isFirstLaunch,
|
||||||
int? concurrentDownloads,
|
int? concurrentDownloads,
|
||||||
|
|
@ -207,6 +210,7 @@ class AppSettings {
|
||||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||||
artistTagMode: artistTagMode ?? this.artistTagMode,
|
artistTagMode: artistTagMode ?? this.artistTagMode,
|
||||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||||
|
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
|
||||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||||
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
|
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
|
||||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||||
|
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||||
|
|
@ -86,6 +87,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||||
'embedMetadata': instance.embedMetadata,
|
'embedMetadata': instance.embedMetadata,
|
||||||
'artistTagMode': instance.artistTagMode,
|
'artistTagMode': instance.artistTagMode,
|
||||||
'embedLyrics': instance.embedLyrics,
|
'embedLyrics': instance.embedLyrics,
|
||||||
|
'embedReplayGain': instance.embedReplayGain,
|
||||||
'maxQualityCover': instance.maxQualityCover,
|
'maxQualityCover': instance.maxQualityCover,
|
||||||
'isFirstLaunch': instance.isFirstLaunch,
|
'isFirstLaunch': instance.isFirstLaunch,
|
||||||
'concurrentDownloads': instance.concurrentDownloads,
|
'concurrentDownloads': instance.concurrentDownloads,
|
||||||
|
|
|
||||||
|
|
@ -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/ffmpeg_service.dart';
|
||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/history_database.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/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||||
|
|
@ -27,6 +27,9 @@ final _historyLog = AppLogger('DownloadHistory');
|
||||||
|
|
||||||
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
||||||
final _trailingDotsRegex = 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})');
|
final _yearRegex = RegExp(r'^(\d{4})');
|
||||||
const _defaultOutputFolderName = 'SpotiFLAC';
|
const _defaultOutputFolderName = 'SpotiFLAC';
|
||||||
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
||||||
|
|
@ -1222,6 +1225,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
final Set<String> _locallyCancelledItemIds = {};
|
final Set<String> _locallyCancelledItemIds = {};
|
||||||
final Set<String> _pausePendingItemIds = {};
|
final Set<String> _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<String, _AlbumRgAccumulator> _albumRgData = {};
|
||||||
|
|
||||||
double _normalizeProgressForUi(double value) {
|
double _normalizeProgressForUi(double value) {
|
||||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||||
if (clamped <= 0) return 0;
|
if (clamped <= 0) return 0;
|
||||||
|
|
@ -2453,6 +2461,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
item.status == DownloadStatus.queued ||
|
item.status == DownloadStatus.queued ||
|
||||||
item.status == DownloadStatus.downloading ||
|
item.status == DownloadStatus.downloading ||
|
||||||
item.status == DownloadStatus.finalizing;
|
item.status == DownloadStatus.finalizing;
|
||||||
|
final wasFailed =
|
||||||
|
item.status == DownloadStatus.failed ||
|
||||||
|
item.status == DownloadStatus.skipped;
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_pausePendingItemIds.remove(id);
|
_pausePendingItemIds.remove(id);
|
||||||
|
|
@ -2462,15 +2473,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
_locallyCancelledItemIds.remove(id);
|
_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 items = state.items.where((entry) => entry.id != id).toList();
|
||||||
final currentDownload = state.currentDownload?.id == id
|
final currentDownload = state.currentDownload?.id == id
|
||||||
? null
|
? null
|
||||||
: state.currentDownload;
|
: state.currentDownload;
|
||||||
state = state.copyWith(items: items, currentDownload: currentDownload);
|
state = state.copyWith(items: items, currentDownload: currentDownload);
|
||||||
_saveQueueToStorage();
|
_saveQueueToStorage();
|
||||||
|
|
||||||
|
// Dismissing a failed/skipped item may unblock album RG.
|
||||||
|
if (wasFailed) {
|
||||||
|
_retriggerAlbumRgChecks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
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
|
final items = state.items
|
||||||
.where(
|
.where(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
@ -2482,6 +2533,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage();
|
_saveQueueToStorage();
|
||||||
|
|
||||||
|
if (hadFailedOrSkipped) {
|
||||||
|
_retriggerAlbumRgChecks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
|
|
@ -2507,6 +2562,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
state = state.copyWith(items: [], isPaused: false, currentDownload: null);
|
state = state.copyWith(items: [], isPaused: false, currentDownload: null);
|
||||||
_notificationService.cancelDownloadNotification();
|
_notificationService.cancelDownloadNotification();
|
||||||
_saveQueueToStorage();
|
_saveQueueToStorage();
|
||||||
|
_albumRgData.clear();
|
||||||
if (!wasProcessing) {
|
if (!wasProcessing) {
|
||||||
_locallyCancelledItemIds.clear();
|
_locallyCancelledItemIds.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -2572,6 +2628,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||||
_locallyCancelledItemIds.remove(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) {
|
final items = state.items.map((i) {
|
||||||
if (i.id == id) {
|
if (i.id == id) {
|
||||||
return i.copyWith(
|
return i.copyWith(
|
||||||
|
|
@ -2594,10 +2661,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeItem(String id) {
|
void removeItem(String id) {
|
||||||
|
final removedItem = state.items.where((item) => item.id == id).firstOrNull;
|
||||||
_locallyCancelledItemIds.remove(id);
|
_locallyCancelledItemIds.remove(id);
|
||||||
final items = state.items.where((item) => item.id != id).toList();
|
final items = state.items.where((item) => item.id != id).toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage();
|
_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<String?> exportFailedDownloads() async {
|
Future<String?> exportFailedDownloads() async {
|
||||||
|
|
@ -2673,12 +2760,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearFailedDownloads() {
|
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
|
final items = state.items
|
||||||
.where((item) => item.status != DownloadStatus.failed)
|
.where((item) => item.status != DownloadStatus.failed)
|
||||||
.toList();
|
.toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage();
|
_saveQueueToStorage();
|
||||||
_log.d('Cleared failed downloads from queue');
|
_log.d('Cleared failed downloads from queue');
|
||||||
|
|
||||||
|
// Removing failed items may unblock album RG for affected albums.
|
||||||
|
if (failedItems.isNotEmpty) {
|
||||||
|
_retriggerAlbumRgChecks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||||
|
|
@ -2734,6 +2841,287 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<void> _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<void> _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<void> _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
|
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
|
||||||
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||||
|
|
||||||
|
|
@ -3027,6 +3415,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (coverPath != null) {
|
||||||
try {
|
try {
|
||||||
final coverFile = File(coverPath);
|
final coverFile = File(coverPath);
|
||||||
|
|
@ -3181,6 +3591,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
_log.d('Embedding tags to MP3: $metadata');
|
_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(
|
final result = await FFmpegService.embedMetadataToMp3(
|
||||||
mp3Path: mp3Path,
|
mp3Path: mp3Path,
|
||||||
coverPath: coverPath != null && await File(coverPath).exists()
|
coverPath: coverPath != null && await File(coverPath).exists()
|
||||||
|
|
@ -3345,6 +3773,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
_log.d('Embedding tags to Opus: $metadata');
|
_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(
|
final result = await FFmpegService.embedMetadataToOpus(
|
||||||
opusPath: opusPath,
|
opusPath: opusPath,
|
||||||
coverPath: coverPath != null && await File(coverPath).exists()
|
coverPath: coverPath != null && await File(coverPath).exists()
|
||||||
|
|
@ -5098,6 +5544,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
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++;
|
_completedInSession++;
|
||||||
|
|
||||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||||
|
|
@ -5426,3 +5889,27 @@ final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
||||||
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
return DownloadQueueLookup.fromItems(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 = [];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEmbedReplayGain(bool enabled) {
|
||||||
|
state = state.copyWith(embedReplayGain: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setEmbedMetadata(bool enabled) {
|
void setEmbedMetadata(bool enabled) {
|
||||||
state = state.copyWith(embedMetadata: enabled);
|
state = state.copyWith(embedMetadata: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setMaxQualityCover(v),
|
.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,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -3401,18 +3401,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||||
for (final path in finalOutputPaths) {
|
for (final path in finalOutputPaths) {
|
||||||
if (path.toLowerCase().endsWith('.flac')) {
|
if (path.toLowerCase().endsWith('.flac')) {
|
||||||
try {
|
try {
|
||||||
final metadata = await PlatformBridge.readFileMetadata(path);
|
// Only send the cover_path field — EditFlacFields uses
|
||||||
if (metadata['error'] == null) {
|
// field-presence semantics, so omitting artist/album_artist
|
||||||
final fields = <String, String>{'cover_path': coverPath};
|
// means those keys won't be rewritten. This preserves any
|
||||||
for (final entry in metadata.entries) {
|
// existing split artist Vorbis Comments.
|
||||||
if (entry.key == 'error' || entry.value == null) continue;
|
await PlatformBridge.editFileMetadata(path, {
|
||||||
final v = entry.value.toString().trim();
|
'cover_path': coverPath,
|
||||||
if (v.isNotEmpty) {
|
});
|
||||||
fields[entry.key] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await PlatformBridge.editFileMetadata(path, fields);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to embed cover to split track: $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 isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
||||||
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
||||||
|
|
||||||
final vorbisMap = <String, String>{};
|
// Always include all known fields so -map_metadata 0 + explicit
|
||||||
if (metadata['title']?.isNotEmpty == true) {
|
// -metadata flags can both preserve custom tags AND clear fields
|
||||||
vorbisMap['TITLE'] = metadata['title']!;
|
// the user emptied.
|
||||||
}
|
final vorbisMap = <String, String>{
|
||||||
if (metadata['artist']?.isNotEmpty == true) {
|
'TITLE': metadata['title'] ?? '',
|
||||||
vorbisMap['ARTIST'] = metadata['artist']!;
|
'ARTIST': metadata['artist'] ?? '',
|
||||||
}
|
'ALBUM': metadata['album'] ?? '',
|
||||||
if (metadata['album']?.isNotEmpty == true) {
|
'ALBUMARTIST': metadata['album_artist'] ?? '',
|
||||||
vorbisMap['ALBUM'] = metadata['album']!;
|
'DATE': metadata['date'] ?? '',
|
||||||
}
|
'TRACKNUMBER':
|
||||||
if (metadata['album_artist']?.isNotEmpty == true) {
|
(metadata['track_number']?.isNotEmpty == true &&
|
||||||
vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
|
metadata['track_number'] != '0')
|
||||||
}
|
? metadata['track_number']!
|
||||||
if (metadata['date']?.isNotEmpty == true) {
|
: '',
|
||||||
vorbisMap['DATE'] = metadata['date']!;
|
'DISCNUMBER':
|
||||||
}
|
(metadata['disc_number']?.isNotEmpty == true &&
|
||||||
if (metadata['track_number']?.isNotEmpty == true &&
|
metadata['disc_number'] != '0')
|
||||||
metadata['track_number'] != '0') {
|
? metadata['disc_number']!
|
||||||
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
|
: '',
|
||||||
}
|
'GENRE': metadata['genre'] ?? '',
|
||||||
if (metadata['disc_number']?.isNotEmpty == true &&
|
'ISRC': metadata['isrc'] ?? '',
|
||||||
metadata['disc_number'] != '0') {
|
'ORGANIZATION': metadata['label'] ?? '',
|
||||||
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
|
'COPYRIGHT': metadata['copyright'] ?? '',
|
||||||
}
|
'COMPOSER': metadata['composer'] ?? '',
|
||||||
if (metadata['genre']?.isNotEmpty == true) {
|
'COMMENT': metadata['comment'] ?? '',
|
||||||
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']!;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||||
ffmpegTarget,
|
ffmpegTarget,
|
||||||
|
|
@ -5001,8 +4980,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||||
vorbisMap['LYRICS'] = existingLyrics;
|
vorbisMap['LYRICS'] = existingLyrics;
|
||||||
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
|
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
|
||||||
}
|
}
|
||||||
|
// Preserve ReplayGain tags if present — these are computed once
|
||||||
|
// during download and should survive manual metadata edits.
|
||||||
|
final rgFields = <String, String>{
|
||||||
|
'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 (_) {
|
} catch (_) {
|
||||||
// Lyrics preservation is best-effort.
|
// Lyrics/ReplayGain preservation is best-effort.
|
||||||
}
|
}
|
||||||
|
|
||||||
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
|
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
|
||||||
|
|
@ -5036,12 +5032,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||||
mp3Path: ffmpegTarget,
|
mp3Path: ffmpegTarget,
|
||||||
coverPath: existingCoverPath,
|
coverPath: existingCoverPath,
|
||||||
metadata: vorbisMap,
|
metadata: vorbisMap,
|
||||||
|
preserveMetadata: true,
|
||||||
);
|
);
|
||||||
} else if (isM4A) {
|
} else if (isM4A) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||||
m4aPath: ffmpegTarget,
|
m4aPath: ffmpegTarget,
|
||||||
coverPath: existingCoverPath,
|
coverPath: existingCoverPath,
|
||||||
metadata: vorbisMap,
|
metadata: vorbisMap,
|
||||||
|
preserveMetadata: true,
|
||||||
);
|
);
|
||||||
} else if (isOpus) {
|
} else if (isOpus) {
|
||||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||||
|
|
@ -5049,6 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||||
coverPath: existingCoverPath,
|
coverPath: existingCoverPath,
|
||||||
metadata: vorbisMap,
|
metadata: vorbisMap,
|
||||||
artistTagMode: widget.artistTagMode,
|
artistTagMode: widget.artistTagMode,
|
||||||
|
preserveMetadata: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.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<ReplayGainResult?> 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<bool> 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<String?> embedMetadata({
|
static Future<String?> embedMetadata({
|
||||||
required String flacPath,
|
required String flacPath,
|
||||||
String? coverPath,
|
String? coverPath,
|
||||||
|
|
@ -1574,7 +1728,6 @@ class FFmpegService {
|
||||||
for (final entry in metadata.entries) {
|
for (final entry in metadata.entries) {
|
||||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if (value.trim().isEmpty) continue;
|
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'TITLE':
|
case 'TITLE':
|
||||||
|
|
@ -1628,6 +1781,19 @@ class FFmpegService {
|
||||||
vorbis['LYRICS'] = value;
|
vorbis['LYRICS'] = value;
|
||||||
vorbis['UNSYNCEDLYRICS'] = value;
|
vorbis['UNSYNCEDLYRICS'] = value;
|
||||||
break;
|
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? rawValue, {
|
||||||
String artistTagMode = artistTagModeJoined,
|
String artistTagMode = artistTagModeJoined,
|
||||||
}) {
|
}) {
|
||||||
final value = rawValue?.trim() ?? '';
|
if (rawValue == null) return;
|
||||||
|
final value = rawValue.trim();
|
||||||
if (value.isEmpty) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1721,7 +1891,6 @@ class FFmpegService {
|
||||||
for (final entry in metadata.entries) {
|
for (final entry in metadata.entries) {
|
||||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if (value.trim().isEmpty) continue;
|
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'TITLE':
|
case 'TITLE':
|
||||||
|
|
@ -1773,6 +1942,19 @@ class FFmpegService {
|
||||||
case 'UNSYNCEDLYRICS':
|
case 'UNSYNCEDLYRICS':
|
||||||
m4aMap['lyrics'] = value;
|
m4aMap['lyrics'] = value;
|
||||||
break;
|
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 key = entry.key.toUpperCase();
|
||||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if (value.trim().isEmpty) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (normalizedKey) {
|
switch (normalizedKey) {
|
||||||
case 'TITLE':
|
case 'TITLE':
|
||||||
|
|
@ -1836,6 +2015,20 @@ class FFmpegService {
|
||||||
case 'COMMENT':
|
case 'COMMENT':
|
||||||
id3Map['comment'] = value;
|
id3Map['comment'] = value;
|
||||||
break;
|
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:
|
default:
|
||||||
id3Map[key.toLowerCase()] = value;
|
id3Map[key.toLowerCase()] = value;
|
||||||
}
|
}
|
||||||
|
|
@ -2021,3 +2214,29 @@ class LiveDecryptedStreamResult {
|
||||||
required this.session,
|
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)';
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue