import 'dart:async'; import 'dart:convert'; import 'dart:io'; 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'; import 'cache_manager.dart'; import 'logger_service.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 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 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, ); } /// Fehler einer Subsonic-Antwort mit `status:"failed"` (Navidrome liefert /// dafür HTTP 200). Trägt die Server-Meldung, damit die UI sie zeigen kann. class NavidromeException implements Exception { final String message; final int? code; NavidromeException(this.message, {this.code}); @override String toString() => 'NavidromeException(${code ?? '-'}): $message'; } 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 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 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 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; /// Dekodiert eine Subsonic-JSON-Antwort und wirft [NavidromeException], /// wenn der Server `status:"failed"` meldet (z. B. falsches Passwort) — /// solche Antworten kommen mit HTTP 200 und dürfen nicht als „leer" gelten. @visibleForTesting static Map parseSubsonic(String body) { final data = jsonDecode(body) as Map; final resp = data['subsonic-response'] as Map?; if (resp == null) { throw NavidromeException('Ungültige Server-Antwort (kein subsonic-response)'); } if (resp['status'] == 'failed') { final err = resp['error'] as Map?; throw NavidromeException( (err?['message'] as String?) ?? 'Unbekannter Server-Fehler', code: err?['code'] as int?, ); } return resp; } /// Liest Alben aus einer geprüften `getAlbumList2`-Antwort (ID3-basiert). @visibleForTesting static List parseAlben(Map resp) { final list = resp['albumList2']?['album'] as List? ?? []; return list .map((j) => SubsonicAlbum.fromJson(j as Map)) .toList(); } Uri _uri(String endpoint, [Map? extra]) { if (_token == null || _token!.isEmpty) { throw StateError('Navidrome ist nicht verbunden. Bitte zuerst Zugangsdaten setzen.'); } 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 ping() async { try { final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10)); if (r.statusCode != 200) { logger.warning('Navidrome Ping fehlgeschlagen (HTTP ${r.statusCode}): $_serverUrl'); return false; } // Subsonic liefert auch bei falschem Passwort HTTP 200 → Body prüfen, // sonst meldet der Login fälschlich Erfolg (status:"failed"). parseSubsonic(r.body); logger.info('✅ Navidrome Verbindung erfolgreich: $_serverUrl'); return true; } on NavidromeException catch (e) { logger.warning('Navidrome Login abgelehnt: ${e.message}'); return false; } catch (e) { // Details für die Diagnose: ohne URL/Exception-Typ ist im Log nicht // erkennbar, ob DNS, IPv6, TLS oder eine falsche URL die Ursache ist. // Bewusst NICHT die volle Ping-URI loggen — die enthält Token + Salt. final ziel = Uri.tryParse(_serverUrl); final art = switch (e) { TimeoutException() => 'TIMEOUT (10s überschritten)', SocketException(:final osError, :final address) => 'SOCKET (os=${osError?.message ?? '-'} code=${osError?.errorCode ?? '-'} ' 'addr=${address?.address ?? '-'} typ=${address?.type.name ?? '-'})', HandshakeException() => 'TLS-HANDSHAKE', HttpException() => 'HTTP', FormatException() => 'FORMAT (URL unparsbar?)', _ => 'SONSTIGE', }; await logger.error( 'Navidrome Ping Fehler [$art] ' 'url=$_serverUrl ' 'scheme=${ziel?.scheme ?? '-'} host=${ziel?.host ?? '-'} ' 'port=${ziel?.hasPort == true ? ziel?.port : '(default)'} ' 'typ=${e.runtimeType} ' 'msg=$e', e, StackTrace.current, ); debugPrint('Navidrome Ping Fehler [$art] $_serverUrl: $e'); return false; } } Future> getAlben({int anzahl = 50}) async { try { // getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint // getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer. final r = await http .get(_uri('getAlbumList2.view', {'type': 'newest', 'size': '$anzahl'})) .timeout(const Duration(seconds: 15)); if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}'); return parseAlben(parseSubsonic(r.body)); } catch (e) { await logger.error('Navidrome getAlben Fehler: $e', e, StackTrace.current); rethrow; } } Future> 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) { await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current); return []; } } Uri streamUrl(String songId) { return _uri('stream.view', {'id': songId, 'format': 'raw'}); } Uri? coverUrl(String coverId) { return _uri('getCoverArt.view', {'id': coverId}); } Future setFavorite(String songId) async { try { final r = await http.get(_uri('star.view', {'id': songId})).timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (e) { await logger.error('Navidrome setFavorite Fehler: $e', e, StackTrace.current); return false; } } Future removeFavorite(String songId) async { try { final r = await http.get(_uri('unstar.view', {'id': songId})).timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (e) { await logger.error('Navidrome removeFavorite Fehler: $e', e, StackTrace.current); return false; } } Future> getFavorites() async { try { final r = await http.get(_uri('getStarred.view')).timeout(const Duration(seconds: 15)); if (r.statusCode != 200) return []; final data = jsonDecode(r.body); final songs = data['subsonic-response']?['starred']?['song'] as List? ?? []; return songs.map((j) => SubsonicSong.fromJson(j)).toList(); } catch (e) { await logger.error('Navidrome getFavorites Fehler: $e', e, StackTrace.current); return []; } } Future> getArtists() async { try { final r = await http.get(_uri('getArtists.view')).timeout(const Duration(seconds: 15)); if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}'); final resp = parseSubsonic(r.body); final indexData = resp['artists']?['index'] as List? ?? []; final artists = []; for (final idx in indexData) { final artistList = idx['artist'] as List? ?? []; for (final a in artistList) { artists.add(SubsonicArtist.fromJson(a as Map)); } } return artists; } catch (e) { await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current); rethrow; } } Future> getArtistSongs(String artistId) async { try { final r = await http.get(_uri('getArtist.view', {'id': artistId})).timeout(const Duration(seconds: 15)); if (r.statusCode != 200) return []; final data = jsonDecode(r.body); final albums = data['subsonic-response']?['artist']?['album'] as List? ?? []; final songs = []; for (final album in albums) { final albumSongs = album['song'] as List? ?? []; for (final song in albumSongs) { songs.add(SubsonicSong.fromJson(song)); } } return songs; } catch (e) { await logger.error('Navidrome getArtistSongs Fehler: $e', e, StackTrace.current); return []; } } Future getLyrics(String songId) async { try { final r = await http.get(_uri('getLyrics.view', {'id': songId})).timeout(const Duration(seconds: 10)); if (r.statusCode != 200) return Lyrics.empty(); final data = jsonDecode(r.body); final lyrics = data['subsonic-response']?['lyrics']?['value'] as String?; return Lyrics.fromText(lyrics); } catch (e) { await logger.error('Navidrome getLyrics Fehler: $e', e, StackTrace.current); return Lyrics.empty(); } } Future streamAndCacheToLocal(String songId, CacheManager cache) async { try { final streamUri = streamUrl(songId); final cacheFile = await cache.getCacheFile(streamUri.toString()); if (await cacheFile.exists()) { debugPrint('Cache-Hit: ${cacheFile.path}'); return Uri.file(cacheFile.path); } debugPrint('Cache-Miss: Starten download zu ${cacheFile.path}'); final request = http.Request('GET', streamUri); final response = await http.Client().send(request).timeout(const Duration(seconds: 30)); if (response.statusCode != 200) { await logger.error('Stream-Fehler: HTTP ${response.statusCode}'); return streamUri; } final sink = cacheFile.openWrite(); await for (final chunk in response.stream) { sink.add(chunk); } await sink.close(); debugPrint('Cache-Speicherung erfolgreich: ${cacheFile.path}'); return Uri.file(cacheFile.path); } catch (e) { await logger.error('streamAndCacheToLocal Fehler: $e', e, StackTrace.current); return streamUrl(songId); } } /// Speichert die aktuelle Wiedergabe-Position für einen Song zum Server. /// Subsonic-API: scrobble.view mit `submission=true` und Position in Sekunden. Future scrobble(String songId, int positionSeconds) async { if (!istVerbunden) return; try { final uri = _uri('scrobble.view', { 'id': songId, 'submission': 'true', 'position': positionSeconds.toString(), }); await http.get(uri).timeout(const Duration(seconds: 10)); debugPrint('Scrobble erfolgreich: Song=$songId, Position=${positionSeconds}s'); } catch (e) { await logger.error('Scrobble Fehler: $e', e, StackTrace.current); } } /// Lädt die gespeicherte Wiedergabe-Position für einen Song vom Server. /// Gibt Position in Millisekunden zurück oder null wenn nicht gespeichert. Future getBookmark(String songId) async { if (!istVerbunden) return null; try { final uri = _uri('getBookmarks.view', {'id': songId}); final response = await http.get(uri).timeout(const Duration(seconds: 10)); if (response.statusCode != 200) return null; final json = jsonDecode(response.body) as Map; final subsonicResponse = json['subsonic-response'] as Map?; if (subsonicResponse == null) return null; final bookmarks = subsonicResponse['bookmark'] as List?; if (bookmarks == null || bookmarks.isEmpty) return null; final position = (bookmarks[0] as Map)['position'] as int?; if (position == null) return null; debugPrint('Bookmark geladen: Song=$songId, Position=${position}ms'); return position; } catch (e) { await logger.error('Bookmark Fehler: $e', e, StackTrace.current); return null; } } /// Lädt alle Playlisten vom Server. Future> getPlaylists() async { if (!istVerbunden) return []; try { final uri = _uri('getPlaylists.view'); final response = await http.get(uri).timeout(const Duration(seconds: 10)); if (response.statusCode != 200) return []; final json = jsonDecode(response.body) as Map; final subsonicResponse = json['subsonic-response'] as Map?; if (subsonicResponse == null) return []; final playlists = subsonicResponse['playlists'] as List?; if (playlists == null) return []; return playlists .cast>() .map(SubsonicPlaylist.fromJson) .toList(); } catch (e) { await logger.error('getPlaylists Fehler: $e', e, StackTrace.current); return []; } } /// Lädt alle Songs einer Playlist vom Server. Future> getPlaylistSongs(String playlistId) async { if (!istVerbunden) return []; try { final uri = _uri('getPlaylist.view', {'id': playlistId}); final response = await http.get(uri).timeout(const Duration(seconds: 10)); if (response.statusCode != 200) return []; final json = jsonDecode(response.body) as Map; final subsonicResponse = json['subsonic-response'] as Map?; if (subsonicResponse == null) return []; final playlist = subsonicResponse['playlist'] as Map?; if (playlist == null) return []; final songs = playlist['entry'] as List?; if (songs == null) return []; return songs.cast>().map(SubsonicSong.fromJson).toList(); } catch (e) { await logger.error('getPlaylistSongs Fehler: $e', e, StackTrace.current); return []; } } } class SubsonicArtist { final String id; final String name; const SubsonicArtist({ required this.id, required this.name, }); factory SubsonicArtist.fromJson(Map j) => SubsonicArtist( id: j['id'] as String, name: j['name'] as String? ?? 'Unbekannt', ); } class SubsonicPlaylist { final String id; final String name; final String? comment; final int songCount; const SubsonicPlaylist({ required this.id, required this.name, this.comment, this.songCount = 0, }); factory SubsonicPlaylist.fromJson(Map j) => SubsonicPlaylist( id: j['id'] as String, name: j['name'] as String? ?? 'Unbekannt', comment: j['comment'] as String?, songCount: (j['songCount'] as int?) ?? 0, ); } class Lyrics { final String? text; final bool isEmpty; const Lyrics({ this.text, this.isEmpty = true, }); factory Lyrics.empty() => const Lyrics(isEmpty: true); factory Lyrics.fromText(String? text) => Lyrics(text: text, isEmpty: text?.isEmpty ?? true); }