493 lines
15 KiB
Dart
493 lines
15 KiB
Dart
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<String, String> get _authHeader => {'X-API-Key': _apiKey};
|
||
|
||
final DbHelper _db = DbHelper();
|
||
|
||
bool _ladt = false;
|
||
double _fortschritt = 0;
|
||
String? _aktuellerTitel;
|
||
String? _fehler;
|
||
String _standardPfad = '';
|
||
Completer<void>? _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<void>();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Song von YouTube herunterladen
|
||
Future<Song?> downloadVonUrl(String url) async {
|
||
_resetStatus();
|
||
return _downloadEinzeln(url);
|
||
}
|
||
|
||
/// Mehrere URLs/Playlists nacheinander mit Cooldown
|
||
Future<int> 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<Song?> _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',
|
||
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<http.Response> _retryHttpPost(
|
||
Uri url, {
|
||
Map<String, String>? 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<http.Response> _retryHttpGet(
|
||
Uri url, {
|
||
Map<String, String>? 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');
|
||
}
|
||
}
|
||
|
||
/// Korrupten Song mit bekannter ytUrl neu herunterladen
|
||
Future<Song?> reDownload(Song song) async {
|
||
if (song.ytUrl == null || song.ytUrl!.isEmpty) {
|
||
_fehler = 'Keine YouTube-Quell-URL für diesen Song';
|
||
notifyListeners();
|
||
return null;
|
||
}
|
||
|
||
_resetStatus();
|
||
_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!);
|
||
}
|
||
|
||
@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}';
|
||
}
|