CRIT: - Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats - Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr) HIGH: - CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel) - StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt) - CloudService als Singleton (konsistenter Token-Zustand in allen Services) MED/LOW: - Playlist-Erkennung nur noch via list= Parameter - Player: fehlende Quelle wird geloggt statt still - song_tile: null-ID-Guard vor Tag-Dialog - Scanner-Log mit Exception-Objekt - FavoritenService: anzahlFavoriten() fuer StatistikCard
235 lines
7.3 KiB
Dart
235 lines
7.3 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../config/app_config.dart';
|
|
import '../services/melo_logger.dart';
|
|
|
|
/// Cloud-Sync Service für Melo Registry.
|
|
/// Auth: Bearer-JWT vom Baka-Auth-Server (Login mit Nutzername + Passwort).
|
|
/// Der alte X-API-Key/X-User-Mechanismus wurde entfernt (IDOR-Lücke).
|
|
class CloudService {
|
|
static final CloudService _instanz = CloudService._();
|
|
factory CloudService() => _instanz;
|
|
CloudService._();
|
|
|
|
static String get _base => AppConfig.cloudUrl;
|
|
static String get _authBase => AppConfig.authUrl;
|
|
|
|
String _user = '';
|
|
String _token = '';
|
|
|
|
String get user => _user;
|
|
String get token => _token;
|
|
bool get istAngemeldet => _token.isNotEmpty;
|
|
|
|
/// Echter Login gegen den Baka-Auth-Server.
|
|
/// Der Token wird gespeichert und bei allen Cloud-Calls als
|
|
/// Authorization: Bearer `token` mitgeschickt.
|
|
Future<bool> login(String user, String pass) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_authBase/login'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({'username': user, 'password': pass}))
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) {
|
|
final d = jsonDecode(r.body);
|
|
if (d['status'] == 'ok' && d['token'] != null) {
|
|
_user = d['username'] as String? ?? user;
|
|
_token = d['token'] as String;
|
|
await _speichereToken();
|
|
MeloLogger.cloudToken = _token;
|
|
return true;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
return false;
|
|
}
|
|
|
|
/// Stellt gespeicherten Token wieder her (Auto-Login nach App-Start).
|
|
Future<bool> restoreLogin() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final t = prefs.getString('melo_cloud_token') ?? '';
|
|
if (t.isEmpty) return false;
|
|
_token = t;
|
|
_user = prefs.getString('melo_cloud_user') ?? '';
|
|
MeloLogger.cloudToken = t;
|
|
return true;
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
_token = '';
|
|
_user = '';
|
|
MeloLogger.cloudToken = null;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove('melo_cloud_token');
|
|
await prefs.remove('melo_cloud_user');
|
|
}
|
|
|
|
Future<void> _speichereToken() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('melo_cloud_token', _token);
|
|
await prefs.setString('melo_cloud_user', _user);
|
|
}
|
|
|
|
Map<String, String> get _authHeader => {
|
|
if (_token.isNotEmpty) 'Authorization': 'Bearer $_token',
|
|
};
|
|
|
|
Future<Map?> status() => _get('/api/cloud/status');
|
|
|
|
Future<List<Map>> listSongs() async {
|
|
final r = await _get('/api/cloud/list');
|
|
return List<Map>.from(r?['songs'] ?? []);
|
|
}
|
|
|
|
Future<String?> upload(String filepath, String filename) async {
|
|
try {
|
|
final req = http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
|
|
req.headers.addAll(_authHeader);
|
|
req.files.add(await http.MultipartFile.fromPath('file', filepath,
|
|
filename: filename));
|
|
final resp = await req.send().timeout(const Duration(seconds: 120));
|
|
final body = jsonDecode(await resp.stream.bytesToString());
|
|
return body['song_id'] as String?;
|
|
} catch (e) {
|
|
MeloLogger().fehler('cloud_upload', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> download(String songId, String destPath) async {
|
|
try {
|
|
final r = await http
|
|
.get(Uri.parse('$_base/api/cloud/download/$songId'),
|
|
headers: _authHeader)
|
|
.timeout(const Duration(seconds: 120));
|
|
if (r.statusCode == 200) {
|
|
final file = File(destPath);
|
|
if (!await file.parent.exists()) {
|
|
await file.parent.create(recursive: true);
|
|
}
|
|
await file.writeAsBytes(r.bodyBytes);
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
MeloLogger().fehler('cloud_download', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> delete(String songId) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base/api/cloud/delete'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'},
|
|
body: jsonEncode({'song_id': songId}))
|
|
.timeout(const Duration(seconds: 10));
|
|
return r.statusCode == 200;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Map?> _get(String path) async {
|
|
try {
|
|
final r = await http
|
|
.get(Uri.parse('$_base$path'), headers: _authHeader)
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) return jsonDecode(r.body);
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
Future<String?> share(List<String> songIds) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base/api/cloud/share'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'},
|
|
body: jsonEncode({'song_ids': songIds}))
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) {
|
|
final d = jsonDecode(r.body);
|
|
return d['code'] as String?;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
Future<Map?> importCode(String code) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base/api/cloud/import'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'},
|
|
body: jsonEncode({'code': code}))
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) return jsonDecode(r.body);
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
Future<List<Map>> syncChanges(String since) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base/api/cloud/sync'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'},
|
|
body: jsonEncode({'since': since}))
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) {
|
|
final d = jsonDecode(r.body);
|
|
return List<Map>.from(d['changes'] ?? []);
|
|
}
|
|
} catch (_) {}
|
|
return [];
|
|
}
|
|
|
|
Future<List<Map>> globalList() async {
|
|
try {
|
|
final r = await http
|
|
.get(Uri.parse('$_base/api/cloud/global'), headers: _authHeader)
|
|
.timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) {
|
|
return List<Map>.from(jsonDecode(r.body)['songs'] ?? []);
|
|
}
|
|
} catch (_) {}
|
|
return [];
|
|
}
|
|
|
|
Future<bool> toggleGlobal(String songId) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base/api/cloud/toggle-global'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'},
|
|
body: jsonEncode({'song_id': songId}))
|
|
.timeout(const Duration(seconds: 10));
|
|
return r.statusCode == 200;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Löscht alle Cloud-Songs des angemeldeten Users (inkl. Server-Dateien).
|
|
Future<bool> loescheMusik() async {
|
|
return _postOhneBody('/api/cloud/delete-music');
|
|
}
|
|
|
|
/// Löscht ALLE Cloud-Daten des Users (Musik + Shares + Ordner).
|
|
Future<bool> loescheAlles() async {
|
|
return _postOhneBody('/api/cloud/delete-all');
|
|
}
|
|
|
|
Future<bool> _postOhneBody(String path) async {
|
|
try {
|
|
final r = await http
|
|
.post(Uri.parse('$_base$path'),
|
|
headers: {..._authHeader, 'Content-Type': 'application/json'})
|
|
.timeout(const Duration(seconds: 30));
|
|
return r.statusCode == 200;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|