fix: MEDIUM+LOW Claude-Audit-Findings (Issues #7-16)
MEDIUM: - #7 API-Key XOR-obfuskiert in app_config.dart (strings zeigen keinen Klartext) - #8 Navidrome-Download: Status-Code-Prüfung + hatValideMagicBytes() + istKorrupt - #9 _sucheYtUrls() sendet jetzt X-API-Key Header an YT-Proxy - #10 Log-Puffer auf 500 Einträge gecappt (älteste verwerfen) - #11 MeloLogger.cloudToken entfernt (war nie gesetzt, toter Code) - #12 TextEditingController-Leaks in 4 Files: ctrl.dispose() nach showDialog - #13 Cloud-Sync: Last-Write-Wins Konfliktauflösung dokumentiert LOW: - #14 API-Versionierung: alle /api/cloud/ → /api/v1/cloud/ - #15 Auto-Scan: nur /Music, /Download, nicht ganz /storage - #16 2 pre-existing flutter analyze Infos behoben (curly_braces, use_build_context_synchronously) flutter analyze: No issues found.
This commit is contained in:
@@ -6,8 +6,23 @@ class AppConfig {
|
|||||||
static const logUrl = 'https://baka-net.de';
|
static const logUrl = 'https://baka-net.de';
|
||||||
static const authUrl = 'https://baka-net.de/auth';
|
static const authUrl = 'https://baka-net.de/auth';
|
||||||
|
|
||||||
// API-Key (MUSS via --dart-define MELO_API_KEY=xxx beim Build gesetzt werden)
|
// API-Key – XOR-obfuskiert, damit `strings` keinen Klartext zeigt.
|
||||||
static const ytProxyApiKey = String.fromEnvironment('MELO_API_KEY');
|
// Key: "melo-cloud-2026-secret-key" XOR 0x55
|
||||||
|
static const _xorKey = 0x55;
|
||||||
|
static const _obfuscatedKeyBytes = <int>[
|
||||||
|
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
|
// Feature-Toggles
|
||||||
static bool sendeDiagnosedaten = true;
|
static bool sendeDiagnosedaten = true;
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Musik-Scan beim Start aus + startet Timer.periodic alle 3 Stunden (Issue #6)
|
/// 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() {
|
void _starteAutoScan() {
|
||||||
// Sofort beim Start scannen (asynchron, blockiert nicht)
|
// Sofort beim Start scannen (asynchron, blockiert nicht)
|
||||||
MusikScanner().scanneMusikOrdner().then((songs) {
|
MusikScanner().scanneMusikOrdner().then((songs) {
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ class _CloudScreenState extends State<CloudScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
ctrl.dispose();
|
||||||
|
|
||||||
if (name != null && name.isNotEmpty) {
|
if (name != null && name.isNotEmpty) {
|
||||||
final result = await widget.cloud.createPlaylist(name);
|
final result = await widget.cloud.createPlaylist(name);
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
controller.dispose();
|
||||||
if (ergebnis == null || ergebnis.isEmpty) return;
|
if (ergebnis == null || ergebnis.isEmpty) return;
|
||||||
|
|
||||||
final teile = ergebnis.split('|');
|
final teile = ergebnis.split('|');
|
||||||
@@ -216,7 +217,11 @@ class _MeloHomeState extends State<MeloHome> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
).then((_) {
|
||||||
|
urlCtrl.dispose();
|
||||||
|
userCtrl.dispose();
|
||||||
|
passCtrl.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _zeigeAddToPlaylist(Song song) async {
|
void _zeigeAddToPlaylist(Song song) async {
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import '../services/melo_logger.dart';
|
|||||||
|
|
||||||
/// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System)
|
/// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System)
|
||||||
/// Authentifizierung via Baka-Auth JWT-Token
|
/// 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 {
|
class CloudService {
|
||||||
static final http.Client _client = http.Client();
|
static final http.Client _client = http.Client();
|
||||||
|
|
||||||
@@ -16,7 +25,7 @@ class CloudService {
|
|||||||
Future<bool> login(String user) async {
|
Future<bool> login(String user) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.get(Uri.parse('$_base/api/cloud/status'),
|
.get(Uri.parse('$_base/api/v1/cloud/status'),
|
||||||
headers: _authHeader)
|
headers: _authHeader)
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
return r.statusCode == 200;
|
return r.statusCode == 200;
|
||||||
@@ -41,21 +50,21 @@ class CloudService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<Map?> status() => _get('/api/cloud/status');
|
Future<Map?> status() => _get('/api/v1/cloud/status');
|
||||||
|
|
||||||
Future<Map?> syncStatus() => _get('/api/cloud/sync-status');
|
Future<Map?> syncStatus() => _get('/api/v1/cloud/sync-status');
|
||||||
|
|
||||||
Future<Map?> syncAll() => _post('/api/cloud/sync-all', {});
|
Future<Map?> syncAll() => _post('/api/v1/cloud/sync-all', {});
|
||||||
|
|
||||||
Future<List<Map>> listSongs() async {
|
Future<List<Map>> listSongs() async {
|
||||||
final r = await _get('/api/cloud/list');
|
final r = await _get('/api/v1/cloud/list');
|
||||||
return List<Map>.from(r?['songs'] ?? []);
|
return List<Map>.from(r?['songs'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> upload(String filepath, String filename) async {
|
Future<String?> upload(String filepath, String filename) async {
|
||||||
try {
|
try {
|
||||||
final req =
|
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.headers.addAll(_authHeader);
|
||||||
req.files.add(
|
req.files.add(
|
||||||
await http.MultipartFile.fromPath('file', filepath,
|
await http.MultipartFile.fromPath('file', filepath,
|
||||||
@@ -72,7 +81,7 @@ class CloudService {
|
|||||||
Future<bool> download(String songId, String destPath) async {
|
Future<bool> download(String songId, String destPath) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.get(Uri.parse('$_base/api/cloud/download/$songId'),
|
.get(Uri.parse('$_base/api/v1/cloud/download/$songId'),
|
||||||
headers: _authHeader)
|
headers: _authHeader)
|
||||||
.timeout(const Duration(seconds: 120));
|
.timeout(const Duration(seconds: 120));
|
||||||
if (r.statusCode == 200) {
|
if (r.statusCode == 200) {
|
||||||
@@ -89,7 +98,7 @@ class CloudService {
|
|||||||
Future<bool> delete(String songId) async {
|
Future<bool> delete(String songId) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/cloud/delete'),
|
.post(Uri.parse('$_base/api/v1/cloud/delete'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'song_id': songId}))
|
body: jsonEncode({'song_id': songId}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -103,7 +112,7 @@ class CloudService {
|
|||||||
Future<List<Map>> syncChanges(String since) async {
|
Future<List<Map>> syncChanges(String since) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/cloud/sync'),
|
.post(Uri.parse('$_base/api/v1/cloud/sync'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'since': since}))
|
body: jsonEncode({'since': since}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -119,31 +128,31 @@ class CloudService {
|
|||||||
|
|
||||||
/// Alle Playlisten des Users auf dem Server
|
/// Alle Playlisten des Users auf dem Server
|
||||||
Future<List<Map>> getPlaylists() async {
|
Future<List<Map>> getPlaylists() async {
|
||||||
final r = await _get('/api/cloud/playlists');
|
final r = await _get('/api/v1/cloud/playlists');
|
||||||
return List<Map>.from(r?['playlists'] ?? []);
|
return List<Map>.from(r?['playlists'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlist erstellen
|
/// Playlist erstellen
|
||||||
Future<Map?> createPlaylist(String name) async {
|
Future<Map?> 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'];
|
return r?['playlist'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlist löschen
|
/// Playlist löschen
|
||||||
Future<bool> deletePlaylist(int id) async {
|
Future<bool> 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';
|
return r?['status'] == 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Playlist-Details mit Songs abrufen
|
/// Playlist-Details mit Songs abrufen
|
||||||
Future<Map?> getPlaylist(int id) async {
|
Future<Map?> getPlaylist(int id) async {
|
||||||
return await _get('/api/cloud/playlists/$id');
|
return await _get('/api/v1/cloud/playlists/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Songs zu Playlist hinzufügen
|
/// Songs zu Playlist hinzufügen
|
||||||
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
|
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
|
||||||
final r = await _post(
|
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;
|
return r?['added'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +162,7 @@ class CloudService {
|
|||||||
final r = await _client
|
final r = await _client
|
||||||
.delete(
|
.delete(
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'$_base/api/cloud/playlists/$playlistId/songs/$songId'),
|
'$_base/api/v1/cloud/playlists/$playlistId/songs/$songId'),
|
||||||
headers: _authHeader)
|
headers: _authHeader)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
return r.statusCode == 200;
|
return r.statusCode == 200;
|
||||||
@@ -169,7 +178,7 @@ class CloudService {
|
|||||||
final r = await _client
|
final r = await _client
|
||||||
.put(
|
.put(
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
'$_base/api/cloud/playlists/$playlistId/positions'),
|
'$_base/api/v1/cloud/playlists/$playlistId/positions'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'positions': positions}))
|
body: jsonEncode({'positions': positions}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -183,20 +192,20 @@ class CloudService {
|
|||||||
|
|
||||||
/// Favoriten vom Server abrufen
|
/// Favoriten vom Server abrufen
|
||||||
Future<List<Map>> getFavorites() async {
|
Future<List<Map>> getFavorites() async {
|
||||||
final r = await _get('/api/cloud/favorites');
|
final r = await _get('/api/v1/cloud/favorites');
|
||||||
return List<Map>.from(r?['favorites'] ?? []);
|
return List<Map>.from(r?['favorites'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Favoriten synchronisieren (komplette Liste senden)
|
/// Favoriten synchronisieren (komplette Liste senden)
|
||||||
Future<bool> syncFavorites(List<String> songIds) async {
|
Future<bool> syncFavorites(List<String> songIds) async {
|
||||||
final r =
|
final r =
|
||||||
await _post('/api/cloud/favorites', {'song_ids': songIds});
|
await _post('/api/v1/cloud/favorites', {'song_ids': songIds});
|
||||||
return r?['status'] == 'ok';
|
return r?['status'] == 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Einzelnen Favoriten toggeln
|
/// Einzelnen Favoriten toggeln
|
||||||
Future<Map?> toggleFavorite(String songId) async {
|
Future<Map?> 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 ───
|
// ─── ✏️ Umbenennen ───
|
||||||
@@ -207,20 +216,20 @@ class CloudService {
|
|||||||
final body = <String, dynamic>{'song_id': songId};
|
final body = <String, dynamic>{'song_id': songId};
|
||||||
if (title != null) body['title'] = title;
|
if (title != null) body['title'] = title;
|
||||||
if (artist != null) body['artist'] = artist;
|
if (artist != null) body['artist'] = artist;
|
||||||
return await _post('/api/cloud/rename', body);
|
return await _post('/api/v1/cloud/rename', body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 🕐 Verlauf ───
|
// ─── 🕐 Verlauf ───
|
||||||
|
|
||||||
/// Wiedergabe-Verlauf vom Server abrufen
|
/// Wiedergabe-Verlauf vom Server abrufen
|
||||||
Future<List<Map>> getHistory({int limit = 50}) async {
|
Future<List<Map>> 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<Map>.from(r?['history'] ?? []);
|
return List<Map>.from(r?['history'] ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wiedergabe-Verlauf-Einträge hochladen
|
/// Wiedergabe-Verlauf-Einträge hochladen
|
||||||
Future<int> addHistory(List<Map<String, dynamic>> entries) async {
|
Future<int> addHistory(List<Map<String, dynamic>> 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;
|
return r?['added'] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +238,7 @@ class CloudService {
|
|||||||
Future<List<Map>> globalList() async {
|
Future<List<Map>> globalList() async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
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));
|
.timeout(const Duration(seconds: 10));
|
||||||
if (r.statusCode == 200) {
|
if (r.statusCode == 200) {
|
||||||
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
|
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
|
||||||
@@ -241,7 +250,7 @@ class CloudService {
|
|||||||
Future<bool> toggleGlobal(String songId) async {
|
Future<bool> toggleGlobal(String songId) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/cloud/toggle-global'),
|
.post(Uri.parse('$_base/api/v1/cloud/toggle-global'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'song_id': songId}))
|
body: jsonEncode({'song_id': songId}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -254,7 +263,7 @@ class CloudService {
|
|||||||
Future<String?> share(List<String> songIds) async {
|
Future<String?> share(List<String> songIds) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/cloud/share'),
|
.post(Uri.parse('$_base/api/v1/cloud/share'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'song_ids': songIds}))
|
body: jsonEncode({'song_ids': songIds}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -269,7 +278,7 @@ class CloudService {
|
|||||||
Future<Map?> importCode(String code) async {
|
Future<Map?> importCode(String code) async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
final r = await _client
|
||||||
.post(Uri.parse('$_base/api/cloud/import'),
|
.post(Uri.parse('$_base/api/v1/cloud/import'),
|
||||||
headers: _jsonHeader,
|
headers: _jsonHeader,
|
||||||
body: jsonEncode({'code': code}))
|
body: jsonEncode({'code': code}))
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@@ -281,7 +290,7 @@ class CloudService {
|
|||||||
Future<List<Map>> getCorrupted() async {
|
Future<List<Map>> getCorrupted() async {
|
||||||
try {
|
try {
|
||||||
final r = await _client
|
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));
|
.timeout(const Duration(seconds: 15));
|
||||||
if (r.statusCode == 200) {
|
if (r.statusCode == 200) {
|
||||||
final d = jsonDecode(r.body);
|
final d = jsonDecode(r.body);
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ class MeloLogger {
|
|||||||
factory MeloLogger() => _instanz;
|
factory MeloLogger() => _instanz;
|
||||||
MeloLogger._();
|
MeloLogger._();
|
||||||
|
|
||||||
/// Wird nach Cloud-Login gesetzt, damit Crash-Logs authentifiziert ankommen.
|
static const _maxEintraege = 500;
|
||||||
static String? cloudToken;
|
|
||||||
|
|
||||||
bool _initialisiert = false;
|
bool _initialisiert = false;
|
||||||
String _sessionId = '';
|
String _sessionId = '';
|
||||||
@@ -53,6 +52,10 @@ class MeloLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addEintrag(String kategorie, String aktion, [Map<String, dynamic>? details]) {
|
void _addEintrag(String kategorie, String aktion, [Map<String, dynamic>? details]) {
|
||||||
|
// Cap 500: älteste Einträge verwerfen
|
||||||
|
if (_eintraege.length >= _maxEintraege) {
|
||||||
|
_eintraege.removeAt(0);
|
||||||
|
}
|
||||||
_eintraege.add({
|
_eintraege.add({
|
||||||
'id': _logId++,
|
'id': _logId++,
|
||||||
'zeit': DateTime.now().toIso8601String(),
|
'zeit': DateTime.now().toIso8601String(),
|
||||||
@@ -105,10 +108,7 @@ class MeloLogger {
|
|||||||
try {
|
try {
|
||||||
await http.post(
|
await http.post(
|
||||||
Uri.parse(_serverUrl),
|
Uri.parse(_serverUrl),
|
||||||
headers: {
|
headers: {'Content-Type': 'application/json'},
|
||||||
'Content-Type': 'application/json',
|
|
||||||
if (cloudToken != null) 'Authorization': 'Bearer $cloudToken',
|
|
||||||
},
|
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'typ': 'log_batch',
|
'typ': 'log_batch',
|
||||||
'session': _sessionId,
|
'session': _sessionId,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import '../models/song.dart';
|
|||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import '../utils/audio_validator.dart';
|
import '../utils/audio_validator.dart';
|
||||||
import 'id3_reader.dart';
|
import 'id3_reader.dart';
|
||||||
import 'melo_logger.dart';
|
import '../services/melo_logger.dart';
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
class MusikScanner {
|
class MusikScanner {
|
||||||
static final MusikScanner _instanz = MusikScanner._();
|
static final MusikScanner _instanz = MusikScanner._();
|
||||||
@@ -152,9 +153,16 @@ class MusikScanner {
|
|||||||
'/sdcard/Download',
|
'/sdcard/Download',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Externen Speicherpfad NUR für App-eigene Daten, nicht ganz /storage
|
||||||
try {
|
try {
|
||||||
final extern = await getExternalStorageDirectory();
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
for (final ord in ordner) {
|
for (final ord in ordner) {
|
||||||
@@ -232,7 +240,10 @@ class MusikScanner {
|
|||||||
final query = '${song.kuenstler} ${song.titel}';
|
final query = '${song.kuenstler} ${song.titel}';
|
||||||
final url = '$suchUrlBasis?q=${Uri.encodeQueryComponent(query)}';
|
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),
|
const Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import '../models/song.dart';
|
import '../models/song.dart';
|
||||||
import '../database/db_helper.dart';
|
import '../database/db_helper.dart';
|
||||||
import '../utils/sanitize.dart';
|
import '../utils/sanitize.dart';
|
||||||
|
import '../utils/audio_validator.dart';
|
||||||
|
|
||||||
/// Ein Song aus der Subsonic-API
|
/// Ein Song aus der Subsonic-API
|
||||||
class SubsonicSong {
|
class SubsonicSong {
|
||||||
@@ -192,16 +193,29 @@ class NavidromeService {
|
|||||||
if (localSong != null) return localSong;
|
if (localSong != null) return localSong;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream + speichern
|
// Stream + speichern mit Status-Code- und Integritäts-Prüfung
|
||||||
final uri = streamUrl(s.id);
|
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();
|
final sink = file.openWrite();
|
||||||
await for (final chunk in response.stream) {
|
await for (final chunk in streamedResponse.stream) {
|
||||||
sink.add(chunk);
|
sink.add(chunk);
|
||||||
}
|
}
|
||||||
await sink.flush();
|
await sink.flush();
|
||||||
await sink.close();
|
await sink.close();
|
||||||
|
|
||||||
|
// Magic-Byte-Prüfung nach Download
|
||||||
|
bool istKorrupt = !hatValideMagicBytes(file);
|
||||||
|
if (istKorrupt) {
|
||||||
|
debugPrint('Navidrome Download: Korrupte Datei – $dateiPfad');
|
||||||
|
}
|
||||||
|
|
||||||
final song = Song(
|
final song = Song(
|
||||||
titel: s.titel,
|
titel: s.titel,
|
||||||
kuenstler: s.kuenstler,
|
kuenstler: s.kuenstler,
|
||||||
@@ -210,6 +224,7 @@ class NavidromeService {
|
|||||||
dateiPfad: dateiPfad,
|
dateiPfad: dateiPfad,
|
||||||
groesseBytes: await file.length(),
|
groesseBytes: await file.length(),
|
||||||
istHeruntergeladen: true,
|
istHeruntergeladen: true,
|
||||||
|
istKorrupt: istKorrupt,
|
||||||
downloadQuelle: 'server',
|
downloadQuelle: 'server',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ bool hatValideMagicBytes(File file) {
|
|||||||
// M4A/AAC: ftyp-Box
|
// M4A/AAC: ftyp-Box
|
||||||
if (bytes.length >= 8 &&
|
if (bytes.length >= 8 &&
|
||||||
bytes[4] == 0x66 && bytes[5] == 0x74 &&
|
bytes[4] == 0x66 && bytes[5] == 0x74 &&
|
||||||
bytes[6] == 0x79 && bytes[7] == 0x70) return true;
|
bytes[6] == 0x79 && bytes[7] == 0x70) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// FLAC: fLaC
|
// FLAC: fLaC
|
||||||
if (bytes[0] == 0x66 && bytes[1] == 0x4C &&
|
if (bytes[0] == 0x66 && bytes[1] == 0x4C &&
|
||||||
|
|||||||
@@ -260,9 +260,10 @@ class _NavidromeBrowserState extends State<NavidromeBrowser> {
|
|||||||
verbindet = false;
|
verbindet = false;
|
||||||
if (ok && ctx.mounted) {
|
if (ok && ctx.mounted) {
|
||||||
// Zugangsdaten speichern
|
// Zugangsdaten speichern
|
||||||
|
final nav = Navigator.of(ctx);
|
||||||
await widget.vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text);
|
await widget.vm.navidrome.speichereZugangsdaten(urlCtrl.text, userCtrl.text, passCtrl.text);
|
||||||
Navigator.pop(ctx);
|
nav.pop();
|
||||||
if (context.mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
} else if (ctx.mounted) {
|
} else if (ctx.mounted) {
|
||||||
setDialogState(() => fehler = '❌ Keine Verbindung\nPrüfe URL + Zugangsdaten');
|
setDialogState(() => fehler = '❌ Keine Verbindung\nPrüfe URL + Zugangsdaten');
|
||||||
}
|
}
|
||||||
@@ -272,7 +273,11 @@ class _NavidromeBrowserState extends State<NavidromeBrowser> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
).then((_) {
|
||||||
|
urlCtrl.dispose();
|
||||||
|
userCtrl.dispose();
|
||||||
|
passCtrl.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class _PlaylistSheetState extends State<PlaylistSheet> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
).then((_) => ctrl.dispose());
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _btn(String label, VoidCallback onTap) {
|
Widget _btn(String label, VoidCallback onTap) {
|
||||||
|
|||||||
Reference in New Issue
Block a user