Merge: Fix Server-Bibliothek-Laden + Tab-Abschnitt

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Hermes (Server)
2026-08-20 09:34:11 +02:00
4 changed files with 183 additions and 33 deletions
+45 -18
View File
@@ -40,6 +40,9 @@ class LibraryScreen extends StatelessWidget {
),
],
bottom: const TabBar(
// Engere Abstände, damit '🌐 Server' nicht abgeschnitten wird
// (4 feste Tabs, gleich breit) — Schriftgröße bleibt unverändert.
labelPadding: EdgeInsets.symmetric(horizontal: 4),
tabs: [
Tab(text: 'Alle'),
Tab(text: 'Künstler'),
@@ -256,6 +259,7 @@ class _NavidromeTabState extends State<_NavidromeTab> {
List<SubsonicArtist> _artists = [];
bool _loading = false;
bool _showArtists = false;
String? _error;
@override
void initState() {
@@ -271,7 +275,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
if (!_nav.istVerbunden) {
return;
}
setState(() => _loading = true);
setState(() {
_loading = true;
_error = null;
});
try {
final alben = await _nav.getAlben(anzahl: 50);
if (mounted) {
@@ -282,7 +289,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
}
} catch (e) {
if (mounted) {
setState(() => _loading = false);
setState(() {
_loading = false;
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
});
}
debugPrint('Fehler beim Laden der Alben: $e');
}
@@ -290,7 +300,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
Future<void> _loadArtists() async {
if (!_nav.istVerbunden) return;
setState(() => _loading = true);
setState(() {
_loading = true;
_error = null;
});
try {
final artists = await _nav.getArtists();
if (mounted) {
@@ -300,7 +313,12 @@ class _NavidromeTabState extends State<_NavidromeTab> {
});
}
} catch (e) {
if (mounted) setState(() => _loading = false);
if (mounted) {
setState(() {
_loading = false;
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
});
}
debugPrint('Fehler beim Laden der Künstler: $e');
}
}
@@ -331,21 +349,30 @@ class _NavidromeTabState extends State<_NavidromeTab> {
}
if (_albums.isEmpty) {
final hatFehler = _error != null;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.album, size: 64, color: Colors.white24),
const SizedBox(height: 12),
const Text('Keine Alben geladen',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
onPressed: _loadAlbums,
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(hatFehler ? Icons.error_outline : Icons.album,
size: 64,
color: hatFehler ? Colors.redAccent : Colors.white24),
const SizedBox(height: 12),
Text(
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 16),
FilledButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
onPressed: _loadAlbums,
),
],
),
),
);
}
+61 -15
View File
@@ -58,6 +58,16 @@ class SubsonicAlbum {
);
}
/// 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();
@@ -123,6 +133,35 @@ class NavidromeService {
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<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.');
@@ -142,13 +181,18 @@ class NavidromeService {
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 {
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.
@@ -181,14 +225,16 @@ class NavidromeService {
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();
// 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);
return [];
rethrow;
}
}
@@ -249,20 +295,20 @@ class NavidromeService {
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? ?? [];
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));
artists.add(SubsonicArtist.fromJson(a as Map<String, dynamic>));
}
}
return artists;
} catch (e) {
await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current);
return [];
rethrow;
}
}