import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; import '../utils/audio_validator.dart'; import 'melo_logger.dart'; import '../config/app_config.dart'; /// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter. /// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder. class DownloadService extends ChangeNotifier { static final DownloadService _instanz = DownloadService._(); factory DownloadService() => _instanz; DownloadService._(); static const String _proxyBasisUrl = 'https://yt.baka-net.de'; static String get _apiKey => AppConfig.ytProxyApiKey; static Map get _authHeader => {'X-API-Key': _apiKey}; final DbHelper _db = DbHelper(); bool _ladt = false; double _fortschritt = 0; String? _aktuellerTitel; String? _fehler; String _standardPfad = ''; Completer? _abbruchCompleter; bool get ladt => _ladt; double get fortschritt => _fortschritt; String? get aktuellerTitel => _aktuellerTitel; String? get fehler => _fehler; String get standardPfad => _standardPfad; /// Synchroner Abbruch-Check – prüft ob der Abbruch-Completer completed wurde. /// KEIN async/await, KEIN Duration.zero-Trick – einfach bool. bool get sollAbbrechen => _abbruchCompleter?.isCompleted ?? false; void setzeSpeicherPfad(String pfad) { _standardPfad = pfad; } void abbrechen() { if (_abbruchCompleter != null && !_abbruchCompleter!.isCompleted) { _abbruchCompleter!.complete(); } _abbruchCompleter = null; } void _resetStatus() { _ladt = true; _fortschritt = 0; _aktuellerTitel = null; _fehler = null; _abbruchCompleter = Completer(); notifyListeners(); } /// Song von YouTube herunterladen Future downloadVonUrl(String url) async { _resetStatus(); return _downloadEinzeln(url); } /// Mehrere URLs/Playlists nacheinander mit Cooldown Future downloadBatch(String input, {int cooldownSekunden = 5}) async { _resetStatus(); MeloLogger().zustand('download_batch_start', { 'input_len': input.length, }); final urls = _extrahiereUrls(input); MeloLogger().zustand('urls_extrahiert', { 'anzahl': urls.length, }); if (urls.isEmpty) { _fehler = 'Keine gültigen URLs gefunden'; _ladt = false; notifyListeners(); MeloLogger().fehler('keine_urls', 'Input: ${input.substring(0, min(input.length, 80))}'); return 0; } int erfolgreich = 0; _aktuellerTitel = '0/${urls.length} – Starte...'; notifyListeners(); for (int i = 0; i < urls.length; i++) { if (sollAbbrechen) break; _aktuellerTitel = '${i + 1}/${urls.length} – ${urls[i]._titelKurz()}'; notifyListeners(); MeloLogger().zustand('download_einzeln_start', { 'index': i, 'gesamt': urls.length, 'url': urls[i].url.substring(0, min(urls[i].url.length, 60)), }); final song = await _downloadEinzeln(urls[i].url); if (song != null) { erfolgreich++; MeloLogger().zustand('download_einzeln_erfolg', { 'titel': song.titel, 'index': i, 'erfolgreich': erfolgreich, }); } else { MeloLogger().zustand('download_einzeln_fehlgeschlagen', { 'index': i, 'fehler': _fehler ?? 'unbekannt', }); } // Cooldown zwischen Downloads (außer beim letzten) if (i < urls.length - 1 && cooldownSekunden > 0) { _aktuellerTitel = '✅ $erfolgreich/${urls.length} – Warte ${cooldownSekunden}s...'; notifyListeners(); for (int s = 0; s < cooldownSekunden; s++) { if (sollAbbrechen) break; await Future.delayed(const Duration(seconds: 1)); } } } _ladt = false; if (sollAbbrechen) { _aktuellerTitel = '❌ Abgebrochen ($erfolgreich fertig)'; } else { _aktuellerTitel = '✅ $erfolgreich/${urls.length} Songs geladen'; } notifyListeners(); MeloLogger().zustand('download_batch_ende', { 'erfolgreich': erfolgreich, 'gesamt': urls.length, 'abgebrochen': sollAbbrechen, }); return erfolgreich; } /// Einzelnen Song über den yt-proxy herunterladen Future _downloadEinzeln(String url) async { String? dateiPfad; try { // URL-Validierung if (!url.contains('youtube.com') && !url.contains('youtu.be')) { _fehler = 'Keine gültige YouTube-URL'; _ladt = false; notifyListeners(); return null; } // ─── 1/3: Proxy anfragen (Metadaten + Download) ─── _aktuellerTitel = 'Proxy wird kontaktiert...'; notifyListeners(); final stopwatch = Stopwatch()..start(); http.Response dlAntwort; try { dlAntwort = await _retryHttpPost( Uri.parse('$_proxyBasisUrl/api/yt-dl'), headers: { ..._authHeader, 'Content-Type': 'application/json', }, body: jsonEncode({'url': url}), maxVersuche: 2, timeout: const Duration(seconds: 150), ); stopwatch.stop(); } catch (e) { stopwatch.stop(); MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl', fehler: e.toString(), dauerMs: stopwatch.elapsedMilliseconds); _fehler = 'Proxy nicht erreichbar: ${e.toString().split('\n').first}'; _ladt = false; notifyListeners(); return null; } if (sollAbbrechen) { _ladt = false; notifyListeners(); return null; } if (dlAntwort.statusCode != 200) { String fehlerText; try { final fehlerJson = jsonDecode(dlAntwort.body); fehlerText = fehlerJson['error'] ?? 'Unbekannter Proxy-Fehler'; // Cooldown-Info anzeigen wenn vorhanden if (fehlerJson.containsKey('cooldown')) { final cd = fehlerJson['cooldown'] as int; fehlerText += ' (Cooldown: ${cd ~/ 60} min)'; } } catch (_) { fehlerText = dlAntwort.body.isNotEmpty ? dlAntwort.body.substring(0, min(dlAntwort.body.length, 200)) : 'Unbekannter Proxy-Fehler'; } _fehler = 'Proxy-Fehler (${dlAntwort.statusCode}): $fehlerText'; _ladt = false; notifyListeners(); debugPrint('Proxy-Fehler: ${dlAntwort.body}'); MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl', statusCode: dlAntwort.statusCode, fehler: fehlerText); return null; } final daten = jsonDecode(dlAntwort.body); final titel = daten['titel'] ?? 'Unbekannt'; final dauer = daten['dauer'] ?? 0; final dateiname = daten['dateiname'] ?? 'audio.mp3'; final mp3Url = daten['mp3_url'] ?? '/api/dl/$dateiname'; final filesize = daten['filesize'] ?? 0; MeloLogger().netzwerk('POST', '$_proxyBasisUrl/api/yt-dl', statusCode: 200, dauerMs: stopwatch.elapsedMilliseconds); _aktuellerTitel = 'Video gefunden: $titel'; notifyListeners(); // ─── 2/3: MP3 vom Proxy herunterladen ─── final mbStr = filesize > 0 ? ' (${(filesize / 1024 / 1024).toStringAsFixed(1)} MB)' : ''; _aktuellerTitel = 'Lade MP3 herunter$mbStr...'; notifyListeners(); final dir = _standardPfad.isNotEmpty ? Directory(_standardPfad) : Directory('${(await getApplicationDocumentsDirectory()).path}/music'); if (!await dir.exists()) await dir.create(recursive: true); // Sicheren Dateinamen erstellen final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim(); final lokalerName = '${safeName.isEmpty ? "song" : safeName}.mp3'; dateiPfad = '${dir.path}/$lokalerName'; final stopwatch2 = Stopwatch()..start(); bool istKorrupt = false; try { final mp3Antwort = await _retryHttpGet( Uri.parse('$_proxyBasisUrl$mp3Url'), headers: _authHeader, maxVersuche: 2, timeout: const Duration(seconds: 120), ); stopwatch2.stop(); if (mp3Antwort.statusCode != 200) { _fehler = 'MP3-Download fehlgeschlagen (${mp3Antwort.statusCode})'; _ladt = false; notifyListeners(); MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url', statusCode: mp3Antwort.statusCode, fehler: _fehler); return null; } final file = File(dateiPfad); await file.writeAsBytes(mp3Antwort.bodyBytes); // ── Magic-Byte-Prüfung: Echte MP3-Datei? ── istKorrupt = !hatValideMagicBytes(file); if (istKorrupt) { debugPrint('⚠️ Korrupte Datei erkannt (Magic Bytes): $dateiPfad'); MeloLogger().fehler('magic_bytes_check', 'Ungültiger MP3-Header in $dateiPfad'); } MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url', statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds); } catch (e) { stopwatch2.stop(); _fehler = 'Download-Fehler: ${e.toString().split('\n').first}'; _ladt = false; notifyListeners(); debugPrint('MP3-Download Fehler: $e'); MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url', fehler: e.toString(), dauerMs: stopwatch2.elapsedMilliseconds); _raeumeAuf(dateiPfad); return null; } if (sollAbbrechen) { _raeumeAuf(dateiPfad); _ladt = false; notifyListeners(); return null; } // ─── 3/3: Song speichern ─── _aktuellerTitel = 'Speichere in Datenbank...'; notifyListeners(); // Künstler aus Titel extrahieren (yt-dlp liefert nur Titel) String kuenstler = 'YouTube'; final titelTeile = titel.split(' - '); if (titelTeile.length >= 2) { kuenstler = titelTeile.first.trim(); } final song = Song( titel: titel, kuenstler: kuenstler, album: 'YouTube', dauerSekunden: dauer, dateiPfad: dateiPfad, groesseBytes: await File(dateiPfad).length(), istHeruntergeladen: true, istKorrupt: istKorrupt, downloadQuelle: 'youtube', ); await _db.songEinfuegen(song); _ladt = false; _aktuellerTitel = '✅ ${song.titel}'; notifyListeners(); return song; } catch (e) { _fehler = 'Fehler: ${e.toString().split('\n').first}'; debugPrint('Download allgemeiner Fehler: $e'); MeloLogger().fehler('download_einzeln_crash', e); _raeumeAuf(dateiPfad); _ladt = false; notifyListeners(); return null; } } /// HTTP POST mit Retry (exponentieller Backoff) Future _retryHttpPost( Uri url, { Map? headers, String? body, int maxVersuche = 2, Duration timeout = const Duration(seconds: 150), }) async { Object? lastError; for (int versuch = 0; versuch < maxVersuche; versuch++) { try { final response = await http .post(url, headers: headers, body: body) .timeout(timeout); return response; } on TimeoutException catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('⏱ POST timeout, retry ${versuch + 1}/$maxVersuche...'); await Future.delayed(Duration(seconds: (versuch + 1) * 2)); } } on SocketException catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('🔌 POST socket error, retry ${versuch + 1}/$maxVersuche...'); await Future.delayed(Duration(seconds: (versuch + 1) * 3)); } } catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('⚠️ POST error, retry ${versuch + 1}/$maxVersuche: $e'); await Future.delayed(Duration(seconds: (versuch + 1) * 2)); } } } throw lastError!; } /// HTTP GET mit Retry (exponentieller Backoff) Future _retryHttpGet( Uri url, { Map? headers, int maxVersuche = 2, Duration timeout = const Duration(seconds: 120), }) async { Object? lastError; for (int versuch = 0; versuch < maxVersuche; versuch++) { try { final response = await http .get(url, headers: headers) .timeout(timeout); return response; } on TimeoutException catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('⏱ GET timeout, retry ${versuch + 1}/$maxVersuche...'); await Future.delayed(Duration(seconds: (versuch + 1) * 2)); } } on SocketException catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('🔌 GET socket error, retry ${versuch + 1}/$maxVersuche...'); await Future.delayed(Duration(seconds: (versuch + 1) * 3)); } } catch (e) { lastError = e; if (versuch < maxVersuche - 1) { debugPrint('⚠️ GET error, retry ${versuch + 1}/$maxVersuche: $e'); await Future.delayed(Duration(seconds: (versuch + 1) * 2)); } } } throw lastError!; } /// Halb-heruntergeladene Datei löschen void _raeumeAuf(String? pfad) { if (pfad == null) return; try { final file = File(pfad); if (file.existsSync()) { file.deleteSync(); debugPrint('🧹 Aufgeräumt: $pfad'); } } catch (e) { debugPrint('Aufräumen fehlgeschlagen: $e'); } } @override void dispose() { abbrechen(); super.dispose(); } } int min(int a, int b) => a < b ? a : b; /// Helper: URLs aus Eingabe extrahieren (einzeln, Playlist, mehrzeilig) List<_UrlEintrag> _extrahiereUrls(String input) { final result = <_UrlEintrag>[]; // FIX: '\n' statt '\\n' – echte Newlines splitten final zeilen = input.split('\n'); for (final zeile in zeilen) { final trimmed = zeile.trim(); if (trimmed.isEmpty) continue; if (trimmed.contains('playlist') || trimmed.contains('list=')) { result.add(_UrlEintrag(trimmed, '📋 Playlist')); } else if (trimmed.contains('youtube.com') || trimmed.contains('youtu.be')) { result.add(_UrlEintrag(trimmed, '🎵 Song')); } } return result; } class _UrlEintrag { final String url; final String typ; _UrlEintrag(this.url, this.typ); String _titelKurz() => '$typ ${url.length > 40 ? url.substring(0, 40) : url}'; }