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 ae11a85872 v2.41 — Issue #3: Musik-Download im Hintergrund (flutter_background_service)
- flutter_background_service ^5.0.0 hinzugefügt
- AndroidManifest: FOREGROUND_SERVICE_DATA_SYNC Permission
- main.dart: Background Service mit configure() initialisiert
- _onServiceStart(): Service-Callback für Foreground-Garantie
- DownloadService._startBackgroundService(): Foreground Service vor Download
- DownloadService._stopBackgroundService(): Stopp nach Batch/Single
- Integration in _resetStatus, downloadBatch, downloadVonUrl
2026-08-02 16:06:04 +02:00

600 lines
19 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 '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 '../config/app_config.dart';
import '../main.dart'; // für notificationsPlugin
/// 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 String get _apiKey => AppConfig.ytProxyApiKey;
static Map<String, String> get _authHeader => {'X-API-Key': _apiKey};
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<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>();
_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<Song?> downloadVonUrl(String url) async {
_resetStatus();
final result = await _downloadEinzeln(url);
if (!_ladt) _stopBackgroundService();
return result;
}
/// 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();
_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);
_stopBackgroundService();
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 _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<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 _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<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!);
}
// ─── #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}';
}