mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 13:22:49 +02:00
fix(metadata): match by primary artist and review results #511
This commit is contained in:
@@ -18,6 +18,134 @@ class _AutoFillPreview {
|
||||
});
|
||||
}
|
||||
|
||||
class _MetadataCandidateArtwork extends StatefulWidget {
|
||||
final String? coverUrl;
|
||||
|
||||
const _MetadataCandidateArtwork({required this.coverUrl});
|
||||
|
||||
@override
|
||||
State<_MetadataCandidateArtwork> createState() =>
|
||||
_MetadataCandidateArtworkState();
|
||||
}
|
||||
|
||||
class _MetadataCandidateArtworkState extends State<_MetadataCandidateArtwork> {
|
||||
String? _tempDir;
|
||||
String? _coverPath;
|
||||
({int width, int height, int bytes})? _details;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.coverUrl?.isNotEmpty == true) {
|
||||
unawaited(_loadMaxQualityCover());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMaxQualityCover() async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'metadata_candidate_cover_',
|
||||
);
|
||||
final path = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
try {
|
||||
final result = await PlatformBridge.downloadCoverToFile(
|
||||
widget.coverUrl!,
|
||||
path,
|
||||
maxQuality: true,
|
||||
);
|
||||
if (result['error'] != null || !await File(path).exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
return;
|
||||
}
|
||||
final dimensions = await FFmpegService.probeImageDimensions(path);
|
||||
final bytes = await File(path).length();
|
||||
if (!mounted) {
|
||||
await tempDir.delete(recursive: true);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_tempDir = tempDir.path;
|
||||
_coverPath = path;
|
||||
if (dimensions != null) {
|
||||
_details = (
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
bytes: bytes,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final tempDir = _tempDir;
|
||||
if (tempDir != null) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
final directory = Directory(tempDir);
|
||||
if (await directory.exists()) await directory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}());
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final coverUrl = widget.coverUrl;
|
||||
final details = _details;
|
||||
final image = _coverPath != null
|
||||
? Image.file(
|
||||
File(_coverPath!),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: coverUrl,
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.music_note),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(borderRadius: BorderRadius.circular(10), child: image),
|
||||
if (details != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${details.width}×${details.height} · '
|
||||
'${(details.bytes / 1024).toStringAsFixed(1)} KB',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 9,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditMetadataSheet extends StatefulWidget {
|
||||
static const _onlineCoverSentinel = '__online_cover__';
|
||||
final ColorScheme colorScheme;
|
||||
@@ -1078,6 +1206,119 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String? _metadataCandidateCoverUrl(Map<String, dynamic> track) {
|
||||
String? fromValue(Object? value) {
|
||||
if (value is String) {
|
||||
final normalized = value.trim();
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final resolved = fromValue(item);
|
||||
if (resolved != null) return resolved;
|
||||
}
|
||||
}
|
||||
if (value is Map) {
|
||||
for (final key in const ['url', 'original', 'large', 'src']) {
|
||||
final resolved = fromValue(value[key]);
|
||||
if (resolved != null) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromValue(track['cover_url']) ?? fromValue(track['images']);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _showAutoFillCandidatePicker({
|
||||
required List<Map<String, dynamic>> candidates,
|
||||
required String sourceName,
|
||||
required String currentTitle,
|
||||
required String currentArtist,
|
||||
}) {
|
||||
return showAppBottomSheet<Map<String, dynamic>>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
title: context.l10n.editMetadataAutoFillSource,
|
||||
subtitle: [
|
||||
currentTitle,
|
||||
currentArtist,
|
||||
sourceName,
|
||||
].where((value) => value.trim().isNotEmpty).join(' · '),
|
||||
maxHeightFactor: 0.82,
|
||||
builder: (sheetContext) => ListView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
children: [
|
||||
SettingsGroup(
|
||||
children: [
|
||||
for (var index = 0; index < candidates.length; index++) ...[
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final candidate = candidates[index];
|
||||
final title =
|
||||
(candidate['name'] ?? candidate['title'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final artists =
|
||||
(candidate['artists'] ?? candidate['artist'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final album =
|
||||
(candidate['album_name'] ?? candidate['album'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final date =
|
||||
candidate['release_date']?.toString().trim() ?? '';
|
||||
final isrc = candidate['isrc']?.toString().trim() ?? '';
|
||||
final coverUrl = _metadataCandidateCoverUrl(candidate);
|
||||
final subtitle = [
|
||||
artists,
|
||||
album,
|
||||
if (date.isNotEmpty) date,
|
||||
if (isrc.isNotEmpty) 'ISRC $isrc',
|
||||
].where((value) => value.isNotEmpty).join('\n');
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
leading: _MetadataCandidateArtwork(coverUrl: coverUrl),
|
||||
title: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subtitle.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
subtitle,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.pop(sheetContext, candidate),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (index != candidates.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 88,
|
||||
endIndent: 16,
|
||||
color: Theme.of(
|
||||
sheetContext,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _fetchAutoFillPreview() async {
|
||||
if (_autoFillFields.isEmpty) {
|
||||
_showSheetSnackBar(context.l10n.editMetadataAutoFillNoneSelected);
|
||||
@@ -1097,6 +1338,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final title = _titleCtrl.text.trim();
|
||||
final artist = _artistCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
final albumArtist = _albumArtistCtrl.text.trim();
|
||||
final searchArtist = primaryArtistName(artist, albumArtist: albumArtist);
|
||||
final currentIsrc = _isrcCtrl.text.trim().toUpperCase();
|
||||
final configuredProviderId = _selectedMetadataProviderId?.trim();
|
||||
final extensionState = ProviderScope.containerOf(
|
||||
@@ -1124,7 +1367,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
|
||||
final queryParts = <String>[];
|
||||
if (title.isNotEmpty) queryParts.add(title);
|
||||
if (artist.isNotEmpty) queryParts.add(artist);
|
||||
if (searchArtist.isNotEmpty) queryParts.add(searchArtist);
|
||||
if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album);
|
||||
|
||||
if (needsTrackLookup && queryParts.isEmpty) {
|
||||
@@ -1135,7 +1378,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
|
||||
final normalizedTitle = _normalizeMetadataText(title);
|
||||
final normalizedArtist = _normalizeMetadataText(artist);
|
||||
final normalizedArtist = _normalizeMetadataText(searchArtist);
|
||||
final normalizedAlbum = _normalizeMetadataText(album);
|
||||
|
||||
if (needsTrackLookup) {
|
||||
@@ -1154,32 +1397,44 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick best match using current metadata, not only provider order.
|
||||
best = results.first;
|
||||
var bestScore = -1;
|
||||
for (final result in results) {
|
||||
final score = _metadataMatchScore(
|
||||
result,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null &&
|
||||
!_metadataMatchIsConfident(
|
||||
best,
|
||||
final rankedResults = [...results]
|
||||
..sort((a, b) {
|
||||
final aScore = _metadataMatchScore(
|
||||
a,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
)) {
|
||||
best = null;
|
||||
);
|
||||
final bScore = _metadataMatchScore(
|
||||
b,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
);
|
||||
return bScore.compareTo(aScore);
|
||||
});
|
||||
|
||||
if (usesAutomaticProvider) {
|
||||
best = rankedResults.first;
|
||||
if (!_metadataMatchIsConfident(
|
||||
best,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
)) {
|
||||
best = null;
|
||||
}
|
||||
} else {
|
||||
best = await _showAutoFillCandidatePicker(
|
||||
candidates: rankedResults,
|
||||
sourceName: _metadataProviderName(selectedProviderId),
|
||||
currentTitle: title,
|
||||
currentArtist: searchArtist,
|
||||
);
|
||||
if (!mounted || best == null) return;
|
||||
}
|
||||
|
||||
if (best == null) {
|
||||
@@ -1375,9 +1630,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
}
|
||||
final coverUrl = selectedBest == null
|
||||
? null
|
||||
: (selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
: _metadataCandidateCoverUrl(selectedBest);
|
||||
final hasSelectedValue = _autoFillFields.any(
|
||||
(key) => key == 'cover'
|
||||
? coverUrl != null && coverUrl.isNotEmpty
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
class CrossExtensionShareResult {
|
||||
final String extensionId;
|
||||
@@ -41,9 +42,10 @@ class CrossExtensionShareService {
|
||||
required String type,
|
||||
required String sourceExtensionId,
|
||||
}) async {
|
||||
final queryArtist = primaryArtistName(artists);
|
||||
final results = await PlatformBridge.findCollectionAcrossExtensions(
|
||||
name: name,
|
||||
artists: artists,
|
||||
artists: queryArtist,
|
||||
type: type,
|
||||
sourceExtensionId: sourceExtensionId,
|
||||
);
|
||||
|
||||
@@ -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)?|ft|with)\.?(?=\s|$)\s*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,22 @@ List<String> splitArtistNames(String rawArtists) {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String primaryArtistName(String artists, {String? albumArtist}) {
|
||||
final preferred = albumArtist?.trim() ?? '';
|
||||
final normalizedPreferred = preferred.toLowerCase();
|
||||
final useAlbumArtist =
|
||||
preferred.isNotEmpty &&
|
||||
normalizedPreferred != 'various artists' &&
|
||||
normalizedPreferred != 'various' &&
|
||||
normalizedPreferred != 'va';
|
||||
final source = useAlbumArtist ? preferred : artists.trim();
|
||||
final split = splitArtistNames(source);
|
||||
if (split.isNotEmpty) return split.first;
|
||||
|
||||
final fallback = splitArtistNames(artists);
|
||||
return fallback.isNotEmpty ? fallback.first : artists.trim();
|
||||
}
|
||||
|
||||
bool shouldSplitVorbisArtistTags(String mode) {
|
||||
return mode == artistTagModeSplitVorbis;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
void main() {
|
||||
test('primary artist prefers the first album artist', () {
|
||||
expect(
|
||||
primaryArtistName(
|
||||
'Track Artist, Guest Artist',
|
||||
albumArtist: 'Album Artist & Collaborator',
|
||||
),
|
||||
'Album Artist',
|
||||
);
|
||||
});
|
||||
|
||||
test('primary artist handles provider separator variants', () {
|
||||
expect(primaryArtistName('One; Two & Three'), 'One');
|
||||
expect(primaryArtistName('One feat. Two'), 'One');
|
||||
expect(primaryArtistName('AC/DC'), 'AC/DC');
|
||||
expect(
|
||||
primaryArtistName('Actual Artist, Guest', albumArtist: 'Various Artists'),
|
||||
'Actual Artist',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user