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'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; class CsvImportService { static final _log = AppLogger('CsvImportService'); static final RegExp _lineSplitPattern = RegExp(r'\r\n|\r|\n'); static Future> pickAndParseCsv({ void Function(int current, int total)? onProgress, }) async { try { final picked = await FilePicker.pickFile( type: FileType.custom, allowedExtensions: ['csv', 'm3u', 'm3u8'], ); if (picked != null) { final content = utf8.decode(await picked.readAsBytes()); final extension = p.extension(picked.name).toLowerCase(); final tracks = (extension == '.m3u' || extension == '.m3u8') ? M3uPlaylistService.parseM3u(content) : _parseCsv(content); if (tracks.isNotEmpty) { return await enrichTracksMetadata(tracks, onProgress: onProgress); } return tracks; } } catch (e) { _log.e('Error picking/parsing playlist file: $e'); } return []; } @visibleForTesting static Future> enrichTracksMetadata( List tracks, { void Function(int current, int total)? onProgress, Future>> Function(String query, int limit)? lookup, int concurrency = 3, }) async { if (tracks.isEmpty) return const []; final providerLookup = lookup ?? (String query, int limit) => PlatformBridge.searchTracksWithMetadataProviders( query, 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); _log.i( 'Enriching ${tracks.length} imported tracks with configured metadata providers ($workerCount workers)', ); 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); } } 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); if (lines.isEmpty) return tracks; int startIdx = 0; while (startIdx < lines.length && lines[startIdx].trim().isEmpty) { startIdx++; } if (startIdx >= lines.length) return tracks; final headers = _parseLine(lines[startIdx]); final colMap = {}; for (int i = 0; i < headers.length; i++) { String h = _cleanValue(headers[i]).toLowerCase(); colMap[h] = i; } _log.d('CSV Headers: ${colMap.keys.toList()}'); for (int i = startIdx + 1; i < lines.length; i++) { final line = lines[i].trim(); if (line.isEmpty) continue; final values = _parseLine(line); String? getVal(List keys) { return _getValue(values, colMap, keys); } String? trackName = getVal(['track name', 'track', 'name', 'title']); String? artistName = getVal([ 'artist name(s)', 'artist name', 'artist', 'artists', ]); String? albumName = getVal(['album name', 'album']); String? isrc = getVal(['isrc']); String? spotifyId = getVal([ 'track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri', ]); if (spotifyId != null && spotifyId.startsWith('spotify:track:')) { spotifyId = spotifyId.replaceAll('spotify:track:', ''); } if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { tracks.add( Track( id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', name: trackName ?? 'Unknown Track', artistName: artistName ?? 'Unknown Artist', albumName: albumName ?? 'Unknown Album', isrc: isrc, duration: 0, coverUrl: null, ), ); } } _log.i('Parsed ${tracks.length} tracks from CSV'); return tracks; } static String? _getValue( List values, Map colMap, List possibleKeys, ) { for (final key in possibleKeys) { if (colMap.containsKey(key)) { final index = colMap[key]!; if (index < values.length) { return _cleanValue(values[index]); } } } return null; } static String _cleanValue(String val) { val = val.trim(); if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) { val = val.substring(1, val.length - 1); } val = val.replaceAll('""', '"'); return val; } static List _parseLine(String line) { final List result = []; bool inQuote = false; var buffer = StringBuffer(); for (int i = 0; i < line.length; i++) { final char = line[i]; if (char == '"') { if (inQuote && i + 1 < line.length && line[i + 1] == '"') { buffer.write('"'); i++; } else { inQuote = !inQuote; } continue; } if (char == ',' && !inQuote) { result.add(buffer.toString()); buffer = StringBuffer(); continue; } buffer.write(char); } result.add(buffer.toString()); return result; } }