diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index d8f87804..4e7bc975 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -104,58 +104,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { }; } - Map _mapMetadataForTagEmbed(Map source) { - final mapped = {}; - - 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( diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index b484ad7d..5accd1d6 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -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 = {}; + mergePlatformMetadataForTagEmbed(target: mapped, source: result); metadata.addAll(mapped); } } catch (e) { diff --git a/test/track_metadata_lyrics_test.dart b/test/track_metadata_lyrics_test.dart index 6a99de7b..29d92ea8 100644 --- a/test/track_metadata_lyrics_test.dart +++ b/test/track_metadata_lyrics_test.dart @@ -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 [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? 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.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.delayed(const Duration(milliseconds: 10)); + } + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pumpAndSettle(); + expect(embedArguments, isNotNull); + final tags = {}; + 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',