538 lines
18 KiB
Dart
538 lines
18 KiB
Dart
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'dart:convert';
|
||
import '../models/song.dart';
|
||
import '../database/db_helper.dart';
|
||
import '../utils/audio_validator.dart';
|
||
import 'id3_reader.dart';
|
||
import '../services/melo_logger.dart';
|
||
import 'auth_service.dart';
|
||
|
||
class MusikScanner {
|
||
static final MusikScanner _instanz = MusikScanner._();
|
||
factory MusikScanner() => _instanz;
|
||
MusikScanner._();
|
||
|
||
final DbHelper _db = DbHelper();
|
||
int _neueSongs = 0;
|
||
|
||
int get anzahlNeueSongs => _neueSongs;
|
||
|
||
Future<bool> frageSpeicherZugriff() async {
|
||
// App-interner Speicher (Cloud-Downloads) benötigt NIE Berechtigungen
|
||
if (Platform.isIOS) return true;
|
||
|
||
if (Platform.isAndroid) {
|
||
// Android 13+: READ_MEDIA_AUDIO
|
||
var status = await Permission.audio.status;
|
||
if (!status.isGranted) status = await Permission.audio.request();
|
||
if (status.isGranted) return true;
|
||
|
||
// Fallback für Android 10 und älter
|
||
var storageStatus = await Permission.storage.status;
|
||
if (!storageStatus.isGranted) storageStatus = await Permission.storage.request();
|
||
return storageStatus.isGranted;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
Future<List<Song>> scanneMusikOrdner() async {
|
||
_neueSongs = 0;
|
||
final gefunden = <Song>[];
|
||
final pfade = await _sammleMusikPfade();
|
||
|
||
for (final pfad in pfade) {
|
||
try {
|
||
final file = File(pfad);
|
||
if (!await file.exists()) continue;
|
||
final stat = await file.stat();
|
||
final tags = Id3Reader.lesen(pfad);
|
||
|
||
// ── Magic-Byte-Prüfung ──
|
||
final istKorrupt = !hatValideMagicBytes(file);
|
||
if (istKorrupt) {
|
||
MeloLogger().fehler('magic_bytes_scan', 'Ungültiger Audio-Header: $pfad');
|
||
}
|
||
|
||
// Cover-Bytes als Bilddatei speichern
|
||
String? coverPfad;
|
||
if (tags['cover'] != null && tags['cover'] is List<int>) {
|
||
final bytes = tags['cover'] as List<int>;
|
||
if (bytes.isNotEmpty) {
|
||
try {
|
||
final docDir = await getApplicationDocumentsDirectory();
|
||
final coversDir = Directory('${docDir.path}/covers');
|
||
if (!await coversDir.exists()) await coversDir.create(recursive: true);
|
||
final fileHash = pfad.hashCode.abs();
|
||
final coverFile = File('${coversDir.path}/cover_$fileHash.jpg');
|
||
await coverFile.writeAsBytes(bytes);
|
||
coverPfad = coverFile.path;
|
||
} catch (e) {
|
||
MeloLogger().fehler('cover_speichern', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
gefunden.add(Song(
|
||
titel: (tags['titel'] as String).isNotEmpty ? tags['titel'] : _dateiNameOhneEndung(pfad),
|
||
kuenstler: (tags['kuenstler'] as String).isNotEmpty ? tags['kuenstler'] : 'Unbekannt',
|
||
album: tags['album'] ?? '',
|
||
jahr: _nichtLeer(tags['jahr']),
|
||
genre: _nichtLeer(tags['genre']),
|
||
track: _nichtLeer(tags['track']),
|
||
dauerSekunden: await _ermittleDauer(pfad),
|
||
dateiPfad: pfad,
|
||
coverPfad: coverPfad,
|
||
groesseBytes: stat.size,
|
||
istHeruntergeladen: true,
|
||
istKorrupt: istKorrupt,
|
||
downloadQuelle: 'local',
|
||
));
|
||
} catch (e) {
|
||
MeloLogger().fehler('scanner_datei', e);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// In DB speichern
|
||
final vorhandene = await _db.alleSongs();
|
||
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
|
||
|
||
final neue = gefunden.where((s) => !vorhandenePfade.contains(s.dateiPfad)).toList();
|
||
if (neue.isNotEmpty) {
|
||
await _db.songsEinfuegen(neue);
|
||
_neueSongs = neue.length;
|
||
}
|
||
|
||
// ── YouTube-Quell-URLs für Songs ohne ytUrl nachschlagen ──
|
||
await _sucheYtUrls();
|
||
|
||
return gefunden;
|
||
}
|
||
|
||
Future<List<String>> _sammleMusikPfade() async {
|
||
final pfade = <String>{};
|
||
// 1. PRIMÄR: App-interner + Downloads-Ordner
|
||
try {
|
||
final appDir = await getApplicationDocumentsDirectory();
|
||
final internDir = Directory('${appDir.path}/music');
|
||
if (await internDir.exists()) await _durchsucheOrdner(internDir, pfade);
|
||
|
||
// Downloads/Melo (für Dateimanager sichtbar)
|
||
if (Platform.isAndroid) {
|
||
final dlDir = await getDownloadsDirectory();
|
||
if (dlDir != null) {
|
||
final meloDir = Directory('${dlDir.path}/Melo');
|
||
if (await meloDir.exists()) await _durchsucheOrdner(meloDir, pfade);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('scanner_intern_pfad', e);
|
||
}
|
||
|
||
// 2. Externe System-Ordner (nur Android, nur wenn Berechtigung vorhanden)
|
||
if (Platform.isAndroid) {
|
||
final hatZugriff = await frageSpeicherZugriff();
|
||
if (!hatZugriff) return pfade.toList();
|
||
|
||
final ordner = <String>[
|
||
'/storage/emulated/0/Music',
|
||
'/storage/emulated/0/Download',
|
||
'/storage/emulated/0/Musik',
|
||
'/storage/emulated/0/Downloads',
|
||
'/sdcard/Music',
|
||
'/sdcard/Download',
|
||
];
|
||
|
||
// Externen Speicherpfad NUR für App-eigene Daten, nicht ganz /storage
|
||
try {
|
||
final extern = await getExternalStorageDirectory();
|
||
if (extern != null) {
|
||
// Nur scannen wenn es ein spezifischer Unterordner ist (nicht Root)
|
||
final externPath = extern.path;
|
||
if (externPath.contains('Android/data') || externPath.contains('Melo')) {
|
||
ordner.add(externPath);
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
for (final ord in ordner) {
|
||
try {
|
||
final dir = Directory(ord);
|
||
if (await dir.exists()) {
|
||
await _durchsucheOrdner(dir, pfade);
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('scanner_ordner_zugriff_${ord.split('/').last}', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
return pfade.toList();
|
||
}
|
||
|
||
Future<void> _durchsucheOrdner(Directory dir, Set<String> pfade, {int tiefe = 0}) async {
|
||
if (tiefe > 4) return;
|
||
try {
|
||
final stream = dir.list(followLinks: false);
|
||
await for (final entity in stream.handleError((_) {})) {
|
||
if (entity is File) {
|
||
final ext = entity.path.toLowerCase();
|
||
if (ext.endsWith('.mp3') || ext.endsWith('.m4a') ||
|
||
ext.endsWith('.flac') || ext.endsWith('.wav') ||
|
||
ext.endsWith('.aac') || ext.endsWith('.ogg')) {
|
||
pfade.add(entity.path);
|
||
}
|
||
} else if (entity is Directory) {
|
||
await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('scanner_durchsuchen_fehler', '$dir: $e');
|
||
}
|
||
}
|
||
|
||
/// Leichtgewichtige Dauer-Ermittlung OHNE AudioPlayer (Issue #15).
|
||
/// Parst Container-Header direkt aus der Datei – nur stdlib, kein neues Package:
|
||
/// - MP3: MPEG-Frame-Header (Bitrate) → CBR-Schätzung
|
||
/// - M4A: moov/mvhd-Box (Timescale + Duration)
|
||
/// - FLAC: STREAMINFO (TotalSamples / SampleRate)
|
||
/// - WAV: data-Chunk / ByteRate
|
||
/// - OGG/AAC: 0 (überspringen, Dauer unbekannt)
|
||
Future<int> _ermittleDauer(String pfad) async {
|
||
try {
|
||
final file = File(pfad);
|
||
if (!await file.exists()) return 0;
|
||
final size = await file.length();
|
||
if (size < 16) return 0;
|
||
|
||
final raf = await file.open();
|
||
try {
|
||
await raf.setPosition(0);
|
||
final kopf = await raf.read(12);
|
||
if (kopf.length < 4) return 0;
|
||
|
||
// MP3: ID3v2-Tag oder direkter MPEG-Frame-Sync
|
||
if (kopf[0] == 0x49 && kopf[1] == 0x44 && kopf[2] == 0x33) {
|
||
return await _dauerMp3(raf, size, id3v2: true);
|
||
}
|
||
if (kopf[0] == 0xFF && (kopf[1] & 0xE0) == 0xE0) {
|
||
return await _dauerMp3(raf, size, id3v2: false);
|
||
}
|
||
// FLAC: "fLaC"
|
||
if (kopf[0] == 0x66 && kopf[1] == 0x4C &&
|
||
kopf[2] == 0x61 && kopf[3] == 0x43) {
|
||
return await _dauerFlac(raf);
|
||
}
|
||
// WAV: "RIFF"
|
||
if (kopf[0] == 0x52 && kopf[1] == 0x49 &&
|
||
kopf[2] == 0x46 && kopf[3] == 0x46) {
|
||
return await _dauerWav(raf, size);
|
||
}
|
||
// M4A/AAC: ftyp-Box an Position 4
|
||
if (kopf.length >= 8 &&
|
||
kopf[4] == 0x66 && kopf[5] == 0x74 &&
|
||
kopf[6] == 0x79 && kopf[7] == 0x70) {
|
||
return await _dauerM4a(raf, size);
|
||
}
|
||
return 0; // OGG/AAC: überspringen
|
||
} finally {
|
||
await raf.close();
|
||
}
|
||
} catch (_) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// ── MP3: MPEG-Frame-Header (Bitraten-Tabellen, kbps) ──
|
||
|
||
static const List<int> _mpeg1Bitraten = [
|
||
// Index 0 = free-format (nicht unterstützt)
|
||
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
|
||
];
|
||
static const List<int> _mpeg2Bitraten = [
|
||
0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160,
|
||
];
|
||
|
||
/// Parst einen MPEG-Audio-Frame-Header ab Position [i].
|
||
/// Liefert (bitrateKbps, frameLen) oder null bei ungültigem Header.
|
||
(int, int)? _parseMpegHeader(Uint8List d, int i) {
|
||
if (i + 3 >= d.length) return null;
|
||
if (d[i] != 0xFF) return null;
|
||
if ((d[i + 1] & 0xE0) != 0xE0) return null;
|
||
|
||
final versionBits = (d[i + 1] >> 3) & 0x03;
|
||
final layerBits = (d[i + 1] >> 1) & 0x03;
|
||
if (versionBits == 1) return null; // reserviert
|
||
if (layerBits == 0) return null; // reserviert
|
||
|
||
final bitrateIdx = d[i + 2] >> 4;
|
||
final sampleIdx = (d[i + 2] >> 2) & 0x03;
|
||
final padding = (d[i + 2] >> 1) & 0x01;
|
||
if (bitrateIdx == 15 || bitrateIdx == 0 || sampleIdx == 3) return null;
|
||
|
||
final isMpeg1 = versionBits == 3;
|
||
final layer1 = layerBits == 3; // Layer I
|
||
final bitrate = (isMpeg1 ? _mpeg1Bitraten : _mpeg2Bitraten)[bitrateIdx];
|
||
|
||
final sampleRate = isMpeg1
|
||
? (sampleIdx == 0 ? 44100 : sampleIdx == 1 ? 48000 : 32000)
|
||
: versionBits == 2
|
||
? (sampleIdx == 0 ? 22050 : sampleIdx == 1 ? 24000 : 16000)
|
||
: (sampleIdx == 0 ? 11025 : sampleIdx == 1 ? 12000 : 8000);
|
||
|
||
final frameLen = layer1
|
||
? (12 * bitrate * 1000 ~/ sampleRate + padding) * 4
|
||
: 144 * bitrate * 1000 ~/ sampleRate + padding;
|
||
if (frameLen < 24) return null;
|
||
return (bitrate, frameLen);
|
||
}
|
||
|
||
Future<int> _dauerMp3(RandomAccessFile raf, int dateiGroesse,
|
||
{required bool id3v2}) async {
|
||
// ID3v2-Tag überspringen (Syncsafe-Größe in Bytes 6-9)
|
||
var start = 0;
|
||
if (id3v2) {
|
||
await raf.setPosition(0);
|
||
final id3 = await raf.read(10);
|
||
if (id3.length >= 10) {
|
||
var tagSize = 0;
|
||
for (var i = 6; i < 10; i++) {
|
||
tagSize = (tagSize << 7) | (id3[i] & 0x7F);
|
||
}
|
||
start = 10 + tagSize;
|
||
}
|
||
}
|
||
|
||
// Ersten gültigen Frame-Header suchen (max. 2 MB Scan für große Cover-Tags)
|
||
const maxScan = 2 * 1024 * 1024;
|
||
final scanBis = (start + maxScan < dateiGroesse) ? start + maxScan : dateiGroesse;
|
||
var scanPos = start;
|
||
var rest = Uint8List(0); // bis zu 3 Bytes Überlappung zwischen Chunks
|
||
|
||
while (scanPos < scanBis) {
|
||
await raf.setPosition(scanPos);
|
||
final chunk = await raf.read(4096);
|
||
if (chunk.isEmpty) break;
|
||
final data = Uint8List.fromList([...rest, ...chunk]);
|
||
|
||
for (var i = 0; i < data.length - 4; i++) {
|
||
final header = _parseMpegHeader(data, i);
|
||
if (header == null) continue;
|
||
final abs = scanPos - rest.length + i;
|
||
final frameLen = header.$2;
|
||
// 2 Folge-Frames müssen ebenfalls Sync haben (False-Positive-Schutz)
|
||
if (abs + 2 * frameLen > dateiGroesse) continue;
|
||
if (!await _hatFrameSync(raf, abs + frameLen)) continue;
|
||
if (!await _hatFrameSync(raf, abs + 2 * frameLen)) continue;
|
||
|
||
final audioBytes = dateiGroesse - abs;
|
||
if (audioBytes <= 0) return 0;
|
||
// CBR-Schätzung: audioBytes * 8 Bit / Bitrate
|
||
return audioBytes * 8 ~/ (header.$1 * 1000);
|
||
}
|
||
|
||
// Nächster Chunk mit 3-Byte-Überlappung
|
||
final uLen = data.length > 3 ? 3 : data.length;
|
||
rest = Uint8List.fromList(data.sublist(data.length - uLen));
|
||
scanPos += chunk.length;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
Future<bool> _hatFrameSync(RandomAccessFile raf, int pos) async {
|
||
if (pos < 0) return false;
|
||
await raf.setPosition(pos);
|
||
final b = await raf.read(2);
|
||
return b.length == 2 && b[0] == 0xFF && (b[1] & 0xE0) == 0xE0;
|
||
}
|
||
|
||
// ── M4A: moov → mvhd (Timescale + Duration) ──
|
||
|
||
Future<int> _dauerM4a(RandomAccessFile raf, int dateiGroesse) async {
|
||
var pos = 0;
|
||
while (pos + 8 <= dateiGroesse) {
|
||
await raf.setPosition(pos);
|
||
final kopf = await raf.read(8);
|
||
if (kopf.length < 8) break;
|
||
var boxGroesse = _u32(kopf, 0);
|
||
final typ = String.fromCharCodes(kopf.sublist(4, 8));
|
||
if (boxGroesse == 1) {
|
||
// 64-Bit-Größe (nur bei >4GB-Dateien relevant)
|
||
final ext = await raf.read(8);
|
||
if (ext.length < 8) break;
|
||
final high = _u32(ext, 0);
|
||
boxGroesse = high > 0 ? dateiGroesse - pos : _u32(ext, 4);
|
||
} else if (boxGroesse == 0) {
|
||
boxGroesse = dateiGroesse - pos; // Box reicht bis EOF
|
||
}
|
||
if (boxGroesse < 8) break;
|
||
if (typ == 'moov') {
|
||
return await _dauerMvhd(raf, pos + 8, boxGroesse - 8);
|
||
}
|
||
pos += boxGroesse;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
Future<int> _dauerMvhd(RandomAccessFile raf, int start, int laenge) async {
|
||
final ende = start + laenge;
|
||
var pos = start;
|
||
while (pos + 8 <= ende) {
|
||
await raf.setPosition(pos);
|
||
final kopf = await raf.read(8);
|
||
if (kopf.length < 8) break;
|
||
var boxGroesse = _u32(kopf, 0);
|
||
final typ = String.fromCharCodes(kopf.sublist(4, 8));
|
||
if (boxGroesse == 0) boxGroesse = ende - pos;
|
||
if (boxGroesse < 8) break;
|
||
if (typ == 'mvhd') {
|
||
await raf.setPosition(pos + 8);
|
||
final payload = await raf.read(32);
|
||
if (payload.length < 20) return 0;
|
||
final version = payload[0];
|
||
if (version == 1) {
|
||
if (payload.length < 32) return 0;
|
||
final timescale = _u32(payload, 20);
|
||
final duration = _u64(payload, 24);
|
||
if (timescale == 0) return 0;
|
||
return duration ~/ timescale;
|
||
}
|
||
final timescale = _u32(payload, 12);
|
||
final duration = _u32(payload, 16);
|
||
if (timescale == 0) return 0;
|
||
return duration ~/ timescale;
|
||
}
|
||
pos += boxGroesse;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ── FLAC: STREAMINFO (TotalSamples / SampleRate) ──
|
||
|
||
Future<int> _dauerFlac(RandomAccessFile raf) async {
|
||
await raf.setPosition(4);
|
||
final blockKopf = await raf.read(4);
|
||
if (blockKopf.length < 4) return 0;
|
||
final blockTyp = blockKopf[0] & 0x7F;
|
||
final blockLen = (blockKopf[1] << 16) | (blockKopf[2] << 8) | blockKopf[3];
|
||
if (blockTyp != 0 || blockLen < 18) return 0; // STREAMINFO erwartet
|
||
await raf.setPosition(8);
|
||
final d = await raf.read(blockLen);
|
||
if (d.length < 18) return 0;
|
||
final sampleRate = (d[10] << 12) | (d[11] << 4) | (d[12] >> 4);
|
||
final totalSamples = ((d[13] & 0x0F) << 32) |
|
||
(d[14] << 24) | (d[15] << 16) | (d[16] << 8) | d[17];
|
||
if (sampleRate == 0) return 0;
|
||
return totalSamples ~/ sampleRate;
|
||
}
|
||
|
||
// ── WAV: data-Chunk / ByteRate ──
|
||
|
||
Future<int> _dauerWav(RandomAccessFile raf, int dateiGroesse) async {
|
||
// fmt-Chunk: ByteRate liegt bei Offset 28 (nach RIFF+WAVE+fmt -Header)
|
||
await raf.setPosition(24);
|
||
final b = await raf.read(8);
|
||
if (b.length < 8) return 0;
|
||
final byteRate = _u32le(b, 4);
|
||
|
||
// data-Chunk finden (Chunks word-aligned)
|
||
var pos = 12;
|
||
while (pos + 8 <= dateiGroesse) {
|
||
await raf.setPosition(pos);
|
||
final kopf = await raf.read(8);
|
||
if (kopf.length < 8) break;
|
||
final size = _u32le(kopf, 4);
|
||
final typ = String.fromCharCodes(kopf.sublist(0, 4));
|
||
if (typ == 'data') {
|
||
if (byteRate == 0) return 0;
|
||
return size ~/ byteRate;
|
||
}
|
||
if (size < 8) break;
|
||
pos += 8 + size + (size % 2);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// ── Byte-Helfer ──
|
||
|
||
static int _u32(List<int> b, int o) =>
|
||
(b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
|
||
|
||
static int _u32le(List<int> b, int o) =>
|
||
(b[o + 3] << 24) | (b[o + 2] << 16) | (b[o + 1] << 8) | b[o];
|
||
|
||
static int _u64(List<int> b, int o) =>
|
||
(b[o] << 56) | (b[o + 1] << 48) | (b[o + 2] << 40) | (b[o + 3] << 32) |
|
||
(b[o + 4] << 24) | (b[o + 5] << 16) | (b[o + 6] << 8) | b[o + 7];
|
||
|
||
String _dateiNameOhneEndung(String pfad) {
|
||
final name = pfad.split('/').last;
|
||
final dot = name.lastIndexOf('.');
|
||
return dot > 0 ? name.substring(0, dot) : name;
|
||
}
|
||
|
||
String? _nichtLeer(dynamic wert) {
|
||
if (wert == null) return null;
|
||
final s = wert.toString().trim();
|
||
return s.isEmpty ? null : s;
|
||
}
|
||
|
||
/// Sucht YouTube-Quell-URLs für Songs ohne ytUrl (max 50 pro Scan, 1/s Rate-Limit)
|
||
Future<void> _sucheYtUrls() async {
|
||
const maxSuchanfragen = 50;
|
||
const suchUrlBasis = 'https://yt.baka-net.de/api/search';
|
||
|
||
final songsOhneUrl = await _db.songsOhneYtUrl(limit: maxSuchanfragen);
|
||
if (songsOhneUrl.isEmpty) return;
|
||
|
||
int gefunden = 0;
|
||
for (final song in songsOhneUrl) {
|
||
if (gefunden >= maxSuchanfragen) break;
|
||
|
||
try {
|
||
final query = '${song.kuenstler} ${song.titel}';
|
||
final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}';
|
||
|
||
final antwort = await http.get(
|
||
Uri.parse(url),
|
||
headers: AuthService().authHeader,
|
||
).timeout(
|
||
const Duration(seconds: 10),
|
||
);
|
||
|
||
if (antwort.statusCode == 200) {
|
||
final daten = jsonDecode(antwort.body);
|
||
if (daten is List && daten.isNotEmpty) {
|
||
final erstes = daten[0];
|
||
final ytUrl = erstes['url'] as String?;
|
||
if (ytUrl != null && ytUrl.isNotEmpty) {
|
||
await _db.ytUrlAktualisieren(song.id!, ytUrl);
|
||
gefunden++;
|
||
MeloLogger().zustand('yt_suche_treffer', {
|
||
'song_id': song.id,
|
||
'titel': song.titel,
|
||
'yt_url': ytUrl,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('yt_suche_fehler', '${song.titel}: $e');
|
||
}
|
||
|
||
// Rate-Limit: 1 Anfrage pro Sekunde
|
||
await Future.delayed(const Duration(seconds: 1));
|
||
}
|
||
|
||
if (gefunden > 0) {
|
||
MeloLogger().zustand('yt_suche_abschluss', {'gefunden': gefunden});
|
||
}
|
||
}
|
||
}
|