Flutter-App Seite: - LoggerService: Centralized Logging (DEBUG/INFO/WARNING/ERROR) - Automatic Upload zu logs.baka-net.de/api/logs - Fehler sofort senden - Andere Logs collected (batch von 20) - Device-Info: Model + OS-Version tracken - Integration in NavidromeService: Login-Fehler loggen + URL + Status - Global logger Instance für überall nutzbar Auto-Features: ✅ Timestamp + Device-Info + Error Stack-Traces ✅ Auto-Upload nach 20 Logs oder sofort bei Fehler ✅ JSON-Format für Server-Verarbeitung Nächste Schritte: - Log-Server deployen (FastAPI, SQLite, HTML-Viewer) - Caddy-Route für logs.baka-net.de - Basic Auth für Log-Seite Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XJzQjUtnvYUtHdnTs3iCru
453 lines
14 KiB
Dart
453 lines
14 KiB
Dart
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';
|
|
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<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]) {
|
|
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<bool> ping() async {
|
|
try {
|
|
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
|
if (r.statusCode == 200) {
|
|
logger.info('✅ Navidrome Verbindung erfolgreich: $_serverUrl');
|
|
return true;
|
|
} else {
|
|
logger.warning('Navidrome Ping fehlgeschlagen (HTTP ${r.statusCode}): $_serverUrl');
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
logger.error('Navidrome Ping Fehler', e, StackTrace.current);
|
|
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});
|
|
}
|
|
|
|
Future<bool> 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) {
|
|
debugPrint('Navidrome setFavorite Fehler: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> 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) {
|
|
debugPrint('Navidrome removeFavorite Fehler: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<List<SubsonicSong>> 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) {
|
|
debugPrint('Navidrome getFavorites Fehler: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<List<SubsonicArtist>> getArtists() async {
|
|
try {
|
|
final r = await http.get(_uri('getArtists.view')).timeout(const Duration(seconds: 15));
|
|
if (r.statusCode != 200) return [];
|
|
final data = jsonDecode(r.body);
|
|
final indexData = data['subsonic-response']?['artists']?['index'] as List? ?? [];
|
|
final artists = <SubsonicArtist>[];
|
|
for (final idx in indexData) {
|
|
final artistList = idx['artist'] as List? ?? [];
|
|
for (final a in artistList) {
|
|
artists.add(SubsonicArtist.fromJson(a));
|
|
}
|
|
}
|
|
return artists;
|
|
} catch (e) {
|
|
debugPrint('Navidrome getArtists Fehler: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<List<SubsonicSong>> 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 = <SubsonicSong>[];
|
|
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) {
|
|
debugPrint('Navidrome getArtistSongs Fehler: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<Lyrics> 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) {
|
|
debugPrint('Navidrome getLyrics Fehler: $e');
|
|
return Lyrics.empty();
|
|
}
|
|
}
|
|
|
|
Future<Uri?> 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) {
|
|
debugPrint('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) {
|
|
debugPrint('streamAndCacheToLocal Fehler: $e');
|
|
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<void> 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) {
|
|
debugPrint('Scrobble Fehler: $e');
|
|
}
|
|
}
|
|
|
|
/// Lädt die gespeicherte Wiedergabe-Position für einen Song vom Server.
|
|
/// Gibt Position in Millisekunden zurück oder null wenn nicht gespeichert.
|
|
Future<int?> 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<String, dynamic>;
|
|
final subsonicResponse = json['subsonic-response'] as Map<String, dynamic>?;
|
|
if (subsonicResponse == null) return null;
|
|
|
|
final bookmarks = subsonicResponse['bookmark'] as List<dynamic>?;
|
|
if (bookmarks == null || bookmarks.isEmpty) return null;
|
|
|
|
final position = (bookmarks[0] as Map<String, dynamic>)['position'] as int?;
|
|
if (position == null) return null;
|
|
debugPrint('Bookmark geladen: Song=$songId, Position=${position}ms');
|
|
return position;
|
|
} catch (e) {
|
|
debugPrint('Bookmark Fehler: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Lädt alle Playlisten vom Server.
|
|
Future<List<SubsonicPlaylist>> 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<String, dynamic>;
|
|
final subsonicResponse = json['subsonic-response'] as Map<String, dynamic>?;
|
|
if (subsonicResponse == null) return [];
|
|
|
|
final playlists = subsonicResponse['playlists'] as List<dynamic>?;
|
|
if (playlists == null) return [];
|
|
|
|
return playlists
|
|
.cast<Map<String, dynamic>>()
|
|
.map(SubsonicPlaylist.fromJson)
|
|
.toList();
|
|
} catch (e) {
|
|
debugPrint('getPlaylists Fehler: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Lädt alle Songs einer Playlist vom Server.
|
|
Future<List<SubsonicSong>> 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<String, dynamic>;
|
|
final subsonicResponse = json['subsonic-response'] as Map<String, dynamic>?;
|
|
if (subsonicResponse == null) return [];
|
|
|
|
final playlist = subsonicResponse['playlist'] as Map<String, dynamic>?;
|
|
if (playlist == null) return [];
|
|
|
|
final songs = playlist['entry'] as List<dynamic>?;
|
|
if (songs == null) return [];
|
|
|
|
return songs.cast<Map<String, dynamic>>().map(SubsonicSong.fromJson).toList();
|
|
} catch (e) {
|
|
debugPrint('getPlaylistSongs Fehler: $e');
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
class SubsonicArtist {
|
|
final String id;
|
|
final String name;
|
|
|
|
const SubsonicArtist({
|
|
required this.id,
|
|
required this.name,
|
|
});
|
|
|
|
factory SubsonicArtist.fromJson(Map<String, dynamic> 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<String, dynamic> 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);
|
|
}
|