refactor(dart): extract queue search and media policies

This commit is contained in:
zarzet
2026-07-26 01:13:52 +07:00
parent 1cfda41ef2
commit 20081b14bd
11 changed files with 1647 additions and 1367 deletions
+380
View File
@@ -0,0 +1,380 @@
import 'package:spotiflac_android/utils/artist_utils.dart';
/// Pure metadata-key conversion used by the different audio containers.
///
/// Keeping these mappings independent from FFmpeg execution makes the tag
/// contract directly testable and prevents container-specific policy from
/// being buried in the conversion orchestration.
class AudioMetadataMapper {
const AudioMetadataMapper._();
static String? extractLyricsForId3(Map<String, String>? metadata) {
if (metadata == null) return null;
String? fallback;
for (final entry in metadata.entries) {
final key = _normalizeKey(entry.key);
if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue;
final value = entry.value;
if (value.trim().isEmpty) continue;
if (key == 'UNSYNCEDLYRICS') return value;
fallback ??= value;
}
return fallback;
}
/// Maps Vorbis-comment keys to the fields consumed by the native WAV/AIFF
/// metadata writer.
static Map<String, String> vorbisToNativeChunkFields(
Map<String, String> metadata,
) {
final fields = <String, String>{};
void setIndexPair(String numberKey, String totalKey, String value) {
final normalized = value.trim();
if (normalized.isEmpty || normalized == '0') return;
if (normalized.contains('/')) {
final parts = normalized.split('/');
fields[numberKey] = parts[0].trim();
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
fields[totalKey] = parts[1].trim();
}
} else {
fields[numberKey] = normalized;
}
}
for (final entry in metadata.entries) {
final key = _normalizeKey(entry.key);
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
fields['title'] = value;
case 'ARTIST':
fields['artist'] = value;
case 'ALBUM':
fields['album'] = value;
case 'ALBUMARTIST':
fields['album_artist'] = value;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
setIndexPair('track_number', 'track_total', value);
case 'TRACKTOTAL':
case 'TOTALTRACKS':
if (value.trim() != '0') fields['track_total'] = value.trim();
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
setIndexPair('disc_number', 'disc_total', value);
case 'DISCTOTAL':
case 'TOTALDISCS':
if (value.trim() != '0') fields['disc_total'] = value.trim();
case 'DATE':
fields['date'] = value;
case 'YEAR':
if ((fields['date'] ?? '').isEmpty) fields['date'] = value;
case 'ISRC':
fields['isrc'] = value;
case 'GENRE':
fields['genre'] = value;
case 'COMPOSER':
fields['composer'] = value;
case 'ORGANIZATION':
case 'LABEL':
case 'PUBLISHER':
fields['label'] = value;
case 'COPYRIGHT':
fields['copyright'] = value;
case 'COMMENT':
case 'DESCRIPTION':
fields['comment'] = value;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
fields['lyrics'] = value;
case 'REPLAYGAINTRACKGAIN':
fields['replaygain_track_gain'] = value;
case 'REPLAYGAINTRACKPEAK':
fields['replaygain_track_peak'] = value;
case 'REPLAYGAINALBUMGAIN':
fields['replaygain_album_gain'] = value;
case 'REPLAYGAINALBUMPEAK':
fields['replaygain_album_peak'] = value;
}
}
return fields;
}
/// Normalizes arbitrary tag spellings to standard Vorbis comment names.
static Map<String, String> normalizeToVorbisComments(
Map<String, String> metadata,
) {
final vorbis = <String, String>{};
for (final entry in metadata.entries) {
final key = _normalizeKey(entry.key);
final value = entry.value;
switch (key) {
case 'TITLE':
vorbis['TITLE'] = value;
case 'ARTIST':
vorbis['ARTIST'] = value;
case 'ALBUM':
vorbis['ALBUM'] = value;
case 'ALBUMARTIST':
vorbis['ALBUMARTIST'] = value;
case 'TRACKNUMBER':
case 'TRACKNBR':
case 'TRACK':
case 'TRCK':
if (value != '0') vorbis['TRACKNUMBER'] = value;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') vorbis['DISCNUMBER'] = value;
case 'DATE':
vorbis['DATE'] = value;
final yearMatch = RegExp(r'^(\d{4})').firstMatch(value);
if (yearMatch != null &&
(!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) {
vorbis['YEAR'] = yearMatch.group(1)!;
}
case 'YEAR':
vorbis['YEAR'] = value;
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
vorbis['DATE'] = value;
}
case 'GENRE':
vorbis['GENRE'] = value;
case 'ISRC':
vorbis['ISRC'] = value;
case 'LABEL':
case 'ORGANIZATION':
vorbis['ORGANIZATION'] = value;
case 'COPYRIGHT':
vorbis['COPYRIGHT'] = value;
case 'COMPOSER':
vorbis['COMPOSER'] = value;
case 'COMMENT':
vorbis['COMMENT'] = value;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
vorbis['LYRICS'] = value;
vorbis['UNSYNCEDLYRICS'] = value;
case 'REPLAYGAINTRACKGAIN':
vorbis['REPLAYGAIN_TRACK_GAIN'] = value;
case 'REPLAYGAINTRACKPEAK':
vorbis['REPLAYGAIN_TRACK_PEAK'] = value;
case 'REPLAYGAINALBUMGAIN':
vorbis['REPLAYGAIN_ALBUM_GAIN'] = value;
case 'REPLAYGAINALBUMPEAK':
vorbis['REPLAYGAIN_ALBUM_PEAK'] = value;
case 'R128TRACKGAIN':
vorbis['R128_TRACK_GAIN'] = value;
case 'R128ALBUMGAIN':
vorbis['R128_ALBUM_GAIN'] = value;
}
}
return vorbis;
}
static void appendVorbisMetadataArguments(
List<String> arguments,
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
for (final entry in buildVorbisMetadataEntries(
metadata,
artistTagMode: artistTagMode,
)) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static void appendMappedMetadataArguments(
List<String> arguments,
Map<String, String> metadata,
) {
for (final entry in metadata.entries) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static List<MapEntry<String, String>> buildVorbisMetadataEntries(
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
final vorbis = normalizeToVorbisComments(metadata);
final entries = <MapEntry<String, String>>[];
for (final entry in vorbis.entries) {
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
continue;
}
entries.add(entry);
}
_appendVorbisArtistEntries(
entries,
'ARTIST',
vorbis['ARTIST'],
artistTagMode: artistTagMode,
);
_appendVorbisArtistEntries(
entries,
'ALBUMARTIST',
vorbis['ALBUMARTIST'],
artistTagMode: artistTagMode,
);
return entries;
}
/// Maps generic metadata keys to M4A/MP4 tag names understood by FFmpeg.
static Map<String, String> convertToM4aTags(Map<String, String> metadata) {
final m4a = <String, String>{};
for (final entry in metadata.entries) {
final key = _normalizeKey(entry.key);
final value = entry.value;
switch (key) {
case 'TITLE':
m4a['title'] = value;
case 'ARTIST':
m4a['artist'] = value;
case 'ALBUM':
m4a['album'] = value;
case 'ALBUMARTIST':
m4a['album_artist'] = value;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
m4a['track'] = value;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
m4a['disc'] = value;
case 'DATE':
m4a['date'] = value;
case 'YEAR':
if (!m4a.containsKey('date') || m4a['date']!.isEmpty) {
m4a['date'] = value;
}
case 'GENRE':
m4a['genre'] = value;
case 'ISRC':
m4a['isrc'] = value;
case 'COMPOSER':
m4a['composer'] = value;
case 'COMMENT':
m4a['comment'] = value;
case 'COPYRIGHT':
m4a['copyright'] = value;
case 'LABEL':
case 'ORGANIZATION':
m4a['organization'] = value;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4a['lyrics'] = value;
}
}
return m4a;
}
/// Maps generic metadata keys to ID3 names understood by FFmpeg.
static Map<String, String> convertToId3Tags(Map<String, String> metadata) {
final id3 = <String, String>{};
for (final entry in metadata.entries) {
final originalKey = entry.key.toUpperCase();
final key = _normalizeKey(originalKey);
final value = entry.value;
switch (key) {
case 'TITLE':
id3['title'] = value;
case 'ARTIST':
id3['artist'] = value;
case 'ALBUM':
id3['album'] = value;
case 'ALBUMARTIST':
id3['album_artist'] = value;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
if (value != '0') id3['track'] = value;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') id3['disc'] = value;
case 'DATE':
id3['date'] = value;
case 'YEAR':
if (!id3.containsKey('date') || id3['date']!.isEmpty) {
id3['date'] = value;
}
case 'ISRC':
id3['TSRC'] = value;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3['lyrics'] = value;
case 'COMPOSER':
id3['composer'] = value;
case 'COMMENT':
id3['comment'] = value;
case 'REPLAYGAINTRACKGAIN':
id3['REPLAYGAIN_TRACK_GAIN'] = value;
case 'REPLAYGAINTRACKPEAK':
id3['REPLAYGAIN_TRACK_PEAK'] = value;
case 'REPLAYGAINALBUMGAIN':
id3['REPLAYGAIN_ALBUM_GAIN'] = value;
case 'REPLAYGAINALBUMPEAK':
id3['REPLAYGAIN_ALBUM_PEAK'] = value;
default:
id3[originalKey.toLowerCase()] = value;
}
}
return id3;
}
static void _appendVorbisArtistEntries(
List<MapEntry<String, String>> entries,
String key,
String? rawValue, {
String artistTagMode = artistTagModeJoined,
}) {
if (rawValue == null) return;
final value = rawValue.trim();
if (value.isEmpty) {
// Preserve an empty entry so FFmpeg clears an existing tag.
entries.add(MapEntry(key, ''));
return;
}
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
entries.add(MapEntry(key, value));
return;
}
for (final artist in splitArtistTagValues(value)) {
entries.add(MapEntry(key, artist));
}
}
static String _normalizeKey(String key) =>
key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
}
+181
View File
@@ -0,0 +1,181 @@
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
/// Describes an extension-requested decryption step without coupling callers
/// to the FFmpeg execution service.
class DownloadDecryptionDescriptor {
final String strategy;
final String key;
final String? iv;
final String? inputFormat;
final String? outputExtension;
final Map<String, dynamic> options;
const DownloadDecryptionDescriptor({
required this.strategy,
required this.key,
this.iv,
this.inputFormat,
this.outputExtension,
this.options = const {},
});
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
final rawOptions = json['options'];
return DownloadDecryptionDescriptor(
strategy: (json['strategy'] as String? ?? '').trim(),
key: (json['key'] as String? ?? '').trim(),
iv: (json['iv'] as String?)?.trim(),
inputFormat: (json['input_format'] as String?)?.trim(),
outputExtension: (json['output_extension'] as String?)?.trim(),
options: rawOptions is Map
? Map<String, dynamic>.from(rawOptions)
: const {},
);
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'strategy': strategy, 'key': key};
if (iv != null && iv!.isNotEmpty) {
json['iv'] = iv;
}
if (inputFormat != null && inputFormat!.isNotEmpty) {
json['input_format'] = inputFormat;
}
if (outputExtension != null && outputExtension!.isNotEmpty) {
json['output_extension'] = outputExtension;
}
if (options.isNotEmpty) {
json['options'] = options;
}
return json;
}
static DownloadDecryptionDescriptor? fromDownloadResult(
Map<String, dynamic> result,
) {
final rawDecryption = result['decryption'];
if (rawDecryption is Map) {
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
descriptorJson['output_extension'] ??= result['output_extension'];
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
descriptor.key.isNotEmpty) {
return descriptor;
}
}
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
if (legacyKey.isEmpty) {
return null;
}
return DownloadDecryptionDescriptor(
strategy: 'ffmpeg.mov_key',
key: legacyKey,
inputFormat: 'mov',
outputExtension: (result['output_extension'] as String?)?.trim(),
);
}
String get normalizedStrategy {
switch (strategy.trim().toLowerCase()) {
case '':
case 'ffmpeg.mov_key':
case 'ffmpeg_mov_key':
case 'mov_decryption_key':
case 'mp4_decryption_key':
case 'ffmpeg.mp4_decryption_key':
return 'ffmpeg.mov_key';
default:
return strategy.trim();
}
}
String? get normalizedOutputExtension {
final trimmed = (outputExtension ?? '').trim().toLowerCase();
if (trimmed.isEmpty) return null;
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
}
}
class CueSplitTrackInfo {
final int number;
final String title;
final String artist;
final String isrc;
final String composer;
final double startSec;
final double endSec;
CueSplitTrackInfo({
required this.number,
required this.title,
required this.artist,
this.isrc = '',
this.composer = '',
required this.startSec,
required this.endSec,
});
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
return CueSplitTrackInfo(
number: json['number'] as int? ?? 0,
title: json['title'] as String? ?? '',
artist: json['artist'] as String? ?? '',
isrc: json['isrc'] as String? ?? '',
composer: json['composer'] as String? ?? '',
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
);
}
}
class FFmpegResult {
final bool success;
final int returnCode;
final String output;
FFmpegResult({
required this.success,
required this.returnCode,
required this.output,
});
}
class LiveDecryptedStreamResult {
final String localUrl;
final String format;
final FFmpegSession session;
LiveDecryptedStreamResult({
required this.localUrl,
required this.format,
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)';
}
+24 -799
View File
@@ -9,109 +9,18 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/audio_metadata_mapper.dart';
import 'package:spotiflac_android/services/ffmpeg_models.dart';
import 'package:spotiflac_android/services/id3v23_lyrics.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
export 'package:spotiflac_android/services/ffmpeg_models.dart';
final _log = AppLogger('FFmpeg');
class DownloadDecryptionDescriptor {
final String strategy;
final String key;
final String? iv;
final String? inputFormat;
final String? outputExtension;
final Map<String, dynamic> options;
const DownloadDecryptionDescriptor({
required this.strategy,
required this.key,
this.iv,
this.inputFormat,
this.outputExtension,
this.options = const {},
});
factory DownloadDecryptionDescriptor.fromJson(Map<String, dynamic> json) {
final rawOptions = json['options'];
return DownloadDecryptionDescriptor(
strategy: (json['strategy'] as String? ?? '').trim(),
key: (json['key'] as String? ?? '').trim(),
iv: (json['iv'] as String?)?.trim(),
inputFormat: (json['input_format'] as String?)?.trim(),
outputExtension: (json['output_extension'] as String?)?.trim(),
options: rawOptions is Map
? Map<String, dynamic>.from(rawOptions)
: const {},
);
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{'strategy': strategy, 'key': key};
if (iv != null && iv!.isNotEmpty) {
json['iv'] = iv;
}
if (inputFormat != null && inputFormat!.isNotEmpty) {
json['input_format'] = inputFormat;
}
if (outputExtension != null && outputExtension!.isNotEmpty) {
json['output_extension'] = outputExtension;
}
if (options.isNotEmpty) {
json['options'] = options;
}
return json;
}
static DownloadDecryptionDescriptor? fromDownloadResult(
Map<String, dynamic> result,
) {
final rawDecryption = result['decryption'];
if (rawDecryption is Map) {
final descriptorJson = Map<String, dynamic>.from(rawDecryption);
descriptorJson['output_extension'] ??= result['output_extension'];
final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson);
if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' &&
descriptor.key.isNotEmpty) {
return descriptor;
}
}
final legacyKey = (result['decryption_key'] as String?)?.trim() ?? '';
if (legacyKey.isEmpty) {
return null;
}
return DownloadDecryptionDescriptor(
strategy: 'ffmpeg.mov_key',
key: legacyKey,
inputFormat: 'mov',
outputExtension: (result['output_extension'] as String?)?.trim(),
);
}
String get normalizedStrategy {
switch (strategy.trim().toLowerCase()) {
case '':
case 'ffmpeg.mov_key':
case 'ffmpeg_mov_key':
case 'mov_decryption_key':
case 'mp4_decryption_key':
case 'ffmpeg.mp4_decryption_key':
return 'ffmpeg.mov_key';
default:
return strategy.trim();
}
}
String? get normalizedOutputExtension {
final trimmed = (outputExtension ?? '').trim().toLowerCase();
if (trimmed.isEmpty) return null;
return trimmed.startsWith('.') ? trimmed : '.$trimmed';
}
}
class _ResolvedLosslessConversionQuality {
final int? targetBitDepth;
final int? targetSampleRate;
@@ -1764,7 +1673,7 @@ class FFmpegService {
..add('copy');
if (metadata != null) {
_appendVorbisMetadataToArguments(
AudioMetadataMapper.appendVorbisMetadataArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
@@ -1810,7 +1719,7 @@ class FFmpegService {
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final lyrics = _extractLyricsForId3(metadata);
final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata);
// Try with -c:a copy first (fastest, preserves original codec)
var result = await _runMp3Embed(
@@ -1928,7 +1837,10 @@ class FFmpegService {
}
if (metadata != null) {
_appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata));
AudioMetadataMapper.appendMappedMetadataArguments(
arguments,
AudioMetadataMapper.convertToId3Tags(metadata),
);
}
arguments
@@ -1958,23 +1870,6 @@ class FFmpegService {
return mp3Path;
}
static String? _extractLyricsForId3(Map<String, String>? metadata) {
if (metadata == null) return null;
String? fallback;
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue;
final value = entry.value;
if (value.trim().isEmpty) continue;
if (key == 'UNSYNCEDLYRICS') return value;
fallback ??= value;
}
return fallback;
}
static Future<void> _ensureMp3UnsyncedLyricsFrame(
String mp3Path,
String lyrics,
@@ -1984,7 +1879,7 @@ class FFmpegService {
if (!await file.exists()) return;
final bytes = await file.readAsBytes();
final updated = _writeId3v23UnsyncedLyrics(bytes, lyrics);
final updated = Id3v23Lyrics.writeUnsyncedLyrics(bytes, lyrics);
if (updated == null) {
_log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag');
return;
@@ -1997,165 +1892,6 @@ class FFmpegService {
}
}
static Uint8List? _writeId3v23UnsyncedLyrics(Uint8List bytes, String lyrics) {
final lyricsFrame = _buildId3v23UnsyncedLyricsFrame(lyrics);
if (!_hasId3Header(bytes)) {
final builder = BytesBuilder(copy: false)
..add(_buildId3v23Tag(lyricsFrame))
..add(bytes);
return builder.toBytes();
}
if (bytes.length < 10 || bytes[3] != 3) {
return null;
}
final flags = bytes[5];
const unsupportedFlags = 0x80 | 0x40 | 0x20;
if ((flags & unsupportedFlags) != 0) {
return null;
}
final tagSize = _readSynchsafeInt(bytes, 6);
if (tagSize == null) return null;
final tagEnd = 10 + tagSize;
if (tagEnd < 10 || tagEnd > bytes.length) {
return null;
}
final tagPayload = bytes.sublist(10, tagEnd);
final preservedFrames = _removeId3v23Frames(tagPayload, {'USLT'});
final newPayload = BytesBuilder(copy: false)
..add(preservedFrames)
..add(lyricsFrame);
final newTag = _buildId3v23Tag(newPayload.toBytes());
final builder = BytesBuilder(copy: false)
..add(newTag)
..add(bytes.sublist(tagEnd));
return builder.toBytes();
}
static bool _hasId3Header(Uint8List bytes) {
return bytes.length >= 10 &&
bytes[0] == 0x49 &&
bytes[1] == 0x44 &&
bytes[2] == 0x33;
}
static Uint8List _removeId3v23Frames(
Uint8List tagPayload,
Set<String> frameIds,
) {
final builder = BytesBuilder(copy: false);
var offset = 0;
while (offset + 10 <= tagPayload.length) {
final idBytes = tagPayload.sublist(offset, offset + 4);
if (idBytes.every((byte) => byte == 0)) break;
final frameId = ascii.decode(idBytes, allowInvalid: true);
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
final frameSize = _readUint32(tagPayload, offset + 4);
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
break;
}
if (!frameIds.contains(frameId)) {
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
}
offset += 10 + frameSize;
}
return builder.toBytes();
}
static Uint8List _buildId3v23Tag(Uint8List payload) {
final header = Uint8List(10)
..[0] = 0x49
..[1] = 0x44
..[2] = 0x33
..[3] = 3;
final size = _writeSynchsafeInt(payload.length);
header.setRange(6, 10, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _buildId3v23UnsyncedLyricsFrame(String lyrics) {
final payload = BytesBuilder(copy: false)
..add(const [0x01, 0x65, 0x6e, 0x67])
..add(const [0xff, 0xfe, 0x00, 0x00])
..add(_utf16LeWithBom(lyrics));
return _buildId3v23Frame('USLT', payload.toBytes());
}
static Uint8List _buildId3v23Frame(String frameId, Uint8List payload) {
final header = Uint8List(10);
header.setRange(0, 4, ascii.encode(frameId));
final size = _writeUint32(payload.length);
header.setRange(4, 8, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _utf16LeWithBom(String value) {
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
for (final codeUnit in value.codeUnits) {
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
}
return bytes.toBytes();
}
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
if (offset + 4 > bytes.length) return null;
final b0 = bytes[offset];
final b1 = bytes[offset + 1];
final b2 = bytes[offset + 2];
final b3 = bytes[offset + 3];
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
}
static Uint8List _writeSynchsafeInt(int value) {
return Uint8List.fromList([
(value >> 21) & 0x7f,
(value >> 14) & 0x7f,
(value >> 7) & 0x7f,
value & 0x7f,
]);
}
static int _readUint32(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
static Uint8List _writeUint32(int value) {
return Uint8List.fromList([
(value >> 24) & 0xff,
(value >> 16) & 0xff,
(value >> 8) & 0xff,
value & 0xff,
]);
}
static Future<String?> embedMetadataToOpus({
required String opusPath,
String? coverPath,
@@ -2183,7 +1919,7 @@ class FFmpegService {
];
if (metadata != null) {
_appendVorbisMetadataToArguments(
AudioMetadataMapper.appendVorbisMetadataArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
@@ -2290,9 +2026,9 @@ class FFmpegService {
}
if (metadata != null) {
_appendMappedMetadataToArguments(
AudioMetadataMapper.appendMappedMetadataArguments(
arguments,
_convertToM4aTags(metadata),
AudioMetadataMapper.convertToM4aTags(metadata),
);
}
@@ -2668,9 +2404,12 @@ class FFmpegService {
..add(isAlac ? '-1' : '0');
if (isAlac) {
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
AudioMetadataMapper.appendMappedMetadataArguments(
arguments,
AudioMetadataMapper.convertToM4aTags(metadata),
);
} else {
_appendVorbisMetadataToArguments(
AudioMetadataMapper.appendVorbisMetadataArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
@@ -2796,7 +2535,9 @@ class FFmpegService {
Map<String, String> vorbisMetadata,
String? coverPath,
) async {
final fields = _vorbisToNativeChunkFields(vorbisMetadata);
final fields = AudioMetadataMapper.vorbisToNativeChunkFields(
vorbisMetadata,
);
if (coverPath != null && coverPath.trim().isNotEmpty) {
fields['cover_path'] = coverPath;
}
@@ -2810,293 +2551,6 @@ class FFmpegService {
}
}
/// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field
/// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer.
static Map<String, String> _vorbisToNativeChunkFields(
Map<String, String> metadata,
) {
final out = <String, String>{};
void setIndexPair(String numberKey, String totalKey, String value) {
final v = value.trim();
if (v.isEmpty || v == '0') return;
if (v.contains('/')) {
final parts = v.split('/');
out[numberKey] = parts[0].trim();
if (parts.length > 1 && parts[1].trim().isNotEmpty) {
out[totalKey] = parts[1].trim();
}
} else {
out[numberKey] = v;
}
}
for (final entry in metadata.entries) {
final normalizedKey = entry.key.toUpperCase().replaceAll(
RegExp(r'[^A-Z0-9]'),
'',
);
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (normalizedKey) {
case 'TITLE':
out['title'] = value;
break;
case 'ARTIST':
out['artist'] = value;
break;
case 'ALBUM':
out['album'] = value;
break;
case 'ALBUMARTIST':
out['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
setIndexPair('track_number', 'track_total', value);
break;
case 'TRACKTOTAL':
case 'TOTALTRACKS':
if (value.trim() != '0') out['track_total'] = value.trim();
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
setIndexPair('disc_number', 'disc_total', value);
break;
case 'DISCTOTAL':
case 'TOTALDISCS':
if (value.trim() != '0') out['disc_total'] = value.trim();
break;
case 'DATE':
out['date'] = value;
break;
case 'YEAR':
if ((out['date'] ?? '').isEmpty) out['date'] = value;
break;
case 'ISRC':
out['isrc'] = value;
break;
case 'GENRE':
out['genre'] = value;
break;
case 'COMPOSER':
out['composer'] = value;
break;
case 'ORGANIZATION':
case 'LABEL':
case 'PUBLISHER':
out['label'] = value;
break;
case 'COPYRIGHT':
out['copyright'] = value;
break;
case 'COMMENT':
case 'DESCRIPTION':
out['comment'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
out['lyrics'] = value;
break;
case 'REPLAYGAINTRACKGAIN':
out['replaygain_track_gain'] = value;
break;
case 'REPLAYGAINTRACKPEAK':
out['replaygain_track_peak'] = value;
break;
case 'REPLAYGAINALBUMGAIN':
out['replaygain_album_gain'] = value;
break;
case 'REPLAYGAINALBUMPEAK':
out['replaygain_album_peak'] = value;
break;
}
}
return out;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
Map<String, String> metadata,
) {
final vorbis = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
switch (key) {
case 'TITLE':
vorbis['TITLE'] = value;
break;
case 'ARTIST':
vorbis['ARTIST'] = value;
break;
case 'ALBUM':
vorbis['ALBUM'] = value;
break;
case 'ALBUMARTIST':
vorbis['ALBUMARTIST'] = value;
break;
case 'TRACKNUMBER':
case 'TRACKNBR':
case 'TRACK':
case 'TRCK':
if (value != '0') vorbis['TRACKNUMBER'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') vorbis['DISCNUMBER'] = value;
break;
case 'DATE':
vorbis['DATE'] = value;
final yearMatch = RegExp(r'^(\d{4})').firstMatch(value);
if (yearMatch != null &&
(!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) {
vorbis['YEAR'] = yearMatch.group(1)!;
}
break;
case 'YEAR':
vorbis['YEAR'] = value;
if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) {
vorbis['DATE'] = value;
}
break;
case 'GENRE':
vorbis['GENRE'] = value;
break;
case 'ISRC':
vorbis['ISRC'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
vorbis['ORGANIZATION'] = value;
break;
case 'COPYRIGHT':
vorbis['COPYRIGHT'] = value;
break;
case 'COMPOSER':
vorbis['COMPOSER'] = value;
break;
case 'COMMENT':
vorbis['COMMENT'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
vorbis['LYRICS'] = value;
vorbis['UNSYNCEDLYRICS'] = value;
break;
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;
case 'R128TRACKGAIN':
vorbis['R128_TRACK_GAIN'] = value;
break;
case 'R128ALBUMGAIN':
vorbis['R128_ALBUM_GAIN'] = value;
break;
}
}
return vorbis;
}
static void _appendVorbisMetadataToArguments(
List<String> arguments,
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
for (final entry in _buildVorbisMetadataEntries(
metadata,
artistTagMode: artistTagMode,
)) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static void _appendMappedMetadataToArguments(
List<String> arguments,
Map<String, String> metadata,
) {
for (final entry in metadata.entries) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
final vorbis = _normalizeToVorbisComments(metadata);
final entries = <MapEntry<String, String>>[];
for (final entry in vorbis.entries) {
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
continue;
}
entries.add(entry);
}
_appendVorbisArtistEntries(
entries,
'ARTIST',
vorbis['ARTIST'],
artistTagMode: artistTagMode,
);
_appendVorbisArtistEntries(
entries,
'ALBUMARTIST',
vorbis['ALBUMARTIST'],
artistTagMode: artistTagMode,
);
return entries;
}
static void _appendVorbisArtistEntries(
List<MapEntry<String, String>> entries,
String key,
String? rawValue, {
String artistTagMode = artistTagModeJoined,
}) {
if (rawValue == null) return;
final value = rawValue.trim();
if (value.isEmpty) {
// Emit an empty entry so that with preserveMetadata the old tag is
// overridden (cleared) by FFmpeg's `-metadata key=""`.
entries.add(MapEntry(key, ''));
return;
}
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
entries.add(MapEntry(key, value));
return;
}
for (final artist in splitArtistTagValues(value)) {
entries.add(MapEntry(key, artist));
}
}
/// Writes ISRC and label into an M4A/MP4 file natively (iTunes freeform
/// atoms), since FFmpeg's MP4 muxer drops these keys. Only keys present in
/// [metadata] are written; an empty value clears the corresponding tag.
@@ -3125,153 +2579,6 @@ class FFmpegService {
}
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
final m4aMap = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
switch (key) {
case 'TITLE':
m4aMap['title'] = value;
break;
case 'ARTIST':
m4aMap['artist'] = value;
break;
case 'ALBUM':
m4aMap['album'] = value;
break;
case 'ALBUMARTIST':
m4aMap['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
m4aMap['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
m4aMap['disc'] = value;
break;
case 'DATE':
m4aMap['date'] = value;
break;
case 'YEAR':
if (!m4aMap.containsKey('date') || m4aMap['date']!.isEmpty) {
m4aMap['date'] = value;
}
break;
case 'GENRE':
m4aMap['genre'] = value;
break;
case 'ISRC':
m4aMap['isrc'] = value;
break;
case 'COMPOSER':
m4aMap['composer'] = value;
break;
case 'COMMENT':
m4aMap['comment'] = value;
break;
case 'COPYRIGHT':
m4aMap['copyright'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
m4aMap['organization'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value;
break;
}
}
return m4aMap;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
switch (normalizedKey) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
if (value != '0') {
id3Map['track'] = value;
}
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') {
id3Map['disc'] = value;
}
break;
case 'DATE':
id3Map['date'] = value;
break;
case 'YEAR':
if (!id3Map.containsKey('date') || id3Map['date']!.isEmpty) {
id3Map['date'] = value;
}
break;
case 'ISRC':
id3Map['TSRC'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
// FFmpeg writes these as TXXX frames automatically with uppercase keys
case 'REPLAYGAINTRACKGAIN':
id3Map['REPLAYGAIN_TRACK_GAIN'] = value;
break;
case 'REPLAYGAINTRACKPEAK':
id3Map['REPLAYGAIN_TRACK_PEAK'] = value;
break;
case 'REPLAYGAINALBUMGAIN':
id3Map['REPLAYGAIN_ALBUM_GAIN'] = value;
break;
case 'REPLAYGAINALBUMPEAK':
id3Map['REPLAYGAIN_ALBUM_PEAK'] = value;
break;
default:
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
/// Split a CUE+audio file into individual track files using FFmpeg.
/// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded.
/// [audioPath] is the source audio file (FLAC, WAV, etc.)
@@ -3368,7 +2675,7 @@ class FFmpegService {
if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc);
if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer);
_appendMappedMetadataToArguments(arguments, cueMetadata);
AudioMetadataMapper.appendMappedMetadataArguments(arguments, cueMetadata);
arguments
..add(outputPath)
..add('-y');
@@ -3404,86 +2711,4 @@ class FFmpegService {
}
}
class CueSplitTrackInfo {
final int number;
final String title;
final String artist;
final String isrc;
final String composer;
final double startSec;
final double endSec;
CueSplitTrackInfo({
required this.number,
required this.title,
required this.artist,
this.isrc = '',
this.composer = '',
required this.startSec,
required this.endSec,
});
factory CueSplitTrackInfo.fromJson(Map<String, dynamic> json) {
return CueSplitTrackInfo(
number: json['number'] as int? ?? 0,
title: json['title'] as String? ?? '',
artist: json['artist'] as String? ?? '',
isrc: json['isrc'] as String? ?? '',
composer: json['composer'] as String? ?? '',
startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0,
endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0,
);
}
}
class FFmpegResult {
final bool success;
final int returnCode;
final String output;
FFmpegResult({
required this.success,
required this.returnCode,
required this.output,
});
}
enum _LiveDecryptFormat { flac, m4a }
class LiveDecryptedStreamResult {
final String localUrl;
final String format;
final FFmpegSession session;
LiveDecryptedStreamResult({
required this.localUrl,
required this.format,
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)';
}
+166
View File
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'dart:typed_data';
/// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding.
///
/// It intentionally supports only plain ID3v2.3 tags without extended,
/// compressed, or unsynchronized headers. Unsupported tags are left unchanged.
class Id3v23Lyrics {
const Id3v23Lyrics._();
static Uint8List? writeUnsyncedLyrics(Uint8List bytes, String lyrics) {
final lyricsFrame = _buildUnsyncedLyricsFrame(lyrics);
if (!_hasId3Header(bytes)) {
final builder = BytesBuilder(copy: false)
..add(_buildTag(lyricsFrame))
..add(bytes);
return builder.toBytes();
}
if (bytes.length < 10 || bytes[3] != 3) {
return null;
}
final flags = bytes[5];
const unsupportedFlags = 0x80 | 0x40 | 0x20;
if ((flags & unsupportedFlags) != 0) {
return null;
}
final tagSize = _readSynchsafeInt(bytes, 6);
if (tagSize == null) return null;
final tagEnd = 10 + tagSize;
if (tagEnd < 10 || tagEnd > bytes.length) {
return null;
}
final tagPayload = bytes.sublist(10, tagEnd);
final preservedFrames = _removeFrames(tagPayload, {'USLT'});
final newPayload = BytesBuilder(copy: false)
..add(preservedFrames)
..add(lyricsFrame);
final newTag = _buildTag(newPayload.toBytes());
final builder = BytesBuilder(copy: false)
..add(newTag)
..add(bytes.sublist(tagEnd));
return builder.toBytes();
}
static bool _hasId3Header(Uint8List bytes) {
return bytes.length >= 10 &&
bytes[0] == 0x49 &&
bytes[1] == 0x44 &&
bytes[2] == 0x33;
}
static Uint8List _removeFrames(Uint8List tagPayload, Set<String> frameIds) {
final builder = BytesBuilder(copy: false);
var offset = 0;
while (offset + 10 <= tagPayload.length) {
final idBytes = tagPayload.sublist(offset, offset + 4);
if (idBytes.every((byte) => byte == 0)) break;
final frameId = ascii.decode(idBytes, allowInvalid: true);
if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break;
final frameSize = _readUint32(tagPayload, offset + 4);
if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) {
break;
}
if (!frameIds.contains(frameId)) {
builder.add(tagPayload.sublist(offset, offset + 10 + frameSize));
}
offset += 10 + frameSize;
}
return builder.toBytes();
}
static Uint8List _buildTag(Uint8List payload) {
final header = Uint8List(10)
..[0] = 0x49
..[1] = 0x44
..[2] = 0x33
..[3] = 3;
final size = _writeSynchsafeInt(payload.length);
header.setRange(6, 10, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _buildUnsyncedLyricsFrame(String lyrics) {
final payload = BytesBuilder(copy: false)
..add(const [0x01, 0x65, 0x6e, 0x67])
..add(const [0xff, 0xfe, 0x00, 0x00])
..add(_utf16LeWithBom(lyrics));
return _buildFrame('USLT', payload.toBytes());
}
static Uint8List _buildFrame(String frameId, Uint8List payload) {
final header = Uint8List(10);
header.setRange(0, 4, ascii.encode(frameId));
final size = _writeUint32(payload.length);
header.setRange(4, 8, size);
final builder = BytesBuilder(copy: false)
..add(header)
..add(payload);
return builder.toBytes();
}
static Uint8List _utf16LeWithBom(String value) {
final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]);
for (final codeUnit in value.codeUnits) {
bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]);
}
return bytes.toBytes();
}
static int? _readSynchsafeInt(Uint8List bytes, int offset) {
if (offset + 4 > bytes.length) return null;
final b0 = bytes[offset];
final b1 = bytes[offset + 1];
final b2 = bytes[offset + 2];
final b3 = bytes[offset + 3];
if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null;
return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3;
}
static Uint8List _writeSynchsafeInt(int value) {
return Uint8List.fromList([
(value >> 21) & 0x7f,
(value >> 14) & 0x7f,
(value >> 7) & 0x7f,
value & 0x7f,
]);
}
static int _readUint32(Uint8List bytes, int offset) {
return (bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3];
}
static Uint8List _writeUint32(int value) {
return Uint8List.fromList([
(value >> 24) & 0xff,
(value >> 16) & 0xff,
(value >> 8) & 0xff,
value & 0xff,
]);
}
}