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:
zarzet
2026-03-15 20:18:58 +07:00
parent 82f59d32b9
commit 42f0267277
4 changed files with 667 additions and 11 deletions

View File

@@ -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"
}
}
}

View File

@@ -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,

View File

@@ -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

View 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));
}
}