mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 09:38:35 +02:00
The provider and the redownload service each hand-rolled the same backend-map parsing and duration extraction. The factory ports the richer provider variant (cover normalization, native-vs-spotify preferredId), so the redownload service gains those for free. checkAvailability's 30-line manual reconstruction becomes copyWith, which also stops silently dropping previewUrl, totalDiscs, and deezerId.
305 lines
8.7 KiB
Dart
305 lines
8.7 KiB
Dart
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.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 bool isFlacUpgradeEligible(LocalLibraryItem item) {
|
|
final format = item.format?.trim().toLowerCase();
|
|
if (format == 'flac') {
|
|
return false;
|
|
}
|
|
|
|
return !item.filePath.toLowerCase().endsWith('.flac');
|
|
}
|
|
|
|
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: Track.fromBackendMap(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,
|
|
ExtensionState extensionState,
|
|
) {
|
|
return resolveEffectiveDownloadService(
|
|
settings.defaultService,
|
|
extensionState,
|
|
);
|
|
}
|
|
|
|
static String preferredFlacQualityForService(
|
|
String service,
|
|
ExtensionState extensionState,
|
|
) {
|
|
if (service.trim().isEmpty) {
|
|
return 'LOSSLESS';
|
|
}
|
|
return isDeezerCompatibleDownloadService(service, extensionState)
|
|
? '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 int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
|
|
final track = Track.fromBackendMap(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));
|
|
}
|
|
}
|