diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8257c8b1..410ccf8f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -403,6 +403,14 @@ "@optionsArtistTagModeSplitVorbisSubtitle": { "description": "Subtitle for split Vorbis artist tag mode" }, + "optionsArtistTagModePrimary": "Primary artist only", + "@optionsArtistTagModePrimary": { + "description": "Artist tag mode option that writes only the first credited artist" + }, + "optionsArtistTagModePrimarySubtitle": "Write only the first artist, like \"Artist A\", to Artist and Album Artist in every format, so players list one artist.", + "@optionsArtistTagModePrimarySubtitle": { + "description": "Subtitle for the primary-artist tag mode. Applies when downloading and re-enriching; manual edits keep what the user typed." + }, "optionsExtensionStore": "Extension Repo", "@optionsExtensionStore": { "description": "Show/hide repo tab" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 5e11b515..42d874c4 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -360,6 +360,8 @@ "@optionsArtistTagModeSplitVorbisSubtitle": { "description": "Subtitle for split Vorbis artist tag mode" }, + "optionsArtistTagModePrimary": "Artis utama saja", + "optionsArtistTagModePrimarySubtitle": "Tulis hanya artis pertama, seperti \"Artis A\", ke Artis dan Artis Album di semua format, agar pemutar menampilkan satu artis.", "optionsExtensionStore": "Repo Ekstensi", "@optionsExtensionStore": { "description": "Show/hide repo tab" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 732b767a..208397d8 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -26,7 +26,7 @@ class AppSettings { /// Zero preserves the provider's original image. final int embeddedCoverMaxDimension; final String - artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats + artistTagMode; // 'joined', 'split_vorbis' (Vorbis formats), or 'primary' final bool embedLyrics; final bool embedReplayGain; // Apply ReplayGain/R128 tags as volume normalization in the built-in player. diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index da45e31f..5963f9d9 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -639,11 +639,19 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { // no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg. if (isM4a) { try { + // The AC-4 writer takes no artist mode; resolve it here. final ac4Meta = { 'title': track.name, - 'artist': track.artistName, + 'artist': artistTagValueForMode( + track.artistName, + settings.artistTagMode, + ), 'album': track.albumName, - 'albumArtist': ?albumArtist, + if (albumArtist != null) + 'albumArtist': artistTagValueForMode( + albumArtist, + settings.artistTagMode, + ), if (track.releaseDate != null) 'date': track.releaseDate!, if (resolvedGenre != null && resolvedGenre.isNotEmpty) 'genre': resolvedGenre, @@ -761,12 +769,14 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { m4aPath: filePath, coverPath: validCover, metadata: metadata, + artistTagMode: settings.artistTagMode, ); } else if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( mp3Path: filePath, coverPath: validCover, metadata: metadata, + artistTagMode: settings.artistTagMode, ); } else { ffmpegResult = await FFmpegService.embedMetadataToOpus( diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 088fdc27..14f1b35c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -607,7 +607,7 @@ class SettingsNotifier extends Notifier { } void setArtistTagMode(String mode) { - if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) { + if (artistTagModes.contains(mode)) { state = state.copyWith(artistTagMode: mode); _saveSettings(); } diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 90cd522f..daf23db4 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -155,6 +155,8 @@ class MetadataSettingsPage extends ConsumerWidget { switch (mode) { case artistTagModeSplitVorbis: return context.l10n.optionsArtistTagModeSplitVorbis; + case artistTagModePrimary: + return context.l10n.optionsArtistTagModePrimary; default: return context.l10n.optionsArtistTagModeJoined; } @@ -240,62 +242,83 @@ class MetadataSettingsPage extends ConsumerWidget { showAppModalBottomSheet( context: context, useRootNavigator: true, + // Three described options can exceed the default sheet height on + // small or landscape screens, as the cover-size picker above does. + isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerHigh, builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.optionsArtistTagMode, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.optionsArtistTagModeDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + context.l10n.optionsArtistTagMode, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ), - AppSheetOption( - leading: const Icon(Icons.segment_outlined), - title: Text(context.l10n.optionsArtistTagModeJoined), - subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), - trailing: currentMode == artistTagModeJoined - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setArtistTagMode(artistTagModeJoined); - Navigator.pop(context); - }, - ), - AppSheetOption( - leading: const Icon(Icons.library_music_outlined), - title: Text(context.l10n.optionsArtistTagModeSplitVorbis), - subtitle: Text( - context.l10n.optionsArtistTagModeSplitVorbisSubtitle, + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + context.l10n.optionsArtistTagModeDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ), - trailing: currentMode == artistTagModeSplitVorbis - ? const Icon(Icons.check) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setArtistTagMode(artistTagModeSplitVorbis); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], + AppSheetOption( + leading: const Icon(Icons.segment_outlined), + title: Text(context.l10n.optionsArtistTagModeJoined), + subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle), + trailing: currentMode == artistTagModeJoined + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeJoined); + Navigator.pop(context); + }, + ), + AppSheetOption( + leading: const Icon(Icons.library_music_outlined), + title: Text(context.l10n.optionsArtistTagModeSplitVorbis), + subtitle: Text( + context.l10n.optionsArtistTagModeSplitVorbisSubtitle, + ), + trailing: currentMode == artistTagModeSplitVorbis + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModeSplitVorbis); + Navigator.pop(context); + }, + ), + AppSheetOption( + leading: const Icon(Icons.person_outline), + title: Text(context.l10n.optionsArtistTagModePrimary), + subtitle: Text( + context.l10n.optionsArtistTagModePrimarySubtitle, + ), + trailing: currentMode == artistTagModePrimary + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setArtistTagMode(artistTagModePrimary); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), ), ), ); diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 5462acc1..2cf8ca96 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -275,7 +275,11 @@ class SettingsSearchCatalog { SettingsSearchEntry( icon: Icons.people_alt_outlined, title: l10n.optionsArtistTagMode, - keywords: const ['artist separator', 'multiple artists'], + keywords: const [ + 'artist separator', + 'multiple artists', + 'primary artist', + ], ), SettingsSearchEntry( icon: Icons.photo_size_select_large_outlined, diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 40f322e0..734aeca9 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -69,7 +69,10 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { durationMs: initialDurationSeconds > 0 ? initialDurationSeconds * 1000 : 0, - artistTagMode: ref.read(settingsProvider).artistTagMode, + // Typed artist names are saved as entered, even in primary mode. + artistTagMode: artistTagModeForExistingTags( + ref.read(settingsProvider).artistTagMode, + ), ), ); diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index e0ee3071..49e996df 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -546,7 +546,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { } } catch (_) {} - final artistTagMode = ref.read(settingsProvider).artistTagMode; + // Embedding lyrics rewrites the tags read above; it must not drop + // existing artist credits, so the primary mode keeps them joined. + final artistTagMode = artistTagModeForExistingTags( + ref.read(settingsProvider).artistTagMode, + ); String? ffmpegResult; if (isMp3) { ffmpegResult = await FFmpegService.embedMetadataToMp3( @@ -1110,12 +1114,14 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { mp3Path: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) { ffmpegResult = await FFmpegService.embedMetadataToM4a( m4aPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { ffmpegResult = await FFmpegService.embedMetadataToOpus( diff --git a/lib/services/audio_metadata_mapper.dart b/lib/services/audio_metadata_mapper.dart index c97f9544..1a1ec3e2 100644 --- a/lib/services/audio_metadata_mapper.dart +++ b/lib/services/audio_metadata_mapper.dart @@ -406,7 +406,7 @@ class AudioMetadataMapper { } if (!shouldSplitVorbisArtistTags(artistTagMode)) { - entries.add(MapEntry(key, value)); + entries.add(MapEntry(key, artistTagValueForMode(value, artistTagMode))); return; } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 9eb6a3a4..f0994c31 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1343,8 +1343,13 @@ class FFmpegService { required String mp3Path, String? coverPath, Map? metadata, + String artistTagMode = artistTagModeJoined, bool preserveMetadata = false, }) async { + // ID3 keeps one artist frame: only the primary mode changes its value. + if (metadata != null) { + metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); + } final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata); @@ -1614,8 +1619,13 @@ class FFmpegService { required String m4aPath, String? coverPath, Map? metadata, + String artistTagMode = artistTagModeJoined, bool preserveMetadata = true, }) async { + // MP4 keeps one artist atom: only the primary mode changes its value. + if (metadata != null) { + metadata = applyArtistTagModeToMetadata(metadata, artistTagMode); + } final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a'); @@ -1866,6 +1876,10 @@ class FFmpegService { _log.e('Unsupported target format: $targetFormat'); return null; } + // Conversion carries existing tags over. Primary mode applies to provider + // credits (download, re-enrichment) and must not drop names here; the + // download auto-conversion embeds its credits after this step. + artistTagMode = artistTagModeForExistingTags(artistTagMode); final resolvedLosslessQuality = isLosslessConversionTarget(format) ? await _resolveLosslessQuality( @@ -1947,12 +1961,14 @@ class FFmpegService { mp3Path: outputPath, coverPath: coverPath, metadata: metadata, + artistTagMode: artistTagMode, ); } else if (format == 'aac') { embedResult = await embedMetadataToM4a( m4aPath: outputPath, coverPath: coverPath, metadata: metadata, + artistTagMode: artistTagMode, preserveMetadata: true, ); } else { diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart index 7ff0da81..a3091a26 100644 --- a/lib/utils/artist_utils.dart +++ b/lib/utils/artist_utils.dart @@ -5,6 +5,22 @@ final RegExp _artistNameSplitPattern = RegExp( const artistTagModeJoined = 'joined'; const artistTagModeSplitVorbis = 'split_vorbis'; +const artistTagModePrimary = 'primary'; + +const artistTagModes = { + artistTagModeJoined, + artistTagModeSplitVorbis, + artistTagModePrimary, +}; + +// Same separator set as the Rust tag writer and the Android finalizer, so +// every write path stores the same primary artist. Unlike the display split +// above, a standalone "x" needs spaces on both sides ("Malcolm X" stays). +final RegExp _primaryArtistSeparator = RegExp( + r'\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring)?|ft|with)\.?(?:\s+|$)', + caseSensitive: false, +); +final RegExp _metadataKeyPunctuation = RegExp(r'[^A-Z0-9]'); List splitArtistNames(String rawArtists) { final raw = rawArtists.trim(); @@ -37,6 +53,51 @@ bool shouldSplitVorbisArtistTags(String mode) { return mode == artistTagModeSplitVorbis; } +bool isPrimaryArtistTagMode(String mode) => + mode.trim().toLowerCase() == artistTagModePrimary; + +/// The first credited artist, or the trimmed value when nothing separates it. +String primaryArtistTagValue(String rawArtists) { + final trimmed = rawArtists.trim(); + for (final part in trimmed.split(_primaryArtistSeparator)) { + final artist = part.trim(); + if (artist.isNotEmpty) return artist; + } + return trimmed; +} + +/// Mode for rewriting tags that already exist or that the user typed (editor, +/// lyrics embedding). Primary is meant for provider credits at download and +/// re-enrichment time and would silently discard names here. +String artistTagModeForExistingTags(String mode) => + isPrimaryArtistTagMode(mode) ? artistTagModeJoined : mode; + +/// Artist tag value written for [mode]. Joined and split modes keep the full +/// credit; Vorbis splitting happens in the writers. +String artistTagValueForMode(String value, String mode) => + isPrimaryArtistTagMode(mode) ? primaryArtistTagValue(value) : value; + +/// Rewrites artist and album-artist entries of an embed metadata map for +/// [mode], whichever key spelling the caller used (ARTIST, album_artist, +/// "ALBUM ARTIST", albumArtist). Other entries and empty values are kept. +Map applyArtistTagModeToMetadata( + Map metadata, + String mode, +) { + if (!isPrimaryArtistTagMode(mode)) return metadata; + return { + for (final entry in metadata.entries) + entry.key: switch (entry.key.toUpperCase().replaceAll( + _metadataKeyPunctuation, + '', + )) { + 'ARTIST' || 'ALBUMARTIST' when entry.value.trim().isNotEmpty => + primaryArtistTagValue(entry.value), + _ => entry.value, + }, + }; +} + List splitArtistTagValues(String rawArtists) { final seen = {}; final values = []; diff --git a/lib/utils/ffmpeg_reenrich.dart b/lib/utils/ffmpeg_reenrich.dart index 7b5a69cf..c480bbca 100644 --- a/lib/utils/ffmpeg_reenrich.dart +++ b/lib/utils/ffmpeg_reenrich.dart @@ -97,6 +97,7 @@ Future applyFfmpegReEnrichResult({ mp3Path: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, preserveMetadata: true, ); } else if (isM4A) { @@ -104,6 +105,7 @@ Future applyFfmpegReEnrichResult({ m4aPath: ffmpegTarget, coverPath: effectiveCoverPath, metadata: metadata, + artistTagMode: artistTagMode, preserveMetadata: true, ); } else if (isOpus) { diff --git a/test/artist_tag_mode_settings_test.dart b/test/artist_tag_mode_settings_test.dart new file mode 100644 index 00000000..8da02e18 --- /dev/null +++ b/test/artist_tag_mode_settings_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/settings/metadata_settings_page.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; + +/// Skips loading persisted settings; mode changes use the real validation. +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('primary artist mode survives settings round trips', () { + final settings = const AppSettings().copyWith( + artistTagMode: artistTagModePrimary, + ); + expect( + AppSettings.fromJson(settings.toJson()).artistTagMode, + artistTagModePrimary, + ); + }); + + for (final size in [const Size(390, 844), const Size(844, 390)]) { + testWidgets('picker offers and saves the primary artist mode ($size)', ( + tester, + ) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer( + overrides: [settingsProvider.overrideWith(_Settings.new)], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: MetadataSettingsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + // The row subtitle names the current mode before the picker opens. + expect(find.text('Single joined value'), findsOneWidget); + await tester.tap(find.text('Artist Tag Mode')); + await tester.pumpAndSettle(); + Finder inSheet(String text) => find.descendant( + of: find.byType(BottomSheet), + matching: find.text(text), + ); + expect(inSheet('Single joined value'), findsOneWidget); + expect(inSheet('Split tags for FLAC/Opus'), findsOneWidget); + final primary = inSheet('Primary artist only'); + await tester.ensureVisible(primary); + await tester.pumpAndSettle(); + await tester.tap(primary); + await tester.pumpAndSettle(); + + expect(container.read(settingsProvider).artistTagMode, 'primary'); + expect(find.byType(BottomSheet), findsNothing); + // The settings row now names the selected mode. + expect(find.text('Primary artist only'), findsOneWidget); + expect(tester.takeException(), isNull); + + container.read(settingsProvider.notifier).setArtistTagMode('unknown'); + expect(container.read(settingsProvider).artistTagMode, 'primary'); + }); + } +} diff --git a/test/maintainability_contracts_test.dart b/test/maintainability_contracts_test.dart index 07d74209..3b6d7186 100644 --- a/test/maintainability_contracts_test.dart +++ b/test/maintainability_contracts_test.dart @@ -305,6 +305,21 @@ void main() { entries.where((entry) => entry.key == 'TITLE').single.value, 'Track', ); + final primary = AudioMetadataMapper.buildVorbisMetadataEntries({ + 'ARTIST': 'First & Second', + 'ALBUMARTIST': 'First, Second', + 'TITLE': 'Track', + }, artistTagMode: artistTagModePrimary); + expect( + primary.where((entry) => entry.key == 'ARTIST').map((e) => e.value), + ['First'], + ); + expect( + primary + .where((entry) => entry.key == 'ALBUMARTIST') + .map((e) => e.value), + ['First'], + ); final advisory = AudioMetadataMapper.buildVorbisMetadataEntries({ 'EXPLICIT': 'true', }).single; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 0c7a6f1e..0c191037 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -1355,6 +1355,76 @@ void main() { expect(shouldSplitVorbisArtistTags(artistTagModeSplitVorbis), isTrue); expect(shouldSplitVorbisArtistTags(artistTagModeJoined), isFalse); }); + + // Shared with the Rust and Kotlin primary-artist tests; keep in sync. + test('primary artist mode keeps the first credited artist', () { + for (final (input, expected) in [ + ('Calle 24, Chino Pacas', 'Calle 24'), + ('Calle 24 & Chino Pacas', 'Calle 24'), + ('Artist A; Artist B', 'Artist A'), + ('Artist A feat. Artist B', 'Artist A'), + ('Artist A Feat Artist B', 'Artist A'), + ('Artist A ft. Artist B', 'Artist A'), + ('Artist A featuring Artist B', 'Artist A'), + ('Artist A with Artist B', 'Artist A'), + ('Artist A x Artist B', 'Artist A'), + ('Artist A X Artist B', 'Artist A'), + (' , Artist A, Artist B', 'Artist A'), + ('Malcolm X', 'Malcolm X'), + ('Artist Without Fear', 'Artist Without Fear'), + ('Maxx', 'Maxx'), + ('AC/DC', 'AC/DC'), + (' Various Artists ', 'Various Artists'), + ('', ''), + ]) { + expect(primaryArtistTagValue(input), expected, reason: input); + expect( + artistTagValueForMode(input, ' Primary '), + expected, + reason: input, + ); + } + for (final mode in [artistTagModeJoined, artistTagModeSplitVorbis]) { + expect(artistTagValueForMode('A, B', mode), 'A, B'); + } + expect(artistTagModes, contains(artistTagModePrimary)); + expect( + artistTagModeForExistingTags(artistTagModePrimary), + artistTagModeJoined, + ); + expect( + artistTagModeForExistingTags(artistTagModeSplitVorbis), + artistTagModeSplitVorbis, + ); + }); + + test('primary artist mode rewrites every artist key spelling', () { + final metadata = { + 'ARTIST': 'A, B', + 'album_artist': 'A & B', + 'ALBUM ARTIST': 'A feat. B', + 'albumArtist': 'A x B', + 'TITLE': 'A, B', + 'COMPOSER': 'A, B', + 'artist': '', + }; + expect(applyArtistTagModeToMetadata(metadata, artistTagModePrimary), { + 'ARTIST': 'A', + 'album_artist': 'A', + 'ALBUM ARTIST': 'A', + 'albumArtist': 'A', + 'TITLE': 'A, B', + 'COMPOSER': 'A, B', + 'artist': '', + }); + expect( + identical( + applyArtistTagModeToMetadata(metadata, artistTagModeJoined), + metadata, + ), + isTrue, + ); + }); }); group('audio conversion utils', () {