Files
Melo/lib/library/scan_service.dart
T
Hermes (Server)andClaude Opus 5 c7a7a7c458 UI: Alben sind Kategorien — Reiter Lieder/Kategorie/Kuenstler
Auf Wunsch von Dustin: seine beste Freundin sortiert ihre Sammlung ueber
das Album-Feld. In Melo ist der Album-Titel deshalb ab jetzt die Kategorie
— sie muss nichts neu machen.

1) Album = Kategorie
- kategorienAusTags(): Album-Titel steht VORN in der Kategorienliste (die
  erste Kategorie bestimmt das Coverbild), Genres dahinter.
- Beide Scans (android_scan + scan_service) tragen ihn ein; von Hand
  gepflegte Kategorien (categoriesEdited) bleiben unberuehrt.
- DB-Schema 9: einmalige Nachruestung bestehender Bibliotheken, damit das
  nicht erst beim naechsten vollstaendigen Scan sichtbar wird (der auf
  Android nur laeuft, wenn sich die Dateianzahl aendert).

2) Reiter: 'Songs/Kuenstler/Alben' -> 'Lieder/Kategorie/Kuenstler'
- Neu: library/category_list.dart mit groupByCategory(); Lieder ohne
  Kategorie sammeln sich am Ende unter "Ohne Kategorie".
- Entfernt: library/album_list.dart, groupByAlbum(), albumArtistLabel() —
  mit dem Alben-Reiter tot geworden. Das Album-FELD bleibt erhalten.

3) YouTube-Downloads ohne Original-Album
- Feld "Kategorie (optional)" im YouTube-Bereich, mit Vorschlaegen aus der
  Bibliothek und freier Eingabe.
- ordneDownloadEin() verwirft nach dem Scan das Album-Tag (yt-dlp leitet es
  aus Kanal/Playlist ab — als Kategorie waere das Unsinn) und setzt
  stattdessen die gewaehlte Kategorie. Beides als "von Hand gesetzt"
  markiert, damit der naechste Scan es nicht zurueckholt.
- MeloDb.songByPath() und MeloDb.verwirfAlbum() neu.
- CategoryService.alleNamen: Kategorienamen ohne zusaetzliche Abfrage —
  ohne das flackerte die Vorschlagsliste und im Widget-Test blieb ein
  Aufraeum-Timer von drift haengen.

295 Tests gruen (19 neue, 1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 09:36:46 +02:00

152 lines
5.0 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.
}
}
// 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] = kategorienAusTags(
album: behalten ? prev!.album : meta?.album,
genres: 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;
}