mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(metadata): recognize featured artist credits consistently
Handle feature and featured separators across display, Android finalization and Rust tag writing. Split conjunction credits only when separate artist IDs confirm them, and offer a picker for multi-artist navigation.
This commit is contained in:
1 parent
9ea2647c39
commit
4eef5542be
6 files changed
+105
-10
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String> splitArtistNames(String rawArtists) {
|
||||
List<String> 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}) {
|
||||
|
||||
@@ -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<void> 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<void> 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 = <String>[];
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Regex> = 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<String> {
|
||||
static SPLIT: LazyLock<Regex> = 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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user