Melo v2.10 - YouTube Proxy, Download-Screen, Logger, iOS
This commit is contained in:
@@ -1,117 +1,303 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.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';
|
||||
|
||||
class DownloadService {
|
||||
/// 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._();
|
||||
|
||||
final YoutubeExplode _yt = YoutubeExplode();
|
||||
static const String _proxyBasisUrl = 'https://yt.baka-net.de';
|
||||
static const String _apiKey = 'melo-yT9xK7pQ3nR5vW2b';
|
||||
static const Map<String, String> _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;
|
||||
}
|
||||
|
||||
String titel = '';
|
||||
String kuenstler = '';
|
||||
int dauer = 0;
|
||||
// ─── 1/3: Proxy anfragen (Metadaten + Download) ───
|
||||
_aktuellerTitel = 'Proxy wird kontaktiert...';
|
||||
notifyListeners();
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
http.Response dlAntwort;
|
||||
|
||||
// Video-Info mit Timeout
|
||||
_aktuellerTitel = 'YouTube wird kontaktiert...';
|
||||
try {
|
||||
final video = await _yt.videos.get(url).timeout(const Duration(seconds: 15));
|
||||
titel = video.title;
|
||||
kuenstler = video.author;
|
||||
dauer = video.duration?.inSeconds ?? 0;
|
||||
_aktuellerTitel = 'Video gefunden: $titel';
|
||||
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) {
|
||||
_fehler = 'Timeout/Fehler bei Video-Info: ${e.toString()}';
|
||||
debugPrint('Video-Info Fehler (vollständig): $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;
|
||||
}
|
||||
|
||||
// Manifest abrufen
|
||||
_aktuellerTitel = 'Audio-Stream wird ermittelt... (2/4)';
|
||||
AudioStreamInfo? audio;
|
||||
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 manifest = await _yt.videos.streamsClient
|
||||
.getManifest(url).timeout(const Duration(seconds: 15));
|
||||
final streams = manifest.audioOnly.toList();
|
||||
if (streams.isEmpty) {
|
||||
_fehler = 'Kein Audio-Stream verfügbar';
|
||||
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;
|
||||
}
|
||||
audio = streams.reduce((a, b) => a.bitrate.bitsPerSecond > b.bitrate.bitsPerSecond ? a : b);
|
||||
} catch (e) {
|
||||
_fehler = 'Stream-Fehler: ${e.toString()}';
|
||||
debugPrint('Stream Manifest Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Zielpfad
|
||||
_aktuellerTitel = 'Speicher wird vorbereitet... (3/4)';
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musikDir = Directory('${dir.path}/music');
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp4';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
// Download
|
||||
_aktuellerTitel = 'Lade herunter... (4/4)';
|
||||
try {
|
||||
final fileStream = _yt.videos.streamsClient.get(audio);
|
||||
final file = File(dateiPfad);
|
||||
final sink = file.openWrite();
|
||||
int downloaded = 0;
|
||||
final total = audio.size.totalBytes;
|
||||
await for (final chunk in fileStream) {
|
||||
sink.add(chunk);
|
||||
downloaded += chunk.length;
|
||||
_fortschritt = total > 0 ? downloaded / total : 0.0;
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
await file.writeAsBytes(mp3Antwort.bodyBytes);
|
||||
|
||||
MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
|
||||
statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds);
|
||||
|
||||
} catch (e) {
|
||||
_fehler = 'Download-Fehler: ${e.toString()}';
|
||||
debugPrint('Download Stream Fehler (vollständig): $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;
|
||||
}
|
||||
|
||||
// Song speichern
|
||||
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,
|
||||
@@ -125,17 +311,141 @@ class DownloadService {
|
||||
await _db.songEinfuegen(song);
|
||||
|
||||
_ladt = false;
|
||||
_aktuellerTitel = '✅ ${song.titel} heruntergeladen';
|
||||
_aktuellerTitel = '✅ ${song.titel}';
|
||||
notifyListeners();
|
||||
return song;
|
||||
|
||||
} catch (e) {
|
||||
_fehler = 'Fehler: ${e.toString()}';
|
||||
debugPrint('Download allgemeiner Fehler (vollständig): $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() {
|
||||
_yt.close();
|
||||
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}';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Umfassendes Log-System für die Melo App.
|
||||
/// Zeichnet ALLES auf: Aktionen, Fehler, Netzwerk, Performance.
|
||||
class MeloLogger {
|
||||
static final MeloLogger _instanz = MeloLogger._();
|
||||
factory MeloLogger() => _instanz;
|
||||
MeloLogger._();
|
||||
|
||||
bool _initialisiert = false;
|
||||
String _sessionId = '';
|
||||
String _version = '2.7';
|
||||
final List<Map<String, dynamic>> _eintraege = [];
|
||||
int _logId = 0;
|
||||
Timer? _flushTimer;
|
||||
|
||||
static const String _serverUrl = 'http://159.195.51.99:8991/api/log';
|
||||
|
||||
void init(String version) {
|
||||
if (_initialisiert) return;
|
||||
_initialisiert = true;
|
||||
_version = version;
|
||||
_sessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
_flushTimer = Timer.periodic(const Duration(seconds: 30), (_) => _senden());
|
||||
_addEintrag('app', 'start', {'version': version, 'session': _sessionId});
|
||||
|
||||
FlutterError.onError = (details) {
|
||||
FlutterError.presentError(details);
|
||||
_addEintrag('crash', 'flutter_error', {
|
||||
'fehler': details.exceptionAsString(),
|
||||
'stack': details.stack.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
_addEintrag('crash', 'dart_error', {
|
||||
'fehler': error.toString(),
|
||||
'stack': stack.toString(),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
debugPrint('📋 MeloLogger ready (Session: $_sessionId)');
|
||||
}
|
||||
|
||||
void _addEintrag(String kategorie, String aktion, [Map<String, dynamic>? details]) {
|
||||
_eintraege.add({
|
||||
'id': _logId++,
|
||||
'zeit': DateTime.now().toIso8601String(),
|
||||
'kategorie': kategorie,
|
||||
'aktion': aktion,
|
||||
'details': details ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
void aktion(String name, [Map<String, dynamic>? details]) {
|
||||
_addEintrag('aktion', name, details);
|
||||
}
|
||||
|
||||
void netzwerk(String methode, String url, {int? statusCode, String? fehler, int? dauerMs}) {
|
||||
_addEintrag('netzwerk', '$methode $url', {
|
||||
if (statusCode != null) 'status': statusCode,
|
||||
if (fehler != null) 'fehler': fehler,
|
||||
if (dauerMs != null) 'dauer_ms': dauerMs,
|
||||
});
|
||||
}
|
||||
|
||||
void fehler(String kontext, Object? error, [StackTrace? stack]) {
|
||||
_addEintrag('fehler', kontext, {
|
||||
'error': error.toString(),
|
||||
'stack': stack?.toString() ?? '',
|
||||
});
|
||||
_senden();
|
||||
}
|
||||
|
||||
void performance(String name, int dauerMs) {
|
||||
_addEintrag('performance', name, {'dauer_ms': dauerMs});
|
||||
}
|
||||
|
||||
void zustand(String name, Map<String, dynamic> daten) {
|
||||
_addEintrag('zustand', name, daten);
|
||||
}
|
||||
|
||||
void manuell(String nachricht) {
|
||||
_addEintrag('user', nachricht);
|
||||
_senden();
|
||||
}
|
||||
|
||||
Future<void> _senden() async {
|
||||
if (_eintraege.isEmpty) return;
|
||||
|
||||
final batch = _eintraege.toList();
|
||||
_eintraege.clear();
|
||||
|
||||
try {
|
||||
await http.post(
|
||||
Uri.parse(_serverUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'typ': 'log_batch',
|
||||
'session': _sessionId,
|
||||
'version': _version,
|
||||
'device': '${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
'eintraege': batch,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
} catch (_) {
|
||||
_eintraege.insertAll(0, batch);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_flushTimer?.cancel();
|
||||
_senden();
|
||||
}
|
||||
}
|
||||
@@ -100,27 +100,38 @@ class NavidromeService {
|
||||
try {
|
||||
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
||||
return r.statusCode == 200;
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
debugPrint('Navidrome Ping Fehler: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Alben abrufen
|
||||
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
||||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
||||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
||||
try {
|
||||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
||||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Navidrome getAlben Fehler: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Songs eines Albums abrufen
|
||||
Future<List<SubsonicSong>> getSongs(String albumId) async {
|
||||
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
|
||||
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
||||
try {
|
||||
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
|
||||
if (r.statusCode != 200) return [];
|
||||
final data = jsonDecode(r.body);
|
||||
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
|
||||
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('Navidrome getSongs Fehler: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Song-Stream-URL
|
||||
@@ -141,7 +152,8 @@ class NavidromeService {
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
|
||||
final safeName = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp3';
|
||||
final kurzId = s.id.length > 8 ? s.id.substring(0, 8) : s.id;
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}_$kurzId.mp3';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
final file = File(dateiPfad);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../models/song.dart';
|
||||
@@ -43,7 +44,16 @@ class PlayerService {
|
||||
_aktuellerIndex = _warteschlange.length - 1;
|
||||
}
|
||||
try {
|
||||
await _p.setFilePath(song.dateiPfad);
|
||||
if (song.streamUrl != null && song.streamUrl!.isNotEmpty) {
|
||||
// Streaming vom Server
|
||||
await _p.setUrl(song.streamUrl!);
|
||||
} else if (song.dateiPfad.isNotEmpty && await File(song.dateiPfad).exists()) {
|
||||
// Lokale Datei
|
||||
await _p.setFilePath(song.dateiPfad);
|
||||
} else {
|
||||
debugPrint('Keine gültige Quelle für: ${song.titel}');
|
||||
return;
|
||||
}
|
||||
if (position > 0) await _p.seek(Duration(seconds: position));
|
||||
await _p.play();
|
||||
_songWechsel.add(song);
|
||||
|
||||
Reference in New Issue
Block a user