fix(playback): sync downloaded watch progress

close #1171, close #1183
This commit is contained in:
edde746
2026-05-29 19:55:01 +02:00
parent 5e5702c961
commit 7501f461b9
23 changed files with 418 additions and 69 deletions
+6 -1
View File
@@ -493,13 +493,18 @@ abstract class MediaServerClient {
});
/// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes
/// the session row.
/// the session row. [offline] and [updatedAt] are used by Plex when replaying
/// queued offline watch progress; backends that have no equivalent may ignore
/// them.
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
});
/// Resolve the video URL, media info, and external subtitle list for
+9 -8
View File
@@ -373,19 +373,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (base == null) continue;
final action = entry.value;
bool? isWatched;
int? viewOffsetMs;
switch (action.actionType) {
case 'watched':
isWatched = true;
viewOffsetMs = 0;
case 'unwatched':
isWatched = false;
viewOffsetMs = 0;
case 'progress':
isWatched = action.shouldMarkWatched;
viewOffsetMs = action.shouldMarkWatched ? 0 : action.viewOffset;
}
if (isWatched == null) continue;
_metadata[entry.key] = base.copyWith(
viewCount: isWatched ? 1 : 0,
viewOffsetMs: isWatched ? base.viewOffsetMs : 0,
);
_metadata[entry.key] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
}
} catch (e) {
appLogger.w('Failed to apply offline watch overlay', error: e);
@@ -494,9 +495,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
void _onWatchStateChanged(WatchStateEvent event) {
// Progress ticks fire continuously during playback; only react to discrete
// watched/unwatched flips so we don't churn listeners on every frame.
if (event.changeType == WatchStateChangeType.progressUpdate) return;
// Progress ticks fire continuously during playback; only react when a
// progress update crosses the watched threshold.
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) return;
if (event.isNowWatched == null) return;
final globalKey = buildGlobalKey(event.serverId, event.itemId);
@@ -504,7 +505,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (base == null) return;
final isWatched = event.isNowWatched!;
_metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: isWatched ? base.viewOffsetMs : 0);
_metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: 0);
// Persist into the per-backend pinned cache so the patch survives reloads
// (`_loadPersistedDownloads` rehydrates `_metadata` from the cache).
unawaited(
@@ -76,6 +76,9 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
return localOffset;
}
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus == true) return null;
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey);
return metadata?.viewOffsetMs;
@@ -81,10 +81,10 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.progressUpdate => WatchStateOverlayPatch(
hasViewOffsetMs: event.viewOffset != null,
viewOffsetMs: event.viewOffset,
),
WatchStateChangeType.progressUpdate =>
event.isNowWatched == true
? const WatchStateOverlayPatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0)
: WatchStateOverlayPatch(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset),
WatchStateChangeType.removedFromContinueWatching => null,
};
@@ -81,7 +81,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
context,
metadata: episodeMetadata,
usePushReplacement: true,
isOffline: _isOfflinePlayback,
isOffline: widget.isOffline,
),
);
}
@@ -97,7 +97,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
context,
metadata: episodeMetadata,
usePushReplacement: true,
isOffline: _isOfflinePlayback,
isOffline: widget.isOffline,
),
);
}
@@ -123,7 +123,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preferredSubtitleTrack: currentSubtitleTrack,
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
usePushReplacement: true,
isOffline: _isOfflinePlayback,
isOffline: widget.isOffline,
),
);
}
@@ -146,9 +146,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// backend. We still narrow to [plexClient] for [TrackManager]'s
// server-side track persistence, which is Plex-only — Jellyfin
// sessions get a null `getPlexClient` and skip that path.
final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context);
final mediaClient = _getOnlineMediaServerClient(context);
final plexClient = mediaClient is PlexClient ? mediaClient : null;
final streamHeaders = mediaClient?.streamHeaders ?? const <String, String>{};
final streamHeaders = mediaClient?.streamHeaders;
final offlineWatchService = context.read<OfflineWatchSyncService>();
final userProfileProvider = context.read<UserProfileProvider>();
final playbackState = context.read<PlaybackStateProvider>();
@@ -176,7 +176,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final result = await playbackService.getPlaybackData(
metadata: episodeMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
preferOffline: _isOfflinePlayback || _selectedQualityPreset.isOriginal,
preferOffline: widget.isOffline || _selectedQualityPreset.isOriginal,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
@@ -224,7 +224,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
);
await currentPlayer.setProperty('force-seekable', result.isTranscoding ? 'yes' : 'no');
await currentPlayer.open(
Media(result.videoUrl!, start: openTiming.mediaStart, headers: streamHeaders),
Media(result.videoUrl!, start: openTiming.mediaStart, headers: result.usesLocalMedia ? null : streamHeaders),
play: isExoPlayer || !hasExternalSubs,
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
timelineOffset: openTiming.timelineOffset,
@@ -5,8 +5,8 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
Future<void> _ensurePlayQueue() async {
if (!mounted) return;
// Skip play queue in offline mode (requires server connection)
if (_isOfflinePlayback) return;
// Download/offline library mode uses the local downloaded queue instead.
if (widget.isOffline) return;
// Skip play queue for live TV (would interfere with tuner session)
if (widget.isLive) return;
@@ -80,7 +80,7 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
Future<void> _loadAdjacentEpisodes() async {
if (!mounted || widget.isLive) return;
if (_isOfflinePlayback) {
if (widget.isOffline) {
// Offline mode: find next/previous from downloaded episodes
_loadAdjacentEpisodesOffline();
return;
@@ -12,7 +12,12 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
// never fires false. Normalize all playback-dependent state.
unawaited(_setWakelock(false));
unawaited(_progressTracker?.sendProgress('paused'));
final duration = player?.state.duration;
unawaited(
duration != null && duration.inMilliseconds > 0
? _sendStoppedProgressOnce(positionOverride: duration)
: _sendStoppedProgressOnce(),
);
_updateMediaControlsPlaybackState();
unawaited(DiscordRPCService.instance.pausePlayback());
unawaited(TraktScrobbleService.instance.pausePlayback());
@@ -83,6 +88,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
void _cancelAutoPlay() {
_autoPlayTimer?.cancel();
_stoppedProgressFuture = null;
_completionTriggered = false; // Reset so it can trigger again if user seeks near end
_setPlayerState(() {
_showPlayNextDialog = false;
@@ -22,9 +22,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (currentPlayer == null) return;
_stoppedProgressFuture = null;
// Progress tracker — offline mode queues for later sync; online mode
// dispatches to the right backend through the neutral client.
if (_isOfflinePlayback) {
// Progress tracker — local media still reports live when its server is
// online; only queue locally when no reporting client is reachable.
if (mediaClient != null) {
_progressTracker = PlaybackProgressTracker(
client: mediaClient,
metadata: metadata,
player: currentPlayer,
offlineWatchService: offlineWatchService,
queueOnOnlineFailure: _usesLocalPlaybackSource,
playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'),
playSessionId: playSessionId,
mediaInfo: mediaInfo,
);
_progressTracker!.startTracking();
} else if (_isOfflinePlayback) {
_progressTracker = PlaybackProgressTracker(
client: null,
metadata: metadata,
@@ -33,16 +45,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
offlineWatchService: offlineWatchService,
);
_progressTracker!.startTracking();
} else if (mediaClient != null) {
_progressTracker = PlaybackProgressTracker(
client: mediaClient,
metadata: metadata,
player: currentPlayer,
playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'),
playSessionId: playSessionId,
mediaInfo: mediaInfo,
);
_progressTracker!.startTracking();
}
// Media controls metadata. Fire-and-forget — the OS plugin downloads
@@ -78,10 +80,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
return;
}
// Get client (null in offline mode). Backend-neutral lookup so Jellyfin
// items also wire a [PlaybackProgressTracker]; the tracker dispatches
// to the right backend's reporting endpoints internally.
final mediaClient = _isOfflinePlayback ? null : _getMediaServerClient(context);
// Get a live reporting client when possible. Downloaded/local playback
// still uses this path when the server is reachable.
final mediaClient = _getOnlineMediaServerClient(context);
final offlineWatchService = context.read<OfflineWatchSyncService>();
// Initialize media controls manager (must exist before the per-item
@@ -135,7 +135,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// (possibly null) cached client. The service reads cached media
// info via the client when available, falls back to local file +
// sidecar subtitles otherwise.
final cachedSourceClient = _getMediaServerClient(context);
final cachedSourceClient = _getOnlineMediaServerClient(context);
final offlineService = PlaybackInitializationService(
client: cachedSourceClient,
database: context.read<AppDatabase>(),
@@ -149,6 +149,14 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
if (!result.usesLocalMedia) {
streamHeaders = cachedSourceClient?.streamHeaders;
}
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
} else {
// Online path: `_playbackDataFuture` was kicked off in `_initializePlayer`
// in parallel with MPV setup. Quality preset + server capabilities +
@@ -160,6 +168,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
}
result = await playbackDataFuture;
if (!mounted || player != currentPlayer) return;
if (result.usesLocalMedia) {
streamHeaders = null;
}
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
+16 -3
View File
@@ -420,6 +420,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return context.read<MultiServerProvider>().serverManager.getClient(id);
}
MediaServerClient? _getOnlineMediaServerClient(BuildContext context) {
final id = _currentMetadata.serverId;
if (id == null) return null;
final manager = context.read<MultiServerProvider>().serverManager;
if (!manager.isClientOnline(id)) return null;
return manager.getClient(id);
}
bool get _usesLocalPlaybackSource => _effectiveIsOffline;
bool get _isOfflinePlayback => widget.isOffline || _effectiveIsOffline;
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
@@ -442,7 +452,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier();
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier();
_selectedAudioStreamId = widget.selectedAudioStreamId;
_effectiveIsOffline = widget.isOffline;
_effectiveIsOffline = false;
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
@@ -1258,14 +1268,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
String get playbackSessionIdentifier => _playbackSessionIdentifier;
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
Future<void> _sendStoppedProgressOnce() {
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
final existing = _stoppedProgressFuture;
if (existing != null) return existing;
final tracker = _progressTracker;
if (tracker == null) return Future<void>.value();
final future = tracker.sendProgress('stopped').catchError((Object e, StackTrace st) {
final future = tracker.sendProgress('stopped', positionOverride: positionOverride).catchError((
Object e,
StackTrace st,
) {
appLogger.d('Stopped progress flush failed', error: e, stackTrace: st);
});
_stoppedProgressFuture = future;
@@ -647,6 +647,9 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
final response = await _http.post(
'/Sessions/Playing/Stopped',
+22 -13
View File
@@ -363,6 +363,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
// Only return offset for progress actions
if (action.actionType == OfflineActionType.progress.id) {
if (action.shouldMarkWatched) return null;
return action.viewOffset;
}
@@ -578,22 +579,30 @@ class OfflineWatchSyncService extends ChangeNotifier {
break;
case 'progress':
// Push the resume position. Jellyfin's `/Sessions/Playing/Stopped`
// ignores events that arrive without an open session row, so we
// bracket with a Started call. Plex's `/:/timeline` collapses both
// into a single row and treats the second as the canonical state.
// Push resumable progress, or a completed offline playback. Jellyfin's
// `/Sessions/Playing/Stopped` ignores events without an open session
// row, so non-Plex backends still get a lightweight Started call.
if (action.viewOffset != null && action.duration != null) {
final position = Duration(milliseconds: action.viewOffset!);
final duration = Duration(milliseconds: action.duration!);
try {
await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration);
} catch (e) {
// Plex sometimes 5xxs the start when nothing follows; treat as
// best-effort and continue to the stop call which is the one
// that actually persists the resume position.
appLogger.d('Offline progress: started call failed (continuing)', error: e);
final position = action.shouldMarkWatched ? duration : Duration(milliseconds: action.viewOffset!);
if (!action.shouldMarkWatched || client.backend != MediaBackend.plex) {
try {
await client.reportPlaybackStarted(itemId: action.ratingKey, position: position, duration: duration);
} catch (e) {
// Plex sometimes 5xxs the start when nothing follows; treat as
// best-effort and continue to the stop call which is the one
// that actually persists the resume position.
appLogger.d('Offline progress: started call failed (continuing)', error: e);
}
}
await client.reportPlaybackStopped(itemId: action.ratingKey, position: position, duration: duration);
await client.reportPlaybackStopped(
itemId: action.ratingKey,
position: position,
duration: duration,
offline: true,
updatedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt),
continuing: false,
);
}
// If progress exceeded threshold, also mark as watched.
@@ -191,6 +191,7 @@ class PlaybackInitializationService {
mediaInfo: mediaInfo,
externalSubtitles: sidecarSubtitles,
isOffline: true,
playMethod: 'DirectPlay',
);
}
@@ -77,6 +77,10 @@ class PlaybackInitializationResult {
/// expects one of `DirectPlay`, `DirectStream`, or `Transcode`.
final String? playMethod;
/// True when [videoUrl] points at a downloaded/local copy. This is a media
/// source detail, not a statement about whether server reporting is possible.
bool get usesLocalMedia => isOffline;
PlaybackInitializationResult({
required this.availableVersions,
this.videoUrl,
+36 -5
View File
@@ -37,6 +37,10 @@ class PlaybackProgressTracker {
/// Service for queuing offline progress updates
final OfflineWatchSyncService? offlineWatchService;
/// Queue the latest progress locally if online reporting fails. Used for
/// downloaded/local playback where playback can continue without a server.
final bool queueOnOnlineFailure;
final String? playMethod;
/// Backend session ID to echo in progress reports. Jellyfin uses this to
@@ -82,6 +86,7 @@ class PlaybackProgressTracker {
required this.player,
this.isOffline = false,
this.offlineWatchService,
this.queueOnOnlineFailure = false,
this.playMethod,
this.playSessionId,
this.mediaInfo,
@@ -152,15 +157,19 @@ class PlaybackProgressTracker {
appLogger.d('Stopped progress tracking');
}
/// [state] can be 'playing', 'paused', or 'stopped'
Future<void> sendProgress(String state) async {
await _sendProgress(state);
/// [state] can be 'playing', 'paused', or 'stopped'.
Future<void> sendProgress(String state, {Duration? positionOverride}) async {
await _sendProgress(state, positionOverride: positionOverride);
}
Future<void> _sendProgress(String state) async {
Future<void> _sendProgress(String state, {Duration? positionOverride}) async {
Duration? attemptedPosition;
Duration? attemptedDuration;
try {
final position = player.state.position;
final duration = player.state.duration;
final position = _clampPosition(positionOverride ?? player.state.position, duration);
attemptedPosition = position;
attemptedDuration = duration;
// Don't send progress if no duration (not ready)
if (duration.inMilliseconds == 0) {
@@ -197,6 +206,7 @@ class PlaybackProgressTracker {
'skipping next $_ticksToSkip tick(s)',
error: e,
);
unawaited(_queueOnlineFailureProgress(position, duration));
}),
);
}
@@ -209,12 +219,33 @@ class PlaybackProgressTracker {
'skipping next $_ticksToSkip tick(s)',
error: e,
);
await _queueOnlineFailureProgress(
attemptedPosition ?? player.state.position,
attemptedDuration ?? player.state.duration,
);
} else {
appLogger.d('Failed to send progress update (non-critical)', error: e);
}
}
}
Duration _clampPosition(Duration position, Duration duration) {
if (duration.inMilliseconds <= 0) return position;
if (position.isNegative) return Duration.zero;
if (position > duration) return duration;
return position;
}
Future<void> _queueOnlineFailureProgress(Duration position, Duration duration) async {
if (!queueOnOnlineFailure || offlineWatchService == null) return;
if (duration.inMilliseconds == 0) return;
try {
await _sendOfflineProgress(_clampPosition(position, duration), duration);
} catch (e) {
appLogger.d('Failed to queue fallback progress after online report failure', error: e);
}
}
void _resetBackoff() {
if (_consecutiveFailures > 0) {
_consecutiveFailures = 0;
+18 -1
View File
@@ -1610,6 +1610,9 @@ class PlexClient
required int time,
required String state, // 'playing', 'paused', 'stopped', 'buffering'
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
final response = await _http.post(
'/:/timeline',
@@ -1619,6 +1622,9 @@ class PlexClient
'time': time,
'state': state,
'duration': ?duration,
if (offline) 'offline': 1,
if (updatedAt != null) 'updated': updatedAt.millisecondsSinceEpoch ~/ 1000,
if (continuing != null) 'continuing': continuing ? 1 : 0,
},
);
// Surface non-2xx instead of swallowing — progress is the cornerstone
@@ -3922,7 +3928,18 @@ class PlexClient
Duration? duration,
String? playSessionId,
String? mediaSourceId,
}) => updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds);
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) => updateProgress(
itemId,
time: position.inMilliseconds,
state: 'stopped',
duration: duration?.inMilliseconds,
offline: offline,
updatedAt: updatedAt,
continuing: continuing,
);
// ── Downloads ────────────────────────────────────────────────────
+12 -2
View File
@@ -62,7 +62,10 @@ Future<bool?> navigateToVideoPlayer(
// Use the manager-routed lookup so Jellyfin items don't trip the
// Plex-only client. The player branches on the returned type internally.
final manager = context.read<MultiServerProvider>().serverManager;
final mediaClient = isOffline ? null : manager.getClient(metadata.serverId ?? '');
final serverId = metadata.serverId ?? '';
final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId))
? manager.getClient(serverId)
: null;
int mediaIndex = selectedMediaIndex ?? 0;
if (selectedMediaIndex == null) {
@@ -87,7 +90,14 @@ Future<bool?> navigateToVideoPlayer(
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
if (videoPath != null && context.mounted) {
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
launched = await ExternalPlayerService.launch(context: context, videoUrl: videoUrl);
launched = await ExternalPlayerService.launch(
context: context,
videoUrl: videoUrl,
metadata: metadata,
client: mediaClient,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
}
} else if (context.mounted) {
launched = await ExternalPlayerService.launch(
@@ -731,6 +731,32 @@ void main() {
expect(p.getMetadata('srv:absent'), isNull);
p.dispose();
});
test('watched progress events mark downloaded metadata watched and clear resume', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
final item = MediaItem(
id: '42',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: 'srv',
durationMs: 100000,
viewOffsetMs: 12000,
viewCount: 0,
);
p.debugSeedState(metadata: {'srv:42': item});
WatchStateNotifier().notifyProgress(item: item, viewOffset: 95000, duration: 100000, watchedThreshold: 0.9);
await Future<void>.delayed(Duration.zero);
final updated = p.getMetadata('srv:42');
expect(updated?.isWatched, isTrue);
expect(updated?.viewOffsetMs, 0);
p.dispose();
});
});
group('DownloadProvider — progress stream', () {
@@ -64,6 +64,17 @@ void main() {
p.dispose();
});
test('getViewOffset returns null for local progress that crossed watched threshold', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
await syncService.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
expect(await p.isWatched('srv:42'), isTrue);
expect(await p.getViewOffset('srv:42'), isNull);
p.dispose();
});
test('getNextUnwatchedEpisode returns null for show with no downloads', () async {
final p = OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
expect(await p.getNextUnwatchedEpisode('show-123'), isNull);
@@ -45,6 +45,9 @@ class _FakeJellyfinClient implements JellyfinClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
calls.add('stopped:$itemId:$playSessionId');
}
@@ -6,6 +6,10 @@ import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
@@ -59,6 +63,75 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource
bool get hasListeners => super.hasListeners;
}
class _RecordingMediaClient implements MediaServerClient {
_RecordingMediaClient({required this.serverId, required this.backend});
@override
final String serverId;
@override
final MediaBackend backend;
@override
double get watchedThreshold => 0.9;
@override
void close() {}
final started = <({String itemId, int positionMs, int? durationMs})>[];
final stopped =
<({String itemId, int positionMs, int? durationMs, bool offline, DateTime? updatedAt, bool? continuing})>[];
final watched = <String>[];
@override
Future<MediaItem?> fetchItem(String id) async =>
MediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId);
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds));
}
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
stopped.add((
itemId: itemId,
positionMs: position.inMilliseconds,
durationMs: duration?.inMilliseconds,
offline: offline,
updatedAt: updatedAt,
continuing: continuing,
));
}
@override
Future<void> markWatched(MediaItem item) async {
watched.add(item.id);
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Build a service against an in-memory database and a bare-metal
/// [MultiServerManager] (no servers added).
({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() {
@@ -262,6 +335,57 @@ void main() {
expect(retained!.syncAttempts, OfflineWatchSyncService.maxSyncAttempts);
expect(retained.lastError, 'server error');
});
test('partial Plex offline progress replays as offline stopped progress', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 50000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
expect(client.started, hasLength(1));
expect(client.started.single.positionMs, 50000);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 50000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, isEmpty);
expect(await svc.getPendingSyncCount(), 0);
});
test('completed Plex offline progress replays at duration and marks watched', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _RecordingMediaClient(serverId: 'srv', backend: MediaBackend.plex);
mgr.debugRegisterClientForTesting(client);
await svc.queueProgressUpdate(serverId: 'srv', itemId: '42', viewOffset: 95000, duration: 100000);
final queued = await db.getLatestWatchAction('srv:42');
await svc.syncPendingItems();
expect(client.started, isEmpty);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 100000);
expect(client.stopped.single.durationMs, 100000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.continuing, isFalse);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, ['42']);
expect(await svc.getPendingSyncCount(), 0);
});
});
// ============================================================
@@ -646,7 +770,7 @@ void main() {
);
expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), 90000);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull);
expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse);
expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000);
});
@@ -129,7 +129,15 @@ class _FakePlexClient implements PlexClient {
Object? throwOnNextCall;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {
Future<void> updateProgress(
String ratingKey, {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
if (throwOnNextCall != null) {
final err = throwOnNextCall!;
throwOnNextCall = null;
@@ -193,6 +201,9 @@ class _FakePlexClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) {
playbackSessionIds.add(playSessionId);
playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null));
@@ -342,6 +353,23 @@ void main() {
expect(call.duration, 100000); // 100s in ms
});
test('"stopped" can override stale player position for completion', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100));
expect(client.updateProgressCalls.single.time, 100000);
expect(client.markWatchedCalls, ['42']);
});
test('"playing" fires-and-forgets but eventually invokes updateProgress', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
@@ -760,6 +788,34 @@ void main() {
await tracker.sendProgress('playing');
expect(await svc.getPendingSyncCount(), 0);
});
test('online local playback queues fallback progress when reporting fails', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final client = _FakePlexClient()..throwOnNextCall = StateError('offline');
final player = _FakePlayer(position: const Duration(seconds: 10), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
player: player,
isOffline: false,
offlineWatchService: svc,
queueOnOnlineFailure: true,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped', positionOverride: const Duration(seconds: 100));
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.viewOffset, 100000);
expect(action.shouldMarkWatched, isTrue);
});
});
// ============================================================
@@ -913,7 +969,15 @@ class _ScrobblePreciseClient implements PlexClient {
int markWatchedSuccesses = 0;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {}
Future<void> updateProgress(
String ratingKey, {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {}
@override
Future<void> reportPlaybackStarted({
@@ -947,6 +1011,9 @@ class _ScrobblePreciseClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {}
@override
@@ -47,6 +47,9 @@ class _RecordingClient implements MediaServerClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
}) async {
calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId');
if (failNextStop) {