Files
Melo/lib/services/navidrome_service.dart
T
Hermes (Server)andClaude Opus 5 b42e045e67 Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze
Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb
nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen".

Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner:
den darf Android bei Speichernot jederzeit selbst leeren, und genau das
darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben
der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den
Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt.

- Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch
- Rueckfrage ab 30 Titeln samt Groessenschaetzung
- Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB;
  am laengsten nicht Gehoertes weicht zuerst
- Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle
  entfernbar (Schema 10)
- Wiedergabe nimmt erst den Download, dann den Zwischenspeicher

Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB)
schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den
Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server
anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien
loeschen, die die laufende Warteschlange schon als Quelle eingetragen
hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein
heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache
geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim
Aufbau der Warteschlange statt beim Abspielen gesetzt wurde.

Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji
als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im
ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings".

433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
2026-08-23 21:31:53 +02:00

560 lines
20 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;
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,
);
}
/// 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;
/// 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;
}
}
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) 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'}))
.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;
}
}
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) {
await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current);
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) {
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;
}
}
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) {
await logger.error('Navidrome getArtistSongs Fehler: $e', e, StackTrace.current);
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) {
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);
}