feat(metadata): add a primary artist tag mode

Artist Tag Mode gains "Primary artist only": downloads and re-enrichment write only the first credited artist to Artist and Album Artist, so players list one artist. The FFmpeg MP3, M4A and Vorbis embeds and the AC-4 path now honor the mode; MP3 and M4A previously ignored it.

Manual metadata edits, lyrics embedding and format conversion keep existing or typed credits joined instead of dropping names. The picker scrolls so three options fit on small and landscape screens. Closes #618.
This commit is contained in:
zarzet committed 2026-10-01 16:12:58 +07:00
1 parent 41340a4a21
commit a549671c4e
16 files changed
+358 -59

No files matched your search

+79
View File
@@ -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');
});
}
}
+15
View File
@@ -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;
+70
View File
@@ -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', () {