mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 09:01:33 +02:00
feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching (ISRC, title, artist, album, duration, track/disc number, year) to find reliable online matches for locally-stored tracks. Wire up 'Queue FLAC' selection action in both local_album_screen and queue_tab (library tab). Shows progress snackbar during resolution, skips ambiguous or low-confidence matches, and reports results. Add Indonesian (id) translations for all queueFlac l10n keys.
This commit is contained in:
@@ -2755,6 +2755,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Antrekan FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -3114,4 +3155,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||
),
|
||||
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||
);
|
||||
}
|
||||
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
@@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item != null) {
|
||||
selected.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.queueFlacFindingProgress(i + 1, total),
|
||||
),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selected[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -1525,6 +1649,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label: '${context.l10n.queueFlacAction} ($selectedCount)',
|
||||
onPressed: selectedCount > 0
|
||||
? () => _queueSelectedAsFlac(tracks)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
|
||||
@@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
@@ -1321,8 +1323,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
.where((p) => _selectedPlaylistIds.contains(p.id))
|
||||
.toList();
|
||||
|
||||
final totalTracks =
|
||||
selectedPlaylists.fold<int>(0, (sum, p) => sum + p.tracks.length);
|
||||
final totalTracks = selectedPlaylists.fold<int>(
|
||||
0,
|
||||
(sum, p) => sum + p.tracks.length,
|
||||
);
|
||||
|
||||
if (totalTracks == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1336,7 +1340,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.dialogDownloadAllTitle),
|
||||
content: Text(
|
||||
ctx.l10n.dialogDownloadPlaylistsMessage(totalTracks, selectedPlaylists.length),
|
||||
ctx.l10n.dialogDownloadPlaylistsMessage(
|
||||
totalTracks,
|
||||
selectedPlaylists.length,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1392,9 +1399,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_exitPlaylistSelectionMode();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(totalTracks),
|
||||
),
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1547,7 +1552,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? context.l10n.bulkDownloadPlaylistsButton(selectedCount)
|
||||
? context.l10n.bulkDownloadPlaylistsButton(
|
||||
selectedCount,
|
||||
)
|
||||
: context.l10n.bulkDownloadSelectPlaylists,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -4477,6 +4484,127 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedLocalAsFlac(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
final selectedItems = _selectedItemsFromAll(allItems);
|
||||
final selectedLocalItems = selectedItems
|
||||
.map((item) => item.localItem)
|
||||
.whereType<LocalLibraryItem>()
|
||||
.toList(growable: false);
|
||||
|
||||
if (selectedLocalItems.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(
|
||||
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selectedLocalItems.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.queueFlacFindingProgress(i + 1, total),
|
||||
),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selectedLocalItems[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelectedLocalFromQueue(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
@@ -5244,6 +5372,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
// Action buttons row: Share/Re-enrich, Convert, Delete
|
||||
Row(
|
||||
children: [
|
||||
if (localOnlySelection) ...[
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($selectedCount)',
|
||||
onPressed: selectedCount > 0
|
||||
? () => _queueSelectedLocalAsFlac(unifiedItems)
|
||||
: null,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _SelectionActionButton(
|
||||
icon: localOnlySelection
|
||||
|
||||
338
lib/services/local_track_redownload_service.dart
Normal file
338
lib/services/local_track_redownload_service.dart
Normal file
@@ -0,0 +1,338 @@
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
class LocalTrackRedownloadResolution {
|
||||
final LocalLibraryItem localItem;
|
||||
final Track? match;
|
||||
final int score;
|
||||
final String reason;
|
||||
|
||||
const LocalTrackRedownloadResolution({
|
||||
required this.localItem,
|
||||
required this.match,
|
||||
required this.score,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
bool get canQueue => match != null;
|
||||
}
|
||||
|
||||
class LocalTrackRedownloadService {
|
||||
static const int _minimumConfidenceScore = 85;
|
||||
static const int _ambiguousScoreGap = 8;
|
||||
|
||||
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
|
||||
LocalLibraryItem item, {
|
||||
required bool includeExtensions,
|
||||
}) async {
|
||||
final query = _buildSearchQuery(item);
|
||||
final rawResults = await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 10,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
|
||||
if (rawResults.isEmpty) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: 0,
|
||||
reason: 'No candidates found',
|
||||
);
|
||||
}
|
||||
|
||||
final scored =
|
||||
rawResults
|
||||
.map(
|
||||
(raw) => (
|
||||
track: _parseSearchTrack(raw),
|
||||
score: _scoreMatch(item, raw),
|
||||
),
|
||||
)
|
||||
.where((entry) => entry.track.name.trim().isNotEmpty)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.score.compareTo(a.score));
|
||||
|
||||
if (scored.isEmpty) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: 0,
|
||||
reason: 'No usable candidates found',
|
||||
);
|
||||
}
|
||||
|
||||
final best = scored.first;
|
||||
final runnerUp = scored.length > 1 ? scored[1] : null;
|
||||
final exactIsrc =
|
||||
_normalizedIsrc(item.isrc) != null &&
|
||||
_normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc);
|
||||
final isAmbiguous =
|
||||
!exactIsrc &&
|
||||
runnerUp != null &&
|
||||
best.score < (_minimumConfidenceScore + 10) &&
|
||||
(best.score - runnerUp.score) <= _ambiguousScoreGap;
|
||||
|
||||
if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) {
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: null,
|
||||
score: best.score,
|
||||
reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match',
|
||||
);
|
||||
}
|
||||
|
||||
return LocalTrackRedownloadResolution(
|
||||
localItem: item,
|
||||
match: best.track,
|
||||
score: best.score,
|
||||
reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match',
|
||||
);
|
||||
}
|
||||
|
||||
static String preferredFlacService(AppSettings settings) {
|
||||
switch (settings.defaultService.toLowerCase()) {
|
||||
case 'tidal':
|
||||
case 'qobuz':
|
||||
case 'deezer':
|
||||
return settings.defaultService.toLowerCase();
|
||||
default:
|
||||
return 'tidal';
|
||||
}
|
||||
}
|
||||
|
||||
static String preferredFlacQualityForService(String service) {
|
||||
return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS';
|
||||
}
|
||||
|
||||
static String _buildSearchQuery(LocalLibraryItem item) {
|
||||
final artist = _primaryArtist(item.artistName);
|
||||
final album = item.albumName.trim();
|
||||
if (album.isNotEmpty && album.toLowerCase() != 'unknown album') {
|
||||
return '${item.trackName} $artist $album'.trim();
|
||||
}
|
||||
return '${item.trackName} $artist'.trim();
|
||||
}
|
||||
|
||||
static Track _parseSearchTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source: data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
static int _extractDurationMs(Map<String, dynamic> data) {
|
||||
final durationMsRaw = data['duration_ms'];
|
||||
if (durationMsRaw is num && durationMsRaw > 0) {
|
||||
return durationMsRaw.toInt();
|
||||
}
|
||||
if (durationMsRaw is String) {
|
||||
final parsed = num.tryParse(durationMsRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return parsed.toInt();
|
||||
}
|
||||
}
|
||||
|
||||
final durationSecRaw = data['duration'];
|
||||
if (durationSecRaw is num && durationSecRaw > 0) {
|
||||
return (durationSecRaw * 1000).toInt();
|
||||
}
|
||||
if (durationSecRaw is String) {
|
||||
final parsed = num.tryParse(durationSecRaw.trim());
|
||||
if (parsed != null && parsed > 0) {
|
||||
return (parsed * 1000).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
|
||||
final track = _parseSearchTrack(raw);
|
||||
var score = 0;
|
||||
|
||||
final localIsrc = _normalizedIsrc(item.isrc);
|
||||
final candidateIsrc = _normalizedIsrc(track.isrc);
|
||||
if (localIsrc != null && candidateIsrc != null) {
|
||||
score += localIsrc == candidateIsrc ? 140 : -120;
|
||||
}
|
||||
|
||||
final localTitle = _normalizedTitle(item.trackName);
|
||||
final candidateTitle = _normalizedTitle(track.name);
|
||||
if (localTitle == candidateTitle) {
|
||||
score += 45;
|
||||
} else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) {
|
||||
score += 24;
|
||||
} else {
|
||||
score -= 25;
|
||||
}
|
||||
|
||||
final localArtist = _normalizedArtistGroup(item.artistName);
|
||||
final candidateArtist = _normalizedArtistGroup(track.artistName);
|
||||
final artistOverlap = _tokenOverlap(localArtist, candidateArtist);
|
||||
if (localArtist == candidateArtist) {
|
||||
score += 30;
|
||||
} else if (artistOverlap >= 0.6) {
|
||||
score += 16;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
final localAlbum = _normalizedText(item.albumName);
|
||||
final candidateAlbum = _normalizedText(track.albumName);
|
||||
if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
|
||||
if (localAlbum == candidateAlbum) {
|
||||
score += 12;
|
||||
} else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) {
|
||||
score += 6;
|
||||
}
|
||||
}
|
||||
|
||||
final localDuration = item.duration ?? 0;
|
||||
final candidateDuration = track.duration;
|
||||
if (localDuration > 0 && candidateDuration > 0) {
|
||||
final diff = (localDuration - candidateDuration).abs();
|
||||
if (diff <= 2) {
|
||||
score += 20;
|
||||
} else if (diff <= 5) {
|
||||
score += 12;
|
||||
} else if (diff <= 10) {
|
||||
score += 5;
|
||||
} else if (diff > 20) {
|
||||
score -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.trackNumber != null &&
|
||||
track.trackNumber != null &&
|
||||
item.trackNumber == track.trackNumber) {
|
||||
score += 6;
|
||||
}
|
||||
if (item.discNumber != null &&
|
||||
track.discNumber != null &&
|
||||
item.discNumber == track.discNumber) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
final localYear = _extractYear(item.releaseDate);
|
||||
final candidateYear = _extractYear(track.releaseDate);
|
||||
if (localYear != null &&
|
||||
candidateYear != null &&
|
||||
localYear == candidateYear) {
|
||||
score += 4;
|
||||
}
|
||||
|
||||
score += _versionPenalty(item.trackName, track.name);
|
||||
return score;
|
||||
}
|
||||
|
||||
static String? _normalizedIsrc(String? value) {
|
||||
final normalized = value?.trim().toUpperCase();
|
||||
if (normalized == null || normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static String _normalizedTitle(String value) {
|
||||
final cleaned = _normalizedText(value)
|
||||
.replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ')
|
||||
.replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
static String _normalizedArtistGroup(String value) {
|
||||
return _normalizedText(
|
||||
value
|
||||
.replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',')
|
||||
.replaceAll('&', ','),
|
||||
);
|
||||
}
|
||||
|
||||
static String _primaryArtist(String value) {
|
||||
final parts = _normalizedArtistGroup(
|
||||
value,
|
||||
).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty);
|
||||
return parts.isEmpty ? value.trim() : parts.first;
|
||||
}
|
||||
|
||||
static String _normalizedText(String value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ')
|
||||
.replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
static double _tokenOverlap(String left, String right) {
|
||||
final leftTokens = left
|
||||
.split(RegExp(r'[\s,]+'))
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
final rightTokens = right
|
||||
.split(RegExp(r'[\s,]+'))
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toSet();
|
||||
if (leftTokens.isEmpty || rightTokens.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final intersection = leftTokens.intersection(rightTokens).length;
|
||||
final denominator = leftTokens.length > rightTokens.length
|
||||
? leftTokens.length
|
||||
: rightTokens.length;
|
||||
return intersection / denominator;
|
||||
}
|
||||
|
||||
static int _versionPenalty(String localTitle, String candidateTitle) {
|
||||
const riskyMarkers = [
|
||||
'live',
|
||||
'karaoke',
|
||||
'instrumental',
|
||||
'acoustic',
|
||||
'radio edit',
|
||||
'sped up',
|
||||
'slowed',
|
||||
];
|
||||
final local = _normalizedText(localTitle);
|
||||
final candidate = _normalizedText(candidateTitle);
|
||||
var penalty = 0;
|
||||
for (final marker in riskyMarkers) {
|
||||
final localHas = local.contains(marker);
|
||||
final candidateHas = candidate.contains(marker);
|
||||
if (!localHas && candidateHas) {
|
||||
penalty -= 18;
|
||||
}
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
|
||||
static int? _extractYear(String? date) {
|
||||
if (date == null || date.length < 4) {
|
||||
return null;
|
||||
}
|
||||
return int.tryParse(date.substring(0, 4));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user