diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index b5328179..5126ffb3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -380,7 +380,7 @@ internal object NativeFinalizationPolicy { private val primaryArtistSeparator = Regex( "$PRIMARY_ARTIST_SPACE*[,;&]$PRIMARY_ARTIST_SPACE*" + "|$PRIMARY_ARTIST_SPACE+x$PRIMARY_ARTIST_SPACE+" + - "|$PRIMARY_ARTIST_SPACE+(?:feat(?:uring)?|ft|with)\\.?" + + "|$PRIMARY_ARTIST_SPACE+(?:feat(?:uring|ured|ure)?|ft|with)\\.?" + "(?:$PRIMARY_ARTIST_SPACE+|$)", RegexOption.IGNORE_CASE, ) diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index 9140ab30..b68c3215 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -18,6 +18,8 @@ class NativeFinalizationPolicyTest { "Artist A Feat Artist B" to "Artist A", "Artist A ft. Artist B" to "Artist A", "Artist A featuring Artist B" to "Artist A", + "Artist A featured Artist B" to "Artist A", + "Artist A feature Artist B" to "Artist A", "Artist A with Artist B" to "Artist A", "Artist A x Artist B" to "Artist A", "Artist A X Artist B" to "Artist A", diff --git a/lib/utils/artist_utils.dart b/lib/utils/artist_utils.dart index a3091a26..48e7c94a 100644 --- a/lib/utils/artist_utils.dart +++ b/lib/utils/artist_utils.dart @@ -1,5 +1,5 @@ final RegExp _artistNameSplitPattern = RegExp( - r'\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?(?=\s|$)\s*', + r'\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?=\s|$)\s*', caseSensitive: false, ); @@ -17,20 +17,33 @@ const artistTagModes = { // 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+|$)', + r'\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)', caseSensitive: false, ); final RegExp _metadataKeyPunctuation = RegExp(r'[^A-Z0-9]'); -List splitArtistNames(String rawArtists) { +List splitArtistNames(String rawArtists, {int? creditedArtistCount}) { final raw = rawArtists.trim(); if (raw.isEmpty) return const []; - return raw + final names = raw .split(_artistNameSplitPattern) .map((part) => part.trim()) .where((part) => part.isNotEmpty) .toList(growable: false); + // "and" can be part of a band's name. Only treat it as a separator when + // the provider's separate artist IDs confirm that many credited artists. + if (creditedArtistCount != null && creditedArtistCount > names.length) { + final expanded = names + .expand( + (name) => name.split(RegExp(r'\s+and\s+', caseSensitive: false)), + ) + .map((name) => name.trim()) + .where((name) => name.isNotEmpty) + .toList(growable: false); + if (expanded.length == creditedArtistCount) return expanded; + } + return names; } String primaryArtistName(String artists, {String? albumArtist}) { diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 6df11dc4..0d30dad0 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; final _log = AppLogger('ClickableMetadata'); @@ -126,6 +127,42 @@ bool _canSearchMetadataProvider( ); } +Future navigateToArtistCredits( + BuildContext context, { + required String artistNames, + String? artistIds, + String? extensionId, +}) async { + final artists = _buildArtistTapTargets(artistNames, artistIds); + if (artists.isEmpty) return; + final selected = artists.length == 1 + ? artists.single + : await showAppBottomSheet<_ArtistTapTarget>( + context: context, + useRootNavigator: true, + title: context.l10n.mornyeGoToArtist, + maxHeightFactor: 0.6, + builder: (context) => ListView( + shrinkWrap: true, + children: [ + for (final artist in artists) + AppSheetOption( + leading: const Icon(Icons.person_outline), + title: Text(artist.name), + onTap: () => Navigator.pop(context, artist), + ), + ], + ), + ); + if (!context.mounted || selected == null) return; + await navigateToArtist( + context, + artistName: selected.name, + artistId: selected.artistId, + extensionId: extensionId, + ); +} + Future navigateToArtist( BuildContext context, { required String artistName, @@ -636,7 +673,11 @@ List<_ArtistTapTarget> _buildArtistTapTargets( String rawArtistNames, String? rawArtistIds, ) { - final parsedNames = splitArtistNames(rawArtistNames); + final parsedIds = _parseArtistIds(rawArtistIds); + final parsedNames = splitArtistNames( + rawArtistNames, + creditedArtistCount: parsedIds.length, + ); if (parsedNames.isEmpty) return const []; final uniqueNames = []; @@ -657,7 +698,6 @@ List<_ArtistTapTarget> _buildArtistTapTargets( ]; } - final parsedIds = _parseArtistIds(rawArtistIds); if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) { return uniqueNames .map((name) => _ArtistTapTarget(name: name)) diff --git a/rust_backend/crates/core/src/tags/write.rs b/rust_backend/crates/core/src/tags/write.rs index 34f6c496..9d8bf888 100644 --- a/rust_backend/crates/core/src/tags/write.rs +++ b/rust_backend/crates/core/src/tags/write.rs @@ -655,10 +655,11 @@ fn primary_artist_mode(mode: &str) -> bool { /// The first credited artist, or the trimmed value when nothing separates it. /// Dart and Kotlin share this exact separator set so every writer agrees; -/// Go-parity split mode keeps its own `split_artists` pattern. +/// Vorbis split mode also recognizes these featured-credit variants. fn primary_artist(value: &str) -> String { static SEPARATOR: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring)?|ft|with)\.?(?:\s+|$)").unwrap() + Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)") + .unwrap() }); let value = value.trim(); SEPARATOR @@ -691,7 +692,10 @@ fn artist_mode_fields(fields: &Fields) -> Cow<'_, Fields> { fn split_artists(value: &str) -> Vec { static SPLIT: LazyLock = LazyLock::new(|| { - Regex::new(r"(?-u:\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*)").unwrap() + Regex::new( + r"(?i)\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)", + ) + .unwrap() }); let value = value.trim(); if value.is_empty() { @@ -829,6 +833,23 @@ mod tests { } /// Shared with the Dart and Kotlin primary-artist tests; keep in sync. + #[test] + fn split_artist_credits_recognize_variants_without_splitting_band_words() { + assert_eq!( + split_artists("One Featured Two; Three"), + vec!["One", "Two", "Three"] + ); + assert_eq!(split_artists("One feature Two"), vec!["One", "Two"]); + assert_eq!( + split_artists("Florence and the Machine"), + vec!["Florence and the Machine"] + ); + assert_eq!( + split_artists("Artist Without Fear"), + vec!["Artist Without Fear"] + ); + } + #[test] fn primary_artist_uses_the_shared_separator_set() { for (input, expected) in [ @@ -839,6 +860,8 @@ mod tests { ("Artist A Feat Artist B", "Artist A"), ("Artist A ft. Artist B", "Artist A"), ("Artist A featuring Artist B", "Artist A"), + ("Artist A featured Artist B", "Artist A"), + ("Artist A feature 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"), diff --git a/test/artist_utils_test.dart b/test/artist_utils_test.dart index fdb60e63..51aa4445 100644 --- a/test/artist_utils_test.dart +++ b/test/artist_utils_test.dart @@ -2,6 +2,23 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; void main() { + test('featured variants and confirmed conjunction credits are clickable', () { + expect(splitArtistNames('One featured Two'), ['One', 'Two']); + expect(splitArtistNames('One feature Two'), ['One', 'Two']); + expect(primaryArtistTagValue('One featured Two'), 'One'); + expect(primaryArtistTagValue('One feature Two'), 'One'); + expect(splitArtistNames('One and Two', creditedArtistCount: 2), [ + 'One', + 'Two', + ]); + expect(splitArtistNames('Florence and the Machine'), [ + 'Florence and the Machine', + ]); + expect( + splitArtistNames('Florence and the Machine', creditedArtistCount: 1), + ['Florence and the Machine'], + ); + }); test('primary artist prefers the first album artist', () { expect( primaryArtistName(