Server-User (Dustin, Baka, Tinker) melden sich beim Öffnen des YouTube-Bereichs automatisch im Hintergrund mit ihren Navidrome- Zugangsdaten beim Baka-Konto an (BakaAuth.autoAnmelden) — die manuelle Anmeldung bleibt nur für Gäste sichtbar. Schlägt die Auto-Anmeldung fehl, gibt es einen Rückfallweg zur manuellen Anmeldung, statt den User auszusperren. Login-Buttons vergrößert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
587 lines
21 KiB
Dart
587 lines
21 KiB
Dart
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<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;
|
|
final String? kuenstler;
|
|
|
|
const SubsonicAlbum({
|
|
required this.id,
|
|
required this.name,
|
|
this.coverId,
|
|
this.songCount = 0,
|
|
this.kuenstler,
|
|
});
|
|
|
|
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,
|
|
kuenstler: j['artist'] as String?,
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// Server-Meldung des letzten abgelehnten Logins (z. B. „Wrong username
|
|
/// or password"), damit der Login-Dialog die echte Ursache zeigen kann.
|
|
String? letzterLoginFehler;
|
|
|
|
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;
|
|
|
|
/// Für die automatische Baka-Anmeldung verbundener Server-User (siehe
|
|
/// [BakaAuth.autoAnmelden]) — Navidrome- und Baka-Konto teilen sich die
|
|
/// Zugangsdaten.
|
|
String get benutzer => _user;
|
|
String get passwort => _password;
|
|
|
|
/// 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<String, dynamic> parseSubsonic(String body) {
|
|
final data = jsonDecode(body) as Map<String, dynamic>;
|
|
final resp = data['subsonic-response'] as Map<String, dynamic>?;
|
|
if (resp == null) {
|
|
throw NavidromeException('Ungültige Server-Antwort (kein subsonic-response)');
|
|
}
|
|
if (resp['status'] == 'failed') {
|
|
final err = resp['error'] as Map<String, dynamic>?;
|
|
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<SubsonicAlbum> parseAlben(Map<String, dynamic> resp) {
|
|
final list = resp['albumList2']?['album'] as List? ?? [];
|
|
return list
|
|
.map((j) => SubsonicAlbum.fromJson(j as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
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 {
|
|
letzterLoginFehler = null;
|
|
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) {
|
|
letzterLoginFehler = e.message;
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// [ab] überspringt die ersten Alben — damit lässt sich über die erste
|
|
/// Seite hinaus nachladen, statt bei 50 stehenzubleiben.
|
|
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50, int ab = 0}) 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', 'offset': '$ab'}))
|
|
.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;
|
|
}
|
|
}
|
|
|
|
/// Die Titel eines Albums.
|
|
///
|
|
/// Wirft bei Fehlern weiter, statt eine leere Liste zu liefern: „keine
|
|
/// Titel" und „Server nicht erreichbar" sehen sonst gleich aus, und der
|
|
/// Nutzer bekäme keinen Wiederholen-Knopf zu sehen.
|
|
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) throw NavidromeException('HTTP ${r.statusCode}');
|
|
final data = parseSubsonic(r.body);
|
|
final songs = data['album']?['song'] as List? ?? [];
|
|
return songs.map((j) => SubsonicSong.fromJson(j)).toList();
|
|
} catch (e) {
|
|
await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
await logger.error('Navidrome setFavorite Fehler: $e', e, StackTrace.current);
|
|
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) {
|
|
await logger.error('Navidrome removeFavorite Fehler: $e', e, StackTrace.current);
|
|
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) {
|
|
await logger.error('Navidrome getFavorites Fehler: $e', e, StackTrace.current);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<List<SubsonicArtist>> 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 = <SubsonicArtist>[];
|
|
for (final idx in indexData) {
|
|
final artistList = idx['artist'] as List? ?? [];
|
|
for (final a in artistList) {
|
|
artists.add(SubsonicArtist.fromJson(a as Map<String, dynamic>));
|
|
}
|
|
}
|
|
return artists;
|
|
} catch (e) {
|
|
await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Alle Titel eines Künstlers, Album für Album.
|
|
///
|
|
/// `getArtist` liefert die Alben **ohne** ihre Titel — das steht so in der
|
|
/// Subsonic-API. Vorher las diese Methode `album[].song`, was es dort nie
|
|
/// gibt: die Liste war immer leer, und der Künstler wirkte titellos.
|
|
/// Deshalb je Album ein `getAlbum` hinterher.
|
|
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) throw NavidromeException('HTTP ${r.statusCode}');
|
|
final data = parseSubsonic(r.body);
|
|
final alben = data['artist']?['album'] as List? ?? [];
|
|
final songs = <SubsonicSong>[];
|
|
for (final album in alben) {
|
|
final id = (album as Map)['id'] as String?;
|
|
if (id == null) continue;
|
|
songs.addAll(await getSongs(id));
|
|
}
|
|
return songs;
|
|
} catch (e) {
|
|
await logger.error(
|
|
'Navidrome getArtistSongs Fehler: $e', e, StackTrace.current);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
await logger.error('Navidrome getLyrics Fehler: $e', e, StackTrace.current);
|
|
return Lyrics.empty();
|
|
}
|
|
}
|
|
|
|
/// Nur eine vollständige Audio-Antwort darf in den Cache. Subsonic meldet
|
|
/// Fehler mit HTTP 200 und einem JSON-Rumpf — landet der als ".mp3" im
|
|
/// Cache, ist der Titel dauerhaft unabspielbar, weil jeder weitere Versuch
|
|
/// den Cache-Treffer nimmt.
|
|
@visibleForTesting
|
|
static bool istAudioAntwort(int statusCode, String? contentType) {
|
|
if (statusCode != 200) return false;
|
|
final typ = contentType?.toLowerCase() ?? '';
|
|
return typ.startsWith('audio/') || typ.startsWith('application/octet-stream');
|
|
}
|
|
|
|
/// Schlüssel, unter dem ein Server-Titel im Cache liegt.
|
|
///
|
|
/// Bewusst die Song-ID und nicht die Stream-Adresse: die trägt Token und
|
|
/// Salt, und beides wird bei jedem App-Start neu gewürfelt. Als Schlüssel
|
|
/// hätte damit derselbe Titel jedes Mal einen anderen — der Cache wäre nie
|
|
/// wieder auffindbar und würde nur wachsen.
|
|
static String cacheSchluessel(String songId) => 'navidrome:$songId';
|
|
|
|
/// Legt den Titel [songId] vollständig im Cache ab, damit er später ohne
|
|
/// Netz läuft. Läuft im Hintergrund — die Wiedergabe wartet nie darauf.
|
|
///
|
|
/// Geschrieben wird zuerst in eine `.part`-Datei; erst der vollständige,
|
|
/// als Audio bestätigte Download wird umbenannt. Ein Abbruch hinterlässt
|
|
/// damit keine halbe Datei, die als gültiger Cache-Treffer gälte.
|
|
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
|
|
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
|
|
if (await ziel.exists()) return ziel;
|
|
return ladeDateiHerunter(streamUri, ziel);
|
|
}
|
|
|
|
/// Lädt [streamUri] vollständig nach [ziel] und gibt die fertige Datei
|
|
/// zurück, oder `null`, wenn dabei etwas schiefging.
|
|
///
|
|
/// Dieselbe Logik für den Zwischenspeicher wie für einen bewussten
|
|
/// Download — nur das Ziel unterscheidet sich. Geschrieben wird zuerst in
|
|
/// eine `.part`-Datei; erst der vollständige, als Audio bestätigte
|
|
/// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe
|
|
/// Datei, die später als gültiger Treffer gälte.
|
|
Future<File?> ladeDateiHerunter(Uri streamUri, File ziel) async {
|
|
final teil = File('${ziel.path}.part');
|
|
final klient = http.Client();
|
|
try {
|
|
final antwort = await klient
|
|
.send(http.Request('GET', streamUri))
|
|
.timeout(const Duration(seconds: 30));
|
|
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
|
|
await logger.error('Download abgebrochen: HTTP ${antwort.statusCode}, '
|
|
'Typ ${antwort.headers['content-type'] ?? '-'}');
|
|
return null;
|
|
}
|
|
await antwort.stream.pipe(teil.openWrite());
|
|
await teil.rename(ziel.path);
|
|
return ziel;
|
|
} catch (e) {
|
|
await logger.error('Download fehlgeschlagen: $e', e, StackTrace.current);
|
|
if (await teil.exists()) await teil.delete();
|
|
return null;
|
|
} finally {
|
|
klient.close();
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
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<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) {
|
|
await logger.error('Bookmark Fehler: $e', e, StackTrace.current);
|
|
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) {
|
|
await logger.error('getPlaylists Fehler: $e', e, StackTrace.current);
|
|
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) {
|
|
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<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);
|
|
}
|