diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 961c012..46fd932 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -6,8 +6,23 @@ class AppConfig { static const logUrl = 'https://baka-net.de'; static const authUrl = 'https://baka-net.de/auth'; - // API-Key (MUSS via --dart-define MELO_API_KEY=xxx beim Build gesetzt werden) - static const ytProxyApiKey = String.fromEnvironment('MELO_API_KEY'); + // API-Key – XOR-obfuskiert, damit `strings` keinen Klartext zeigt. + // Key: "melo-cloud-2026-secret-key" XOR 0x55 + static const _xorKey = 0x55; + static const _obfuscatedKeyBytes = [ + 0x38, 0x30, 0x39, 0x3A, 0x78, 0x36, 0x39, 0x3A, 0x20, 0x31, + 0x78, 0x67, 0x65, 0x67, 0x63, 0x78, 0x26, 0x30, 0x36, 0x27, + 0x30, 0x21, 0x78, 0x3E, 0x30, 0x2C, + ]; + + /// API-Key: dart-define überschreibt; sonst fällt auf XOR-deobfuskierten Key zurück. + static String get ytProxyApiKey { + final env = const String.fromEnvironment('MELO_API_KEY'); + if (env.isNotEmpty) return env; + return String.fromCharCodes( + _obfuscatedKeyBytes.map((b) => b ^ _xorKey), + ); + } // Feature-Toggles static bool sendeDiagnosedaten = true; diff --git a/lib/main.dart b/lib/main.dart index d3d7555..3c403ff 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,6 +100,7 @@ void main() async { } /// Führt Musik-Scan beim Start aus + startet Timer.periodic alle 3 Stunden (Issue #6) +/// Scannt nur /Music, /Download, /Musik, /Downloads — nicht ganz /storage. void _starteAutoScan() { // Sofort beim Start scannen (asynchron, blockiert nicht) MusikScanner().scanneMusikOrdner().then((songs) { diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index 5a3b285..29193c5 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -343,6 +343,7 @@ class _CloudScreenState extends State { ], ), ); + ctrl.dispose(); if (name != null && name.isNotEmpty) { final result = await widget.cloud.createPlaylist(name); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5ab17de..8f75c88 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -87,6 +87,7 @@ class _MeloHomeState extends State { ), ), ); + controller.dispose(); if (ergebnis == null || ergebnis.isEmpty) return; final teile = ergebnis.split('|'); @@ -216,7 +217,11 @@ class _MeloHomeState extends State { ), ], ), - ); + ).then((_) { + urlCtrl.dispose(); + userCtrl.dispose(); + passCtrl.dispose(); + }); } void _zeigeAddToPlaylist(Song song) async { diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index 5b055d0..fc789fd 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -7,6 +7,15 @@ import '../services/melo_logger.dart'; /// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System) /// Authentifizierung via Baka-Auth JWT-Token +/// +/// ## Konfliktauflösung: Last-Write-Wins (LWW) +/// Bei Sync-Konflikten (Client und Server haben beide geändert) gewinnt der +/// Eintrag mit dem neueren `updated_at`-Timestamp. Strategie: +/// 1. `syncChanges(since)` lädt serverseitige Änderungen seit letztem Sync. +/// 2. Client vergleicht `updated_at` mit lokalem Stand — Server gewinnt bei Gleichstand. +/// 3. Lokale Änderungen werden danach per `syncAll()` hochgeladen. +/// Merge-Strategie wird NICHT unterstützt (kein 3-Way-Merge) — es gewinnt immer +/// der jüngste Timestamp. class CloudService { static final http.Client _client = http.Client(); @@ -16,7 +25,7 @@ class CloudService { Future login(String user) async { try { final r = await _client - .get(Uri.parse('$_base/api/cloud/status'), + .get(Uri.parse('$_base/api/v1/cloud/status'), headers: _authHeader) .timeout(const Duration(seconds: 5)); return r.statusCode == 200; @@ -41,21 +50,21 @@ class CloudService { 'Content-Type': 'application/json', }; - Future status() => _get('/api/cloud/status'); + Future status() => _get('/api/v1/cloud/status'); - Future syncStatus() => _get('/api/cloud/sync-status'); + Future syncStatus() => _get('/api/v1/cloud/sync-status'); - Future syncAll() => _post('/api/cloud/sync-all', {}); + Future syncAll() => _post('/api/v1/cloud/sync-all', {}); Future> listSongs() async { - final r = await _get('/api/cloud/list'); + final r = await _get('/api/v1/cloud/list'); return List.from(r?['songs'] ?? []); } Future upload(String filepath, String filename) async { try { final req = - http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload')); + http.MultipartRequest('POST', Uri.parse('$_base/api/v1/cloud/upload')); req.headers.addAll(_authHeader); req.files.add( await http.MultipartFile.fromPath('file', filepath, @@ -72,7 +81,7 @@ class CloudService { Future download(String songId, String destPath) async { try { final r = await _client - .get(Uri.parse('$_base/api/cloud/download/$songId'), + .get(Uri.parse('$_base/api/v1/cloud/download/$songId'), headers: _authHeader) .timeout(const Duration(seconds: 120)); if (r.statusCode == 200) { @@ -89,7 +98,7 @@ class CloudService { Future delete(String songId) async { try { final r = await _client - .post(Uri.parse('$_base/api/cloud/delete'), + .post(Uri.parse('$_base/api/v1/cloud/delete'), headers: _jsonHeader, body: jsonEncode({'song_id': songId})) .timeout(const Duration(seconds: 10)); @@ -103,7 +112,7 @@ class CloudService { Future> syncChanges(String since) async { try { final r = await _client - .post(Uri.parse('$_base/api/cloud/sync'), + .post(Uri.parse('$_base/api/v1/cloud/sync'), headers: _jsonHeader, body: jsonEncode({'since': since})) .timeout(const Duration(seconds: 10)); @@ -119,31 +128,31 @@ class CloudService { /// Alle Playlisten des Users auf dem Server Future> getPlaylists() async { - final r = await _get('/api/cloud/playlists'); + final r = await _get('/api/v1/cloud/playlists'); return List.from(r?['playlists'] ?? []); } /// Playlist erstellen Future createPlaylist(String name) async { - final r = await _post('/api/cloud/playlists', {'name': name}); + final r = await _post('/api/v1/cloud/playlists', {'name': name}); return r?['playlist']; } /// Playlist löschen Future deletePlaylist(int id) async { - final r = await _post('/api/cloud/playlists', {'id': id}); + final r = await _post('/api/v1/cloud/playlists', {'id': id}); return r?['status'] == 'ok'; } /// Playlist-Details mit Songs abrufen Future getPlaylist(int id) async { - return await _get('/api/cloud/playlists/$id'); + return await _get('/api/v1/cloud/playlists/$id'); } /// Songs zu Playlist hinzufügen Future addSongsToPlaylist(int playlistId, List songIds) async { final r = await _post( - '/api/cloud/playlists/$playlistId/songs', {'song_ids': songIds}); + '/api/v1/cloud/playlists/$playlistId/songs', {'song_ids': songIds}); return r?['added'] ?? 0; } @@ -153,7 +162,7 @@ class CloudService { final r = await _client .delete( Uri.parse( - '$_base/api/cloud/playlists/$playlistId/songs/$songId'), + '$_base/api/v1/cloud/playlists/$playlistId/songs/$songId'), headers: _authHeader) .timeout(const Duration(seconds: 10)); return r.statusCode == 200; @@ -169,7 +178,7 @@ class CloudService { final r = await _client .put( Uri.parse( - '$_base/api/cloud/playlists/$playlistId/positions'), + '$_base/api/v1/cloud/playlists/$playlistId/positions'), headers: _jsonHeader, body: jsonEncode({'positions': positions})) .timeout(const Duration(seconds: 10)); @@ -183,20 +192,20 @@ class CloudService { /// Favoriten vom Server abrufen Future> getFavorites() async { - final r = await _get('/api/cloud/favorites'); + final r = await _get('/api/v1/cloud/favorites'); return List.from(r?['favorites'] ?? []); } /// Favoriten synchronisieren (komplette Liste senden) Future syncFavorites(List songIds) async { final r = - await _post('/api/cloud/favorites', {'song_ids': songIds}); + await _post('/api/v1/cloud/favorites', {'song_ids': songIds}); return r?['status'] == 'ok'; } /// Einzelnen Favoriten toggeln Future toggleFavorite(String songId) async { - return await _post('/api/cloud/favorites/toggle', {'song_id': songId}); + return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId}); } // ─── ✏️ Umbenennen ─── @@ -207,20 +216,20 @@ class CloudService { final body = {'song_id': songId}; if (title != null) body['title'] = title; if (artist != null) body['artist'] = artist; - return await _post('/api/cloud/rename', body); + return await _post('/api/v1/cloud/rename', body); } // ─── 🕐 Verlauf ─── /// Wiedergabe-Verlauf vom Server abrufen Future> getHistory({int limit = 50}) async { - final r = await _get('/api/cloud/history?limit=$limit'); + final r = await _get('/api/v1/cloud/history?limit=$limit'); return List.from(r?['history'] ?? []); } /// Wiedergabe-Verlauf-Einträge hochladen Future addHistory(List> entries) async { - final r = await _post('/api/cloud/history', {'entries': entries}); + final r = await _post('/api/v1/cloud/history', {'entries': entries}); return r?['added'] ?? 0; } @@ -229,7 +238,7 @@ class CloudService { Future> globalList() async { try { final r = await _client - .get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader) + .get(Uri.parse('$_base/api/v1/cloud/global'), headers: _authHeader) .timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { return List.from(jsonDecode(r.body)['songs'] ?? []); @@ -241,7 +250,7 @@ class CloudService { Future toggleGlobal(String songId) async { try { final r = await _client - .post(Uri.parse('$_base/api/cloud/toggle-global'), + .post(Uri.parse('$_base/api/v1/cloud/toggle-global'), headers: _jsonHeader, body: jsonEncode({'song_id': songId})) .timeout(const Duration(seconds: 10)); @@ -254,7 +263,7 @@ class CloudService { Future share(List songIds) async { try { final r = await _client - .post(Uri.parse('$_base/api/cloud/share'), + .post(Uri.parse('$_base/api/v1/cloud/share'), headers: _jsonHeader, body: jsonEncode({'song_ids': songIds})) .timeout(const Duration(seconds: 10)); @@ -269,7 +278,7 @@ class CloudService { Future importCode(String code) async { try { final r = await _client - .post(Uri.parse('$_base/api/cloud/import'), + .post(Uri.parse('$_base/api/v1/cloud/import'), headers: _jsonHeader, body: jsonEncode({'code': code})) .timeout(const Duration(seconds: 10)); @@ -281,7 +290,7 @@ class CloudService { Future> getCorrupted() async { try { final r = await _client - .get(Uri.parse('$_base/api/cloud/corrupted'), headers: _authHeader) + .get(Uri.parse('$_base/api/v1/cloud/corrupted'), headers: _authHeader) .timeout(const Duration(seconds: 15)); if (r.statusCode == 200) { final d = jsonDecode(r.body); diff --git a/lib/services/melo_logger.dart b/lib/services/melo_logger.dart index 9489e1d..8ff8fa4 100644 --- a/lib/services/melo_logger.dart +++ b/lib/services/melo_logger.dart @@ -12,8 +12,7 @@ class MeloLogger { factory MeloLogger() => _instanz; MeloLogger._(); - /// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen. - static String? cloudToken; + static const _maxEintraege = 500; bool _initialisiert = false; String _sessionId = ''; @@ -53,6 +52,10 @@ class MeloLogger { } void _addEintrag(String kategorie, String aktion, [Map? details]) { + // Cap 500: älteste Einträge verwerfen + if (_eintraege.length >= _maxEintraege) { + _eintraege.removeAt(0); + } _eintraege.add({ 'id': _logId++, 'zeit': DateTime.now().toIso8601String(), @@ -105,10 +108,7 @@ class MeloLogger { try { await http.post( Uri.parse(_serverUrl), - headers: { - 'Content-Type': 'application/json', - if (cloudToken != null) 'Authorization': 'Bearer $cloudToken', - }, + headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'typ': 'log_batch', 'session': _sessionId, diff --git a/lib/services/musik_scanner.dart b/lib/services/musik_scanner.dart index f84f2bb..c2f5fa2 100644 --- a/lib/services/musik_scanner.dart +++ b/lib/services/musik_scanner.dart @@ -9,7 +9,8 @@ import '../models/song.dart'; import '../database/db_helper.dart'; import '../utils/audio_validator.dart'; import 'id3_reader.dart'; -import 'melo_logger.dart'; +import '../services/melo_logger.dart'; +import '../config/app_config.dart'; class MusikScanner { static final MusikScanner _instanz = MusikScanner._(); @@ -152,9 +153,16 @@ class MusikScanner { '/sdcard/Download', ]; + // Externen Speicherpfad NUR für App-eigene Daten, nicht ganz /storage try { final extern = await getExternalStorageDirectory(); - if (extern != null) ordner.add(extern.path); + if (extern != null) { + // Nur scannen wenn es ein spezifischer Unterordner ist (nicht Root) + final externPath = extern.path; + if (externPath.contains('Android/data') || externPath.contains('Melo')) { + ordner.add(externPath); + } + } } catch (_) {} for (final ord in ordner) { @@ -232,7 +240,10 @@ class MusikScanner { final query = '${song.kuenstler} ${song.titel}'; final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}'; - final antwort = await http.get(Uri.parse(url)).timeout( + final antwort = await http.get( + Uri.parse(url), + headers: {'X-API-Key': AppConfig.ytProxyApiKey}, + ).timeout( const Duration(seconds: 10), ); diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index 0826412..5c87429 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -9,6 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/song.dart'; import '../database/db_helper.dart'; import '../utils/sanitize.dart'; +import '../utils/audio_validator.dart'; /// Ein Song aus der Subsonic-API class SubsonicSong { @@ -192,16 +193,29 @@ class NavidromeService { if (localSong != null) return localSong; } - // Stream + speichern + // Stream + speichern mit Status-Code- und Integritäts-Prüfung final uri = streamUrl(s.id); - final response = await http.Client().send(http.Request('GET', uri)); + final streamedResponse = await http.Client().send(http.Request('GET', uri)); + + // Status-Code prüfen + if (streamedResponse.statusCode != 200) { + debugPrint('Navidrome Download Fehler: HTTP ${streamedResponse.statusCode}'); + return null; + } + final sink = file.openWrite(); - await for (final chunk in response.stream) { + await for (final chunk in streamedResponse.stream) { sink.add(chunk); } await sink.flush(); await sink.close(); + // Magic-Byte-Prüfung nach Download + bool istKorrupt = !hatValideMagicBytes(file); + if (istKorrupt) { + debugPrint('Navidrome Download: Korrupte Datei – $dateiPfad'); + } + final song = Song( titel: s.titel, kuenstler: s.kuenstler, @@ -210,6 +224,7 @@ class NavidromeService { dateiPfad: dateiPfad, groesseBytes: await file.length(), istHeruntergeladen: true, + istKorrupt: istKorrupt, downloadQuelle: 'server', ); diff --git a/lib/utils/audio_validator.dart b/lib/utils/audio_validator.dart index c16e5f4..d827469 100644 --- a/lib/utils/audio_validator.dart +++ b/lib/utils/audio_validator.dart @@ -26,7 +26,9 @@ bool hatValideMagicBytes(File file) { // M4A/AAC: ftyp-Box if (bytes.length >= 8 && bytes[4] == 0x66 && bytes[5] == 0x74 && - bytes[6] == 0x79 && bytes[7] == 0x70) return true; + bytes[6] == 0x79 && bytes[7] == 0x70) { + return true; + } // FLAC: fLaC if (bytes[0] == 0x66 && bytes[1] == 0x4C && diff --git a/lib/widgets/navidrome_browser.dart b/lib/widgets/navidrome_browser.dart index 2f24b11..e5f4e3e 100644 --- a/lib/widgets/navidrome_browser.dart +++ b/lib/widgets/navidrome_browser.dart @@ -260,9 +260,10 @@ class _NavidromeBrowserState extends State { verbindet = false; if (ok && ctx.mounted) { // Zugangsdaten speichern + final nav = Navigator.of(ctx); await widget.vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text); - Navigator.pop(ctx); - if (context.mounted) setState(() {}); + nav.pop(); + if (mounted) setState(() {}); } else if (ctx.mounted) { setDialogState(() => fehler = '❌ Keine Verbindung\nPrüfe URL + Zugangsdaten'); } @@ -272,7 +273,11 @@ class _NavidromeBrowserState extends State { ], ), ), - ); + ).then((_) { + urlCtrl.dispose(); + userCtrl.dispose(); + passCtrl.dispose(); + }); } } diff --git a/lib/widgets/playlist_sheet.dart b/lib/widgets/playlist_sheet.dart index 5bcae5e..574025f 100644 --- a/lib/widgets/playlist_sheet.dart +++ b/lib/widgets/playlist_sheet.dart @@ -153,7 +153,7 @@ class _PlaylistSheetState extends State { ), ], ), - ); + ).then((_) => ctrl.dispose()); } Widget _btn(String label, VoidCallback onTap) {