From 2d4c7c8b98b1128bad2c243ea6a455ef9bafdb4b Mon Sep 17 00:00:00 2001 From: Dustin Date: Sat, 1 Aug 2026 16:40:42 +0200 Subject: [PATCH] =?UTF-8?q?v2.32=20=E2=80=94=20Defekte=20Musik=20erkennen?= =?UTF-8?q?=20(Magic=20Bytes,=20ffprobe,=20DB-Flag,=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/database/db_helper.dart | 35 +++++++- lib/models/song.dart | 4 + lib/screens/cloud_screen.dart | 129 ++++++++++++++++++++++++++++- lib/services/cloud_service.dart | 16 ++++ lib/services/download_service.dart | 52 ++++++++++++ lib/services/musik_scanner.dart | 38 +++++++++ lib/services/player_service.dart | 39 +++++++++ lib/widgets/song_tile.dart | 33 +++++++- test/song_test.dart | 10 +++ 9 files changed, 352 insertions(+), 4 deletions(-) diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 5cfeabd..2ec3634 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -20,7 +20,7 @@ class DbHelper { final pfad = await getDatabasesPath(); return openDatabase( p.join(pfad, 'melo.db'), - version: 2, + version: 3, onCreate: (db, version) async { await db.execute(''' CREATE TABLE songs ( @@ -33,6 +33,7 @@ class DbHelper { cover_pfad TEXT, groesse_bytes INTEGER DEFAULT 0, ist_heruntergeladen INTEGER DEFAULT 0, + ist_korrupt INTEGER DEFAULT 0, hinzugefuegt_am TEXT NOT NULL, download_quelle TEXT DEFAULT 'local', stream_url TEXT, @@ -91,7 +92,14 @@ class DbHelper { // 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(); } + /// Markiert einen Song als korrupt + Future 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 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> 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 ──────────────────────────────────────── Future tagErstellen(String name, {String? icon, String? farbe}) async { diff --git a/lib/models/song.dart b/lib/models/song.dart index a434050..d4676bf 100644 --- a/lib/models/song.dart +++ b/lib/models/song.dart @@ -8,6 +8,7 @@ class Song { final String? coverPfad; final int groesseBytes; final bool istHeruntergeladen; + final bool istKorrupt; // true wenn Datei defekt (Magic-Byte-Check, ffprobe, Playback-Fehler) final String hinzugefuegtAm; final String downloadQuelle; // "local", "youtube", "server" 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.groesseBytes = 0, this.istHeruntergeladen = false, + this.istKorrupt = false, String? hinzugefuegtAm, this.downloadQuelle = 'local', this.streamUrl, @@ -40,6 +42,7 @@ class Song { 'cover_pfad': coverPfad, 'groesse_bytes': groesseBytes, 'ist_heruntergeladen': istHeruntergeladen ? 1 : 0, + 'ist_korrupt': istKorrupt ? 1 : 0, 'hinzugefuegt_am': hinzugefuegtAm, 'download_quelle': downloadQuelle, 'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit) @@ -56,6 +59,7 @@ class Song { coverPfad: m['cover_pfad'] as String?, groesseBytes: m['groesse_bytes'] as int? ?? 0, istHeruntergeladen: (m['ist_heruntergeladen'] as int?) == 1, + istKorrupt: (m['ist_korrupt'] as int?) == 1, hinzugefuegtAm: m['hinzugefuegt_am'] as String?, downloadQuelle: m['download_quelle'] as String? ?? 'local', streamUrl: m['stream_url'] as String?, diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index ecd61a8..17bf480 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -25,6 +25,8 @@ class _CloudScreenState extends State { int _syncIntervall = 6; Timer? _syncTimer; String _letzterSync = 'Nie'; + List _korrupteSongs = []; + bool _ladtKorrupt = false; @override void initState() { @@ -91,6 +93,18 @@ class _CloudScreenState extends State { }); } + Future _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 _upload() async { setState(() => _ladt = true); _setzeStatus('Suche lokale Songs...'); @@ -284,7 +298,14 @@ class _CloudScreenState extends State { ), const SizedBox(height: 8), _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 Container( @@ -483,6 +504,112 @@ class _CloudScreenState extends State { ), ); } + + 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 { diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index b059f39..d5d1412 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -168,4 +168,20 @@ class CloudService { return false; } } + + /// Ruft defekte/korrupte Songs vom Cloud-Server ab + Future> 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.from(d['corrupted'] ?? []); + } + } catch (e) { + MeloLogger().fehler('cloud_corrupted', e); + } + return []; + } } diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 4eea7fc..52c7b6c 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -245,6 +245,7 @@ class DownloadService extends ChangeNotifier { dateiPfad = '${dir.path}/$lokalerName'; final stopwatch2 = Stopwatch()..start(); + bool istKorrupt = false; try { final mp3Antwort = await _retryHttpGet( Uri.parse('$_proxyBasisUrl$mp3Url'), @@ -266,6 +267,13 @@ class DownloadService extends ChangeNotifier { 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); @@ -307,6 +315,7 @@ class DownloadService extends ChangeNotifier { dateiPfad: dateiPfad, groesseBytes: await File(dateiPfad).length(), istHeruntergeladen: true, + istKorrupt: istKorrupt, downloadQuelle: 'youtube', ); 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) Future _retryHttpPost( Uri url, { diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index 6198894..3bbfafc 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -51,6 +51,12 @@ class MusikScanner { final stat = await file.stat(); 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 String? coverPfad; if (tags['cover'] != null && tags['cover'] is List) { @@ -79,6 +85,7 @@ class MusikScanner { coverPfad: coverPfad, groesseBytes: stat.size, istHeruntergeladen: true, + istKorrupt: istKorrupt, downloadQuelle: 'local', )); } 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) { final name = pfad.split('/').last; final dot = name.lastIndexOf('.'); diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 8a76709..6019dac 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import '../models/song.dart'; +import '../database/db_helper.dart'; import 'melo_logger.dart'; class PlayerService { @@ -14,6 +15,7 @@ class PlayerService { final List _warteschlange = []; int _aktuellerIndex = -1; StreamSubscription? _autoNextSub; + StreamSubscription? _fehlerSub; AudioPlayer get _p { if (_player == null) { @@ -23,6 +25,21 @@ class PlayerService { 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!; } @@ -60,6 +77,11 @@ class PlayerService { _songWechsel.add(song); } catch (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() { _autoNextSub?.cancel(); + _fehlerSub?.cancel(); _player?.dispose(); _player = null; _songWechsel.close(); } + + /// Markiert den aktuellen Song in der DB als korrupt + Future _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'); + } + } } diff --git a/lib/widgets/song_tile.dart b/lib/widgets/song_tile.dart index ab1bd75..a9a5284 100644 --- a/lib/widgets/song_tile.dart +++ b/lib/widgets/song_tile.dart @@ -12,6 +12,7 @@ class SongTile extends StatelessWidget { final ValueChanged onPlay; final VoidCallback onMetadataChanged; final ValueChanged? onAddToPlaylist; + final ValueChanged? onErneutHerunterladen; const SongTile({ super.key, @@ -21,6 +22,7 @@ class SongTile extends StatelessWidget { required this.onPlay, required this.onMetadataChanged, this.onAddToPlaylist, + this.onErneutHerunterladen, }); @override @@ -41,11 +43,40 @@ class SongTile extends StatelessWidget { : 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)), trailing: Row( mainAxisSize: MainAxisSize.min, 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( borderRadius: BorderRadius.circular(50), onTap: () async { diff --git a/test/song_test.dart b/test/song_test.dart index 686bf7a..bc0e4da 100644 --- a/test/song_test.dart +++ b/test/song_test.dart @@ -26,6 +26,16 @@ void main() { expect(restored.dateiPfad, song.dateiPfad); expect(restored.groesseBytes, song.groesseBytes); 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', () {