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

This commit is contained in:
zarzet
2026-09-25 17:12:21 +07:00
parent c24132cb9b
commit 9d530cea9e
19 changed files with 835 additions and 171 deletions
+31
View File
@@ -259,6 +259,37 @@
"@trackReplayGainFailed": {
"description": "Snackbar message when ReplayGain scan/write fails"
},
"trackRemoveReplayGain": "Remove ReplayGain",
"trackRemoveReplayGainSuccess": "ReplayGain tags removed",
"trackRemoveReplayGainFailed": "Failed to remove ReplayGain tags",
"replayGainRemoving": "Removing ReplayGain...",
"replayGainRemoveConfirmMessage": "Remove track and album ReplayGain tags from {count} track(s)? Audio, artwork, and other metadata will be preserved.",
"@replayGainRemoveConfirmMessage": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionRemoveReplayGainCount": "Remove ReplayGain ({count})",
"@selectionRemoveReplayGainCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainRemoveBatchSuccess": "ReplayGain removed from {success} of {total} tracks",
"@replayGainRemoveBatchSuccess": {
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionReplayGainCount": "ReplayGain ({count})",
"@selectionReplayGainCount": {
"description": "Batch selection action button label for ReplayGain",
+7
View File
@@ -6051,6 +6051,13 @@
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"libraryFilterSortGenreAsc": "Genre (A-Z)",
"trackReplayGainFailed": "Gagal menambahkan tag ReplayGain",
"trackRemoveReplayGain": "Hapus ReplayGain",
"trackRemoveReplayGainSuccess": "Tag ReplayGain dihapus",
"trackRemoveReplayGainFailed": "Gagal menghapus tag ReplayGain",
"replayGainRemoving": "Menghapus ReplayGain...",
"replayGainRemoveConfirmMessage": "Hapus tag ReplayGain lagu dan album dari {count} lagu? Audio, sampul, dan metadata lainnya tetap tersimpan.",
"selectionRemoveReplayGainCount": "Hapus ReplayGain ({count})",
"replayGainRemoveBatchSuccess": "ReplayGain dihapus dari {success} dari {total} lagu",
"collectionPlaylistRemoveCover": "Remove cover image",
"storeEmptyNoResults": "Tidak ada ekstensi ditemukan",
"logIssueTrackNotFoundDescription": "Some tracks could not be found on download services",
+18 -12
View File
@@ -763,18 +763,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
: null,
colorScheme: colorScheme,
),
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
for (final remove in [false, true])
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
remove: remove,
)
: null,
colorScheme: colorScheme,
),
];
return Wrap(
+21 -14
View File
@@ -572,20 +572,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
),
);
actions.add(
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
);
for (final remove in [false, true]) {
actions.add(
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
remove: remove,
)
: null,
colorScheme: colorScheme,
),
);
}
return Wrap(
spacing: spacing,
+5 -1
View File
@@ -119,11 +119,15 @@ extension _QueueTabBatchActions on _QueueTabState {
);
}
Future<void> _runBatchReplayGain(List<UnifiedLibraryItem> allItems) {
Future<void> _runBatchReplayGain(
List<UnifiedLibraryItem> allItems, {
bool remove = false,
}) {
return runBatchReplayGain(
context,
_selectedItemsFromAll(allItems),
onExitSelectionMode: _exitSelectionMode,
remove: remove,
onConfirmOpen: () {
_suppressSelectionOverlay = true;
_hideSelectionOverlay();
+16 -10
View File
@@ -75,16 +75,22 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
);
actions.add(
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(unifiedItems)
: null,
colorScheme: colorScheme,
),
);
for (final remove in [false, true]) {
actions.add(
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(unifiedItems, remove: remove)
: null,
colorScheme: colorScheme,
),
);
}
return Wrap(
spacing: spacing,
+42 -7
View File
@@ -191,7 +191,32 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
return normalized;
}
Future<void> _rescanReplayGain() async {
Future<void> _removeReplayGain() async {
if (!_fileExists) return;
final sourcePath = cleanFilePath;
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(ctx.l10n.trackRemoveReplayGain),
content: Text(ctx.l10n.replayGainRemoveConfirmMessage(1)),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.trackRemoveReplayGain),
),
],
),
);
if (confirmed != true || !mounted || sourcePath != cleanFilePath) return;
await _updateReplayGain(remove: true);
}
Future<void> _updateReplayGain({bool remove = false}) async {
if (!_fileExists) return;
final sourcePath = cleanFilePath;
final generation = _metadataLoadGeneration;
@@ -199,15 +224,21 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(
content: Text(context.l10n.trackReplayGainScanning),
content: Text(
remove
? context.l10n.replayGainRemoving
: context.l10n.trackReplayGainScanning,
),
duration: const Duration(seconds: 30),
),
);
bool ok = false;
try {
ok = await ReplayGainService.applyToFile(sourcePath);
ok = remove
? await ReplayGainService.removeFromFile(sourcePath)
: await ReplayGainService.applyToFile(sourcePath);
} catch (e) {
_log.w('ReplayGain rescan failed: $e');
_log.w('ReplayGain update failed: $e');
}
if (!mounted) return;
if (ok &&
@@ -221,9 +252,13 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? context.l10n.trackReplayGainSuccess
: context.l10n.trackReplayGainFailed,
remove
? (ok
? context.l10n.trackRemoveReplayGainSuccess
: context.l10n.trackRemoveReplayGainFailed)
: (ok
? context.l10n.trackReplayGainSuccess
: context.l10n.trackReplayGainFailed),
),
),
);
+7 -1
View File
@@ -72,7 +72,13 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState {
_MetadataOption(
icon: Icons.graphic_eq,
label: l10n.trackReplayGain,
onTap: () => _rescanReplayGain(),
onTap: () => _updateReplayGain(),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.remove_circle_outline,
label: l10n.trackRemoveReplayGain,
onTap: () => _removeReplayGain(),
),
if (_fileExists && _isCueFile)
_MetadataOption(
+27 -10
View File
@@ -536,7 +536,7 @@ Future<void> _performBatchConversion(
}
}
/// Batch-scans loudness and writes ReplayGain tags to [selectedItems].
/// Adds or removes ReplayGain tags for [selectedItems].
///
/// [onConfirmOpen] / [onConfirmClosed] let the caller hide and restore any
/// selection UI around the confirmation dialog; [onConfirmClosed] receives
@@ -545,6 +545,7 @@ Future<void> runBatchReplayGain(
BuildContext context,
List<UnifiedLibraryItem> selectedItems, {
required VoidCallback onExitSelectionMode,
bool remove = false,
VoidCallback? onConfirmOpen,
Future<void> Function(bool confirmed)? onConfirmClosed,
}) async {
@@ -555,9 +556,15 @@ Future<void> runBatchReplayGain(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
title: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
remove
? ctx.l10n.replayGainRemoveConfirmMessage(selectedItems.length)
: ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
),
actions: [
AppDialogAction(
@@ -568,7 +575,11 @@ Future<void> runBatchReplayGain(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
child: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
),
],
),
@@ -585,7 +596,9 @@ Future<void> runBatchReplayGain(
BatchProgressDialog.show(
context: context,
title: context.l10n.replayGainBatchAnalyzing,
title: remove
? context.l10n.replayGainRemoving
: context.l10n.replayGainBatchAnalyzing,
total: total,
icon: Icons.graphic_eq,
onCancel: () {
@@ -599,10 +612,12 @@ Future<void> runBatchReplayGain(
final item = selectedItems[i];
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final ok = await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
final ok = remove
? await ReplayGainService.removeFromFile(item.filePath)
: await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
if (ok) successCount++;
} catch (_) {}
}
@@ -618,7 +633,9 @@ Future<void> runBatchReplayGain(
SnackBar(
content: Text(
[
context.l10n.replayGainBatchSuccess(successCount, total),
remove
? context.l10n.replayGainRemoveBatchSuccess(successCount, total)
: context.l10n.replayGainBatchSuccess(successCount, total),
if (unsupportedDecoder) context.l10n.replayGainUnsupportedDecoder,
].join('\n'),
),
+31 -4
View File
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Standalone ReplayGain (re)scanning for existing audio files.
/// ReplayGain scanning and tag removal for existing audio files.
///
/// Computes EBU R128 loudness via FFmpeg and writes gain tags using the native
/// metadata editors where supported. Opus uses R128_* rather than legacy tags.
@@ -104,6 +104,33 @@ class ReplayGainService {
(path) => _writeLocalTags(path, gain, peak, album: true),
);
/// Removes track/album tags, including Opus R128 and M4A Sound Check tags.
/// Native editors copy the audio payload unchanged and publish atomically.
static Future<bool> removeFromFile(String filePath) =>
_updateFile(filePath, (path) async {
if (!_isNativeWritableFormat(path)) return false;
const fields = {
'replaygain_track_gain': '',
'replaygain_track_peak': '',
'replaygain_album_gain': '',
'replaygain_album_peak': '',
};
final result = await PlatformBridge.editFileMetadata(path, fields);
final method = result['method'];
if (result['success'] != true ||
result['error'] != null ||
method is! String ||
!(method == 'native' || method.startsWith('native_'))) {
return false;
}
final metadata = await PlatformBridge.readFileMetadata(path);
return metadata['error'] == null &&
metadata['audio_codec'] != null &&
fields.keys.every(
(key) => (metadata[key]?.toString() ?? '').trim().isEmpty,
);
});
static Future<bool> _writeLocalTags(
String path,
String gain,
@@ -182,7 +209,7 @@ class ReplayGainService {
if (isSaf) {
safTempPath = await PlatformBridge.copyContentUriToTemp(filePath);
if (safTempPath == null || safTempPath.isEmpty) {
_log.w('Failed to copy SAF file to temp for ReplayGain scan');
_log.w('Failed to copy SAF file to temp for ReplayGain update');
return false;
}
workingPath = safTempPath;
@@ -199,10 +226,10 @@ class ReplayGainService {
}
refreshPlaybackNormalization(filePath);
_log.i('ReplayGain tags written and verified: $filePath');
_log.i('ReplayGain tags updated and verified: $filePath');
return true;
} catch (e) {
_log.e('Failed to apply ReplayGain', e);
_log.e('Failed to update ReplayGain', e);
return false;
} finally {
if (safTempPath != null) {
@@ -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());
@@ -0,0 +1,298 @@
use spotiflac_core::tags::{
extract_cover, read_audio_tags, rewrite_audio_tags, rewrite_m4a_freeform,
};
use std::collections::BTreeMap;
use std::io::Cursor;
const PAYLOAD: &[u8] = b"\xff\xf8\x12\x34unchanged audio payload";
const COVER: &[u8] = b"\xff\xd8\xff\xd9";
const GAIN_FIELDS: [&str; 4] = [
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
];
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[
((body.len() + 8) as u32).to_be_bytes().as_slice(),
kind,
body,
]
.concat()
}
fn ogg_page(packet: &[u8], sequence: u32, flags: u8) -> Vec<u8> {
let mut header = vec![0; 27];
header[..4].copy_from_slice(b"OggS");
header[5] = flags;
header[14..18].copy_from_slice(&1_u32.to_le_bytes());
header[18..22].copy_from_slice(&sequence.to_le_bytes());
header[26] = 1;
header.push(packet.len() as u8);
header.extend(packet);
header
}
fn source(format: &str) -> Vec<u8> {
match format {
"flac" => [b"fLaC\x80\0\0\x22".as_slice(), &[0; 34], PAYLOAD].concat(),
"mp3" | "ape" => PAYLOAD.to_vec(),
"m4a" => [
atom(b"ftyp", b"M4A \0\0\0\0"),
atom(b"moov", &[]),
atom(b"mdat", PAYLOAD),
]
.concat(),
"opus" => [
ogg_page(b"OpusHead\x01\x02\0\0\x80\xbb\0\0\0\0\0", 0, 2),
ogg_page(b"OpusTags\0\0\0\0\0\0\0\0", 1, 0),
ogg_page(PAYLOAD, 2, 4),
]
.concat(),
"wav" | "aiff" => {
let aiff = format == "aiff";
let mut body = if aiff { b"AIFFSSND" } else { b"WAVEdata" }.to_vec();
let length = PAYLOAD.len() as u32;
body.extend(if aiff {
length.to_be_bytes()
} else {
length.to_le_bytes()
});
body.extend(PAYLOAD);
if length % 2 == 1 {
body.push(0);
}
let length = body.len() as u32;
[
if aiff { b"FORM" } else { b"RIFF" }.as_slice(),
&if aiff {
length.to_be_bytes()
} else {
length.to_le_bytes()
},
&body,
]
.concat()
}
_ => unreachable!(),
}
}
#[test]
fn removal_preserves_audio_artwork_and_other_metadata_in_native_containers() {
for format in ["flac", "mp3", "m4a", "opus", "ape", "wav", "aiff"] {
let fields = BTreeMap::from([
("title".into(), "Preserved title".into()),
("artist".into(), "Preserved artist".into()),
("lyrics".into(), "Preserved lyrics".into()),
("cover_path".into(), "cover.jpg".into()),
(GAIN_FIELDS[0].into(), "-6.00 dB".into()),
(GAIN_FIELDS[1].into(), "0.950000".into()),
(GAIN_FIELDS[2].into(), "-4.00 dB".into()),
(GAIN_FIELDS[3].into(), "0.980000".into()),
]);
let empty = GAIN_FIELDS.map(|key| (key.into(), String::new())).into();
let mut tagged = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(source(format)),
&mut tagged,
format,
&fields,
Some(COVER),
&|| Ok(()),
)
.unwrap();
let before = read_audio_tags(&mut Cursor::new(&tagged), format, &|| Ok(())).unwrap();
assert!(!before.replay_gain_track_gain.is_empty(), "{format}");
assert!(!before.replay_gain_album_gain.is_empty(), "{format}");
for freeform in [false, true] {
if freeform && format != "m4a" {
continue;
}
let mut removed = Vec::new();
if freeform {
assert!(
rewrite_m4a_freeform(
&mut Cursor::new(&tagged),
&mut removed,
&empty,
true,
&|| Ok(()),
)
.unwrap()
);
} else {
rewrite_audio_tags(
&mut Cursor::new(&tagged),
&mut removed,
format,
&empty,
None,
&|| Ok(()),
)
.unwrap();
}
let after = read_audio_tags(&mut Cursor::new(&removed), format, &|| Ok(())).unwrap();
assert!(after.replay_gain_track_gain.is_empty(), "{format}");
assert!(after.replay_gain_track_peak.is_empty(), "{format}");
assert!(after.replay_gain_album_gain.is_empty(), "{format}");
assert!(after.replay_gain_album_peak.is_empty(), "{format}");
let mut expected = before.clone();
expected.replay_gain_track_gain.clear();
expected.replay_gain_track_peak.clear();
expected.replay_gain_album_gain.clear();
expected.replay_gain_album_peak.clear();
assert_eq!(after, expected, "{format}");
if matches!(format, "flac" | "mp3" | "m4a" | "opus") {
assert_eq!(
extract_cover(&mut Cursor::new(&removed), format, &|| Ok(()))
.unwrap()
.data,
COVER,
"{format}"
);
} else {
assert!(
removed.windows(COVER.len()).any(|part| part == COVER),
"{format}"
);
}
assert_eq!(
removed
.windows(PAYLOAD.len())
.filter(|part| *part == PAYLOAD)
.count(),
1,
"{format}"
);
for tag in [
"REPLAYGAIN_",
"R128_TRACK_GAIN",
"R128_ALBUM_GAIN",
"ITUNNORM",
] {
assert!(
!String::from_utf8_lossy(&removed)
.to_uppercase()
.contains(tag),
"{format}: {tag}"
);
}
}
}
}
#[test]
fn partial_empty_m4a_updates_do_not_remove_existing_gain() {
let fields = BTreeMap::from([(GAIN_FIELDS[0].into(), "-6.00 dB".into())]);
let mut tagged = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(source("m4a")),
&mut tagged,
"m4a",
&fields,
None,
&|| Ok(()),
)
.unwrap();
let mut output = Vec::new();
let empty = BTreeMap::from([(GAIN_FIELDS[0].into(), String::new())]);
assert!(
!rewrite_m4a_freeform(
&mut Cursor::new(&tagged),
&mut output,
&empty,
true,
&|| Ok(())
)
.unwrap()
);
assert!(output.is_empty());
}
#[test]
fn removal_can_clear_the_last_ape_tags() {
let fields = BTreeMap::from([(GAIN_FIELDS[0].into(), "-6.00 dB".into())]);
let mut tagged = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(PAYLOAD),
&mut tagged,
"ape",
&fields,
None,
&|| Ok(()),
)
.unwrap();
let empty = GAIN_FIELDS.map(|key| (key.into(), String::new())).into();
let mut id3v1 = b"TAGPreserved legacy title".to_vec();
id3v1.resize(128, 0);
for suffix in [b"".as_slice(), id3v1.as_slice()] {
let input = [tagged.as_slice(), suffix].concat();
let mut removed = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(&input),
&mut removed,
"ape",
&empty,
None,
&|| Ok(()),
)
.unwrap();
assert_eq!(removed, [PAYLOAD, suffix].concat());
}
}
#[test]
fn riff_removal_preserves_unrecognized_id3_frames() {
// A private ID3 frame that is deliberately absent from AudioMetadata.
let private = b"private-owner\0preserved bytes";
let mut frame = b"PRIV\0\0\0".to_vec();
frame.push(private.len() as u8);
frame.extend([0, 0]);
frame.extend(private);
let mut tag = b"ID3\x04\0\0\0\0\0".to_vec();
tag.push(frame.len() as u8);
tag.extend(frame);
for format in ["wav", "aiff"] {
let aiff = format == "aiff";
let mut input = source(format);
input.extend(b"ID3 ");
let size = tag.len() as u32;
input.extend(if aiff {
size.to_be_bytes()
} else {
size.to_le_bytes()
});
input.extend(&tag);
if size & 1 == 1 {
input.push(0);
}
let size = (input.len() - 8) as u32;
input[4..8].copy_from_slice(&if aiff {
size.to_be_bytes()
} else {
size.to_le_bytes()
});
let empty = GAIN_FIELDS.map(|key| (key.into(), String::new())).into();
let mut removed = Vec::new();
rewrite_audio_tags(
&mut Cursor::new(&input),
&mut removed,
format,
&empty,
None,
&|| Ok(()),
)
.unwrap();
assert!(
removed.windows(private.len()).any(|part| part == private),
"{format}"
);
assert!(
removed.windows(PAYLOAD.len()).any(|part| part == PAYLOAD),
"{format}"
);
}
}
@@ -116,20 +116,17 @@ impl Backend {
});
check()?;
let mut replay_gain = false;
let only_replay_gain = fields
.iter()
.filter(|(_, value)| !value.trim().is_empty())
.all(|(key, _)| {
let allowed = matches!(
key.trim().to_lowercase().as_str(),
"replaygain_track_gain"
| "replaygain_track_peak"
| "replaygain_album_gain"
| "replaygain_album_peak"
);
replay_gain |= allowed;
allowed
});
let only_replay_gain = fields.iter().all(|(key, _)| {
let allowed = matches!(
key.trim().to_lowercase().as_str(),
"replaygain_track_gain"
| "replaygain_track_peak"
| "replaygain_album_gain"
| "replaygain_album_peak"
);
replay_gain |= allowed;
allowed
});
let success = |method: &str| json!({"success":true,"method":method});
if only_replay_gain && replay_gain && (m4a || mp4) {
self.edit_m4a_freeform(path, &fields, true, &check)
@@ -251,7 +248,7 @@ impl Backend {
"replaygain_album_peak",
]
.iter()
.any(|key| fields.get(*key).is_some_and(|v| !v.trim().is_empty()))
.any(|key| fields.contains_key(*key))
} else {
fields.contains_key("isrc") || fields.contains_key("label")
};
+113 -91
View File
@@ -21,102 +21,124 @@ void main() {
for (final mornye in [false, true]) {
for (final downloaded in [false, true]) {
testWidgets(
'ReplayGain survives hiding the album selection (Mornye: $mornye, downloaded: $downloaded)',
(tester) async {
SharedPreferences.setMockInitialValues({});
var attempts = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safCopyToTemp') attempts++;
// Simulate an inaccessible file. Reaching this call and showing
// the result proves confirmation actually starts the operation.
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
const path = 'content://library/document/track.flac';
final item = DownloadHistoryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
service: 'provider-a',
downloadedAt: DateTime(2026),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
for (final remove in [false, true]) {
testWidgets(
'ReplayGain survives hiding the album selection (Mornye: $mornye, downloaded: $downloaded, remove: $remove)',
(tester) async {
SharedPreferences.setMockInitialValues({});
var attempts = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safCopyToTemp') attempts++;
// Simulate an inaccessible file. Reaching this call and showing
// the result proves confirmation actually starts the operation.
return null;
});
addTearDown(
() => messenger.setMockMethodCallHandler(channel, null),
);
const path = 'content://library/document/track.flac';
final item = DownloadHistoryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
service: 'provider-a',
downloadedAt: DateTime(2026),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
),
).overrideWith((ref) async => [item]),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates:
AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SelectionOverlayHost(
child: downloaded
? const DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
)
: LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
tracks: [
LocalLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
scannedAt: DateTime(2026),
),
],
),
),
).overrideWith((ref) async => [item]),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SelectionOverlayHost(
child: downloaded
? const DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
)
: LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
tracks: [
LocalLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
scannedAt: DateTime(2026),
),
],
),
),
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Track'), 200);
await tester.longPress(find.text('Track'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(
tester.element(find.byType(SelectionBottomBar)),
);
Future<void> openConfirmation() async {
await tester.tap(find.text(l10n.selectionReplayGainCount(1)));
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Track'), 200);
await tester.longPress(find.text('Track'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(
tester.element(find.byType(SelectionBottomBar)),
);
Future<void> openConfirmation() async {
final action = find.text(
remove
? l10n.selectionRemoveReplayGainCount(1)
: l10n.selectionReplayGainCount(1),
);
await tester.ensureVisible(action);
await tester.tap(action);
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openConfirmation();
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(attempts, 0);
expect(find.byType(SelectionBottomBar), findsOneWidget);
await openConfirmation();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text(
remove
? l10n.trackRemoveReplayGain
: l10n.replayGainBatchConfirmTitle,
),
),
);
await tester.pumpAndSettle();
expect(attempts, 1);
expect(
find.text(
remove
? l10n.replayGainRemoveBatchSuccess(0, 1)
: l10n.replayGainBatchSuccess(0, 1),
),
findsOneWidget,
);
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openConfirmation();
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(attempts, 0);
expect(find.byType(SelectionBottomBar), findsOneWidget);
await openConfirmation();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text(l10n.replayGainBatchConfirmTitle),
),
);
await tester.pumpAndSettle();
expect(attempts, 1);
expect(find.text(l10n.replayGainBatchSuccess(0, 1)), findsOneWidget);
expect(find.byType(SelectionBottomBar), findsNothing);
expect(tester.takeException(), isNull);
},
);
expect(tester.takeException(), isNull);
},
);
}
}
}
}
+74
View File
@@ -194,4 +194,78 @@ void main() {
expect(metadata['replaygain_track_peak'], '1.258925');
expect(calls, ['editFileMetadata', 'readFileMetadata']);
});
test('removal clears both scopes and verifies before saving SAF', () async {
metadata['title'] = 'Preserved title';
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isTrue,
);
expect(editedFields, {
'replaygain_track_gain': '',
'replaygain_track_peak': '',
'replaygain_album_gain': '',
'replaygain_album_peak': '',
});
expect(metadata['title'], 'Preserved title');
expect(calls, [
'safCopyToTemp',
'editFileMetadata',
'readFileMetadata',
'writeTempToSaf',
]);
expect(await File(tempPath).exists(), isFalse);
});
test('removal refuses to save when the gain tags remain', () async {
applyEdits = false;
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata', 'readFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test('removal does not treat a fallback instruction as success', () async {
method = 'ffmpeg';
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test(
'removal reports SAF save failure and cleans its temporary copy',
() async {
saveSaf = false;
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls.last, 'writeTempToSaf');
expect(await File(tempPath).exists(), isFalse);
},
);
test('removal rejects an empty metadata response', () async {
metadata = {};
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata', 'readFileMetadata']);
});
test('removal is idempotent for an untagged local file', () async {
method = 'native';
metadata = {'audio_codec': 'flac'};
expect(
await ReplayGainService.removeFromFile('${directory.path}/song.flac'),
isTrue,
);
expect(calls, ['editFileMetadata', 'readFileMetadata']);
});
}
+47
View File
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -28,10 +29,26 @@ void main() {
'replaygain_album_peak': '1.234567',
}
: {'replaygain_track_gain': ' ', 'replaygain_album_peak': null};
var edits = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'editFileMetadata') {
edits++;
final args = call.arguments as Map;
final fields = Map<String, String>.from(
jsonDecode(args['metadata_json'] as String) as Map,
);
metadata.addAll(fields);
return jsonEncode({'success': true, 'method': 'native'});
}
return switch (call.method) {
'safStat' => jsonEncode({'exists': true, 'size': 100}),
'readAudioMetadata' => jsonEncode(metadata),
'readFileMetadata' => jsonEncode({
...metadata,
'audio_codec': 'flac',
}),
'safCopyToTemp' => 'temporary-song.flac',
'writeTempToSaf' => jsonEncode({'success': true}),
'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}),
'getSafFileModTimes' => '{}',
_ => null,
@@ -74,6 +91,36 @@ void main() {
]) {
expect(find.text(text), hasTags ? findsOneWidget : findsNothing);
}
Future<void> openRemoval() async {
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Remove ReplayGain'));
await tester.tap(find.text('Remove ReplayGain'));
await tester.pumpAndSettle();
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openRemoval();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(edits, 0);
if (hasTags) expect(find.text('-6.20 dB'), findsOneWidget);
await openRemoval();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text('Remove ReplayGain'),
),
);
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pumpAndSettle();
expect(edits, 1);
expect(find.text('ReplayGain tags removed'), findsOneWidget);
expect(find.text('ReplayGain Track Gain'), findsNothing);
expect(find.text('ReplayGain Album Gain'), findsNothing);
expect(tester.takeException(), isNull);
});
}