From f93b2d35daca12402c62579ee17817fa3459316b Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Wed, 19 Aug 2026 19:26:41 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8A=20Feature:=20History-Sync=20vom=20?= =?UTF-8?q?Navidrome-Server=20(Phase=202,=20Part=205/X)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01XJzQjUtnvYUtHdnTs3iCru --- lib/player/audio_handler.dart | 33 ++++++++++++++++++---- lib/services/navidrome_service.dart | 43 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index c407216..608af19 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -38,10 +38,15 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { _player.playbackEventStream.map(_transformEvent).pipe(playbackState); // Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird. + // Synche auch zum Server wenn verbunden. _positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) { final songId = mediaItem.value?.extras?['songId'] as String?; 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); // Bei bekannter letzter Position an dieser Stelle fortsetzen, - // statt immer von vorne zu beginnen. - final songId = items[startIndex].extras?['songId'] as String?; + // statt immer von vorne zu beginnen. Priorisiert Server-Position über lokal. + final item = items[startIndex]; + final songId = item.extras?['songId'] as String?; if (songId != null) { - final lastMs = await db.lastPosition(songId); - if (lastMs != null && shouldResumeAt(lastMs, items[startIndex].duration)) { - await _player.seek(Duration(milliseconds: lastMs), index: startIndex); + int? resumeMs; + + 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); } } diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index 4ebd637..520bee8 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -298,6 +298,49 @@ class NavidromeService { 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 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 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; + final subsonicResponse = json['subsonic-response'] as Map?; + if (subsonicResponse == null) return null; + + final bookmarks = subsonicResponse['bookmark'] as List?; + if (bookmarks == null || bookmarks.isEmpty) return null; + + final position = (bookmarks[0] as Map)['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 {