This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/download_service.dart
T
Dustin 36c744d6e3 v2.31 — Login, Einstellungen & Cloud Sync Redesign
- Login-Screen im Melo-Design (Schwarz+Rot) mit Baka-Auth JWT
- AuthService fuer Token-Management ueber baka-net.de/auth
- Registrierungs-Modus + 'Ohne Login fortfahren'
- Navidrome-Credentials optional im Login aufklappbar

- SettingsScreen ersetzt AlertDialog
- BottomNav navigiert statt Dialog zu triggern
- Cloud Sync, Server verbinden, Diagnosedaten, App-Info, Abmelden

- CloudEinstellungen-Dialog entfernt, alles in CloudScreen konsolidiert
- Hardcode login('Baka','') entfernt → AuthService.benutzer
- Gradient-Statuskarte, animierte Segment-Intervall-Auswahl
- JWT Authorization statt Klartext-Passwort

- AppConfig: API-Key defaultValue entfernt (kein Fallback-Leak)
- Build crasht ohne --dart-define MELO_API_KEY

Build: split APK (arm64 19.6M, armeabi 17.1M, x86_64 21.1M)
2026-08-01 15:03:22 +02:00

453 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '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();
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);
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,
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<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');
}
}
@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}';
}