Files
Melo/lib/library/scan_service.dart
T
dustinandClaude Opus 5 4ebd0a8dd4 Songtext aus Datei-Tags, Warteschlangen-Aktionen, Kategorie-Cover im
Sperrbildschirm und ReplayGain

- DB-Schema 6: lyrics + gain_db
- Songtext bevorzugt lokalen Tag, Server nur als Rueckfall; Song-ID-Bug
  beim Lyrics-Aufruf behoben
- "Als Naechstes spielen" / "Zur Warteschlange hinzufuegen" ohne Eingriff
  in Favoriten oder Wiedergabelisten
- songToMediaItem nimmt eine Cover-Vorgabe: Sperrbildschirm zeigt das
  Kategorie-Cover
- ReplayGain: Tags werden gelesen, laute Titel abgesenkt, abschaltbar

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
2026-08-20 18:41:36 +02:00

144 lines
4.6 KiB
Dart

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<int> scanFolders(
MeloDb db,
List<String> 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 = <File>[];
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 = <SongsCompanion>[];
// Kategorien werden erst nach dem Upsert geschrieben — vorher gibt es die
// Songzeile noch nicht, auf die sie verweisen.
final categories = <String, List<String>>{};
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 <Picture>[];
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.
}
}
companions.add(SongsCompanion.insert(
id: id,
path: file.path,
title: title,
artist: Value(meta?.artist),
album: Value(meta?.album),
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;
}