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 '../config/app_config.dart'; class MusikScanner { static final MusikScanner _instanz = MusikScanner._(); factory MusikScanner() => _instanz; MusikScanner._(); final DbHelper _db = DbHelper(); int _neueSongs = 0; int get anzahlNeueSongs => _neueSongs; Future 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> scanneMusikOrdner() async { _neueSongs = 0; final gefunden = []; 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) { final bytes = tags['cover'] as List; 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> _sammleMusikPfade() async { final pfade = {}; // 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 = [ '/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 _durchsucheOrdner(Directory dir, Set 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 _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 _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 _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 _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 _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 _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 _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 _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 _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 b, int o) => (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]; static int _u32le(List b, int o) => (b[o + 3] << 24) | (b[o + 2] << 16) | (b[o + 1] << 8) | b[o]; static int _u64(List 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 _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: {'X-API-Key': AppConfig.ytProxyApiKey}, ).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}); } } }