feat(metadata): remove ReplayGain from individual and selected tracks

This commit is contained in:
zarzet committed 2026-09-25 17:12:21 +07:00
1 parent c24132cb9b
commit 9d530cea9e
19 files changed
+835 -171

No files matched your search

@@ -19,6 +19,21 @@ use std::sync::LazyLock;
type Fields = BTreeMap<String, String>;
const MAX_TAG_BYTES: usize = 64 * 1024 * 1024;
fn clears_replay_gain(fields: &Fields) -> bool {
[
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
]
.iter()
.all(|key| {
fields
.get(*key)
.is_some_and(|value| value.trim().is_empty())
})
}
struct Section {
start: u64,
end: u64,
+13 -1
View File
@@ -16,7 +16,7 @@ pub(super) fn edit(
cover: Option<&[u8]>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Section, String> {
let end = seek(source, SeekFrom::End(0))?;
let mut end = seek(source, SeekFrom::End(0))?;
let mut start = end;
let mut existing = None;
for offset in [end.checked_sub(32), end.checked_sub(161).map(|_| end - 160)]
@@ -32,6 +32,11 @@ pub(super) fn edit(
start = (offset + 32)
.checked_sub(size)
.ok_or("invalid APE tag size")?;
if fields.len() == 4 && super::clears_replay_gain(fields) {
// A legacy ID3v1 tag can follow the APE footer. Removing gain
// must leave that unrelated metadata in place too.
end = offset + 32;
}
}
if existing.is_none()
&& let Ok(begin) = footer.items_start(offset)
@@ -140,6 +145,13 @@ pub(super) fn edit(
items.retain(|item| !remove.contains(&uppercase(&String::from_utf8_lossy(&item.key))));
items.extend(added);
if items.is_empty() {
if super::clears_replay_gain(fields) {
return Ok(Section {
start,
end,
data: Vec::new(),
});
}
return Err("empty APE tag".into());
}
let mut body = Vec::new();
+10 -4
View File
@@ -82,8 +82,8 @@ pub(super) fn edit(
}
let replay_gain = replay_gain(fields);
if !replay_gain.is_empty() {
// Go replaces the entire ReplayGain group when any supplied value is
// nonempty. A request containing only empty values leaves it alone.
// Replace the entire group, or explicitly clear it when all four
// ReplayGain fields are supplied empty (including derived Sound Check).
remove_names.extend(
[
"REPLAYGAIN_TRACK_GAIN",
@@ -95,7 +95,9 @@ pub(super) fn edit(
.map(str::to_owned),
);
for (name, value) in replay_gain {
appended.extend(freeform(&name, &value));
if !value.is_empty() {
appended.extend(freeform(&name, &value));
}
}
}
let edit_track = fields.contains_key("track_number") || fields.contains_key("track_total");
@@ -499,8 +501,12 @@ fn replay_gain(fields: &Fields) -> Fields {
.get(name)
.map(|v| (name.to_owned(), v.trim().to_owned()))
})
.filter(|(_, value)| !value.is_empty())
.collect();
// Preserve partial empty updates as no-ops; clearing the whole group is an
// explicit request so ordinary metadata edits cannot discard normalization.
if !super::clears_replay_gain(fields) {
result.retain(|_, value| !value.is_empty());
}
static NUMBER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[+-]?[0-9]+(?:\.[0-9]+)?").unwrap());
let gain = result
@@ -1,6 +1,6 @@
use super::{Fields, Section, bytes, id3, metadata_fields, seek};
use crate::tags::{file::ObservedReader, read_audio_tags};
use std::io::{Read, Seek, SeekFrom};
use std::io::{Cursor, Read, Seek, SeekFrom};
pub(super) fn edit(
source: &mut (impl Read + Seek),
@@ -30,6 +30,7 @@ pub(super) fn edit(
let mut start = 12_u64;
let mut body_size = 4_u64;
let mut embedded_cover = None;
let remove_gain_only = fields.len() == 4 && super::clears_replay_gain(fields);
for _ in 0..65536 {
check()?;
if start + 8 > length {
@@ -44,6 +45,38 @@ pub(super) fn edit(
};
let end = start + 8 + u64::from(size) + u64::from(size & 1);
if header[..4].eq_ignore_ascii_case(b"id3 ") {
if remove_gain_only {
// Edit the existing ID3 frames so unknown tags and all artwork
// survive removal, instead of rebuilding from parsed metadata.
if size as usize > super::MAX_TAG_BYTES || end > length {
return Err("invalid RIFF ID3 chunk size".into());
}
let (tag, _) = id3::header(
&mut Cursor::new(bytes(source, size as usize)?),
fields,
None,
check,
)?;
let tag_size = tag.len() as u32;
let mut chunk = header[..4].to_vec();
chunk.extend(if aiff {
tag_size.to_be_bytes()
} else {
tag_size.to_le_bytes()
});
chunk.extend(tag);
if tag_size & 1 == 1 {
chunk.push(0);
}
body_size += chunk.len() as u64;
sections.push(Section {
start,
end,
data: chunk,
});
start = end;
continue;
}
if cover.is_none()
&& size > 0
&& size <= 16 * 1024 * 1024
@@ -68,6 +101,20 @@ pub(super) fn edit(
if start + 8 <= length {
return Err("RIFF chunk count exceeds 65536".into());
}
if remove_gain_only {
let size = u32::try_from(body_size).map_err(|_| "RIFF container exceeds 32-bit size")?;
sections.push(Section {
start: 4,
end: 8,
data: if aiff {
size.to_be_bytes()
} else {
size.to_le_bytes()
}
.to_vec(),
});
return Ok(sections);
}
let cover_path = fields.get("cover_path").map(|p| p.trim()).unwrap_or("");
if !cover_path.is_empty() && cover.is_none() {
return Err("read cover art: file not found".into());