diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index de9d90d..bab66bf 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" } -} \ No newline at end of file +} diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e5584b3..122a4ba 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -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 { void _showCueVirtualTrackSnackBar() { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text(cueVirtualTrackRequiresSplitMessage), - ), + const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)), ); } + late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; String? _commonQualityCache; @@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState { return false; } + Future _queueSelectedAsFlac(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item != null) { + selected.add(item); + } + } + + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + 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 = []; + 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 _reEnrichSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -1525,6 +1649,17 @@ class _LocalAlbumScreenState extends ConsumerState { 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, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5b14ff2..52ca330 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { .where((p) => _selectedPlaylistIds.contains(p.id)) .toList(); - final totalTracks = - selectedPlaylists.fold(0, (sum, p) => sum + p.tracks.length); + final totalTracks = selectedPlaylists.fold( + 0, + (sum, p) => sum + p.tracks.length, + ); if (totalTracks == 0) { ScaffoldMessenger.of(context).showSnackBar( @@ -1336,7 +1340,10 @@ class _QueueTabState extends ConsumerState { 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 { _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 { 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 { return false; } + Future _queueSelectedLocalAsFlac( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + 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 = []; + 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 _reEnrichSelectedLocalFromQueue( List allItems, ) async { @@ -5244,6 +5372,20 @@ class _QueueTabState extends ConsumerState { // 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 diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart new file mode 100644 index 0000000..4b8500c --- /dev/null +++ b/lib/services/local_track_redownload_service.dart @@ -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 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 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 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 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)); + } +}