fix(replaygain): write and verify native Opus gain tags

Preserve audio and artwork while replacing conflicting Opus gain tags with R128 comments. Verify manual, download, and album writes, handle SAF failures, and refresh playback normalization after saving.
This commit is contained in:
zarzet
2026-09-06 21:03:57 +07:00
parent 0acdd6d0b0
commit bfffb8da11
14 changed files with 745 additions and 138 deletions
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/verification_notification.dart';
import 'package:spotiflac_android/utils/logger.dart' hide log;
@@ -563,16 +563,16 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
final rgResult = await FFmpegService.scanReplayGain(filePath);
if (rgResult != null) {
scannedReplayGain = rgResult;
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
if (format == 'opus') {
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
if (format != 'opus') {
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
}
_log.d(
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
);
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
if (format != 'opus') {
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
}
}
} catch (e) {
_log.w('Failed to scan ReplayGain for $format: $e');
@@ -634,10 +634,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
// audio through untouched — no FFmpeg spawn, no full container remux,
// no temp-promote copy. The Go side answers method=ffmpeg for files it
// can't handle natively, and any failure falls back to FFmpeg below.
// Scanned ReplayGain (opt-in, non-FLAC) keeps the FFmpeg path: its
// extra tags (e.g. Opus R128_TRACK_GAIN) ride the FFmpeg metadata map.
// Opus ReplayGain is written and verified separately below, through the
// same native R128 writer used by manual scans and album gain updates.
var embeddedNatively = false;
if (scannedReplayGain == null) {
if (scannedReplayGain == null || format == 'opus') {
try {
final nativeFields = <String, String>{
'title': track.name,
@@ -686,7 +686,10 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
filePath,
nativeFields,
);
embeddedNatively = response['method'] != 'ffmpeg';
embeddedNatively =
response['success'] == true &&
response['error'] == null &&
response['method'] != 'ffmpeg';
} catch (e) {
_log.w('Native $format tag embed failed, falling back to FFmpeg: $e');
}
@@ -731,6 +734,19 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
}
}
if (format == 'opus' && scannedReplayGain != null) {
final written = await ReplayGainService.writeTrackTags(
filePath,
scannedReplayGain.trackGain,
scannedReplayGain.trackPeak,
);
if (written) {
_storeTrackReplayGainForAlbum(track, filePath, scannedReplayGain);
} else {
_log.w('Failed to write Opus ReplayGain');
}
}
if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
try {
await PlatformBridge.editFileMetadata(filePath, {
@@ -1415,7 +1415,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
actualBitrate = autoConvertBitrateKbps(settings.autoConvertBitrate);
}
await _writeNativeWorkerReplayGain(
context: context,
settings: settings,
track: trackToDownload,
filePath: filePath,
@@ -1547,7 +1546,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
Future<void> _writeNativeWorkerReplayGain({
required _NativeWorkerRequestContext context,
required AppSettings settings,
required Track track,
required String filePath,
@@ -1555,19 +1553,20 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (!settings.embedReplayGain) {
return;
}
if (context.outputExt != '.flac' && context.outputExt != '.m4a') {
final ext = audioFormatForPath(filePath)?.toLowerCase();
if (ext != 'flac' &&
ext != 'm4a' &&
ext != 'mp3' &&
ext != 'opus' &&
!isContentUri(filePath)) {
return;
}
try {
final rgResult = await FFmpegService.scanReplayGain(filePath);
final rgResult = await ReplayGainService.scanAndApplyToFile(filePath);
if (rgResult == null) {
return;
}
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_track_gain': rgResult.trackGain,
'replaygain_track_peak': rgResult.trackPeak,
});
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
_updateAlbumRgFilePath(track, filePath);
await _checkAndWriteAlbumReplayGain(track);
@@ -172,55 +172,13 @@ extension _DownloadQueueReplayGain on DownloadQueueNotifier {
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 {
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');
}
final ok = await ReplayGainService.writeAlbumTags(
filePath,
albumGain,
albumPeak,
);
if (!ok) {
_log.w('Album ReplayGain write failed for: $filePath');
}
}