Files
Melo/lib/services/yt_download_service.dart
T
Hermes (Server)andClaude Sonnet 5 d03a4e93a0 Cloud-Account-Kontingent: App liest cloud_remaining, sobald der Server es liefert
BakaAuth bekommt verbleibend/merkeVerbleibend analog zu GastZugang.
YtDownloadService liest cloud_remaining bei Cloud-Zugriff (nicht bei
Gast-Zugriff — Felder bleiben getrennt). UI zeigt "Noch N von 100
heute" für angemeldete Cloud-Accounts. Reine App-Seite, wirkungslos
bis der Server (separates Vorhaben) cloud_remaining liefert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
2026-08-29 13:22:04 +02:00

237 lines
7.3 KiB
Dart

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<void>? _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<T> _abbrechbar<T>(Future<T> aufgabe, Completer<void> abbruch) {
return Future.any<T>([
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<YtErgebnis?> herunterladen(String url,
{required String zielOrdner, bool cookies = true}) async {
_fehler = null;
_laeuft = true;
final abbruch = _abbruch = Completer<void>();
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<String, dynamic>;
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);
}
final cloudRest = daten['cloud_remaining'] as int?;
if (!gastAktiv && cloudRest != null) {
auth.merkeVerbleibend(cloudRest);
}
// ─── 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<String, dynamic>;
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})';
}
}