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 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; import '../utils/audio_validator.dart'; import 'melo_logger.dart'; import 'auth_service.dart'; import '../main.dart'; // für notificationsPlugin import '../utils/sanitize.dart'; /// Download-Service: Lädt YouTube-Audio über den yt-proxy herunter. /// Nutzt ChangeNotifier für UI-Updates via ListenableBuilder. /// Issue #9: Fortschritt per System-Notification (ProgressBar). class DownloadService extends ChangeNotifier { static final http.Client _client = http.Client(); static final DownloadService _instanz = DownloadService._(); factory DownloadService() => _instanz; DownloadService._(); static const String _proxyBasisUrl = 'https://yt.baka-net.de'; static Map get _authHeader => AuthService().authHeader; static const String _channelId = 'de.baka.melo.downloads'; static const int _notifyProgressId = 100; static const int _notifyCompleteId = 101; 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(); _startBackgroundService(); notifyListeners(); } /// Issue #3: Foreground Service starten – hält App im Hintergrund aktiv void _startBackgroundService() { try { final svc = FlutterBackgroundService(); svc.isRunning().then((running) { if (!running) { svc.startService(); } }); debugPrint('🔵 Background Service gestartet'); } catch (e) { debugPrint('Background Service Start fehlgeschlagen: $e'); } } /// Issue #3: Foreground Service stoppen void _stopBackgroundService() { try { final svc = FlutterBackgroundService(); svc.isRunning().then((running) { if (running) { svc.invoke('stopService'); } }); debugPrint('🔴 Background Service gestoppt'); } catch (e) { debugPrint('Background Service Stop fehlgeschlagen: $e'); } } /// Song von YouTube herunterladen Future downloadVonUrl(String url) async { _resetStatus(); try { return await _downloadEinzeln(url); } finally { _stopBackgroundService(); } } /// Mehrere URLs/Playlists nacheinander mit Cooldown Future downloadBatch(String input, {int cooldownSekunden = 5}) async { _resetStatus(); try { 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(); _showProgress(0, urls.length); for (int i = 0; i < urls.length; i++) { if (sollAbbrechen) break; _aktuellerTitel = '${i + 1}/${urls.length} – ${urls[i]._titelKurz()}'; notifyListeners(); _showProgress(i, urls.length, titel: urls[i]._titelKurz()); 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(); _completeNotification(erfolgreich, urls.length, abgebrochen: sollAbbrechen); MeloLogger().zustand('download_batch_ende', { 'erfolgreich': erfolgreich, 'gesamt': urls.length, 'abgebrochen': sollAbbrechen, }); return erfolgreich; } finally { _stopBackgroundService(); } } /// 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.length > 200 ? '${dlAntwort.body.substring(0, 200)}...' : 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 (zentrale Sanitize-Funktion) final safeName = sanitizeDateiname(titel); final lokalerName = '$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', ytUrl: url, ); 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 _client .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 _client .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'); } } /// Korrupten Song mit bekannter ytUrl neu herunterladen Future reDownload(Song song) async { if (song.ytUrl == null || song.ytUrl!.isEmpty) { _fehler = 'Keine YouTube-Quell-URL für diesen Song'; notifyListeners(); return null; } _resetStatus(); try { _aktuellerTitel = '🔄 Lade neu: ${song.titel}'; notifyListeners(); // Alten Song-Eintrag aus DB löschen if (song.id != null) { await _db.loeschSong(song.id!); } // Alte Datei löschen try { final alteDatei = File(song.dateiPfad); if (await alteDatei.exists()) { await alteDatei.delete(); } } catch (_) {} // Neu herunterladen mit der gespeicherten ytUrl return _downloadEinzeln(song.ytUrl!); } finally { _stopBackgroundService(); } } // ─── #9: Notification-Progress ───────────────── /// Zeigt oder aktualisiert eine Fortschritts-Notification. /// [current] = aktueller Index (1-basiert), [total] = Gesamtanzahl. void _showProgress(int current, int total, {String? titel}) { if (!Platform.isAndroid) return; notificationsPlugin.show( id: _notifyProgressId, title: 'Melo lädt herunter', body: titel ?? '$current / $total Songs', notificationDetails: NotificationDetails( android: AndroidNotificationDetails( _channelId, 'Melo Downloads', channelDescription: 'Download-Fortschritt', importance: Importance.low, priority: Priority.low, onlyAlertOnce: true, showProgress: true, maxProgress: total, progress: current, ongoing: true, autoCancel: false, ), ), ); } /// Zeigt Abschluss-Notification (Erfolg oder Fehler). void _completeNotification(int erfolgreich, int gesamt, {bool abgebrochen = false}) { if (!Platform.isAndroid) return; // Fortschritts-Notification abbrechen notificationsPlugin.cancel(id: _notifyProgressId); final titel = abgebrochen ? 'Download abgebrochen' : (erfolgreich > 0 ? 'Download fertig' : 'Download fehlgeschlagen'); final body = abgebrochen ? '$erfolgreich / $gesamt Songs geladen' : (erfolgreich > 0 ? '$erfolgreich / $gesamt Songs geladen ✅' : 'Kein Song konnte geladen werden ❌'); notificationsPlugin.show( id: _notifyCompleteId, title: titel, body: body, notificationDetails: NotificationDetails( android: AndroidNotificationDetails( _channelId, 'Melo Downloads', channelDescription: 'Download-Abschluss', importance: Importance.defaultImportance, priority: Priority.defaultPriority, autoCancel: true, ), ), ); } @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}'; }