import 'dart:io'; import 'package:just_audio/just_audio.dart'; 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 'melo_logger.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(); final player = AudioPlayer(); 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'] ?? '', dauerSekunden: await _ermittleDauer(player, pfad), dateiPfad: pfad, coverPfad: coverPfad, groesseBytes: stat.size, istHeruntergeladen: true, istKorrupt: istKorrupt, downloadQuelle: 'local', )); } catch (e) { MeloLogger().fehler('scanner_datei', e); continue; } } try { await player.dispose(); } catch (_) {} // 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) if (Platform.isAndroid) { final ordner = [ '/storage/emulated/0/Music', '/storage/emulated/0/Download', '/storage/emulated/0/Musik', '/storage/emulated/0/Downloads', '/sdcard/Music', '/sdcard/Download', ]; try { final extern = await getExternalStorageDirectory(); if (extern != null) ordner.add(extern.path); } 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'); } } Future _ermittleDauer(AudioPlayer player, String pfad) async { try { await player.setFilePath(pfad).timeout(const Duration(milliseconds: 1500)); final dauer = player.duration; return dauer?.inSeconds ?? 0; } catch (e) { try { await player.stop(); } catch (_) {} return 0; } } String _dateiNameOhneEndung(String pfad) { final name = pfad.split('/').last; final dot = name.lastIndexOf('.'); return dot > 0 ? name.substring(0, dot) : name; } /// 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)).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}); } } }