import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'baka_auth.dart'; import 'gast_zugang.dart'; /// Entfernt alles, was in einem Dateinamen Ärger macht. String sichererDateiname(String titel) => titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); /// Signalisiert, dass [YtDownloadService.abbrechen] während einer laufenden /// Anfrage aufgerufen wurde. class _Abgebrochen implements Exception { const _Abgebrochen(); } /// Ergebnis eines geglückten Downloads. class YtErgebnis { const YtErgebnis({ required this.dateiPfad, required this.titel, required this.kuenstler, required this.dauerSekunden, }); final String dateiPfad; final String titel; final String kuenstler; final int dauerSekunden; } /// Lädt Musik über den Baka-YouTube-Proxy herunter. Der Proxy erledigt die /// eigentliche Arbeit und liefert eine fertige MP3; die App speichert sie nur. class YtDownloadService extends ChangeNotifier { YtDownloadService({required this.auth, this.gast, http.Client? client}) : _client = client ?? http.Client(); static const proxyUrl = 'https://yt.baka-net.de'; final BakaAuth auth; final GastZugang? gast; final http.Client _client; bool _laeuft = false; String? _status; String? _fehler; /// Ist gesetzt, während [herunterladen] läuft. [abbrechen] löst es aus — /// die Netzwerk-Anfrage selbst läuft im Hintergrund weiter, bis der Socket /// sie beendet, aber die App wartet nicht länger darauf. Completer? _abbruch; bool get laeuft => _laeuft; /// Was gerade passiert — für die Anzeige während des Downloads. String? get status => _status; String? get fehler => _fehler; void _melde(String text) { _status = text; notifyListeners(); } void _scheitere(String text) { _fehler = text; _status = null; _laeuft = false; notifyListeners(); } /// Bricht den laufenden Download ab, falls einer läuft. void abbrechen() { _abbruch?.complete(); } /// Wartet auf [aufgabe], bricht aber sofort mit [_Abgebrochen] ab, sobald /// [abbrechen] aufgerufen wird — ohne auf das Ende von [aufgabe] zu warten. Future _abbrechbar(Future aufgabe, Completer abbruch) { return Future.any([ aufgabe, abbruch.future.then((_) => throw const _Abgebrochen()), ]); } /// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab. /// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für /// altersbeschränkte Videos. /// Gibt bei Erfolg das Ergebnis zurück, sonst `null` (siehe [fehler]). Future herunterladen(String url, {required String zielOrdner, bool cookies = true}) async { _fehler = null; _laeuft = true; final abbruch = _abbruch = Completer(); notifyListeners(); final gastAktiv = !auth.istAngemeldet && (gast?.hatToken ?? false); if (!auth.istAngemeldet && !gastAktiv) { _scheitere('Bitte zuerst beim Baka-Konto anmelden'); return null; } if (!url.contains('youtube.com') && !url.contains('youtu.be')) { _scheitere('Das ist keine YouTube-Adresse'); return null; } final zugangsHeader = gastAktiv ? gast!.gastHeader : auth.authHeader; // ─── 1/2: Proxy fragen — er lädt das Video und wandelt es um ─── _melde('Proxy wird gefragt …'); final http.Response antwort; try { antwort = await _abbrechbar( _client .post( Uri.parse('$proxyUrl/api/yt-dl'), headers: { ...zugangsHeader, 'Content-Type': 'application/json' }, body: jsonEncode({'url': url, 'cookies': cookies}), ) .timeout(const Duration(seconds: 180)), abbruch, ); } on _Abgebrochen { _scheitere('Abgebrochen'); return null; } on TimeoutException { // Anders als bei einem Verbindungsfehler ist der Proxy hier vermutlich // erreichbar, arbeitet nur noch — "nicht erreichbar" würde in die // Irre führen. _scheitere('Der Server braucht zu lange — bitte später erneut versuchen'); return null; } catch (e) { debugPrint('YT-Proxy nicht erreichbar: $e'); _scheitere('Proxy nicht erreichbar'); return null; } if (antwort.statusCode == 401) { if (gastAktiv) unawaited(gast!.holeToken()); _scheitere(gastAktiv ? 'Gast-Zugang abgelaufen — bitte erneut versuchen' : 'Anmeldung abgelaufen — bitte neu anmelden'); return null; } if (antwort.statusCode != 200) { _scheitere(_fehlerText(antwort)); return null; } final daten = jsonDecode(antwort.body) as Map; final titel = daten['titel'] as String? ?? 'Unbekannt'; final dauer = (daten['dauer'] as int?) ?? 0; final mp3Url = daten['mp3_url'] as String? ?? '/api/dl/audio.mp3'; final gastRest = daten['guest_remaining'] as int?; if (gastAktiv && gastRest != null) { gast!.merkeVerbleibend(gastRest); } // ─── 2/2: fertige MP3 abholen und speichern ─── _melde('Lade „$titel“ …'); final ordner = Directory(zielOrdner); if (!await ordner.exists()) { await ordner.create(recursive: true); } final datei = File('${ordner.path}/${sichererDateiname(titel)}.mp3'); try { final mp3 = await _abbrechbar( _client .get(Uri.parse('$proxyUrl$mp3Url'), headers: zugangsHeader) .timeout(const Duration(seconds: 180)), abbruch, ); if (mp3.statusCode != 200) { _scheitere('Herunterladen fehlgeschlagen (${mp3.statusCode})'); return null; } await datei.writeAsBytes(mp3.bodyBytes); } on _Abgebrochen { if (await datei.exists()) { await datei.delete(); } _scheitere('Abgebrochen'); return null; } on TimeoutException { if (await datei.exists()) { await datei.delete(); } _scheitere('Der Server braucht zu lange — bitte später erneut versuchen'); return null; } catch (e) { debugPrint('MP3-Abruf fehlgeschlagen: $e'); // Halbe Dateien nicht liegen lassen — sonst taucht Müll in der // Bibliothek auf, sobald der Ordner das nächste Mal gescannt wird. if (await datei.exists()) { await datei.delete(); } _scheitere('Herunterladen fehlgeschlagen'); return null; } _laeuft = false; _status = null; notifyListeners(); // yt-dlp liefert nur einen Titel. "Künstler - Lied" ist die übliche // Form; steht da nichts dergleichen, wird nichts geraten. final teile = titel.split(' - '); return YtErgebnis( dateiPfad: datei.path, titel: titel, kuenstler: teile.length >= 2 ? teile.first.trim() : '', dauerSekunden: dauer, ); } String _fehlerText(http.Response antwort) { try { final j = jsonDecode(antwort.body) as Map; final text = j['error'] as String?; if (text != null && text.isNotEmpty) return text; } catch (_) { // Kein JSON — dann eben der Statuscode. } return 'Proxy-Fehler (${antwort.statusCode})'; } }