From 3d39e8b9226f616c707e0c369a65913bb8669c9b Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 20 Aug 2026 02:24:37 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Server-Bibliothek=20l=C3=A4dt=20nicht=20?= =?UTF-8?q?+=20Tab=20'=F0=9F=8C=90=20Server'=20abgeschnitten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2 (Laden schlägt fehl trotz 'ping ok'): Der Code ignorierte das Subsonic- status-Feld. Navidrome liefert bei falschem Passwort HTTP 200 mit status:"failed" — ping() meldete dadurch fälschlich Erfolg, getAlben/getArtists behandelten die Fehlerantwort still als leer ("Erneut versuchen"). - parseSubsonic() wirft NavidromeException bei status:"failed" (mit Servermsg). - ping() prüft jetzt den Body statt nur HTTP 200. - getAlben/getArtists reichen den Fehler durch (rethrow) statt [] zu liefern. - getAlben nutzt getAlbumList2 (ID3, wie getArtists) statt getAlbumList (Folder). - Server-Tab zeigt die echte Fehlermeldung statt "Keine Alben geladen". Bug 1 (Tab abgeschnitten -> nur "Serve"): TabBar labelPadding verkleinert (engere Abstände), Schriftgröße unverändert. Neue Tests: test/navidrome_parse_test.dart (5). 84/84 grün, analyze sauber. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Tr2UwurtxyJUpWpBSeVmBm --- CHANGELOG.md | 19 ++++++++ lib/library/library_screen.dart | 63 +++++++++++++++++------- lib/services/navidrome_service.dart | 76 +++++++++++++++++++++++------ test/navidrome_parse_test.dart | 58 ++++++++++++++++++++++ 4 files changed, 183 insertions(+), 33 deletions(-) create mode 100644 test/navidrome_parse_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf865a..5ec823b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🐛 Server-Bibliothek lädt + Tab abgeschnitten (2026-08-20) +- 🐛 **Server-Bibliothek lud nicht ("Erneut versuchen"):** Ursache war, dass + der Code das Subsonic-`status`-Feld ignorierte. Navidrome liefert auch bei + falschem Passwort **HTTP 200** mit `status:"failed"` — `ping()` meldete + dadurch fälschlich „Login ok", und `getAlben`/`getArtists` behandelten die + Fehlerantwort still als „leer". + - 🔧 `ping()` prüft jetzt den Body (`status`) — kein Fehlalarm mehr beim Login. + - 🔧 `getAlben`/`getArtists` werfen bei `status:"failed"` eine + `NavidromeException` mit Server-Meldung statt still `[]` zurückzugeben. + - 🔧 Der Server-Tab zeigt die echte Server-Fehlermeldung (z. B. „Wrong + username or password") statt nur „Keine Alben geladen". + - 🔧 Album-Abruf nutzt jetzt den ID3-Endpoint `getAlbumList2` (konsistent zu + `getArtists`) — der alte Folder-Endpoint `getAlbumList` liefert bei + tag-organisierten Libraries leer. +- 🐛 **Tab „🌐 Server" war abgeschnitten** (nur „Serve" sichtbar): Die 4 festen + Tabs waren zu eng. `labelPadding` verkleinert (engere Abstände) — + Schriftgröße unverändert. +- 🧪 Neue Tests: `test/navidrome_parse_test.dart` (5). 84/84 grün, analyze sauber. + ### 🔧 Log-Upload System - Fixes (2026-08-19) - ✅ **ERROR-Level Logging**: Fehler werden SOFORT zum Server hochgeladen (async/await Fixed) - ✅ **Navidrome-Service Error Logging**: Alle API-Fehler (ping, getAlben, getSongs, getFavorites, getArtists, getArtistSongs, getLyrics, scrobble, getBookmarks, getPlaylists, getPlaylistSongs) werden jetzt mit `logger.error()` zum Server geloggt (statt nur debugPrint) diff --git a/lib/library/library_screen.dart b/lib/library/library_screen.dart index bdf698e..a9930ef 100644 --- a/lib/library/library_screen.dart +++ b/lib/library/library_screen.dart @@ -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 _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 _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, + ), + ], + ), ), ); } diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index 0b53e54..5ca905d 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -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 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.'); @@ -142,13 +181,18 @@ class NavidromeService { Future 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> 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> 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 = []; 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)); } } return artists; } catch (e) { await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current); - return []; + rethrow; } } diff --git a/test/navidrome_parse_test.dart b/test/navidrome_parse_test.dart new file mode 100644 index 0000000..ab8b38d --- /dev/null +++ b/test/navidrome_parse_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/navidrome_service.dart'; + +/// Tests für die Subsonic-Antwort-Auswertung. Kern des Bugfixes: +/// Navidrome liefert bei Fehlern (z. B. falsches Passwort) HTTP 200 mit +/// {"subsonic-response":{"status":"failed","error":{...}}}. Diese Antworten +/// dürfen NICHT stillschweigend als „leer" behandelt werden. +void main() { + group('NavidromeService.parseSubsonic', () { + test('status:failed wirft NavidromeException mit Server-Meldung', () { + const body = + '{"subsonic-response":{"status":"failed","version":"1.16.1",' + '"error":{"code":40,"message":"Wrong username or password"}}}'; + expect( + () => NavidromeService.parseSubsonic(body), + throwsA(isA() + .having((e) => e.message, 'message', 'Wrong username or password') + .having((e) => e.code, 'code', 40)), + ); + }); + + test('status:ok liefert den subsonic-response-Block zurück', () { + const body = + '{"subsonic-response":{"status":"ok","version":"1.16.1",' + '"albumList2":{"album":[]}}}'; + final resp = NavidromeService.parseSubsonic(body); + expect(resp['status'], 'ok'); + expect(resp['albumList2'], isNotNull); + }); + + test('fehlender subsonic-response wirft NavidromeException', () { + expect(() => NavidromeService.parseSubsonic('{"foo":1}'), + throwsA(isA())); + }); + }); + + group('NavidromeService.parseAlben (ID3 / getAlbumList2)', () { + test('liest albumList2.album korrekt', () { + const body = '{"subsonic-response":{"status":"ok","albumList2":{"album":[' + '{"id":"a1","name":"Erstes Album","coverArt":"c1","songCount":9},' + '{"id":"a2","title":"Zweites","songCount":3}' + ']}}}'; + final resp = NavidromeService.parseSubsonic(body); + final alben = NavidromeService.parseAlben(resp); + expect(alben.length, 2); + expect(alben[0].id, 'a1'); + expect(alben[0].name, 'Erstes Album'); + expect(alben[0].songCount, 9); + expect(alben[1].name, 'Zweites'); + }); + + test('leere albumList2 liefert leere Liste (kein Fehler)', () { + final resp = NavidromeService.parseSubsonic( + '{"subsonic-response":{"status":"ok","albumList2":{}}}'); + expect(NavidromeService.parseAlben(resp), isEmpty); + }); + }); +}