From bd07d5c233054900dc1ebdebbea5e9bebbb3402f Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 23:18:38 +0700 Subject: [PATCH] perf(import): batch playlist metadata enrichment --- lib/services/csv_import_service.dart | 236 +++++++++++++++++---------- test/csv_import_service_test.dart | 103 ++++++++++++ 2 files changed, 249 insertions(+), 90 deletions(-) create mode 100644 test/csv_import_service_test.dart diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 1c6b08e4..7a3f763c 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'dart:math' as math; + import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/m3u_playlist_service.dart'; @@ -27,7 +30,7 @@ class CsvImportService { : _parseCsv(content); if (tracks.isNotEmpty) { - return await _enrichTracksMetadata(tracks, onProgress: onProgress); + return await enrichTracksMetadata(tracks, onProgress: onProgress); } return tracks; } @@ -37,107 +40,160 @@ class CsvImportService { return []; } - static Future> _enrichTracksMetadata( + @visibleForTesting + static Future> enrichTracksMetadata( List tracks, { void Function(int current, int total)? onProgress, + Future>> Function(String query, int limit)? + lookup, + int concurrency = 3, }) async { - _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); - final enrichedTracks = []; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks[i]; - onProgress?.call(i + 1, tracks.length); - - if (track.coverUrl == null || track.duration == 0) { - Map? trackData; - - if (track.isrc != null && track.isrc!.isNotEmpty) { - try { - trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); - _log.d('ISRC enrichment success for ${track.name}'); - } catch (e) { - _log.w( - 'ISRC search failed for ${track.name}, trying text search...', - ); - } - } - - if (trackData == null) { - try { - final query = '${track.artistName} ${track.name}'; - final searchResult = await PlatformBridge.customSearchWithExtension( - 'deezer', + if (tracks.isEmpty) return const []; + final providerLookup = + lookup ?? + (String query, int limit) => + PlatformBridge.searchTracksWithMetadataProviders( query, - options: {'filter': 'track', 'limit': 5}, + limit: limit, + includeExtensions: true, ); + final results = List.filled(tracks.length, null); + var nextIndex = 0; + var completed = 0; + final workerCount = math.min(math.max(1, concurrency), tracks.length); - if (searchResult.isNotEmpty) { - for (final resultMap in searchResult) { - final resultName = - (resultMap['name'] as String?)?.toLowerCase() ?? ''; - final trackNameLower = track.name.toLowerCase(); + _log.i( + 'Enriching ${tracks.length} imported tracks with configured metadata providers ($workerCount workers)', + ); - if (resultName.contains(trackNameLower) || - trackNameLower.contains(resultName)) { - trackData = resultMap; - _log.d('Text search match for ${track.name}: $resultName'); - break; - } - } - - if (trackData == null) { - trackData = searchResult.first; - _log.d('Using first search result for ${track.name}'); - } - } - } catch (e) { - _log.w('Text search also failed for ${track.name}: $e'); - } - } - - if (trackData != null) { - final coverUrl = trackData['images'] as String?; - final durationMs = trackData['duration_ms'] as int? ?? 0; - final deezerIdRaw = trackData['spotify_id'] as String?; - - enrichedTracks.add( - Track( - id: deezerIdRaw ?? track.id, - name: trackData['name'] as String? ?? track.name, - artistName: trackData['artists'] as String? ?? track.artistName, - albumName: trackData['album_name'] as String? ?? track.albumName, - albumArtist: trackData['album_artist'] as String?, - artistId: trackData['artist_id']?.toString(), - albumId: trackData['album_id']?.toString(), - coverUrl: coverUrl ?? track.coverUrl, - isrc: trackData['isrc'] as String? ?? track.isrc, - duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, - trackNumber: - trackData['track_number'] as int? ?? track.trackNumber, - discNumber: trackData['disc_number'] as int? ?? track.discNumber, - releaseDate: - trackData['release_date'] as String? ?? track.releaseDate, - ), - ); - - _log.d( - 'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s', - ); - - if (i < tracks.length - 1) { - await Future.delayed(const Duration(milliseconds: 100)); - } - continue; - } + Future worker() async { + while (true) { + final index = nextIndex++; + if (index >= tracks.length) return; + final track = tracks[index]; + results[index] = await _enrichTrack(track, providerLookup); + completed++; + onProgress?.call(completed, tracks.length); } - - enrichedTracks.add(track); } - _log.i('Enrichment complete: ${enrichedTracks.length} tracks'); - return enrichedTracks; + await Future.wait(List.generate(workerCount, (_) => worker())); + final enriched = results.cast(); + _log.i('Enrichment complete: ${enriched.length} tracks'); + return enriched; } + static Future _enrichTrack( + Track track, + Future>> Function(String query, int limit) lookup, + ) async { + if (track.coverUrl != null && track.duration > 0) return track; + + Map? selected; + final isrc = track.isrc?.trim().toUpperCase() ?? ''; + if (isrc.isNotEmpty) { + try { + final candidates = await lookup(isrc, 5); + selected = _bestCandidate(track, candidates, requireExactIsrc: true); + } catch (e) { + _log.w('ISRC provider lookup failed for ${track.name}: $e'); + } + } + + if (selected == null) { + try { + final candidates = await lookup('${track.artistName} ${track.name}', 5); + selected = _bestCandidate(track, candidates); + } catch (e) { + _log.w('Metadata provider lookup failed for ${track.name}: $e'); + } + } + if (selected == null) return track; + + final candidate = Track.fromBackendMap(selected); + _log.d( + 'Enriched ${track.name} via ${selected['provider_id'] ?? selected['source'] ?? 'metadata provider'}', + ); + return track.copyWith( + name: candidate.name.isEmpty ? track.name : candidate.name, + artistName: candidate.artistName.isEmpty + ? track.artistName + : candidate.artistName, + albumName: candidate.albumName.isEmpty + ? track.albumName + : candidate.albumName, + albumArtist: candidate.albumArtist, + artistId: candidate.artistId, + albumId: candidate.albumId, + coverUrl: candidate.coverUrl, + isrc: candidate.isrc, + duration: candidate.duration > 0 ? candidate.duration : track.duration, + trackNumber: candidate.trackNumber, + discNumber: candidate.discNumber, + totalDiscs: candidate.totalDiscs, + totalTracks: candidate.totalTracks, + releaseDate: candidate.releaseDate, + genre: candidate.genre, + label: candidate.label, + copyright: candidate.copyright, + composer: candidate.composer, + explicit: candidate.explicit, + upc: candidate.upc, + ); + } + + static Map? _bestCandidate( + Track track, + List> candidates, { + bool requireExactIsrc = false, + }) { + if (candidates.isEmpty) return null; + final targetIsrc = track.isrc?.trim().toUpperCase() ?? ''; + final targetName = _normalizeMatchText(track.name); + final targetArtist = _normalizeMatchText(track.artistName); + Map? best; + var bestScore = -1; + for (final candidate in candidates) { + final candidateIsrc = + candidate['isrc']?.toString().trim().toUpperCase() ?? ''; + if (requireExactIsrc && + (targetIsrc.isEmpty || candidateIsrc != targetIsrc)) { + continue; + } + var score = candidateIsrc.isNotEmpty && candidateIsrc == targetIsrc + ? 100 + : 0; + final name = _normalizeMatchText(candidate['name']?.toString() ?? ''); + final artist = _normalizeMatchText( + (candidate['artists'] ?? candidate['artist'])?.toString() ?? '', + ); + if (targetName.isNotEmpty && name == targetName) { + score += 30; + } else if (targetName.isNotEmpty && + name.isNotEmpty && + (name.contains(targetName) || targetName.contains(name))) { + score += 15; + } + if (targetArtist.isNotEmpty && artist == targetArtist) { + score += 20; + } else if (targetArtist.isNotEmpty && + artist.isNotEmpty && + (artist.contains(targetArtist) || targetArtist.contains(artist))) { + score += 10; + } + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + return bestScore >= (requireExactIsrc ? 100 : 20) ? best : null; + } + + static String _normalizeMatchText(String value) => value + .toLowerCase() + .replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ') + .trim(); + static List _parseCsv(String content) { final List tracks = []; final lines = content.split(_lineSplitPattern); diff --git a/test/csv_import_service_test.dart b/test/csv_import_service_test.dart new file mode 100644 index 00000000..0f7e45a7 --- /dev/null +++ b/test/csv_import_service_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/csv_import_service.dart'; + +void main() { + test( + 'playlist enrichment uses generic lookup with bounded concurrency', + () async { + var active = 0; + var maxActive = 0; + final progress = []; + final tracks = List.generate( + 5, + (index) => Track( + id: 'original-$index', + name: 'Song $index', + artistName: 'Artist', + albumName: '', + isrc: 'TEST0000000$index', + duration: 0, + ), + ); + + final enriched = await CsvImportService.enrichTracksMetadata( + tracks, + concurrency: 2, + onProgress: (current, _) => progress.add(current), + lookup: (query, limit) async { + active++; + if (active > maxActive) maxActive = active; + await Future.delayed(const Duration(milliseconds: 10)); + active--; + final index = int.parse(query.substring(query.length - 1)); + return [ + { + 'id': 'provider-$index', + 'provider_id': 'generic-provider', + 'name': 'Song $index', + 'artists': 'Artist', + 'album_name': 'Album', + 'cover_url': 'https://example.test/$index.jpg', + 'duration_ms': 180000, + 'isrc': query, + }, + ]; + }, + ); + + expect(maxActive, 2); + expect(progress, [1, 2, 3, 4, 5]); + expect( + enriched.map((track) => track.id), + tracks.map((track) => track.id), + ); + expect(enriched.every((track) => track.duration == 180), isTrue); + expect(enriched[3].coverUrl, 'https://example.test/3.jpg'); + }, + ); + + test('ISRC enrichment rejects a provider mismatch', () async { + const track = Track( + id: 'original', + name: 'Wanted Song', + artistName: 'Wanted Artist', + albumName: '', + isrc: 'AAABC1200001', + duration: 0, + ); + var calls = 0; + + final enriched = await CsvImportService.enrichTracksMetadata( + const [track], + lookup: (query, limit) async { + calls++; + if (calls == 1) { + return [ + { + 'id': 'wrong', + 'name': 'Other Song', + 'artists': 'Other Artist', + 'isrc': 'ZZZZZ9900000', + 'duration_ms': 100000, + }, + ]; + } + return [ + { + 'id': 'right', + 'name': 'Wanted Song', + 'artists': 'Wanted Artist', + 'cover_url': 'https://example.test/right.jpg', + 'duration_ms': 210000, + }, + ]; + }, + ); + + expect(calls, 2); + expect(enriched.single.id, 'original'); + expect(enriched.single.duration, 210); + expect(enriched.single.coverUrl, 'https://example.test/right.jpg'); + }); +}