Files
Melo/lib/services/navidrome_service.dart
T
Hermes (Server)andClaude Opus 5 9fa027fca1 Fix: Lokale Wiedergabe + neuer Geraete-Abgleich (Handy <-> Server)
BUG 1 — Lieder vom Handy waren nicht abspielbar
("Wiedergabe fehlgeschlagen: (0) Source error" / "Loading interrupted")

Wurzel-Ursache: MeloAudioHandler.loadPlaylist hat jeden Warteschlangen-
Eintrag durch NavidromeService.streamAndCacheToLocal(item.id) geschickt,
sobald Zugangsdaten existierten. item.id ist aber NIE eine Navidrome-Song-ID
— lokal ist es file:///storage/..., beim Server die fertige Stream-Adresse.
Der Server bekam also 'file:///...' als Song-ID, antwortete mit einem Fehler,
und diese Fehlerantwort wurde an just_audio weitergereicht (Source error) —
bzw. als .mp3 in den Cache geschrieben, wodurch der Titel dauerhaft kaputt
blieb.

Zweite Ursache: die Schleife lud die GANZE Warteschlange seriell vorab
herunter (30s Timeout je Titel), bevor setAudioSources lief. Bei hunderten
Titeln startete die Wiedergabe deshalb nie; ein zweiter Tipp brach den
laufenden Ladevorgang ab ("Loading interrupted").

Fix:
- Server-Titel tragen ihre ID in MediaItem.extras['navidromeId'] statt sie
  aus der Abspiel-Adresse zu raten. Neue reine Funktionen navidromeIdOf,
  songIdOf, nutztServerCache, quelleFuer.
- loadPlaylist baut die Quellen ohne Netzzugriff; Caching des laufenden
  Titels im Hintergrund (unawaited).
- Resume/Scrobble nur noch mit der jeweils passenden ID (Server bzw. lokal).
- ladeInCache() ersetzt streamAndCacheToLocal(): .part-Datei, Pruefung des
  Inhaltstyps (istAudioAntwort), stabiler Cache-Schluessel ueber die
  Song-ID statt der Stream-Adresse (die trug Token+Salt und war je Sitzung
  anders — der Cache war nie wiederauffindbar), Client wird geschlossen.

BUG 2 — kein Abgleich zwischen Handy und Server

Neu: services/melo_cloud_service.dart + services/sync_service.dart gegen
cloud.baka-net.de (Bearer-JWT ueber BakaAuth). Server-Titel herunterladen
(offline verfuegbar), eigene Dateien hochladen, Loeschungen in beide
Richtungen (Tombstones), Favoriten und Wiedergabe-Verlauf. Automatisch beim
App-Start und bei Rueckkehr in die App (max. alle 15 Min), plus Knopf unter
Einstellungen -> Geraete-Abgleich. Reine Planungsfunktion planeSync().

Bewusst NICHT ueber Navidrome: die Subsonic-API kennt keinen Upload-
Endpunkt. Navidrome bleibt die Streaming-Bibliothek, die Melo-Cloud ist der
gemeinsame Speicher.

DB-Schema 8: songs.cloud_id verbindet Geraet und Server.

Nebenbei behoben (blockierte Build bzw. Tests):
- database.g.dart war veraltet — das Projekt liess sich nicht uebersetzen.
- metadataEdited wurde nirgends gesetzt/beachtet: von Hand korrigierte
  Metadaten wurden vom naechsten Scan ueberschrieben. Jetzt in beiden
  Scans respektiert; metadatenUebernahme() setzt die Markierung.
- song_detail_sheet_test.dart haengt beim Oeffnen des Modal-Sheets und
  blockierte den gesamten Testlauf — vorerst uebersprungen (TODO im Code);
  der Zweck wird von metadaten_uebernahme_test.dart abgedeckt.

Enthaelt ausserdem die bis dahin nicht committete Arbeit der Vorsitzung
(Musikerkennung/ACRCloud, MusicBrainz-Metadaten, MediaStore-Datentraeger).

267 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.

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

550 lines
19 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;
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('Cache abgebrochen: HTTP ${antwort.statusCode}, '
'Typ ${antwort.headers['content-type'] ?? '-'}');
return null;
}
await antwort.stream.pipe(teil.openWrite());
await teil.rename(ziel.path);
debugPrint('Im Cache abgelegt: ${ziel.path}');
return ziel;
} catch (e) {
await logger.error('Cache-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);
}