Files
SpotiFLAC-Mobile/lib/services/csv_import_service.dart
T

321 lines
9.8 KiB
Dart

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<List<Track>> 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<List<Track>> enrichTracksMetadata(
List<Track> tracks, {
void Function(int current, int total)? onProgress,
Future<List<Map<String, dynamic>>> 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<Track?>.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<void> 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<Track>();
_log.i('Enrichment complete: ${enriched.length} tracks');
return enriched;
}
static Future<Track> _enrichTrack(
Track track,
Future<List<Map<String, dynamic>>> Function(String query, int limit) lookup,
) async {
if (track.coverUrl != null && track.duration > 0) return track;
Map<String, dynamic>? 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<String, dynamic>? _bestCandidate(
Track track,
List<Map<String, dynamic>> 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<String, dynamic>? 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<Track> _parseCsv(String content) {
final List<Track> 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 = <String, int>{};
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<String> 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<String> values,
Map<String, int> colMap,
List<String> 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<String> _parseLine(String line) {
final List<String> 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;
}
}