📊 Feature: History-Sync vom Navidrome-Server (Phase 2, Part 5/X)

- Neue NavidromeService APIs: scrobble() + getBookmark()
- Lädt Wiedergabe-Position vom Server beim Song-Start (Subsonic-API)
- Sendet Position alle 5s zum Server wenn verbunden
- Priorisiert Server-Position über lokale Position
- Full Sync: lokal speichern + Server synchen parallel

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJzQjUtnvYUtHdnTs3iCru
This commit is contained in:
Hermes (Server)
2026-08-19 19:26:41 +02:00
co-authored by Claude Haiku 4.5
parent 5202b130c5
commit f93b2d35da
2 changed files with 70 additions and 6 deletions
+27 -6
View File
@@ -38,10 +38,15 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
_player.playbackEventStream.map(_transformEvent).pipe(playbackState); _player.playbackEventStream.map(_transformEvent).pipe(playbackState);
// Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird. // Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird.
// Synche auch zum Server wenn verbunden.
_positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) { _positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) {
final songId = mediaItem.value?.extras?['songId'] as String?; final songId = mediaItem.value?.extras?['songId'] as String?;
if (_player.playing && songId != null) { if (_player.playing && songId != null) {
db.recordPlayback(songId, _player.position.inMilliseconds); final posMs = _player.position.inMilliseconds;
db.recordPlayback(songId, posMs);
if (_nav.istVerbunden) {
_nav.scrobble(songId, (posMs ~/ 1000).toInt());
}
} }
}); });
@@ -86,12 +91,28 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
await _player.setAudioSources(sources, initialIndex: startIndex); await _player.setAudioSources(sources, initialIndex: startIndex);
// Bei bekannter letzter Position an dieser Stelle fortsetzen, // Bei bekannter letzter Position an dieser Stelle fortsetzen,
// statt immer von vorne zu beginnen. // statt immer von vorne zu beginnen. Priorisiert Server-Position über lokal.
final songId = items[startIndex].extras?['songId'] as String?; final item = items[startIndex];
final songId = item.extras?['songId'] as String?;
if (songId != null) { if (songId != null) {
final lastMs = await db.lastPosition(songId); int? resumeMs;
if (lastMs != null && shouldResumeAt(lastMs, items[startIndex].duration)) {
await _player.seek(Duration(milliseconds: lastMs), index: startIndex); if (_nav.istVerbunden) {
final serverMs = await _nav.getBookmark(songId);
if (serverMs != null && shouldResumeAt(serverMs, item.duration)) {
resumeMs = serverMs;
}
}
if (resumeMs == null) {
final localMs = await db.lastPosition(songId);
if (localMs != null && shouldResumeAt(localMs, item.duration)) {
resumeMs = localMs;
}
}
if (resumeMs != null) {
await _player.seek(Duration(milliseconds: resumeMs), index: startIndex);
} }
} }
+43
View File
@@ -298,6 +298,49 @@ class NavidromeService {
return streamUrl(songId); return streamUrl(songId);
} }
} }
/// 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) {
debugPrint('Scrobble Fehler: $e');
}
}
/// 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) {
debugPrint('Bookmark Fehler: $e');
return null;
}
}
} }
class SubsonicArtist { class SubsonicArtist {