Merge: Fix Server-Bibliothek-Laden + Tab-Abschnitt
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -25,6 +25,25 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
- ℹ️ **Offen:** Opt-in/Einwilligung für den Diagnose-Versand bleibt ein
|
- ℹ️ **Offen:** Opt-in/Einwilligung für den Diagnose-Versand bleibt ein
|
||||||
separater Folgeschritt (braucht UI).
|
separater Folgeschritt (braucht UI).
|
||||||
|
|
||||||
|
### 🐛 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)
|
### 🔧 Log-Upload System - Fixes (2026-08-19)
|
||||||
- ✅ **ERROR-Level Logging**: Fehler werden SOFORT zum Server hochgeladen (async/await Fixed)
|
- ✅ **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)
|
- ✅ **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)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class LibraryScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottom: const TabBar(
|
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: [
|
tabs: [
|
||||||
Tab(text: 'Alle'),
|
Tab(text: 'Alle'),
|
||||||
Tab(text: 'Künstler'),
|
Tab(text: 'Künstler'),
|
||||||
@@ -256,6 +259,7 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
List<SubsonicArtist> _artists = [];
|
List<SubsonicArtist> _artists = [];
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _showArtists = false;
|
bool _showArtists = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -271,7 +275,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
if (!_nav.istVerbunden) {
|
if (!_nav.istVerbunden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final alben = await _nav.getAlben(anzahl: 50);
|
final alben = await _nav.getAlben(anzahl: 50);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -282,7 +289,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _loading = false);
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = e is NavidromeException ? e.message : 'Verbindungsfehler';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
debugPrint('Fehler beim Laden der Alben: $e');
|
debugPrint('Fehler beim Laden der Alben: $e');
|
||||||
}
|
}
|
||||||
@@ -290,7 +300,10 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
|
|
||||||
Future<void> _loadArtists() async {
|
Future<void> _loadArtists() async {
|
||||||
if (!_nav.istVerbunden) return;
|
if (!_nav.istVerbunden) return;
|
||||||
setState(() => _loading = true);
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final artists = await _nav.getArtists();
|
final artists = await _nav.getArtists();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -300,7 +313,12 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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');
|
debugPrint('Fehler beim Laden der Künstler: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,14 +349,22 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_albums.isEmpty) {
|
if (_albums.isEmpty) {
|
||||||
|
final hatFehler = _error != null;
|
||||||
return Center(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.album, size: 64, color: Colors.white24),
|
Icon(hatFehler ? Icons.error_outline : Icons.album,
|
||||||
|
size: 64,
|
||||||
|
color: hatFehler ? Colors.redAccent : Colors.white24),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('Keine Alben geladen',
|
Text(
|
||||||
style: TextStyle(color: Colors.white54)),
|
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
@@ -347,6 +373,7 @@ class _NavidromeTabState extends State<_NavidromeTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
class NavidromeService {
|
||||||
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
||||||
|
|
||||||
@@ -123,6 +133,35 @@ class NavidromeService {
|
|||||||
|
|
||||||
bool get istVerbunden => _serverUrl.isNotEmpty && _user.isNotEmpty;
|
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]) {
|
Uri _uri(String endpoint, [Map<String, String>? extra]) {
|
||||||
if (_token == null || _token!.isEmpty) {
|
if (_token == null || _token!.isEmpty) {
|
||||||
throw StateError('Navidrome ist nicht verbunden. Bitte zuerst Zugangsdaten setzen.');
|
throw StateError('Navidrome ist nicht verbunden. Bitte zuerst Zugangsdaten setzen.');
|
||||||
@@ -142,13 +181,18 @@ class NavidromeService {
|
|||||||
Future<bool> ping() async {
|
Future<bool> ping() async {
|
||||||
try {
|
try {
|
||||||
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
final r = await http.get(_uri('ping.view')).timeout(const Duration(seconds: 10));
|
||||||
if (r.statusCode == 200) {
|
if (r.statusCode != 200) {
|
||||||
logger.info('✅ Navidrome Verbindung erfolgreich: $_serverUrl');
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
logger.warning('Navidrome Ping fehlgeschlagen (HTTP ${r.statusCode}): $_serverUrl');
|
logger.warning('Navidrome Ping fehlgeschlagen (HTTP ${r.statusCode}): $_serverUrl');
|
||||||
return false;
|
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) {
|
} catch (e) {
|
||||||
// Details für die Diagnose: ohne URL/Exception-Typ ist im Log nicht
|
// 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.
|
// 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 {
|
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async {
|
||||||
try {
|
try {
|
||||||
final r = await http.get(_uri('getAlbumList.view', {'type': 'newest', 'size': '$anzahl'})).timeout(const Duration(seconds: 15));
|
// getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint
|
||||||
if (r.statusCode != 200) return [];
|
// getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer.
|
||||||
final data = jsonDecode(r.body);
|
final r = await http
|
||||||
final list = data['subsonic-response']?['albumList']?['album'] as List? ?? [];
|
.get(_uri('getAlbumList2.view', {'type': 'newest', 'size': '$anzahl'}))
|
||||||
return list.map((j) => SubsonicAlbum.fromJson(j)).toList();
|
.timeout(const Duration(seconds: 15));
|
||||||
|
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
|
||||||
|
return parseAlben(parseSubsonic(r.body));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await logger.error('Navidrome getAlben Fehler: $e', e, StackTrace.current);
|
await logger.error('Navidrome getAlben Fehler: $e', e, StackTrace.current);
|
||||||
return [];
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,20 +295,20 @@ class NavidromeService {
|
|||||||
Future<List<SubsonicArtist>> getArtists() async {
|
Future<List<SubsonicArtist>> getArtists() async {
|
||||||
try {
|
try {
|
||||||
final r = await http.get(_uri('getArtists.view')).timeout(const Duration(seconds: 15));
|
final r = await http.get(_uri('getArtists.view')).timeout(const Duration(seconds: 15));
|
||||||
if (r.statusCode != 200) return [];
|
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
|
||||||
final data = jsonDecode(r.body);
|
final resp = parseSubsonic(r.body);
|
||||||
final indexData = data['subsonic-response']?['artists']?['index'] as List? ?? [];
|
final indexData = resp['artists']?['index'] as List? ?? [];
|
||||||
final artists = <SubsonicArtist>[];
|
final artists = <SubsonicArtist>[];
|
||||||
for (final idx in indexData) {
|
for (final idx in indexData) {
|
||||||
final artistList = idx['artist'] as List? ?? [];
|
final artistList = idx['artist'] as List? ?? [];
|
||||||
for (final a in artistList) {
|
for (final a in artistList) {
|
||||||
artists.add(SubsonicArtist.fromJson(a));
|
artists.add(SubsonicArtist.fromJson(a as Map<String, dynamic>));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return artists;
|
return artists;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current);
|
await logger.error('Navidrome getArtists Fehler: $e', e, StackTrace.current);
|
||||||
return [];
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<NavidromeException>()
|
||||||
|
.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<NavidromeException>()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user