refactor(metadata): reuse the platform tag mapper

This commit is contained in:
zarzet committed 2026-10-18 00:46:09 +07:00
1 parent d2d5593b9c
commit a396bcf08b
3 files changed
+156 -53

No files matched your search

-52
View File
@@ -104,58 +104,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
};
}
Map<String, String> _mapMetadataForTagEmbed(Map<String, dynamic> source) {
final mapped = <String, String>{};
void put(String key, dynamic value) {
final normalized = value?.toString().trim();
if (normalized == null || normalized.isEmpty) return;
mapped[key] = normalized;
}
put('TITLE', source['title']);
put('ARTIST', source['artist']);
put('ALBUM', source['album']);
put('ALBUMARTIST', source['album_artist']);
put('DATE', source['date']);
put('ISRC', source['isrc']);
put('GENRE', source['genre']);
put('ORGANIZATION', source['label']);
put('COPYRIGHT', source['copyright']);
put('COMPOSER', source['composer']);
put('COMMENT', source['comment']);
put('LYRICS', source['lyrics']);
put('UNSYNCEDLYRICS', source['lyrics']);
if (parseExplicitFlag(source['explicit']) == true) {
mapped['ITUNESADVISORY'] = '1';
}
final trackNumber = source['track_number'];
final totalTracks = source['total_tracks'];
if (trackNumber != null && trackNumber.toString() != '0') {
final trackTag =
totalTracks != null &&
totalTracks.toString().isNotEmpty &&
totalTracks.toString() != '0'
? '${trackNumber.toString()}/${totalTracks.toString()}'
: trackNumber;
put('TRACKNUMBER', trackTag);
}
final discNumber = source['disc_number'];
final totalDiscs = source['total_discs'];
if (discNumber != null && discNumber.toString() != '0') {
final discTag =
totalDiscs != null &&
totalDiscs.toString().isNotEmpty &&
totalDiscs.toString() != '0'
? '${discNumber.toString()}/${totalDiscs.toString()}'
: discNumber;
put('DISCNUMBER', discTag);
}
return mapped;
}
String? _extractLossyBitrateLabel(String? quality) {
if (quality == null || quality.isEmpty) return null;
final match = RegExp(
+3 -1
View File
@@ -528,7 +528,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
try {
final result = await PlatformBridge.readFileMetadata(workingPath);
if (result['error'] == null) {
final mapped = _mapMetadataForTagEmbed(result);
// Map separately so a non-explicit file keeps the fallback advisory.
final mapped = <String, String>{};
mergePlatformMetadataForTagEmbed(target: mapped, source: result);
metadata.addAll(mapped);
}
} catch (e) {
+153
View File
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -15,6 +16,158 @@ void main() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
for (final fallbackExplicit in [false, true]) {
for (final fileExplicit in <bool?>[true, false, null]) {
testWidgets(
'lyrics embed preserves tags with fallback explicit=$fallbackExplicit '
'and file explicit=$fileExplicit',
(tester) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1000, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const path = 'content://library/document/song.opus';
final workingPath = '${Directory.systemTemp.path}/lyrics-test.opus';
const lyrics = '[00:01.00]Fresh online lyrics';
List<String>? embedArguments;
const ffmpeg = MethodChannel('flutter.arthenica.com/ffmpeg_kit');
const events = MethodChannel(
'flutter.arthenica.com/ffmpeg_kit_event',
);
const paths = MethodChannel('plugins.flutter.io/path_provider');
messenger.setMockMethodCallHandler(paths, (_) async {
return Directory.systemTemp.path;
});
messenger.setMockMethodCallHandler(events, (_) async => null);
messenger.setMockMethodCallHandler(ffmpeg, (call) async {
if (call.method == 'ffmpegSession') {
embedArguments = List<String>.from(
(call.arguments as Map)['arguments'] as List,
);
// Inspect the real embed command without writing an audio file.
throw PlatformException(code: 'captured_embed');
}
return null;
});
messenger.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'safStat':
return jsonEncode({'exists': true, 'size': 100});
case 'readAudioMetadata':
case 'getSafFileModTimes':
return '{}';
case 'safCopyToTemp':
return workingPath;
case 'extractCoverToFile':
return jsonEncode({'error': 'No cover'});
case 'getLyricsLRCWithSource':
final online = (call.arguments as Map)['file_path'] == '';
return jsonEncode({
'lyrics': online ? lyrics : '',
'source': online ? 'Example Lyrics' : '',
});
case 'readFileMetadata':
if ((call.arguments as Map)['file_path'] != workingPath) {
return '{}';
}
return jsonEncode({
'title': ' File title ',
'artist': ' First Artist; Second Artist ',
'album': ' ',
'album_artist': ' Album Artist ',
'date': ' 2026-01-02 ',
'isrc': ' EXAMPLE12345 ',
'genre': ' Genre ',
'label': ' Label ',
'copyright': ' Copyright ',
'composer': ' Composer ',
'comment': ' Comment ',
'lyrics': 'Old embedded lyrics',
'track_number': 2,
'total_tracks': 12,
'disc_number': 1,
'total_discs': 3,
'explicit': fileExplicit,
});
default:
return null;
}
});
addTearDown(() {
for (final mocked in [channel, ffmpeg, events, paths]) {
messenger.setMockMethodCallHandler(mocked, null);
}
});
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: TrackMetadataScreen(
item: DownloadHistoryItem(
id: 'lyrics-song',
trackName: 'Fallback title',
artistName: 'Fallback artist',
albumName: 'Fallback album',
filePath: path,
service: 'example-provider',
downloadedAt: DateTime(2026),
format: 'opus',
explicit: fallbackExplicit,
),
),
),
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Fetch from Online'));
await tester.tap(find.text('Fetch from Online'));
await tester.pumpAndSettle();
expect(find.text('Fresh online lyrics'), findsOneWidget);
await tester.ensureVisible(find.text('Embed Lyrics'));
await tester.tap(find.text('Embed Lyrics'));
// Allow real filesystem cleanup after the mocked native boundary.
await tester.runAsync(() async {
for (var i = 0; i < 100 && embedArguments == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
await Future<void>.delayed(const Duration(milliseconds: 50));
});
await tester.pumpAndSettle();
expect(embedArguments, isNotNull);
final tags = <String, String>{};
for (var i = 0; i < embedArguments!.length - 1; i++) {
if (embedArguments![i] != '-metadata') continue;
final tag = embedArguments![i + 1];
final separator = tag.indexOf('=');
tags[tag.substring(0, separator)] = tag.substring(separator + 1);
}
expect(tags, {
'TITLE': 'File title',
'ARTIST': 'First Artist; Second Artist',
'ALBUM': 'Fallback album',
'ALBUMARTIST': 'Album Artist',
'DATE': '2026-01-02',
'YEAR': '2026',
'ISRC': 'EXAMPLE12345',
'GENRE': 'Genre',
'ORGANIZATION': 'Label',
'COPYRIGHT': 'Copyright',
'COMPOSER': 'Composer',
'COMMENT': 'Comment',
'TRACKNUMBER': '2/12',
'DISCNUMBER': '1/3',
'LYRICS': lyrics,
'UNSYNCEDLYRICS': lyrics,
if (fallbackExplicit || fileExplicit == true) 'ITUNESADVISORY': '1',
});
expect(tester.takeException(), isNull);
},
);
}
}
for (final scenario in [
'fallback',
'local',