This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/navidrome_service.dart
T
Dustin a24875726f v2.51.4 — Review-Fix MED-2: „Trennen“ (Musikserver) persistiert jetzt
## MED-2 (navidrome_service.dart + home_screen.dart)
- Neues NavidromeService.loescheZugangsdaten(): löscht In-Memory-Zustand
  UND persistierte Credentials (SharedPreferences navidrome_url/user +
  SecureStorage navidrome_pass)
- _zeigeServerBrowser() ist async und ruft loescheZugangsdaten() beim
  Trennen auf — Chip bleibt nach App-Neustart rot statt wieder grün
2026-08-05 08:10:30 +02:00

262 lines
8.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
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 {
final String id;
final String titel;
final String kuenstler;
final String album;
final int dauerSekunden;
final int groesseBytes;
final String? coverId;
const SubsonicSong({
required this.id,
required this.titel,
this.kuenstler = '',
this.album = '',
this.dauerSekunden = 0,
this.groesseBytes = 0,
this.coverId,
});
factory SubsonicSong.fromJson(Map<String, dynamic> j) => SubsonicSong(
id: j['id'] as String,
titel: j['title'] as String? ?? 'Unbekannt',
kuenstler: j['artist'] as String? ?? '',
album: j['album'] as String? ?? '',
dauerSekunden: (j['duration'] as int?) ?? 0,
groesseBytes: (j['size'] as int?) ?? 0,
coverId: j['coverArt'] as String?,
);
}
/// Ein Album aus der Subsonic-API
class SubsonicAlbum {
final String id;
final String name;
final String? coverId;
final int songCount;
const SubsonicAlbum({
required this.id,
required this.name,
this.coverId,
this.songCount = 0,
});
factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum(
id: j['id'] as String,
name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt',
coverId: j['coverArt'] as String?,
songCount: (j['songCount'] as int?) ?? 0,
);
}
class NavidromeService {
final DbHelper _db = DbHelper();
final FlutterSecureStorage _secure = const FlutterSecureStorage();
String _serverUrl = '';
String _user = '';
String _password = '';
String _salt = '';
String? _token;
static const _version = '1.16.1';
static const _client = 'Melo';
void setCredentials(String url, String user, String password) {
_serverUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
_user = user;
_password = password;
_salt = DateTime.now().millisecondsSinceEpoch.toString();
_token = md5.convert(utf8.encode(_password + _salt)).toString();
}
Future<void> ladeGespeicherteZugangsdaten() async {
try {
final prefs = await SharedPreferences.getInstance();
final url = prefs.getString('navidrome_url');
final user = prefs.getString('navidrome_user');
final pass = await _secure.read(key: 'navidrome_pass');
if (url != null && user != null && pass != null && url.isNotEmpty) {
setCredentials(url, user, pass);
}
} catch (e) {
debugPrint('Fehler beim Laden der Navidrome-Zugangsdaten: $e');
}
}
Future<void> speichereZugangsdaten(String url, String user, String password) async {
setCredentials(url, user, password);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('navidrome_url', url);
await prefs.setString('navidrome_user', user);
await _secure.write(key: 'navidrome_pass', value: password);
} catch (e) {
debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e');
}
}
/// Trennt die Verbindung dauerhaft: In-Memory-Zustand UND persistierte
/// Zugangsdaten (SharedPreferences + SecureStorage) werden gelöscht.
/// Sonst lädt `ladeGespeicherteZugangsdaten()` beim nächsten App-Start
/// die alten Daten wieder und der Musikserver-Chip wäre wieder grün.
Future<void> loescheZugangsdaten() async {
_serverUrl = '';
_user = '';
_password = '';
_salt = '';
_token = null;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('navidrome_url');
await prefs.remove('navidrome_user');
await _secure.delete(key: 'navidrome_pass');
} catch (e) {
debugPrint('Fehler beim Löschen der Navidrome-Zugangsdaten: $e');
}
}
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
Uri _uri(String endpoint, [Map<String, String>? extra]) {
final params = {
'u': _user,
't': _token!,
's': _salt,
'v': _version,
'c': _client,
'f': 'json',
};
if (extra != null) params.addAll(extra);
return Uri.parse('$_serverUrl/rest/$endpoint').replace(queryParameters: params);
}
/// Verbindung testen
Future<bool> ping() async {
try {
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (e) {
debugPrint('Navidrome Ping Fehler: $e');
return false;
}
}
/// Alle Alben abrufen
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
try {
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
if (r.statusCode != 200) return [];
final data = jsonDecode(r.body);
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
} catch (e) {
debugPrint('Navidrome getAlben Fehler: $e');
return [];
}
}
/// Songs eines Albums abrufen
Future<List<SubsonicSong>> getSongs(String albumId) async {
try {
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15));
if (r.statusCode != 200) return [];
final data = jsonDecode(r.body);
final songs = data['subsonic-response']?['album']?['song'] as List? ?? [];
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
} catch (e) {
debugPrint('Navidrome getSongs Fehler: $e');
return [];
}
}
/// Song-Stream-URL
Uri streamUrl(String songId) {
return _uri('stream.view', {'id': songId, 'format': 'raw'});
}
/// Cover-URL
Uri? coverUrl(String coverId) {
return _uri('getCoverArt.view', {'id': coverId});
}
/// Song von Navidrome runterladen und lokal speichern
Future<Song?> downloadSong(SubsonicSong s) async {
try {
final dir = await getApplicationDocumentsDirectory();
final musikDir = Directory('${dir.path}/music');
if (!await musikDir.exists()) await musikDir.create(recursive: true);
final safeName = sanitizeDateiname(s.titel);
final kurzId = s.id.length > 8 ? s.id.substring(0, 8) : s.id;
final dateiName = '${safeName}_$kurzId.mp3';
final dateiPfad = '${musikDir.path}/$dateiName';
final file = File(dateiPfad);
if (await file.exists()) {
final localSong = await _db.songNachPfad(dateiPfad);
if (localSong != null) return localSong;
}
// Stream + speichern mit Status-Code- und Integritäts-Prüfung
final uri = streamUrl(s.id);
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 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,
album: s.album,
dauerSekunden: s.dauerSekunden,
dateiPfad: dateiPfad,
groesseBytes: await file.length(),
istHeruntergeladen: true,
istKorrupt: istKorrupt,
downloadQuelle: 'server',
);
// Dauer per just_audio ermitteln wenn Navidrome keine liefert
// (optional, für später)
await _db.songEinfuegen(song);
return song;
} catch (e) {
debugPrint('Navidrome Download Fehler: $e');
return null;
}
}
}