From d474aaddff673056220cc23a284c27a1a7eeacf6 Mon Sep 17 00:00:00 2001 From: jmawet Date: Thu, 15 Oct 2026 16:39:01 -0700 Subject: [PATCH 01/14] consider server progress when bringing up comic state --- lib/ui/screens/playback/book_reader_screen.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 287fa0689f..cd846fa9ce 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:archive/archive.dart'; import 'package:archive_extract/archive_extract.dart'; @@ -304,6 +305,16 @@ class _BookReaderScreenState extends State GetIt.instance(); } + double _getServerProgress() { + final playedPercentage = _item?.playedPercentage ?? 0; + return switch (_mode) { + _ReaderMode.epub => playedPercentage / 100.0 * _epubChapterHtml.length - 1, + _ReaderMode.pdf => playedPercentage / 100.0 * _pdfPageCount - 1, + _ReaderMode.comic => playedPercentage / 100.0 * _comicPageCount - 1, + _ => 0, + }; + } + @override void initState() { super.initState(); @@ -1438,11 +1449,12 @@ class _BookReaderScreenState extends State Future _restoreComicState() async { final prefs = await SharedPreferences.getInstance(); - final initialPage = + final loacalInitialPage = (widget.initialMode == 'comic' && widget.initialPosition != null) ? widget.initialPosition! : (prefs.getInt('${_comicProgressKeyPrefix}_page') ?? 0); - final savedPage = initialPage; + final serverInitialPage = _getServerProgress().toInt(); + final savedPage = max(serverInitialPage, loacalInitialPage); final savedZoom = prefs.getDouble('${_comicProgressKeyPrefix}_zoom') ?? 1.0; final clampedPage = savedPage.clamp(0, _comicPageCount - 1); From 7af74201a9ac4373729064467d81ad26f782a993 Mon Sep 17 00:00:00 2001 From: jmawet Date: Thu, 15 Oct 2026 18:25:38 -0700 Subject: [PATCH 02/14] report comic progress back to server --- lib/data/offline/offline_stub_apis.dart | 22 +++++++++++++++++++ .../screens/playback/book_reader_screen.dart | 4 ++++ .../lib/src/api/user_library_api.dart | 1 + .../lib/src/api/emby_user_library_api.dart | 6 +++++ .../src/api/jellyfin_user_library_api.dart | 5 +++++ 5 files changed, 38 insertions(+) diff --git a/lib/data/offline/offline_stub_apis.dart b/lib/data/offline/offline_stub_apis.dart index 83fbd83285..c26e8f8513 100644 --- a/lib/data/offline/offline_stub_apis.dart +++ b/lib/data/offline/offline_stub_apis.dart @@ -99,6 +99,15 @@ class OfflineUserLibraryApi implements UserLibraryApi { throw offlineUnavailable('/UserPlayedItems/$itemId'); } + @override + Future updatePlayedPosition(String itemId, {required int playedPosition}) async { + await _queueUserData( + itemId, + userDataPatch: {'PlaybackPositionTicks': playedPosition}, + unavailablePath: '/UserItems/$itemId/UserData', + ); + } + @override Future updateUserRating(String itemId, {required bool likes}) async { await _queueRating( @@ -154,4 +163,17 @@ class OfflineUserLibraryApi implements UserLibraryApi { await _pendingRatings.put(entryOf(serverId)); await _offlineRepo.patchUserData(itemId, userDataPatch); } + + /// Same as _queueRating, but works for all user data, not just ratings + Future _queueUserData( + String itemId, { + required Map userDataPatch, + required String unavailablePath, + }) async { + final serverId = _catalog.byId(itemId)?.row.serverId; + if (serverId == null || serverId.isEmpty) { + throw offlineUnavailable(unavailablePath); + } + await _offlineRepo.patchUserData(itemId, userDataPatch); + } } diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index cd846fa9ce..1dd303026c 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -1488,6 +1488,10 @@ class _BookReaderScreenState extends State final prefs = await SharedPreferences.getInstance(); await prefs.setInt('${_comicProgressKeyPrefix}_page', _currentComicPage); await prefs.setDouble('${_comicProgressKeyPrefix}_zoom', _comicZoom); + + final newPlayedPosition = _currentComicPage * 10000; + final client = _resolveClient(); + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); } void _scheduleComicStateSave() { diff --git a/packages/server_core/lib/src/api/user_library_api.dart b/packages/server_core/lib/src/api/user_library_api.dart index af7f31de19..b9077c1bf0 100644 --- a/packages/server_core/lib/src/api/user_library_api.dart +++ b/packages/server_core/lib/src/api/user_library_api.dart @@ -4,6 +4,7 @@ abstract class UserLibraryApi { Future unmarkFavorite(String itemId); Future markPlayed(String itemId); Future unmarkPlayed(String itemId); + Future updatePlayedPosition(String itemId, {required int playedPosition}); Future updateUserRating(String itemId, {required bool likes}); Future updateNumericUserRating(String itemId, {required double rating}); Future deleteUserRating(String itemId); diff --git a/packages/server_emby/lib/src/api/emby_user_library_api.dart b/packages/server_emby/lib/src/api/emby_user_library_api.dart index 22a1f52153..91a6210c45 100644 --- a/packages/server_emby/lib/src/api/emby_user_library_api.dart +++ b/packages/server_emby/lib/src/api/emby_user_library_api.dart @@ -34,6 +34,12 @@ class EmbyUserLibraryApi implements UserLibraryApi { await _dio.delete('/Users/$userId/PlayedItems/$itemId'); } + @override + Future updatePlayedPosition(String itemId, {required int playedPosition}) async { + final userId = _getUserId(); + await _dio.post('/Users/$userId/Items/$itemId/UserData', data: {'PlaybackPositionTicks': playedPosition}); + } + // Emby's own rating routes answer 200 and store nothing, and it leaves the // rating out of the item it hands back, so a rating written through them is // lost. These go through Moonbase, which writes the user data store the diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart index c94086e52d..37761a858c 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart +++ b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart @@ -29,6 +29,11 @@ class JellyfinUserLibraryApi implements UserLibraryApi { await _dio.delete('/UserPlayedItems/$itemId'); } + @override + Future updatePlayedPosition(String itemId, {required int playedPosition}) async { + await _dio.post('/UserItems/$itemId/UserData', data: {'PlaybackPositionTicks': playedPosition}); + } + @override Future updateUserRating(String itemId, {required bool likes}) async { await _dio.post('/UserItems/$itemId/Rating', queryParameters: { From f1096addf6427150a680db0c5ce3242bcb8304d7 Mon Sep 17 00:00:00 2001 From: jmawet Date: Thu, 15 Oct 2026 20:50:32 -0700 Subject: [PATCH 03/14] report comic as played when progress reaches 98 percent or above --- lib/ui/screens/playback/book_reader_screen.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 1dd303026c..114d9fc898 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -1489,9 +1489,14 @@ class _BookReaderScreenState extends State await prefs.setInt('${_comicProgressKeyPrefix}_page', _currentComicPage); await prefs.setDouble('${_comicProgressKeyPrefix}_zoom', _comicZoom); - final newPlayedPosition = _currentComicPage * 10000; final client = _resolveClient(); - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + final newPlayedPercentage = _currentComicPage / _comicPageCount; + if (newPlayedPercentage >= 0.98) { + await client.userLibraryApi.markPlayed(item.id); + } else { + final newPlayedPosition = (_currentComicPage + 1) * 10000; + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + } } void _scheduleComicStateSave() { From a7ccfe24387168672327dce77158599aff695388 Mon Sep 17 00:00:00 2001 From: jmawet Date: Thu, 15 Oct 2026 22:29:35 -0700 Subject: [PATCH 04/14] sync pdf progress with the server, move progress reporting into a common helper function --- .../screens/playback/book_reader_screen.dart | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 114d9fc898..b7cc8deb4b 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -344,6 +344,31 @@ class _BookReaderScreenState extends State } } + Future _reportServerProgress() async { + final client = _resolveClient(); + final item = _item; + if (client == null || item == null) return; + + final currentPage = switch (_mode) { + _ReaderMode.pdf => _currentPdfPage, + _ReaderMode.comic => _currentComicPage, + _ => 0, + }; + final pageCount = switch (_mode) { + _ReaderMode.pdf => _pdfPageCount, + _ReaderMode.comic => _comicPageCount, + _ => 1, + }; + + final newPlayedPercentage = currentPage / pageCount; + if (newPlayedPercentage >= 0.98) { + await client.userLibraryApi.markPlayed(item.id); + } else { + final newPlayedPosition = (currentPage + 1) * 10000; + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + } + } + Future _loadAndPrepare() async { await _loadItem(); if (_item != null && _error == null) { @@ -928,6 +953,8 @@ class _BookReaderScreenState extends State setState(() { _currentPdfPage = clamped; }); + + await _reportServerProgress(); } Future _nextPdfPage() => _goToPdfPage(_currentPdfPage + 1); @@ -1489,14 +1516,7 @@ class _BookReaderScreenState extends State await prefs.setInt('${_comicProgressKeyPrefix}_page', _currentComicPage); await prefs.setDouble('${_comicProgressKeyPrefix}_zoom', _comicZoom); - final client = _resolveClient(); - final newPlayedPercentage = _currentComicPage / _comicPageCount; - if (newPlayedPercentage >= 0.98) { - await client.userLibraryApi.markPlayed(item.id); - } else { - final newPlayedPosition = (_currentComicPage + 1) * 10000; - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); - } + await _reportServerProgress(); } void _scheduleComicStateSave() { @@ -2874,13 +2894,19 @@ class _BookReaderScreenState extends State return; } final pageCount = controller.pageCount; + if (mounted) { + setState(() { + _pdfPageCount = pageCount; + }); + } final prefs = await SharedPreferences.getInstance(); - final initialPage = + final localInitialPage = (widget.initialMode == 'pdf' && widget.initialPosition != null) ? widget.initialPosition! : (prefs.getInt(_pdfProgressPrefKey) ?? 1); - final savedPage = initialPage.clamp(1, pageCount); + final serverInitialPage = _getServerProgress().toInt(); + final savedPage = max(localInitialPage, serverInitialPage).clamp(1, pageCount); final outline = await document.loadOutline(); if (mounted) { final flat = _flattenPdfOutline(outline, 0); From b0569d26f967f5d46523770d2734724a9db1b787 Mon Sep 17 00:00:00 2001 From: jmawet Date: Thu, 15 Oct 2026 22:42:58 -0700 Subject: [PATCH 05/14] reset position when marking a book as read to clear the progress bar --- lib/ui/screens/playback/book_reader_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index b7cc8deb4b..0a36bd6a53 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -363,6 +363,7 @@ class _BookReaderScreenState extends State final newPlayedPercentage = currentPage / pageCount; if (newPlayedPercentage >= 0.98) { await client.userLibraryApi.markPlayed(item.id); + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: 0); } else { final newPlayedPosition = (currentPage + 1) * 10000; await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); From 64926430f6407b075105bf0e7fea341efe15f672 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 10:09:36 -0700 Subject: [PATCH 06/14] add lastpage as alternate completion metric and save pdf page changes on swipes --- lib/ui/screens/playback/book_reader_screen.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 0a36bd6a53..dcfc3599a5 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -361,7 +361,8 @@ class _BookReaderScreenState extends State }; final newPlayedPercentage = currentPage / pageCount; - if (newPlayedPercentage >= 0.98) { + final lastPage = (currentPage + 2) >= pageCount; + if (newPlayedPercentage >= 0.98 || lastPage) { await client.userLibraryApi.markPlayed(item.id); await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: 0); } else { @@ -2934,6 +2935,7 @@ class _BookReaderScreenState extends State () async { final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_pdfProgressPrefKey, pageNumber); + await _reportServerProgress(); }, ); }, From d92f1f70c80eeaa035f4b43275ab15f234f6d806 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 12:41:01 -0700 Subject: [PATCH 07/14] add offline status sync for book progress --- lib/background/auto_download_background.dart | 3 +- ...onnectivity_aware_media_server_client.dart | 3 + lib/data/offline/offline_stub_apis.dart | 8 + .../services/media_server_client_factory.dart | 5 +- .../pending_playbackposition_store.dart | 83 ++++++++ lib/data/services/sync_service.dart | 29 ++- lib/di/modules/app_module.dart | 3 +- lib/di/modules/server_module.dart | 2 + test/offline/offline_progress_sync_test.dart | 3 +- .../pending_playbackposition_sync_test.dart | 196 ++++++++++++++++++ test/offline/pending_rating_sync_test.dart | 6 +- 11 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 lib/data/services/pending_playbackposition_store.dart create mode 100644 test/offline/pending_playbackposition_sync_test.dart diff --git a/lib/background/auto_download_background.dart b/lib/background/auto_download_background.dart index 71a778df27..2c959073e0 100644 --- a/lib/background/auto_download_background.dart +++ b/lib/background/auto_download_background.dart @@ -5,6 +5,7 @@ import 'package:server_core/server_core.dart'; import '../data/repositories/offline_repository.dart'; import '../data/services/auto_download_service.dart'; import '../data/services/pending_rating_store.dart'; +import '../data/services/pending_playbackposition_store.dart'; import '../data/services/sync_service.dart'; import '../preference/user_preferences.dart'; import '../playback/headless_session_bootstrap.dart'; @@ -65,7 +66,7 @@ Future _pushOfflineProgress(String serverId) async { } final sync = getIt.isRegistered() ? getIt() - : SyncService(getIt(), getIt()); + : SyncService(getIt(), getIt(), getIt()); try { await sync.syncPlaybackProgress( getIt(), diff --git a/lib/data/offline/connectivity_aware_media_server_client.dart b/lib/data/offline/connectivity_aware_media_server_client.dart index 297e54460a..881afad78c 100644 --- a/lib/data/offline/connectivity_aware_media_server_client.dart +++ b/lib/data/offline/connectivity_aware_media_server_client.dart @@ -6,6 +6,7 @@ import '../services/connectivity_service.dart'; import '../services/storage_path_service.dart'; import '../repositories/offline_repository.dart'; import '../services/pending_rating_store.dart'; +import '../services/pending_playbackposition_store.dart'; import 'offline_catalog.dart'; import 'offline_image_api.dart'; import 'offline_items_api.dart'; @@ -46,6 +47,7 @@ class ConnectivityAwareMediaServerClient implements MediaServerClient { required OfflineCatalog catalog, required StoragePathService storagePath, required PendingRatingStore pendingRatings, + required PendingPlaybackPositionStore pendingPlaybackPosition, required OfflineRepository offlineRepo, }) : _useOffline = useOffline, _offlineItems = OfflineItemsApi(catalog, storagePath), @@ -54,6 +56,7 @@ class ConnectivityAwareMediaServerClient implements MediaServerClient { _offlineUserLibrary = OfflineUserLibraryApi( catalog, pendingRatings: pendingRatings, + pendingPlaybackPosition: pendingPlaybackPosition, offlineRepo: offlineRepo, ); diff --git a/lib/data/offline/offline_stub_apis.dart b/lib/data/offline/offline_stub_apis.dart index c26e8f8513..97264f9bf9 100644 --- a/lib/data/offline/offline_stub_apis.dart +++ b/lib/data/offline/offline_stub_apis.dart @@ -2,6 +2,7 @@ import 'package:server_core/server_core.dart'; import '../repositories/offline_repository.dart'; import '../services/pending_rating_store.dart'; +import '../services/pending_playbackposition_store.dart'; import 'offline_catalog.dart'; import 'offline_errors.dart'; @@ -58,13 +59,16 @@ class OfflineUsersApi implements UsersApi { class OfflineUserLibraryApi implements UserLibraryApi { final OfflineCatalog _catalog; final PendingRatingStore _pendingRatings; + final PendingPlaybackPositionStore _pendingPlaybackPosition; final OfflineRepository _offlineRepo; OfflineUserLibraryApi( this._catalog, { required PendingRatingStore pendingRatings, + required PendingPlaybackPositionStore pendingPlaybackPosition, required OfflineRepository offlineRepo, }) : _pendingRatings = pendingRatings, + _pendingPlaybackPosition = pendingPlaybackPosition, _offlineRepo = offlineRepo; // Both server types take a numeric value once the queue replays, so the @@ -103,6 +107,8 @@ class OfflineUserLibraryApi implements UserLibraryApi { Future updatePlayedPosition(String itemId, {required int playedPosition}) async { await _queueUserData( itemId, + entryOf: (serverId) => + PendingPlaybackPosition(serverId: serverId, itemId: itemId, value: playedPosition), userDataPatch: {'PlaybackPositionTicks': playedPosition}, unavailablePath: '/UserItems/$itemId/UserData', ); @@ -167,6 +173,7 @@ class OfflineUserLibraryApi implements UserLibraryApi { /// Same as _queueRating, but works for all user data, not just ratings Future _queueUserData( String itemId, { + required PendingPlaybackPosition Function(String serverId) entryOf, required Map userDataPatch, required String unavailablePath, }) async { @@ -174,6 +181,7 @@ class OfflineUserLibraryApi implements UserLibraryApi { if (serverId == null || serverId.isEmpty) { throw offlineUnavailable(unavailablePath); } + await _pendingPlaybackPosition.put(entryOf(serverId)); await _offlineRepo.patchUserData(itemId, userDataPatch); } } diff --git a/lib/data/services/media_server_client_factory.dart b/lib/data/services/media_server_client_factory.dart index 2f765cb8be..97b2674e6e 100644 --- a/lib/data/services/media_server_client_factory.dart +++ b/lib/data/services/media_server_client_factory.dart @@ -8,6 +8,7 @@ import '../offline/connectivity_aware_media_server_client.dart'; import '../offline/offline_catalog.dart'; import '../repositories/offline_repository.dart'; import 'pending_rating_store.dart'; +import 'pending_playbackposition_store.dart'; import 'storage_path_service.dart'; class MediaServerClientFactory { @@ -92,7 +93,8 @@ class MediaServerClientFactory { // to and the raw client is all they need. if (!getIt.isRegistered() || !getIt.isRegistered() || - !getIt.isRegistered()) { + !getIt.isRegistered() || + !getIt.isRegistered()) { return raw; } return ConnectivityAwareMediaServerClient( @@ -101,6 +103,7 @@ class MediaServerClientFactory { catalog: getIt(), storagePath: getIt(), pendingRatings: getIt(), + pendingPlaybackPosition: getIt(), offlineRepo: getIt(), ); } diff --git a/lib/data/services/pending_playbackposition_store.dart b/lib/data/services/pending_playbackposition_store.dart new file mode 100644 index 0000000000..cceb80519b --- /dev/null +++ b/lib/data/services/pending_playbackposition_store.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:jellyfin_preference/jellyfin_preference.dart'; + +/// A userdata update set while the server was unreachable, waiting to be pushed. +class PendingPlaybackPosition { + final String serverId; + final String itemId; + final num? value; + + const PendingPlaybackPosition({ + required this.serverId, + required this.itemId, + this.value, + }); + + Map toJson() => { + 'serverId': serverId, + 'itemId': itemId, + 'UserData': { + 'PlaybackPositionTicks': value, + }, + }; + + static PendingPlaybackPosition? fromJson(Map json) { + final serverId = json['serverId'] as String?; + final itemId = json['itemId'] as String?; + if (serverId == null || itemId == null) return null; + return PendingPlaybackPosition( + serverId: serverId, + itemId: itemId, + value: json['UserData']['PlaybackPositionTicks'] as num?, + ); + } +} + +/// Holds new userdata set offline until [SyncService] replays them. One entry per +/// item, so updating twice offline pushes only the final value. +class PendingPlaybackPositionStore { + static const _storageKey = 'pending_userdata_v1'; + + final PreferenceStore _store; + + PendingPlaybackPositionStore(this._store); + + List get all { + final raw = _store.getStringList(_storageKey) ?? const []; + final entries = []; + for (final line in raw) { + try { + final decoded = jsonDecode(line); + if (decoded is! Map) continue; + final entry = PendingPlaybackPosition.fromJson(decoded); + if (entry != null) entries.add(entry); + } catch (_) {} + } + return entries; + } + + Future put(PendingPlaybackPosition entry) async { + final next = [ + for (final existing in all) + if (existing.serverId != entry.serverId || + existing.itemId != entry.itemId) + existing, + entry, + ]; + await _save(next); + } + + Future remove(String serverId, String itemId) async { + final next = all + .where((e) => e.serverId != serverId || e.itemId != itemId) + .toList(); + await _save(next); + } + + Future _save(List entries) async { + await _store.setStringList(_storageKey, [ + for (final entry in entries) jsonEncode(entry.toJson()), + ]); + } +} diff --git a/lib/data/services/sync_service.dart b/lib/data/services/sync_service.dart index b08c31349e..26d93cad19 100644 --- a/lib/data/services/sync_service.dart +++ b/lib/data/services/sync_service.dart @@ -9,6 +9,7 @@ import '../../util/server_url.dart'; import '../database/offline_database.dart'; import '../repositories/offline_repository.dart'; import 'pending_rating_store.dart'; +import 'pending_playbackposition_store.dart'; enum SyncState { idle, syncing, done, error } @@ -21,13 +22,14 @@ class SyncResult { class SyncService extends ChangeNotifier { final OfflineRepository _offlineRepo; final PendingRatingStore _pendingRatings; + final PendingPlaybackPositionStore _pendingPlaybackPosition; SyncState _state = SyncState.idle; SyncState get state => _state; Timer? _doneResetTimer; - SyncService(this._offlineRepo, this._pendingRatings); + SyncService(this._offlineRepo, this._pendingRatings, this._pendingPlaybackPosition); /// Pushes ratings queued while offline through [client]. Item ids are only /// unique within one server, so [serverId] scopes the replay to the server @@ -68,6 +70,31 @@ class SyncService extends ChangeNotifier { return SyncResult(synced: synced, failed: failed); } + Future syncPendingPlaybackPosition( + MediaServerClient client, { + required String? serverId, + }) async { + if (serverId == null) return const SyncResult(synced: 0, failed: 0); + + var synced = 0, failed = 0; + for (final entry in _pendingPlaybackPosition.all) { + if (entry.serverId != serverId) continue; + try { + final value = entry.value ?? 0; + await client.userLibraryApi.updatePlayedPosition( + entry.itemId, + playedPosition: value.toInt(), + ); + await _pendingPlaybackPosition.remove(entry.serverId, entry.itemId); + synced++; + } catch (e) { + debugPrint('[Sync] Failed to push progress for ${entry.itemId}: $e'); + failed++; + } + } + return SyncResult(synced: synced, failed: failed); + } + /// Whether [row] was downloaded from the server [client] talks to, whose /// app server id is [serverId]. Most writers store that id, a few store the /// server's base URL instead, so a URL-shaped id is matched the way diff --git a/lib/di/modules/app_module.dart b/lib/di/modules/app_module.dart index 996c44529f..a01fd8e6ba 100644 --- a/lib/di/modules/app_module.dart +++ b/lib/di/modules/app_module.dart @@ -42,6 +42,7 @@ import '../../data/services/custom_external_lists_service.dart'; import '../../data/services/row_data_source.dart'; import '../../data/services/socket_handler.dart'; import '../../data/services/pending_rating_store.dart'; +import '../../data/services/pending_playbackposition_store.dart'; import '../../data/services/sync_service.dart'; import '../../data/services/user_data_sync.dart'; import '../../data/services/theme_music_service.dart'; @@ -164,7 +165,7 @@ void registerAppModule() { ), ); _getIt.registerLazySingleton( - () => SyncService(_getIt(), _getIt()), + () => SyncService(_getIt(), _getIt(), _getIt()), ); _getIt.registerLazySingleton(() => const ExternalPlayerService()); diff --git a/lib/di/modules/server_module.dart b/lib/di/modules/server_module.dart index f6cd7deab8..0dc009afd4 100644 --- a/lib/di/modules/server_module.dart +++ b/lib/di/modules/server_module.dart @@ -9,6 +9,7 @@ import '../../data/offline/connectivity_aware_media_server_client.dart'; import '../../data/offline/offline_catalog.dart'; import '../../data/repositories/offline_repository.dart'; import '../../data/services/pending_rating_store.dart'; +import '../../data/services/pending_playbackposition_store.dart'; import '../../data/services/auto_download_service.dart'; import '../../data/services/background_download_coordinator.dart'; import '../../data/services/connectivity_service.dart'; @@ -88,6 +89,7 @@ void setActiveServerClient( catalog: _getIt(), storagePath: _getIt(), pendingRatings: _getIt(), + pendingPlaybackPosition: _getIt(), offlineRepo: _getIt(), ); diff --git a/test/offline/offline_progress_sync_test.dart b/test/offline/offline_progress_sync_test.dart index 971dee57b3..e13a5ff8bb 100644 --- a/test/offline/offline_progress_sync_test.dart +++ b/test/offline/offline_progress_sync_test.dart @@ -15,6 +15,7 @@ import 'package:moonfin/data/database/offline_database.dart'; import 'package:moonfin/data/repositories/offline_repository.dart'; import 'package:moonfin/data/services/connectivity_service.dart'; import 'package:moonfin/data/services/pending_rating_store.dart'; +import 'package:moonfin/data/services/pending_playbackposition_store.dart'; import 'package:moonfin/data/services/sync_service.dart'; import 'package:server_core/server_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -126,7 +127,7 @@ void main() { await prefStore.init(); db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); repo = OfflineRepository(db); - sync = SyncService(repo, PendingRatingStore(prefStore)); + sync = SyncService(repo, PendingRatingStore(prefStore), PendingPlaybackPositionStore(prefStore)); }); tearDown(() async { diff --git a/test/offline/pending_playbackposition_sync_test.dart b/test/offline/pending_playbackposition_sync_test.dart new file mode 100644 index 0000000000..8079ef8838 --- /dev/null +++ b/test/offline/pending_playbackposition_sync_test.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jellyfin_preference/jellyfin_preference.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:moonfin/data/database/offline_database.dart'; +import 'package:moonfin/data/offline/offline_catalog.dart'; +import 'package:moonfin/data/offline/offline_stub_apis.dart'; +import 'package:moonfin/data/repositories/offline_repository.dart'; +import 'package:moonfin/data/services/pending_rating_store.dart'; +import 'package:moonfin/data/services/pending_playbackposition_store.dart'; +import 'package:moonfin/data/services/sync_service.dart'; +import 'package:server_core/server_core.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockClient extends Mock implements MediaServerClient {} + +class _MockUserLibraryApi extends Mock implements UserLibraryApi {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PreferenceStore prefStore; + late PendingRatingStore dummyStore; + late PendingPlaybackPositionStore store; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefStore = PreferenceStore(); + await prefStore.init(); + dummyStore = PendingRatingStore(prefStore); + store = PendingPlaybackPositionStore(prefStore); + }); + + group('PendingPlaybackPositionStore', () { + test('keeps one entry per item, so the last update wins', () async { + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 40), + ); + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 90), + ); + + final all = store.all; + expect(all, hasLength(1)); + expect(all.singleWhere((e) => e.itemId == 'a').value, 90); + }); + + test('the same item id on another server stays a separate entry', + () async { + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 20), + ); + await store.put( + const PendingPlaybackPosition(serverId: 's2', itemId: 'a', value: 30), + ); + + expect(store.all, hasLength(2)); + await store.remove('s1', 'a'); + expect(store.all.single.serverId, 's2'); + }); + }); + + group('OfflineUserLibraryApi', () { + late OfflineDatabase db; + late OfflineRepository repo; + late OfflineCatalog catalog; + late OfflineUserLibraryApi api; + + setUp(() async { + db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); + repo = OfflineRepository(db); + catalog = OfflineCatalog(repo); + api = OfflineUserLibraryApi( + catalog, + pendingRatings: dummyStore, + pendingPlaybackPosition: store, + offlineRepo: repo, + ); + + await repo.upsertItem( + DownloadedItemsCompanion( + itemId: const Value('m1'), + serverId: const Value('server-1'), + type: const Value('Movie'), + name: const Value('Alpha'), + metadataJson: Value( + jsonEncode({ + 'Id': 'm1', + 'Type': 'Movie', + 'Name': 'Alpha', + 'UserData': {'PlaybackPositionTicks': 40}, + }), + ), + downloadStatus: const Value(2), + ), + ); + await catalog.warm(); + }); + + tearDown(() async { + catalog.dispose(); + await db.close(); + }); + + test('a position update is queued and lands in the stored user data', + () async { + await api.updatePlayedPosition('m1', playedPosition: 85); + + final entry = store.all.single; + expect(entry.serverId, 'server-1'); + expect(entry.value, 85); + + final row = await repo.getItem('m1'); + final userData = + (jsonDecode(row!.metadataJson) as Map)['UserData'] as Map; + expect(userData['PlaybackPositionTicks'], 85); + }); + + test('an item outside the catalog still fails like any offline call', + () async { + await expectLater( + api.updatePlayedPosition('missing', playedPosition: 90), + throwsA(anything), + ); + expect(store.all, isEmpty); + }); + }); + + group('SyncService.syncPendingPlaybackPosition', () { + late _MockClient client; + late _MockUserLibraryApi userLibrary; + late SyncService sync; + late OfflineDatabase db; + + setUp(() { + db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); + client = _MockClient(); + userLibrary = _MockUserLibraryApi(); + when(() => client.userLibraryApi).thenReturn(userLibrary); + sync = SyncService(OfflineRepository(db), dummyStore, store); + }); + + tearDown(() async { + await db.close(); + }); + + test('replays only the active server and clears what it pushed', () async { + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), + ); + await store.put( + const PendingPlaybackPosition(serverId: 's2', itemId: 'b', value: 80), + ); + when(() => userLibrary.updatePlayedPosition('a', playedPosition: 70)) + .thenAnswer((_) async {}); + when(() => userLibrary.updatePlayedPosition('b', playedPosition: 80)) + .thenAnswer((_) async {}); + + final result = await sync.syncPendingPlaybackPosition(client, serverId: 's1'); + + expect(result.synced, 1); + expect(result.failed, 0); + expect(store.all.single.serverId, 's2', + reason: 'the other server waits for its own reconnect'); + }); + + test('a failed push keeps its entry for the next reconnect', () async { + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), + ); + when(() => userLibrary.updatePlayedPosition('a', playedPosition: 70)) + .thenThrow(Exception('boom')); + + final result = await sync.syncPendingPlaybackPosition(client, serverId: 's1'); + + expect(result.synced, 0); + expect(result.failed, 1); + expect(store.all, hasLength(1)); + }); + + test('no known active server replays nothing', () async { + await store.put( + const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), + ); + + final result = await sync.syncPendingPlaybackPosition(client, serverId: null); + + expect(result.synced, 0); + expect(store.all, hasLength(1)); + verifyZeroInteractions(userLibrary); + }); + }); +} diff --git a/test/offline/pending_rating_sync_test.dart b/test/offline/pending_rating_sync_test.dart index 4c526255e3..ffb0372768 100644 --- a/test/offline/pending_rating_sync_test.dart +++ b/test/offline/pending_rating_sync_test.dart @@ -10,6 +10,7 @@ import 'package:moonfin/data/offline/offline_catalog.dart'; import 'package:moonfin/data/offline/offline_stub_apis.dart'; import 'package:moonfin/data/repositories/offline_repository.dart'; import 'package:moonfin/data/services/pending_rating_store.dart'; +import 'package:moonfin/data/services/pending_playbackposition_store.dart'; import 'package:moonfin/data/services/sync_service.dart'; import 'package:server_core/server_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,12 +24,14 @@ void main() { late PreferenceStore prefStore; late PendingRatingStore store; + late PendingPlaybackPositionStore dummyStore; setUp(() async { SharedPreferences.setMockInitialValues({}); prefStore = PreferenceStore(); await prefStore.init(); store = PendingRatingStore(prefStore); + dummyStore = PendingPlaybackPositionStore(prefStore); }); group('PendingRatingStore', () { @@ -77,6 +80,7 @@ void main() { api = OfflineUserLibraryApi( catalog, pendingRatings: store, + pendingPlaybackPosition: dummyStore, offlineRepo: repo, ); @@ -156,7 +160,7 @@ void main() { client = _MockClient(); userLibrary = _MockUserLibraryApi(); when(() => client.userLibraryApi).thenReturn(userLibrary); - sync = SyncService(OfflineRepository(db), store); + sync = SyncService(OfflineRepository(db), store, dummyStore); }); tearDown(() async { From 3f8bbda05073bb6efef6c54e499196a4b0b53d72 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 13:02:32 -0700 Subject: [PATCH 08/14] some fixes --- lib/data/services/connectivity_service.dart | 3 +++ lib/di/injection.dart | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/data/services/connectivity_service.dart b/lib/data/services/connectivity_service.dart index 1f0560f063..e6cf7c2ecb 100644 --- a/lib/data/services/connectivity_service.dart +++ b/lib/data/services/connectivity_service.dart @@ -217,6 +217,9 @@ class ConnectivityService extends ChangeNotifier { // pulls back items that already carry them. _inFlightSync = syncService .syncPendingRatings(client, serverId: serverId) + .then( + (_) => syncService.syncPendingPlaybackPosition(client, serverId: serverId), + ) .then( (_) => syncService.syncPlaybackProgress(client, serverId: serverId), ) diff --git a/lib/di/injection.dart b/lib/di/injection.dart index acce72f8b3..8da2801269 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -17,6 +17,7 @@ import '../data/services/log_service.dart'; import '../data/services/media_server_client_factory.dart'; import '../data/services/recent_searches_store.dart'; import '../data/services/pending_rating_store.dart'; +import '../data/services/pending_playbackposition_store.dart'; import '../data/services/storage_path_service.dart'; import '../platform/web_runtime_config.dart'; import '../preference/preference_constants.dart'; @@ -392,13 +393,17 @@ Future migrateTrickplayPreferenceConsolidation( /// worker's headless engine, so the two cannot drift. void registerOfflineStorage() { final storagePath = StoragePathService(); + final prefs = getIt(); getIt.registerSingleton(storagePath); getIt.registerSingleton(OfflineDatabase(openConnection())); final offlineRepo = OfflineRepository(getIt()); getIt.registerSingleton(offlineRepo); getIt.registerSingleton(OfflineCatalog(offlineRepo)); getIt.registerLazySingleton( - () => PendingRatingStore(getIt()), + () => PendingRatingStore(prefs), + ); + getIt.registerLazySingleton( + () => PendingPlaybackPositionStore(prefs), ); } From 9672c60297fdb835cecb40aaf001955ac200394d Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 13:22:09 -0700 Subject: [PATCH 09/14] report progress before marking an item as played to avoid offline failures --- lib/ui/screens/playback/book_reader_screen.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index dcfc3599a5..08ce5452fc 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -360,14 +360,13 @@ class _BookReaderScreenState extends State _ => 1, }; + final newPlayedPosition = (currentPage + 1) * 10000; + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + final newPlayedPercentage = currentPage / pageCount; final lastPage = (currentPage + 2) >= pageCount; if (newPlayedPercentage >= 0.98 || lastPage) { await client.userLibraryApi.markPlayed(item.id); - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: 0); - } else { - final newPlayedPosition = (currentPage + 1) * 10000; - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); } } From 1ad89347c78dbeebd2dc452df80a0a1f548bad90 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 14:00:05 -0700 Subject: [PATCH 10/14] just use the existing offline updateplayback instead of reinventing the wheel like an IDIOT --- lib/background/auto_download_background.dart | 3 +- ...onnectivity_aware_media_server_client.dart | 3 - lib/data/offline/offline_stub_apis.dart | 14 +- lib/data/services/connectivity_service.dart | 3 - .../services/media_server_client_factory.dart | 5 +- .../pending_playbackposition_store.dart | 83 -------- lib/data/services/sync_service.dart | 29 +-- lib/di/injection.dart | 4 - lib/di/modules/app_module.dart | 3 +- lib/di/modules/server_module.dart | 2 - .../screens/playback/book_reader_screen.dart | 12 +- test/offline/offline_progress_sync_test.dart | 3 +- .../pending_playbackposition_sync_test.dart | 196 ------------------ test/offline/pending_rating_sync_test.dart | 6 +- 14 files changed, 17 insertions(+), 349 deletions(-) delete mode 100644 lib/data/services/pending_playbackposition_store.dart delete mode 100644 test/offline/pending_playbackposition_sync_test.dart diff --git a/lib/background/auto_download_background.dart b/lib/background/auto_download_background.dart index 2c959073e0..71a778df27 100644 --- a/lib/background/auto_download_background.dart +++ b/lib/background/auto_download_background.dart @@ -5,7 +5,6 @@ import 'package:server_core/server_core.dart'; import '../data/repositories/offline_repository.dart'; import '../data/services/auto_download_service.dart'; import '../data/services/pending_rating_store.dart'; -import '../data/services/pending_playbackposition_store.dart'; import '../data/services/sync_service.dart'; import '../preference/user_preferences.dart'; import '../playback/headless_session_bootstrap.dart'; @@ -66,7 +65,7 @@ Future _pushOfflineProgress(String serverId) async { } final sync = getIt.isRegistered() ? getIt() - : SyncService(getIt(), getIt(), getIt()); + : SyncService(getIt(), getIt()); try { await sync.syncPlaybackProgress( getIt(), diff --git a/lib/data/offline/connectivity_aware_media_server_client.dart b/lib/data/offline/connectivity_aware_media_server_client.dart index 881afad78c..297e54460a 100644 --- a/lib/data/offline/connectivity_aware_media_server_client.dart +++ b/lib/data/offline/connectivity_aware_media_server_client.dart @@ -6,7 +6,6 @@ import '../services/connectivity_service.dart'; import '../services/storage_path_service.dart'; import '../repositories/offline_repository.dart'; import '../services/pending_rating_store.dart'; -import '../services/pending_playbackposition_store.dart'; import 'offline_catalog.dart'; import 'offline_image_api.dart'; import 'offline_items_api.dart'; @@ -47,7 +46,6 @@ class ConnectivityAwareMediaServerClient implements MediaServerClient { required OfflineCatalog catalog, required StoragePathService storagePath, required PendingRatingStore pendingRatings, - required PendingPlaybackPositionStore pendingPlaybackPosition, required OfflineRepository offlineRepo, }) : _useOffline = useOffline, _offlineItems = OfflineItemsApi(catalog, storagePath), @@ -56,7 +54,6 @@ class ConnectivityAwareMediaServerClient implements MediaServerClient { _offlineUserLibrary = OfflineUserLibraryApi( catalog, pendingRatings: pendingRatings, - pendingPlaybackPosition: pendingPlaybackPosition, offlineRepo: offlineRepo, ); diff --git a/lib/data/offline/offline_stub_apis.dart b/lib/data/offline/offline_stub_apis.dart index 97264f9bf9..a41062dcc4 100644 --- a/lib/data/offline/offline_stub_apis.dart +++ b/lib/data/offline/offline_stub_apis.dart @@ -2,7 +2,6 @@ import 'package:server_core/server_core.dart'; import '../repositories/offline_repository.dart'; import '../services/pending_rating_store.dart'; -import '../services/pending_playbackposition_store.dart'; import 'offline_catalog.dart'; import 'offline_errors.dart'; @@ -59,16 +58,13 @@ class OfflineUsersApi implements UsersApi { class OfflineUserLibraryApi implements UserLibraryApi { final OfflineCatalog _catalog; final PendingRatingStore _pendingRatings; - final PendingPlaybackPositionStore _pendingPlaybackPosition; final OfflineRepository _offlineRepo; OfflineUserLibraryApi( this._catalog, { required PendingRatingStore pendingRatings, - required PendingPlaybackPositionStore pendingPlaybackPosition, required OfflineRepository offlineRepo, }) : _pendingRatings = pendingRatings, - _pendingPlaybackPosition = pendingPlaybackPosition, _offlineRepo = offlineRepo; // Both server types take a numeric value once the queue replays, so the @@ -107,9 +103,7 @@ class OfflineUserLibraryApi implements UserLibraryApi { Future updatePlayedPosition(String itemId, {required int playedPosition}) async { await _queueUserData( itemId, - entryOf: (serverId) => - PendingPlaybackPosition(serverId: serverId, itemId: itemId, value: playedPosition), - userDataPatch: {'PlaybackPositionTicks': playedPosition}, + playbackPositionTicks: playedPosition, unavailablePath: '/UserItems/$itemId/UserData', ); } @@ -173,15 +167,13 @@ class OfflineUserLibraryApi implements UserLibraryApi { /// Same as _queueRating, but works for all user data, not just ratings Future _queueUserData( String itemId, { - required PendingPlaybackPosition Function(String serverId) entryOf, - required Map userDataPatch, + required int playbackPositionTicks, required String unavailablePath, }) async { final serverId = _catalog.byId(itemId)?.row.serverId; if (serverId == null || serverId.isEmpty) { throw offlineUnavailable(unavailablePath); } - await _pendingPlaybackPosition.put(entryOf(serverId)); - await _offlineRepo.patchUserData(itemId, userDataPatch); + await _offlineRepo.updatePlaybackPosition(itemId, playbackPositionTicks); } } diff --git a/lib/data/services/connectivity_service.dart b/lib/data/services/connectivity_service.dart index e6cf7c2ecb..1f0560f063 100644 --- a/lib/data/services/connectivity_service.dart +++ b/lib/data/services/connectivity_service.dart @@ -217,9 +217,6 @@ class ConnectivityService extends ChangeNotifier { // pulls back items that already carry them. _inFlightSync = syncService .syncPendingRatings(client, serverId: serverId) - .then( - (_) => syncService.syncPendingPlaybackPosition(client, serverId: serverId), - ) .then( (_) => syncService.syncPlaybackProgress(client, serverId: serverId), ) diff --git a/lib/data/services/media_server_client_factory.dart b/lib/data/services/media_server_client_factory.dart index 97b2674e6e..2f765cb8be 100644 --- a/lib/data/services/media_server_client_factory.dart +++ b/lib/data/services/media_server_client_factory.dart @@ -8,7 +8,6 @@ import '../offline/connectivity_aware_media_server_client.dart'; import '../offline/offline_catalog.dart'; import '../repositories/offline_repository.dart'; import 'pending_rating_store.dart'; -import 'pending_playbackposition_store.dart'; import 'storage_path_service.dart'; class MediaServerClientFactory { @@ -93,8 +92,7 @@ class MediaServerClientFactory { // to and the raw client is all they need. if (!getIt.isRegistered() || !getIt.isRegistered() || - !getIt.isRegistered() || - !getIt.isRegistered()) { + !getIt.isRegistered()) { return raw; } return ConnectivityAwareMediaServerClient( @@ -103,7 +101,6 @@ class MediaServerClientFactory { catalog: getIt(), storagePath: getIt(), pendingRatings: getIt(), - pendingPlaybackPosition: getIt(), offlineRepo: getIt(), ); } diff --git a/lib/data/services/pending_playbackposition_store.dart b/lib/data/services/pending_playbackposition_store.dart deleted file mode 100644 index cceb80519b..0000000000 --- a/lib/data/services/pending_playbackposition_store.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:convert'; - -import 'package:jellyfin_preference/jellyfin_preference.dart'; - -/// A userdata update set while the server was unreachable, waiting to be pushed. -class PendingPlaybackPosition { - final String serverId; - final String itemId; - final num? value; - - const PendingPlaybackPosition({ - required this.serverId, - required this.itemId, - this.value, - }); - - Map toJson() => { - 'serverId': serverId, - 'itemId': itemId, - 'UserData': { - 'PlaybackPositionTicks': value, - }, - }; - - static PendingPlaybackPosition? fromJson(Map json) { - final serverId = json['serverId'] as String?; - final itemId = json['itemId'] as String?; - if (serverId == null || itemId == null) return null; - return PendingPlaybackPosition( - serverId: serverId, - itemId: itemId, - value: json['UserData']['PlaybackPositionTicks'] as num?, - ); - } -} - -/// Holds new userdata set offline until [SyncService] replays them. One entry per -/// item, so updating twice offline pushes only the final value. -class PendingPlaybackPositionStore { - static const _storageKey = 'pending_userdata_v1'; - - final PreferenceStore _store; - - PendingPlaybackPositionStore(this._store); - - List get all { - final raw = _store.getStringList(_storageKey) ?? const []; - final entries = []; - for (final line in raw) { - try { - final decoded = jsonDecode(line); - if (decoded is! Map) continue; - final entry = PendingPlaybackPosition.fromJson(decoded); - if (entry != null) entries.add(entry); - } catch (_) {} - } - return entries; - } - - Future put(PendingPlaybackPosition entry) async { - final next = [ - for (final existing in all) - if (existing.serverId != entry.serverId || - existing.itemId != entry.itemId) - existing, - entry, - ]; - await _save(next); - } - - Future remove(String serverId, String itemId) async { - final next = all - .where((e) => e.serverId != serverId || e.itemId != itemId) - .toList(); - await _save(next); - } - - Future _save(List entries) async { - await _store.setStringList(_storageKey, [ - for (final entry in entries) jsonEncode(entry.toJson()), - ]); - } -} diff --git a/lib/data/services/sync_service.dart b/lib/data/services/sync_service.dart index 26d93cad19..b08c31349e 100644 --- a/lib/data/services/sync_service.dart +++ b/lib/data/services/sync_service.dart @@ -9,7 +9,6 @@ import '../../util/server_url.dart'; import '../database/offline_database.dart'; import '../repositories/offline_repository.dart'; import 'pending_rating_store.dart'; -import 'pending_playbackposition_store.dart'; enum SyncState { idle, syncing, done, error } @@ -22,14 +21,13 @@ class SyncResult { class SyncService extends ChangeNotifier { final OfflineRepository _offlineRepo; final PendingRatingStore _pendingRatings; - final PendingPlaybackPositionStore _pendingPlaybackPosition; SyncState _state = SyncState.idle; SyncState get state => _state; Timer? _doneResetTimer; - SyncService(this._offlineRepo, this._pendingRatings, this._pendingPlaybackPosition); + SyncService(this._offlineRepo, this._pendingRatings); /// Pushes ratings queued while offline through [client]. Item ids are only /// unique within one server, so [serverId] scopes the replay to the server @@ -70,31 +68,6 @@ class SyncService extends ChangeNotifier { return SyncResult(synced: synced, failed: failed); } - Future syncPendingPlaybackPosition( - MediaServerClient client, { - required String? serverId, - }) async { - if (serverId == null) return const SyncResult(synced: 0, failed: 0); - - var synced = 0, failed = 0; - for (final entry in _pendingPlaybackPosition.all) { - if (entry.serverId != serverId) continue; - try { - final value = entry.value ?? 0; - await client.userLibraryApi.updatePlayedPosition( - entry.itemId, - playedPosition: value.toInt(), - ); - await _pendingPlaybackPosition.remove(entry.serverId, entry.itemId); - synced++; - } catch (e) { - debugPrint('[Sync] Failed to push progress for ${entry.itemId}: $e'); - failed++; - } - } - return SyncResult(synced: synced, failed: failed); - } - /// Whether [row] was downloaded from the server [client] talks to, whose /// app server id is [serverId]. Most writers store that id, a few store the /// server's base URL instead, so a URL-shaped id is matched the way diff --git a/lib/di/injection.dart b/lib/di/injection.dart index 8da2801269..23d64d205e 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -17,7 +17,6 @@ import '../data/services/log_service.dart'; import '../data/services/media_server_client_factory.dart'; import '../data/services/recent_searches_store.dart'; import '../data/services/pending_rating_store.dart'; -import '../data/services/pending_playbackposition_store.dart'; import '../data/services/storage_path_service.dart'; import '../platform/web_runtime_config.dart'; import '../preference/preference_constants.dart'; @@ -402,9 +401,6 @@ void registerOfflineStorage() { getIt.registerLazySingleton( () => PendingRatingStore(prefs), ); - getIt.registerLazySingleton( - () => PendingPlaybackPositionStore(prefs), - ); } /// Minimal DI for background isolates like the Watch Next worker: what a diff --git a/lib/di/modules/app_module.dart b/lib/di/modules/app_module.dart index a01fd8e6ba..996c44529f 100644 --- a/lib/di/modules/app_module.dart +++ b/lib/di/modules/app_module.dart @@ -42,7 +42,6 @@ import '../../data/services/custom_external_lists_service.dart'; import '../../data/services/row_data_source.dart'; import '../../data/services/socket_handler.dart'; import '../../data/services/pending_rating_store.dart'; -import '../../data/services/pending_playbackposition_store.dart'; import '../../data/services/sync_service.dart'; import '../../data/services/user_data_sync.dart'; import '../../data/services/theme_music_service.dart'; @@ -165,7 +164,7 @@ void registerAppModule() { ), ); _getIt.registerLazySingleton( - () => SyncService(_getIt(), _getIt(), _getIt()), + () => SyncService(_getIt(), _getIt()), ); _getIt.registerLazySingleton(() => const ExternalPlayerService()); diff --git a/lib/di/modules/server_module.dart b/lib/di/modules/server_module.dart index 0dc009afd4..f6cd7deab8 100644 --- a/lib/di/modules/server_module.dart +++ b/lib/di/modules/server_module.dart @@ -9,7 +9,6 @@ import '../../data/offline/connectivity_aware_media_server_client.dart'; import '../../data/offline/offline_catalog.dart'; import '../../data/repositories/offline_repository.dart'; import '../../data/services/pending_rating_store.dart'; -import '../../data/services/pending_playbackposition_store.dart'; import '../../data/services/auto_download_service.dart'; import '../../data/services/background_download_coordinator.dart'; import '../../data/services/connectivity_service.dart'; @@ -89,7 +88,6 @@ void setActiveServerClient( catalog: _getIt(), storagePath: _getIt(), pendingRatings: _getIt(), - pendingPlaybackPosition: _getIt(), offlineRepo: _getIt(), ); diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 08ce5452fc..5829d04830 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -345,9 +345,9 @@ class _BookReaderScreenState extends State } Future _reportServerProgress() async { - final client = _resolveClient(); final item = _item; - if (client == null || item == null) return; + if (item == null) return; + final client = _resolveClient(); final currentPage = switch (_mode) { _ReaderMode.pdf => _currentPdfPage, @@ -361,12 +361,16 @@ class _BookReaderScreenState extends State }; final newPlayedPosition = (currentPage + 1) * 10000; - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + try { + await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); + } catch (_) { } final newPlayedPercentage = currentPage / pageCount; final lastPage = (currentPage + 2) >= pageCount; if (newPlayedPercentage >= 0.98 || lastPage) { - await client.userLibraryApi.markPlayed(item.id); + try { + await client.userLibraryApi.markPlayed(item.id); + } catch (_) { } } } diff --git a/test/offline/offline_progress_sync_test.dart b/test/offline/offline_progress_sync_test.dart index e13a5ff8bb..971dee57b3 100644 --- a/test/offline/offline_progress_sync_test.dart +++ b/test/offline/offline_progress_sync_test.dart @@ -15,7 +15,6 @@ import 'package:moonfin/data/database/offline_database.dart'; import 'package:moonfin/data/repositories/offline_repository.dart'; import 'package:moonfin/data/services/connectivity_service.dart'; import 'package:moonfin/data/services/pending_rating_store.dart'; -import 'package:moonfin/data/services/pending_playbackposition_store.dart'; import 'package:moonfin/data/services/sync_service.dart'; import 'package:server_core/server_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -127,7 +126,7 @@ void main() { await prefStore.init(); db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); repo = OfflineRepository(db); - sync = SyncService(repo, PendingRatingStore(prefStore), PendingPlaybackPositionStore(prefStore)); + sync = SyncService(repo, PendingRatingStore(prefStore)); }); tearDown(() async { diff --git a/test/offline/pending_playbackposition_sync_test.dart b/test/offline/pending_playbackposition_sync_test.dart deleted file mode 100644 index 8079ef8838..0000000000 --- a/test/offline/pending_playbackposition_sync_test.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'dart:convert'; - -import 'package:drift/drift.dart' hide isNull; -import 'package:drift/native.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:jellyfin_preference/jellyfin_preference.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:moonfin/data/database/offline_database.dart'; -import 'package:moonfin/data/offline/offline_catalog.dart'; -import 'package:moonfin/data/offline/offline_stub_apis.dart'; -import 'package:moonfin/data/repositories/offline_repository.dart'; -import 'package:moonfin/data/services/pending_rating_store.dart'; -import 'package:moonfin/data/services/pending_playbackposition_store.dart'; -import 'package:moonfin/data/services/sync_service.dart'; -import 'package:server_core/server_core.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class _MockClient extends Mock implements MediaServerClient {} - -class _MockUserLibraryApi extends Mock implements UserLibraryApi {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late PreferenceStore prefStore; - late PendingRatingStore dummyStore; - late PendingPlaybackPositionStore store; - - setUp(() async { - SharedPreferences.setMockInitialValues({}); - prefStore = PreferenceStore(); - await prefStore.init(); - dummyStore = PendingRatingStore(prefStore); - store = PendingPlaybackPositionStore(prefStore); - }); - - group('PendingPlaybackPositionStore', () { - test('keeps one entry per item, so the last update wins', () async { - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 40), - ); - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 90), - ); - - final all = store.all; - expect(all, hasLength(1)); - expect(all.singleWhere((e) => e.itemId == 'a').value, 90); - }); - - test('the same item id on another server stays a separate entry', - () async { - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 20), - ); - await store.put( - const PendingPlaybackPosition(serverId: 's2', itemId: 'a', value: 30), - ); - - expect(store.all, hasLength(2)); - await store.remove('s1', 'a'); - expect(store.all.single.serverId, 's2'); - }); - }); - - group('OfflineUserLibraryApi', () { - late OfflineDatabase db; - late OfflineRepository repo; - late OfflineCatalog catalog; - late OfflineUserLibraryApi api; - - setUp(() async { - db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); - repo = OfflineRepository(db); - catalog = OfflineCatalog(repo); - api = OfflineUserLibraryApi( - catalog, - pendingRatings: dummyStore, - pendingPlaybackPosition: store, - offlineRepo: repo, - ); - - await repo.upsertItem( - DownloadedItemsCompanion( - itemId: const Value('m1'), - serverId: const Value('server-1'), - type: const Value('Movie'), - name: const Value('Alpha'), - metadataJson: Value( - jsonEncode({ - 'Id': 'm1', - 'Type': 'Movie', - 'Name': 'Alpha', - 'UserData': {'PlaybackPositionTicks': 40}, - }), - ), - downloadStatus: const Value(2), - ), - ); - await catalog.warm(); - }); - - tearDown(() async { - catalog.dispose(); - await db.close(); - }); - - test('a position update is queued and lands in the stored user data', - () async { - await api.updatePlayedPosition('m1', playedPosition: 85); - - final entry = store.all.single; - expect(entry.serverId, 'server-1'); - expect(entry.value, 85); - - final row = await repo.getItem('m1'); - final userData = - (jsonDecode(row!.metadataJson) as Map)['UserData'] as Map; - expect(userData['PlaybackPositionTicks'], 85); - }); - - test('an item outside the catalog still fails like any offline call', - () async { - await expectLater( - api.updatePlayedPosition('missing', playedPosition: 90), - throwsA(anything), - ); - expect(store.all, isEmpty); - }); - }); - - group('SyncService.syncPendingPlaybackPosition', () { - late _MockClient client; - late _MockUserLibraryApi userLibrary; - late SyncService sync; - late OfflineDatabase db; - - setUp(() { - db = OfflineDatabase(DatabaseConnection(NativeDatabase.memory())); - client = _MockClient(); - userLibrary = _MockUserLibraryApi(); - when(() => client.userLibraryApi).thenReturn(userLibrary); - sync = SyncService(OfflineRepository(db), dummyStore, store); - }); - - tearDown(() async { - await db.close(); - }); - - test('replays only the active server and clears what it pushed', () async { - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), - ); - await store.put( - const PendingPlaybackPosition(serverId: 's2', itemId: 'b', value: 80), - ); - when(() => userLibrary.updatePlayedPosition('a', playedPosition: 70)) - .thenAnswer((_) async {}); - when(() => userLibrary.updatePlayedPosition('b', playedPosition: 80)) - .thenAnswer((_) async {}); - - final result = await sync.syncPendingPlaybackPosition(client, serverId: 's1'); - - expect(result.synced, 1); - expect(result.failed, 0); - expect(store.all.single.serverId, 's2', - reason: 'the other server waits for its own reconnect'); - }); - - test('a failed push keeps its entry for the next reconnect', () async { - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), - ); - when(() => userLibrary.updatePlayedPosition('a', playedPosition: 70)) - .thenThrow(Exception('boom')); - - final result = await sync.syncPendingPlaybackPosition(client, serverId: 's1'); - - expect(result.synced, 0); - expect(result.failed, 1); - expect(store.all, hasLength(1)); - }); - - test('no known active server replays nothing', () async { - await store.put( - const PendingPlaybackPosition(serverId: 's1', itemId: 'a', value: 70), - ); - - final result = await sync.syncPendingPlaybackPosition(client, serverId: null); - - expect(result.synced, 0); - expect(store.all, hasLength(1)); - verifyZeroInteractions(userLibrary); - }); - }); -} diff --git a/test/offline/pending_rating_sync_test.dart b/test/offline/pending_rating_sync_test.dart index ffb0372768..4c526255e3 100644 --- a/test/offline/pending_rating_sync_test.dart +++ b/test/offline/pending_rating_sync_test.dart @@ -10,7 +10,6 @@ import 'package:moonfin/data/offline/offline_catalog.dart'; import 'package:moonfin/data/offline/offline_stub_apis.dart'; import 'package:moonfin/data/repositories/offline_repository.dart'; import 'package:moonfin/data/services/pending_rating_store.dart'; -import 'package:moonfin/data/services/pending_playbackposition_store.dart'; import 'package:moonfin/data/services/sync_service.dart'; import 'package:server_core/server_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -24,14 +23,12 @@ void main() { late PreferenceStore prefStore; late PendingRatingStore store; - late PendingPlaybackPositionStore dummyStore; setUp(() async { SharedPreferences.setMockInitialValues({}); prefStore = PreferenceStore(); await prefStore.init(); store = PendingRatingStore(prefStore); - dummyStore = PendingPlaybackPositionStore(prefStore); }); group('PendingRatingStore', () { @@ -80,7 +77,6 @@ void main() { api = OfflineUserLibraryApi( catalog, pendingRatings: store, - pendingPlaybackPosition: dummyStore, offlineRepo: repo, ); @@ -160,7 +156,7 @@ void main() { client = _MockClient(); userLibrary = _MockUserLibraryApi(); when(() => client.userLibraryApi).thenReturn(userLibrary); - sync = SyncService(OfflineRepository(db), store, dummyStore); + sync = SyncService(OfflineRepository(db), store); }); tearDown(() async { From 2a1eae3aaed0e1a2ed601d68fdeba24bc51af506 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 14:02:29 -0700 Subject: [PATCH 11/14] undo the last of my dumbass changes --- lib/di/injection.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/di/injection.dart b/lib/di/injection.dart index 23d64d205e..acce72f8b3 100644 --- a/lib/di/injection.dart +++ b/lib/di/injection.dart @@ -392,14 +392,13 @@ Future migrateTrickplayPreferenceConsolidation( /// worker's headless engine, so the two cannot drift. void registerOfflineStorage() { final storagePath = StoragePathService(); - final prefs = getIt(); getIt.registerSingleton(storagePath); getIt.registerSingleton(OfflineDatabase(openConnection())); final offlineRepo = OfflineRepository(getIt()); getIt.registerSingleton(offlineRepo); getIt.registerSingleton(OfflineCatalog(offlineRepo)); getIt.registerLazySingleton( - () => PendingRatingStore(prefs), + () => PendingRatingStore(getIt()), ); } From 084531a839e26f5286c74f97fe1173b8bfdf7b06 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 14:18:26 -0700 Subject: [PATCH 12/14] mark last page as done instead of the page before I guess --- lib/ui/screens/playback/book_reader_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 5829d04830..4ad50dbaa5 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -366,7 +366,7 @@ class _BookReaderScreenState extends State } catch (_) { } final newPlayedPercentage = currentPage / pageCount; - final lastPage = (currentPage + 2) >= pageCount; + final lastPage = (currentPage + 1) >= pageCount; if (newPlayedPercentage >= 0.98 || lastPage) { try { await client.userLibraryApi.markPlayed(item.id); From d46f34d0c19bd9985f9236ca94be9d968b765c25 Mon Sep 17 00:00:00 2001 From: jmawet Date: Fri, 16 Oct 2026 21:12:50 -0700 Subject: [PATCH 13/14] vary completion page threshold based on mode --- lib/ui/screens/playback/book_reader_screen.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 4ad50dbaa5..836f5c944d 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -366,8 +366,10 @@ class _BookReaderScreenState extends State } catch (_) { } final newPlayedPercentage = currentPage / pageCount; - final lastPage = (currentPage + 1) >= pageCount; - if (newPlayedPercentage >= 0.98 || lastPage) { + final lastPage = _mode == _ReaderMode.comic + ? currentPage + (_twoPageSpreadActive ? 2 : 1) + : currentPage; + if (newPlayedPercentage >= 0.98 || lastPage >= pageCount) { try { await client.userLibraryApi.markPlayed(item.id); } catch (_) { } From 250d9da4267814a83feb6720e1375d21bc4bf9dc Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:21:09 -0400 Subject: [PATCH 14/14] updated #1875 to store comic and PDF pages on the jellyfin-web scale, let the newest save win so going back a page on another device carries over, and only report when the page changes --- lib/data/offline/offline_stub_apis.dart | 29 ++--- lib/data/services/book_reader_service.dart | 20 +++ .../screens/playback/book_reader_screen.dart | 123 +++++++++++------- .../lib/src/api/user_library_api.dart | 4 +- .../lib/src/api/emby_user_library_api.dart | 10 +- .../src/api/jellyfin_user_library_api.dart | 10 +- .../services/book_reader_service_test.dart | 46 +++++++ test/offline/pending_rating_sync_test.dart | 13 ++ 8 files changed, 181 insertions(+), 74 deletions(-) diff --git a/lib/data/offline/offline_stub_apis.dart b/lib/data/offline/offline_stub_apis.dart index a41062dcc4..5df93f1340 100644 --- a/lib/data/offline/offline_stub_apis.dart +++ b/lib/data/offline/offline_stub_apis.dart @@ -100,12 +100,16 @@ class OfflineUserLibraryApi implements UserLibraryApi { } @override - Future updatePlayedPosition(String itemId, {required int playedPosition}) async { - await _queueUserData( - itemId, - playbackPositionTicks: playedPosition, - unavailablePath: '/UserItems/$itemId/UserData', - ); + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { + // The progress sync pushes this to the server on reconnect. + final serverId = _catalog.byId(itemId)?.row.serverId; + if (serverId == null || serverId.isEmpty) { + throw offlineUnavailable('/UserItems/$itemId/UserData'); + } + await _offlineRepo.updatePlaybackPosition(itemId, positionTicks); } @override @@ -163,17 +167,4 @@ class OfflineUserLibraryApi implements UserLibraryApi { await _pendingRatings.put(entryOf(serverId)); await _offlineRepo.patchUserData(itemId, userDataPatch); } - - /// Same as _queueRating, but works for all user data, not just ratings - Future _queueUserData( - String itemId, { - required int playbackPositionTicks, - required String unavailablePath, - }) async { - final serverId = _catalog.byId(itemId)?.row.serverId; - if (serverId == null || serverId.isEmpty) { - throw offlineUnavailable(unavailablePath); - } - await _offlineRepo.updatePlaybackPosition(itemId, playbackPositionTicks); - } } diff --git a/lib/data/services/book_reader_service.dart b/lib/data/services/book_reader_service.dart index bd6608949d..ef68bfb861 100644 --- a/lib/data/services/book_reader_service.dart +++ b/lib/data/services/book_reader_service.dart @@ -74,6 +74,26 @@ class BookReaderService { return extension != null && supportedExtensions.contains(extension); } + /// Comic and PDF positions on the server are the zero-based page index + /// times this, the same scale jellyfin-web uses. + static const int ticksPerPage = 10000; + + /// The zero-based page [item]'s server position points at, or null when it + /// has none or this device saved its own page more recently. + static int? newerServerPageIndex( + AggregatedItem item, { + DateTime? localSavedAt, + }) { + final ticks = item.playbackPositionTicks ?? 0; + if (ticks <= 0) return null; + final serverSavedAt = item.lastPlayedDate; + if (localSavedAt != null && + (serverSavedAt == null || !serverSavedAt.isAfter(localSavedAt))) { + return null; + } + return (ticks / ticksPerPage).round(); + } + static List buildDownloadUris(MediaServerClient client, AggregatedItem item) { final itemId = Uri.encodeComponent(item.id); final sourceId = _firstMediaSourceId(item); diff --git a/lib/ui/screens/playback/book_reader_screen.dart b/lib/ui/screens/playback/book_reader_screen.dart index 836f5c944d..490ab68912 100644 --- a/lib/ui/screens/playback/book_reader_screen.dart +++ b/lib/ui/screens/playback/book_reader_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:archive/archive.dart'; import 'package:archive_extract/archive_extract.dart'; @@ -112,6 +111,7 @@ class _BookReaderScreenState extends State bool _epubScrollToBottomOnLoad = false; Timer? _comicStateSaveDebounce; Timer? _pdfPageSaveDebounce; + int? _reportedPageIndex; List<_BookmarkEntry> _bookmarks = const []; List<_HighlightEntry> _highlights = const []; List<({String title, int chapterIndex, int depth})> _epubTocEntries = @@ -283,6 +283,10 @@ class _BookReaderScreenState extends State return 'book_reader_pdf_${item.serverId}_${item.id}_page'; } + String get _pdfSavedAtPrefKey => '${_pdfProgressPrefKey}_saved_at'; + + String get _comicSavedAtPrefKey => '${_comicProgressKeyPrefix}_saved_at'; + String get _bookmarkPrefKey { final item = _item; if (item == null) return 'book_reader_bookmarks_unknown'; @@ -305,14 +309,16 @@ class _BookReaderScreenState extends State GetIt.instance(); } - double _getServerProgress() { - final playedPercentage = _item?.playedPercentage ?? 0; - return switch (_mode) { - _ReaderMode.epub => playedPercentage / 100.0 * _epubChapterHtml.length - 1, - _ReaderMode.pdf => playedPercentage / 100.0 * _pdfPageCount - 1, - _ReaderMode.comic => playedPercentage / 100.0 * _comicPageCount - 1, - _ => 0, - }; + int? _newerServerPageIndex(SharedPreferences prefs, String savedAtKey) { + final item = _item; + if (item == null) return null; + final savedAtMs = prefs.getInt(savedAtKey); + return BookReaderService.newerServerPageIndex( + item, + localSavedAt: savedAtMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(savedAtMs), + ); } @override @@ -346,34 +352,35 @@ class _BookReaderScreenState extends State Future _reportServerProgress() async { final item = _item; - if (item == null) return; - final client = _resolveClient(); - - final currentPage = switch (_mode) { - _ReaderMode.pdf => _currentPdfPage, - _ReaderMode.comic => _currentComicPage, - _ => 0, - }; - final pageCount = switch (_mode) { - _ReaderMode.pdf => _pdfPageCount, - _ReaderMode.comic => _comicPageCount, - _ => 1, + final (pageIndex, lastVisibleIndex, pageCount) = switch (_mode) { + _ReaderMode.pdf => ( + _currentPdfPage - 1, + _currentPdfPage - 1, + _pdfPageCount, + ), + _ReaderMode.comic => ( + _currentComicPage, + _currentComicPage + (_twoPageSpreadActive ? 1 : 0), + _comicPageCount, + ), + _ => (0, 0, 0), }; - - final newPlayedPosition = (currentPage + 1) * 10000; - try { - await client.userLibraryApi.updatePlayedPosition(item.id, playedPosition: newPlayedPosition.toInt()); - } catch (_) { } - - final newPlayedPercentage = currentPage / pageCount; - final lastPage = _mode == _ReaderMode.comic - ? currentPage + (_twoPageSpreadActive ? 2 : 1) - : currentPage; - if (newPlayedPercentage >= 0.98 || lastPage >= pageCount) { - try { - await client.userLibraryApi.markPlayed(item.id); - } catch (_) { } + if (item == null || pageCount <= 0 || pageIndex == _reportedPageIndex) { + return; } + final api = _resolveClient().userLibraryApi; + try { + // The position goes first so it still saves offline, where markPlayed + // throws. + await api.updatePlayedPosition( + item.id, + positionTicks: pageIndex * BookReaderService.ticksPerPage, + ); + if ((lastVisibleIndex + 1) / pageCount >= 0.98) { + await api.markPlayed(item.id); + } + _reportedPageIndex = pageIndex; + } catch (_) {} } Future _loadAndPrepare() async { @@ -960,8 +967,6 @@ class _BookReaderScreenState extends State setState(() { _currentPdfPage = clamped; }); - - await _reportServerProgress(); } Future _nextPdfPage() => _goToPdfPage(_currentPdfPage + 1); @@ -1483,12 +1488,13 @@ class _BookReaderScreenState extends State Future _restoreComicState() async { final prefs = await SharedPreferences.getInstance(); - final loacalInitialPage = + final initialPage = (widget.initialMode == 'comic' && widget.initialPosition != null) ? widget.initialPosition! - : (prefs.getInt('${_comicProgressKeyPrefix}_page') ?? 0); - final serverInitialPage = _getServerProgress().toInt(); - final savedPage = max(serverInitialPage, loacalInitialPage); + : _newerServerPageIndex(prefs, _comicSavedAtPrefKey) ?? + prefs.getInt('${_comicProgressKeyPrefix}_page') ?? + 0; + final savedPage = initialPage; final savedZoom = prefs.getDouble('${_comicProgressKeyPrefix}_zoom') ?? 1.0; final clampedPage = savedPage.clamp(0, _comicPageCount - 1); @@ -1500,6 +1506,7 @@ class _BookReaderScreenState extends State setState(() { _currentComicPage = clampedPage; }); + _reportedPageIndex = clampedPage; _setComicZoom(savedZoom); @@ -1520,7 +1527,16 @@ class _BookReaderScreenState extends State } final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('${_comicProgressKeyPrefix}_page', _currentComicPage); + final pageKey = '${_comicProgressKeyPrefix}_page'; + // Only a page change updates the save time, so a zoom can't outrank a + // newer page from another device. + if (prefs.getInt(pageKey) != _currentComicPage) { + await prefs.setInt( + _comicSavedAtPrefKey, + DateTime.now().millisecondsSinceEpoch, + ); + } + await prefs.setInt(pageKey, _currentComicPage); await prefs.setDouble('${_comicProgressKeyPrefix}_zoom', _comicZoom); await _reportServerProgress(); @@ -2901,19 +2917,20 @@ class _BookReaderScreenState extends State return; } final pageCount = controller.pageCount; - if (mounted) { - setState(() { - _pdfPageCount = pageCount; - }); - } final prefs = await SharedPreferences.getInstance(); - final localInitialPage = + final serverPageIndex = _newerServerPageIndex( + prefs, + _pdfSavedAtPrefKey, + ); + final initialPage = (widget.initialMode == 'pdf' && widget.initialPosition != null) ? widget.initialPosition! + : serverPageIndex != null + ? serverPageIndex + 1 : (prefs.getInt(_pdfProgressPrefKey) ?? 1); - final serverInitialPage = _getServerProgress().toInt(); - final savedPage = max(localInitialPage, serverInitialPage).clamp(1, pageCount); + final savedPage = initialPage.clamp(1, pageCount); + _reportedPageIndex = savedPage - 1; final outline = await document.loadOutline(); if (mounted) { final flat = _flattenPdfOutline(outline, 0); @@ -2939,6 +2956,12 @@ class _BookReaderScreenState extends State const Duration(milliseconds: 300), () async { final prefs = await SharedPreferences.getInstance(); + if (prefs.getInt(_pdfProgressPrefKey) != pageNumber) { + await prefs.setInt( + _pdfSavedAtPrefKey, + DateTime.now().millisecondsSinceEpoch, + ); + } await prefs.setInt(_pdfProgressPrefKey, pageNumber); await _reportServerProgress(); }, diff --git a/packages/server_core/lib/src/api/user_library_api.dart b/packages/server_core/lib/src/api/user_library_api.dart index b9077c1bf0..36a7c69b30 100644 --- a/packages/server_core/lib/src/api/user_library_api.dart +++ b/packages/server_core/lib/src/api/user_library_api.dart @@ -4,7 +4,9 @@ abstract class UserLibraryApi { Future unmarkFavorite(String itemId); Future markPlayed(String itemId); Future unmarkPlayed(String itemId); - Future updatePlayedPosition(String itemId, {required int playedPosition}); + /// Saves a resume position outside a playback session, stamped with the + /// current time so another device can tell which save is newer. + Future updatePlayedPosition(String itemId, {required int positionTicks}); Future updateUserRating(String itemId, {required bool likes}); Future updateNumericUserRating(String itemId, {required double rating}); Future deleteUserRating(String itemId); diff --git a/packages/server_emby/lib/src/api/emby_user_library_api.dart b/packages/server_emby/lib/src/api/emby_user_library_api.dart index 91a6210c45..aca0f5c9f0 100644 --- a/packages/server_emby/lib/src/api/emby_user_library_api.dart +++ b/packages/server_emby/lib/src/api/emby_user_library_api.dart @@ -35,9 +35,15 @@ class EmbyUserLibraryApi implements UserLibraryApi { } @override - Future updatePlayedPosition(String itemId, {required int playedPosition}) async { + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { final userId = _getUserId(); - await _dio.post('/Users/$userId/Items/$itemId/UserData', data: {'PlaybackPositionTicks': playedPosition}); + await _dio.post('/Users/$userId/Items/$itemId/UserData', data: { + 'PlaybackPositionTicks': positionTicks, + 'LastPlayedDate': DateTime.now().toUtc().toIso8601String(), + }); } // Emby's own rating routes answer 200 and store nothing, and it leaves the diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart index 37761a858c..5333a4c724 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart +++ b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart @@ -30,8 +30,14 @@ class JellyfinUserLibraryApi implements UserLibraryApi { } @override - Future updatePlayedPosition(String itemId, {required int playedPosition}) async { - await _dio.post('/UserItems/$itemId/UserData', data: {'PlaybackPositionTicks': playedPosition}); + Future updatePlayedPosition( + String itemId, { + required int positionTicks, + }) async { + await _dio.post('/UserItems/$itemId/UserData', data: { + 'PlaybackPositionTicks': positionTicks, + 'LastPlayedDate': DateTime.now().toUtc().toIso8601String(), + }); } @override diff --git a/test/data/services/book_reader_service_test.dart b/test/data/services/book_reader_service_test.dart index 69c45ed456..d3e3ff8c73 100644 --- a/test/data/services/book_reader_service_test.dart +++ b/test/data/services/book_reader_service_test.dart @@ -20,6 +20,52 @@ void main() { expect(BookReaderService.detectExtension(item), 'epub'); }); + group('newerServerPageIndex', () { + AggregatedItem progress(int ticks, [String? lastPlayed]) => _book({ + 'UserData': { + 'PlaybackPositionTicks': ticks, + 'LastPlayedDate': ?lastPlayed, + }, + }); + final localSave = DateTime.utc(2026, 10, 17, 12); + + test('reads the page index on the jellyfin-web scale', () { + expect(BookReaderService.newerServerPageIndex(progress(30000)), 3); + }); + + test('a book with no server position has no server page', () { + expect(BookReaderService.newerServerPageIndex(progress(0)), isNull); + }); + + test('a later save on another device wins, even going backwards', () { + final item = progress(20000, '2026-10-17T12:05:00.0000000Z'); + + expect( + BookReaderService.newerServerPageIndex(item, localSavedAt: localSave), + 2, + ); + }); + + test("this device's later save wins over an older server position", () { + final item = progress(90000, '2026-10-17T11:55:00.0000000Z'); + + expect( + BookReaderService.newerServerPageIndex(item, localSavedAt: localSave), + isNull, + ); + }); + + test('an undated server position loses to a dated local save', () { + expect( + BookReaderService.newerServerPageIndex( + progress(90000), + localSavedAt: localSave, + ), + isNull, + ); + }); + }); + test('falls back to the item name when there is no path', () { final item = _book({'Path': ' ', 'Name': 'Dune'}); diff --git a/test/offline/pending_rating_sync_test.dart b/test/offline/pending_rating_sync_test.dart index 4c526255e3..8d00192f59 100644 --- a/test/offline/pending_rating_sync_test.dart +++ b/test/offline/pending_rating_sync_test.dart @@ -143,6 +143,19 @@ void main() { ); expect(store.all, isEmpty); }); + + test('a reader position lands on the row for the reconnect sync', + () async { + await api.updatePlayedPosition('m1', positionTicks: 30000); + + final row = await repo.getItem('m1'); + expect(row!.playbackPositionTicks, 30000); + expect(row.progressSynced, isFalse); + await expectLater( + api.updatePlayedPosition('missing', positionTicks: 30000), + throwsA(anything), + ); + }); }); group('SyncService.syncPendingRatings', () {