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:flutter_secure_storage/flutter_secure_storage.dart'; import '../models/song.dart'; import '../database/db_helper.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 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 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(); /// Passwort & Zugangsdaten liegen verschlüsselt im Keychain/Keystore /// (flutter_secure_storage) statt im Klartext in SharedPreferences. static const _secure = 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 ladeGespeicherteZugangsdaten() async { try { final url = await _secure.read(key: 'navidrome_url'); final user = await _secure.read(key: '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 { await _secure.write(key: 'navidrome_url', value: url); await _secure.write(key: 'navidrome_user', value: user); await _secure.write(key: 'navidrome_pass', value: password); } catch (e) { debugPrint('Fehler beim Speichern der Navidrome-Zugangsdaten: $e'); } } bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty; Uri _uri(String endpoint, [Map? 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 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> 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> 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 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 = s.titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim(); final kurzId = s.id.length > 8 ? s.id.substring(0, 8) : s.id; final dateiName = '${safeName.isEmpty ? "song" : 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 final uri = streamUrl(s.id); final response = await http.Client().send(http.Request('GET', uri)); final sink = file.openWrite(); await for (final chunk in response.stream) { sink.add(chunk); } await sink.flush(); await sink.close(); final song = Song( titel: s.titel, kuenstler: s.kuenstler, album: s.album, dauerSekunden: s.dauerSekunden, dateiPfad: dateiPfad, groesseBytes: await file.length(), istHeruntergeladen: true, 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; } } }