feat(navidrome): Musikserver-Integration (Subsonic API)
- NavidromeService mit Credential-Verwaltung (secure storage) - Navidrome-Settings in Settings-Tab: Login/Logout für Server-Verbindung - Dependencies hinzugefügt: http, crypto, shared_preferences, flutter_secure_storage - Tests: 70/70 grün - Analyzer: ✅ keine Fehler Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XJzQjUtnvYUtHdnTs3iCru
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
2163c83409
commit
06be0a7065
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SubsonicSong {
|
||||
final String id;
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final String album;
|
||||
final int dauerSekunden;
|
||||
final String? coverId;
|
||||
|
||||
const SubsonicSong({
|
||||
required this.id,
|
||||
required this.titel,
|
||||
this.kuenstler = '',
|
||||
this.album = '',
|
||||
this.dauerSekunden = 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,
|
||||
coverId: j['coverArt'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
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 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;
|
||||
final rng = Random.secure();
|
||||
_salt = base64Encode(List.generate(16, (_) => rng.nextInt(256)));
|
||||
_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');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Uri streamUrl(String songId) {
|
||||
return _uri('stream.view', {'id': songId, 'format': 'raw'});
|
||||
}
|
||||
|
||||
Uri? coverUrl(String coverId) {
|
||||
return _uri('getCoverArt.view', {'id': coverId});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user