import 'dart:io'; import 'package:audio_metadata_reader/audio_metadata_reader.dart'; import 'package:drift/drift.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import '../player/replay_gain.dart'; import 'categories.dart'; import 'database.dart'; const _audioExt = { '.mp3', '.m4a', '.aac', '.flac', '.wav', '.ogg', '.opus', '.wma', '.aiff', '.aif', '.alac', }; const _uuid = Uuid(); /// Scannt alle [folderPaths] rekursiv nach Audiodateien, liest Tags + Cover /// und schreibt sie in die DB. Bereits bekannte Dateien behalten ihre UUID /// (sync-stabil); verschwundene Dateien werden per Tombstone markiert. /// Gibt die Zahl der gefundenen Songs zurück. // ponytail: Tag-Parsing läuft synchron im UI-Isolate. Reicht für normale // Bibliotheken; bei >~mehreren Tausend Dateien in ein Isolate (compute) auslagern. Future scanFolders( MeloDb db, List folderPaths, { Directory? coverDir, void Function(int done, int total)? onProgress, }) async { final existing = {for (final s in await db.allSongs()) s.path: s}; final covers = coverDir ?? Directory(p.join((await getApplicationSupportDirectory()).path, 'covers')); await covers.create(recursive: true); final files = []; for (final folder in folderPaths) { final dir = Directory(folder); if (!dir.existsSync()) continue; await for (final entity in dir.list(recursive: true, followLinks: false)) { if (entity is File && _audioExt.contains(p.extension(entity.path).toLowerCase())) { files.add(entity); } } } final now = DateTime.now().millisecondsSinceEpoch; final companions = []; // Kategorien werden erst nach dem Upsert geschrieben — vorher gibt es die // Songzeile noch nicht, auf die sie verweisen. final categories = >{}; var done = 0; if (files.isNotEmpty) onProgress?.call(0, files.length); for (final file in files) { final prev = existing[file.path]; final id = prev?.id ?? _uuid.v4(); AudioMetadata? meta; try { meta = readMetadata(file, getImage: true); } catch (_) { // Unlesbare/kaputte Tags: Datei trotzdem mit Dateinamen aufnehmen. } // ReplayGain steht je nach Format an unterschiedlicher Stelle und ist im // vereinheitlichten AudioMetadata nicht enthalten — daher der zweite, // gezielte Blick in die Roh-Tags. final gainDb = _readGain(file); final rawTitle = meta?.title?.trim(); final title = (rawTitle != null && rawTitle.isNotEmpty) ? rawTitle : p.basenameWithoutExtension(file.path); var coverPath = prev?.coverPath; final pics = meta?.pictures ?? const []; if (pics.isNotEmpty && coverPath == null) { try { final f = File(p.join(covers.path, '$id.img')); await f.writeAsBytes(pics.first.bytes); coverPath = f.path; } catch (_) { // Kaputtes Cover verzögert nicht den Scan. } } // Von Hand korrigierte Metadaten überleben jeden weiteren Scan — // sonst holt der nächste Durchlauf die falschen Tags der Datei zurück. final behalten = prev?.metadataEdited == true; companions.add(SongsCompanion.insert( id: id, path: file.path, title: behalten ? prev!.title : title, artist: Value(behalten ? prev!.artist : meta?.artist), album: Value(behalten ? prev!.album : meta?.album), metadataEdited: Value(behalten), durationMs: Value(meta?.duration?.inMilliseconds), coverPath: Value(coverPath), dateAddedMs: prev?.dateAddedMs ?? now, updatedAtMs: now, deleted: const Value(false), lyrics: Value(meta?.lyrics), gainDb: Value(gainDb), )); if (prev?.categoriesEdited != true) { categories[id] = parseCategoryList(meta?.genres ?? const []); } ++done; if (done % 50 == 0 || done == files.length) { onProgress?.call(done, files.length); } } await db.upsertSongs(companions); for (final entry in categories.entries) { await db.setCategories(entry.key, entry.value); } if (companions.isNotEmpty) { await db.markMissing(now); } return companions.length; } /// Liest den ReplayGain-Wert des Titels aus den Roh-Tags: bei FLAC/OGG aus /// dem Vorbis-Kommentar, bei MP3 aus einem TXXX-Feld. Fehlt der Tag oder /// lässt sich die Datei nicht lesen, gibt es keine Angleichung. double? _readGain(File file) { try { final tags = readAllMetadata(file, getImage: false); if (tags is VorbisMetadata) { return parseReplayGain(tags.replayGainTrackGain.firstOrNull); } if (tags is Mp3Metadata) { for (final entry in tags.customMetadata.entries) { if (entry.key.toLowerCase() == 'replaygain_track_gain') { return parseReplayGain(entry.value); } } } } catch (_) { // Kein ReplayGain ist kein Fehler — dann bleibt die Lautstärke wie sie ist. } return null; }