v2.32 — Defekte Musik erkennen (Magic Bytes, ffprobe, DB-Flag, UI)
This commit is contained in:
@@ -20,7 +20,7 @@ class DbHelper {
|
|||||||
final pfad = await getDatabasesPath();
|
final pfad = await getDatabasesPath();
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
p.join(pfad, 'melo.db'),
|
p.join(pfad, 'melo.db'),
|
||||||
version: 2,
|
version: 3,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE songs (
|
CREATE TABLE songs (
|
||||||
@@ -33,6 +33,7 @@ class DbHelper {
|
|||||||
cover_pfad TEXT,
|
cover_pfad TEXT,
|
||||||
groesse_bytes INTEGER DEFAULT 0,
|
groesse_bytes INTEGER DEFAULT 0,
|
||||||
ist_heruntergeladen INTEGER DEFAULT 0,
|
ist_heruntergeladen INTEGER DEFAULT 0,
|
||||||
|
ist_korrupt INTEGER DEFAULT 0,
|
||||||
hinzugefuegt_am TEXT NOT NULL,
|
hinzugefuegt_am TEXT NOT NULL,
|
||||||
download_quelle TEXT DEFAULT 'local',
|
download_quelle TEXT DEFAULT 'local',
|
||||||
stream_url TEXT,
|
stream_url TEXT,
|
||||||
@@ -91,7 +92,14 @@ class DbHelper {
|
|||||||
// Spalte existiert bereits – ignorieren
|
// Spalte existiert bereits – ignorieren
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Weitere Migrationen: if (oldVersion < 3) { ... }
|
if (oldVersion < 3) {
|
||||||
|
try {
|
||||||
|
await db.execute('ALTER TABLE songs ADD COLUMN ist_korrupt INTEGER DEFAULT 0');
|
||||||
|
} catch (_) {
|
||||||
|
// Spalte existiert bereits – ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Weitere Migrationen: if (oldVersion < 4) { ... }
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,6 +182,29 @@ class DbHelper {
|
|||||||
return rows.map((r) => Song.fromMap(r)).toList();
|
return rows.map((r) => Song.fromMap(r)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markiert einen Song als korrupt
|
||||||
|
Future<void> alsKorruptMarkieren(int songId) async {
|
||||||
|
final d = await db;
|
||||||
|
await d.update('songs', {'ist_korrupt': 1},
|
||||||
|
where: 'id = ?', whereArgs: [songId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markiert einen Song als nicht-korrupt (Reparatur)
|
||||||
|
Future<void> korruptZuruecksetzen(int songId) async {
|
||||||
|
final d = await db;
|
||||||
|
await d.update('songs', {'ist_korrupt': 0},
|
||||||
|
where: 'id = ?', whereArgs: [songId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle als korrupt markierten Songs abrufen
|
||||||
|
Future<List<Song>> korrupteSongs() async {
|
||||||
|
final d = await db;
|
||||||
|
final rows = await d.query('songs',
|
||||||
|
where: 'ist_korrupt = 1',
|
||||||
|
orderBy: 'hinzugefuegt_am DESC');
|
||||||
|
return rows.map((r) => Song.fromMap(r)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tags ────────────────────────────────────────
|
// ─── Tags ────────────────────────────────────────
|
||||||
|
|
||||||
Future<int> tagErstellen(String name, {String? icon, String? farbe}) async {
|
Future<int> tagErstellen(String name, {String? icon, String? farbe}) async {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Song {
|
|||||||
final String? coverPfad;
|
final String? coverPfad;
|
||||||
final int groesseBytes;
|
final int groesseBytes;
|
||||||
final bool istHeruntergeladen;
|
final bool istHeruntergeladen;
|
||||||
|
final bool istKorrupt; // true wenn Datei defekt (Magic-Byte-Check, ffprobe, Playback-Fehler)
|
||||||
final String hinzugefuegtAm;
|
final String hinzugefuegtAm;
|
||||||
final String downloadQuelle; // "local", "youtube", "server"
|
final String downloadQuelle; // "local", "youtube", "server"
|
||||||
final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz)
|
final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz)
|
||||||
@@ -24,6 +25,7 @@ class Song {
|
|||||||
this.coverPfad,
|
this.coverPfad,
|
||||||
this.groesseBytes = 0,
|
this.groesseBytes = 0,
|
||||||
this.istHeruntergeladen = false,
|
this.istHeruntergeladen = false,
|
||||||
|
this.istKorrupt = false,
|
||||||
String? hinzugefuegtAm,
|
String? hinzugefuegtAm,
|
||||||
this.downloadQuelle = 'local',
|
this.downloadQuelle = 'local',
|
||||||
this.streamUrl,
|
this.streamUrl,
|
||||||
@@ -40,6 +42,7 @@ class Song {
|
|||||||
'cover_pfad': coverPfad,
|
'cover_pfad': coverPfad,
|
||||||
'groesse_bytes': groesseBytes,
|
'groesse_bytes': groesseBytes,
|
||||||
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
|
'ist_heruntergeladen': istHeruntergeladen ? 1 : 0,
|
||||||
|
'ist_korrupt': istKorrupt ? 1 : 0,
|
||||||
'hinzugefuegt_am': hinzugefuegtAm,
|
'hinzugefuegt_am': hinzugefuegtAm,
|
||||||
'download_quelle': downloadQuelle,
|
'download_quelle': downloadQuelle,
|
||||||
'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
|
'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit)
|
||||||
@@ -56,6 +59,7 @@ class Song {
|
|||||||
coverPfad: m['cover_pfad'] as String?,
|
coverPfad: m['cover_pfad'] as String?,
|
||||||
groesseBytes: m['groesse_bytes'] as int? ?? 0,
|
groesseBytes: m['groesse_bytes'] as int? ?? 0,
|
||||||
istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1,
|
istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1,
|
||||||
|
istKorrupt: (m['ist_korrupt'] as int?) == 1,
|
||||||
hinzugefuegtAm: m['hinzugefuegt_am'] as String?,
|
hinzugefuegtAm: m['hinzugefuegt_am'] as String?,
|
||||||
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
downloadQuelle: m['download_quelle'] as String? ?? 'local',
|
||||||
streamUrl: m['stream_url'] as String?,
|
streamUrl: m['stream_url'] as String?,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class _CloudScreenState extends State<CloudScreen> {
|
|||||||
int _syncIntervall = 6;
|
int _syncIntervall = 6;
|
||||||
Timer? _syncTimer;
|
Timer? _syncTimer;
|
||||||
String _letzterSync = 'Nie';
|
String _letzterSync = 'Nie';
|
||||||
|
List<Map> _korrupteSongs = [];
|
||||||
|
bool _ladtKorrupt = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -91,6 +93,18 @@ class _CloudScreenState extends State<CloudScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _ladeKorrupteSongs() async {
|
||||||
|
setState(() => _ladtKorrupt = true);
|
||||||
|
try {
|
||||||
|
final corrupted = await widget.cloud.getCorrupted();
|
||||||
|
if (mounted) setState(() => _korrupteSongs = corrupted);
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('cloud_corrupted_laden', e);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _ladtKorrupt = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _upload() async {
|
Future<void> _upload() async {
|
||||||
setState(() => _ladt = true);
|
setState(() => _ladt = true);
|
||||||
_setzeStatus('Suche lokale Songs...');
|
_setzeStatus('Suche lokale Songs...');
|
||||||
@@ -284,7 +298,14 @@ class _CloudScreenState extends State<CloudScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_intervallAuswahl(),
|
_intervallAuswahl(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ─── Korrupte Songs (vom Server) ───
|
||||||
|
_sektionsHeader('⚠️ Defekte Musik (Server)'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_korrupteSektion(),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Letzter Sync
|
// Letzter Sync
|
||||||
Container(
|
Container(
|
||||||
@@ -483,6 +504,112 @@ class _CloudScreenState extends State<CloudScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _korrupteSektion() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MeloTheme.dunkel1,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: MeloTheme.dunkel2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Button zum Abrufen
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _ladtKorrupt ? null : _ladeKorrupteSongs,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: MeloTheme.rot.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (_ladtKorrupt)
|
||||||
|
const SizedBox(
|
||||||
|
width: 14, height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: MeloTheme.rot, strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Icon(Icons.warning_amber_rounded, color: MeloTheme.rot, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_ladtKorrupt ? 'Prüfe...' : 'Auf defekte Musik prüfen',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MeloTheme.rot, fontSize: 13, fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Ergebnisliste
|
||||||
|
if (_korrupteSongs.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(color: MeloTheme.dunkel2, height: 1),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${_korrupteSongs.length} defekte Songs gefunden:',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFEF9A9A), fontSize: 12, fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._korrupteSongs.map((s) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('⚠️', style: TextStyle(fontSize: 13)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
s['title']?.toString() ?? 'Unbekannt',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70, fontSize: 12,
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (s['reason'] != null)
|
||||||
|
Text(
|
||||||
|
s['reason'].toString(),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: MeloTheme.textSekundaer, fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
] else if (!_ladtKorrupt && _korrupteSongs.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
'Keine defekten Songs auf dem Server',
|
||||||
|
style: TextStyle(color: Color(0xFFA5D6A7), fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IntervallOption {
|
class _IntervallOption {
|
||||||
|
|||||||
@@ -168,4 +168,20 @@ class CloudService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ruft defekte/korrupte Songs vom Cloud-Server ab
|
||||||
|
Future<List<Map>> getCorrupted() async {
|
||||||
|
try {
|
||||||
|
final r = await http
|
||||||
|
.get(Uri.parse('$_base/api/cloud/corrupted'), headers: _authHeader)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
if (r.statusCode == 200) {
|
||||||
|
final d = jsonDecode(r.body);
|
||||||
|
return List<Map>.from(d['corrupted'] ?? []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
MeloLogger().fehler('cloud_corrupted', e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ class DownloadService extends ChangeNotifier {
|
|||||||
dateiPfad = '${dir.path}/$lokalerName';
|
dateiPfad = '${dir.path}/$lokalerName';
|
||||||
|
|
||||||
final stopwatch2 = Stopwatch()..start();
|
final stopwatch2 = Stopwatch()..start();
|
||||||
|
bool istKorrupt = false;
|
||||||
try {
|
try {
|
||||||
final mp3Antwort = await _retryHttpGet(
|
final mp3Antwort = await _retryHttpGet(
|
||||||
Uri.parse('$_proxyBasisUrl$mp3Url'),
|
Uri.parse('$_proxyBasisUrl$mp3Url'),
|
||||||
@@ -266,6 +267,13 @@ class DownloadService extends ChangeNotifier {
|
|||||||
final file = File(dateiPfad);
|
final file = File(dateiPfad);
|
||||||
await file.writeAsBytes(mp3Antwort.bodyBytes);
|
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',
|
MeloLogger().netzwerk('GET', '$_proxyBasisUrl$mp3Url',
|
||||||
statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds);
|
statusCode: 200, dauerMs: stopwatch2.elapsedMilliseconds);
|
||||||
|
|
||||||
@@ -307,6 +315,7 @@ class DownloadService extends ChangeNotifier {
|
|||||||
dateiPfad: dateiPfad,
|
dateiPfad: dateiPfad,
|
||||||
groesseBytes: await File(dateiPfad).length(),
|
groesseBytes: await File(dateiPfad).length(),
|
||||||
istHeruntergeladen: true,
|
istHeruntergeladen: true,
|
||||||
|
istKorrupt: istKorrupt,
|
||||||
downloadQuelle: 'youtube',
|
downloadQuelle: 'youtube',
|
||||||
);
|
);
|
||||||
await _db.songEinfuegen(song);
|
await _db.songEinfuegen(song);
|
||||||
@@ -327,6 +336,49 @@ class DownloadService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prüft die Magic Bytes einer Audiodatei.
|
||||||
|
/// MP3: ID3-Tag (49 44 33) oder MPEG-Frame (FF FB, FF FA, FF F3, FF F2)
|
||||||
|
/// M4A/AAC: ftyp-Box (66 74 79 70)
|
||||||
|
/// FLAC: fLaC (66 4C 61 43)
|
||||||
|
/// WAV: RIFF (52 49 46 46)
|
||||||
|
/// OGG: OggS (4F 67 67 53)
|
||||||
|
static bool _hatValideMagicBytes(File file) {
|
||||||
|
try {
|
||||||
|
if (!file.existsSync()) return false;
|
||||||
|
final bytes = file.readAsBytesSync().take(16).toList();
|
||||||
|
if (bytes.length < 4) return false;
|
||||||
|
|
||||||
|
// ID3v2 Tag (MP3 mit Metadaten) — Bytes: 49 44 33
|
||||||
|
if (bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) return true;
|
||||||
|
|
||||||
|
// MP3 ohne ID3: MPEG Audio Frame Sync (FF FB, FF FA, FF F3, FF F2)
|
||||||
|
if (bytes[0] == 0xFF && (bytes[1] & 0xFE) == 0xFA) return true; // MPEG v1
|
||||||
|
if (bytes[0] == 0xFF && bytes[1] == 0xF3) return true; // MPEG v2 / v2.5
|
||||||
|
if (bytes[0] == 0xFF && bytes[1] == 0xF2) return true; // MPEG v2 / v2.5
|
||||||
|
|
||||||
|
// M4A/AAC: ftyp-Box
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[4] == 0x66 && bytes[5] == 0x74 &&
|
||||||
|
bytes[6] == 0x79 && bytes[7] == 0x70) return true;
|
||||||
|
|
||||||
|
// FLAC: fLaC
|
||||||
|
if (bytes[0] == 0x66 && bytes[1] == 0x4C &&
|
||||||
|
bytes[2] == 0x61 && bytes[3] == 0x43) return true;
|
||||||
|
|
||||||
|
// WAV: RIFF
|
||||||
|
if (bytes[0] == 0x52 && bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46 && bytes[3] == 0x46) return true;
|
||||||
|
|
||||||
|
// OGG: OggS
|
||||||
|
if (bytes[0] == 0x4F && bytes[1] == 0x67 &&
|
||||||
|
bytes[2] == 0x67 && bytes[3] == 0x53) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTP POST mit Retry (exponentieller Backoff)
|
/// HTTP POST mit Retry (exponentieller Backoff)
|
||||||
Future<http.Response> _retryHttpPost(
|
Future<http.Response> _retryHttpPost(
|
||||||
Uri url, {
|
Uri url, {
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ class MusikScanner {
|
|||||||
final stat = await file.stat();
|
final stat = await file.stat();
|
||||||
final tags = Id3Reader.lesen(pfad);
|
final tags = Id3Reader.lesen(pfad);
|
||||||
|
|
||||||
|
// ── Magic-Byte-Prüfung ──
|
||||||
|
final istKorrupt = !_hatValideMagicBytes(pfad);
|
||||||
|
if (istKorrupt) {
|
||||||
|
MeloLogger().fehler('magic_bytes_scan', 'Ungültiger Audio-Header: $pfad');
|
||||||
|
}
|
||||||
|
|
||||||
// Cover-Bytes als Bilddatei speichern
|
// Cover-Bytes als Bilddatei speichern
|
||||||
String? coverPfad;
|
String? coverPfad;
|
||||||
if (tags['cover'] != null && tags['cover'] is List<int>) {
|
if (tags['cover'] != null && tags['cover'] is List<int>) {
|
||||||
@@ -79,6 +85,7 @@ class MusikScanner {
|
|||||||
coverPfad: coverPfad,
|
coverPfad: coverPfad,
|
||||||
groesseBytes: stat.size,
|
groesseBytes: stat.size,
|
||||||
istHeruntergeladen: true,
|
istHeruntergeladen: true,
|
||||||
|
istKorrupt: istKorrupt,
|
||||||
downloadQuelle: 'local',
|
downloadQuelle: 'local',
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -185,6 +192,37 @@ class MusikScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _hatValideMagicBytes(String pfad) {
|
||||||
|
try {
|
||||||
|
final file = File(pfad);
|
||||||
|
if (!file.existsSync()) return false;
|
||||||
|
final bytes = file.readAsBytesSync().take(16).toList();
|
||||||
|
if (bytes.length < 4) return false;
|
||||||
|
|
||||||
|
// ID3v2 Tag (MP3) — 49 44 33
|
||||||
|
if (bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) return true;
|
||||||
|
// MPEG Audio Frame Sync
|
||||||
|
if (bytes[0] == 0xFF && (bytes[1] & 0xFE) == 0xFA) return true;
|
||||||
|
if (bytes[0] == 0xFF && (bytes[1] == 0xF3 || bytes[1] == 0xF2)) return true;
|
||||||
|
// M4A/AAC
|
||||||
|
if (bytes.length >= 8 && bytes[4] == 0x66 && bytes[5] == 0x74 &&
|
||||||
|
bytes[6] == 0x79 && bytes[7] == 0x70) return true;
|
||||||
|
// FLAC
|
||||||
|
if (bytes[0] == 0x66 && bytes[1] == 0x4C &&
|
||||||
|
bytes[2] == 0x61 && bytes[3] == 0x43) return true;
|
||||||
|
// WAV
|
||||||
|
if (bytes[0] == 0x52 && bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46 && bytes[3] == 0x46) { return true; }
|
||||||
|
// OGG
|
||||||
|
if (bytes[0] == 0x4F && bytes[1] == 0x67 &&
|
||||||
|
bytes[2] == 0x67 && bytes[3] == 0x53) { return true; }
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _dateiNameOhneEndung(String pfad) {
|
String _dateiNameOhneEndung(String pfad) {
|
||||||
final name = pfad.split('/').last;
|
final name = pfad.split('/').last;
|
||||||
final dot = name.lastIndexOf('.');
|
final dot = name.lastIndexOf('.');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
|
import '../database/db_helper.dart';
|
||||||
import 'melo_logger.dart';
|
import 'melo_logger.dart';
|
||||||
|
|
||||||
class PlayerService {
|
class PlayerService {
|
||||||
@@ -14,6 +15,7 @@ class PlayerService {
|
|||||||
final List<Song> _warteschlange = [];
|
final List<Song> _warteschlange = [];
|
||||||
int _aktuellerIndex = -1;
|
int _aktuellerIndex = -1;
|
||||||
StreamSubscription<PlayerState>? _autoNextSub;
|
StreamSubscription<PlayerState>? _autoNextSub;
|
||||||
|
StreamSubscription<PlayerState>? _fehlerSub;
|
||||||
|
|
||||||
AudioPlayer get _p {
|
AudioPlayer get _p {
|
||||||
if (_player == null) {
|
if (_player == null) {
|
||||||
@@ -23,6 +25,21 @@ class PlayerService {
|
|||||||
naechstes();
|
naechstes();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// ── Playback-Fehler abfangen → Song als korrupt markieren ──
|
||||||
|
_fehlerSub = _player!.playerStateStream.listen((state) {
|
||||||
|
if (state.processingState == ProcessingState.completed) return; // bereits behandelt
|
||||||
|
// PlayerState hat kein explizites "failure"-Feld, aber wir prüfen ob
|
||||||
|
// die Quelle nicht geladen werden konnte (idle nach Fehlversuch)
|
||||||
|
if (state.processingState == ProcessingState.idle &&
|
||||||
|
_aktuellerIndex >= 0 &&
|
||||||
|
_aktuellerIndex < _warteschlange.length) {
|
||||||
|
final song = _warteschlange[_aktuellerIndex];
|
||||||
|
// Nur markieren wenn kein Stream (Stream-Fehler sind oft temporär)
|
||||||
|
if (song.streamUrl == null || song.streamUrl!.isEmpty) {
|
||||||
|
_markiereAlsKorrupt(song);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return _player!;
|
return _player!;
|
||||||
}
|
}
|
||||||
@@ -60,6 +77,11 @@ class PlayerService {
|
|||||||
_songWechsel.add(song);
|
_songWechsel.add(song);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Fehler beim Abspielen: $e');
|
debugPrint('Fehler beim Abspielen: $e');
|
||||||
|
MeloLogger().fehler('player_abspiel_fehler', e);
|
||||||
|
// Song als korrupt markieren wenn lokale Datei
|
||||||
|
if (song.streamUrl == null || song.streamUrl!.isEmpty) {
|
||||||
|
_markiereAlsKorrupt(song);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +122,25 @@ class PlayerService {
|
|||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_autoNextSub?.cancel();
|
_autoNextSub?.cancel();
|
||||||
|
_fehlerSub?.cancel();
|
||||||
_player?.dispose();
|
_player?.dispose();
|
||||||
_player = null;
|
_player = null;
|
||||||
_songWechsel.close();
|
_songWechsel.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markiert den aktuellen Song in der DB als korrupt
|
||||||
|
Future<void> _markiereAlsKorrupt(Song song) async {
|
||||||
|
if (song.id == null) return;
|
||||||
|
try {
|
||||||
|
final db = DbHelper();
|
||||||
|
final s = await db.songNachId(song.id!);
|
||||||
|
if (s != null && !s.istKorrupt) {
|
||||||
|
await db.alsKorruptMarkieren(song.id!);
|
||||||
|
debugPrint('⚠️ Song als korrupt markiert: ${song.titel}');
|
||||||
|
MeloLogger().fehler('korrupt_markiert', 'Playback-Fehler: ${song.titel} (${song.dateiPfad})');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Korrupt-Markierung fehlgeschlagen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class SongTile extends StatelessWidget {
|
|||||||
final ValueChanged<Song> onPlay;
|
final ValueChanged<Song> onPlay;
|
||||||
final VoidCallback onMetadataChanged;
|
final VoidCallback onMetadataChanged;
|
||||||
final ValueChanged<Song>? onAddToPlaylist;
|
final ValueChanged<Song>? onAddToPlaylist;
|
||||||
|
final ValueChanged<Song>? onErneutHerunterladen;
|
||||||
|
|
||||||
const SongTile({
|
const SongTile({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -21,6 +22,7 @@ class SongTile extends StatelessWidget {
|
|||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
required this.onMetadataChanged,
|
required this.onMetadataChanged,
|
||||||
this.onAddToPlaylist,
|
this.onAddToPlaylist,
|
||||||
|
this.onErneutHerunterladen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -41,11 +43,40 @@ class SongTile extends StatelessWidget {
|
|||||||
: const Center(child: Text('♪', style: TextStyle(fontSize: 18, color: Colors.white54))),
|
: const Center(child: Text('♪', style: TextStyle(fontSize: 18, color: Colors.white54))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(song.titel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
|
title: Row(
|
||||||
|
children: [
|
||||||
|
if (song.istKorrupt)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 6),
|
||||||
|
child: Text('⚠️', style: TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
song.titel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: song.istKorrupt ? MeloTheme.textSekundaer : Colors.white,
|
||||||
|
decoration: song.istKorrupt ? TextDecoration.lineThrough : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
subtitle: Text(song.kuenstler, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
subtitle: Text(song.kuenstler, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// ── Korrupt: Erneut herunterladen ──
|
||||||
|
if (song.istKorrupt && song.downloadQuelle != 'local' && onErneutHerunterladen != null)
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
onTap: () => onErneutHerunterladen!(song),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(Icons.download_rounded, size: 16, color: MeloTheme.rot),
|
||||||
|
),
|
||||||
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ void main() {
|
|||||||
expect(restored.dateiPfad, song.dateiPfad);
|
expect(restored.dateiPfad, song.dateiPfad);
|
||||||
expect(restored.groesseBytes, song.groesseBytes);
|
expect(restored.groesseBytes, song.groesseBytes);
|
||||||
expect(restored.istHeruntergeladen, song.istHeruntergeladen);
|
expect(restored.istHeruntergeladen, song.istHeruntergeladen);
|
||||||
|
expect(restored.istKorrupt, false); // default
|
||||||
|
});
|
||||||
|
|
||||||
|
test('istKorrupt roundtrip', () {
|
||||||
|
final song = Song(
|
||||||
|
titel: 'Defekt', kuenstler: 'X', dauerSekunden: 0, dateiPfad: '/x.mp3',
|
||||||
|
istKorrupt: true,
|
||||||
|
);
|
||||||
|
final restored = Song.fromMap(song.toMap());
|
||||||
|
expect(restored.istKorrupt, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dauerFormatiert formats correctly', () {
|
test('dauerFormatiert formats correctly', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user