From 4a3295b7fdae03f446b7044eb5b96c4724733981 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:19:28 +0200 Subject: [PATCH] fix(player): handle Plex multi-episode files in up-next and watch state close #1500 --- lib/media/episode_collection.dart | 30 ++++ lib/media/media_item.dart | 38 ++++ lib/media/media_part.dart | 9 + lib/media/media_part.g.dart | 2 + lib/providers/playback_state_provider.dart | 124 ++++++++++--- .../video_player/parts/episode_queue.dart | 11 +- .../video_player/parts/playback_services.dart | 16 ++ lib/services/episode_navigation_service.dart | 14 +- lib/services/jellyfin_mappers.dart | 1 + lib/services/playback_progress_tracker.dart | 19 ++ lib/services/plex_mappers.dart | 1 + .../playback_state_provider_test.dart | 165 ++++++++++++++++++ .../playback_progress_tracker_test.dart | 96 ++++++++++ test/utils/episode_collection_test.dart | 38 ++++ 14 files changed, 530 insertions(+), 34 deletions(-) diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index f9fe1700..a29ffe6d 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -179,6 +179,36 @@ String? _airDateKey(MediaItem episode) { /// ordering rationale. void sortEpisodesByWatchOrder(List episodes) => episodes.sort(compareEpisodesByWatchOrder); +/// The episode after [currentIdx] in [ordered] that is backed by a different +/// file than the current one. Plex lists each episode of a multi-episode file +/// (`S02E24-E25.mkv`) as its own item, and advancing to a same-file sibling +/// would replay the file from the start (#1500). Items without part metadata +/// never match, so this degrades to plain adjacency. +MediaItem? nextEpisodeSkippingSameFile(List ordered, int currentIdx) { + final current = ordered[currentIdx]; + for (var i = currentIdx + 1; i < ordered.length; i++) { + if (!current.sharesFileWith(ordered[i])) return ordered[i]; + } + return null; +} + +/// The episode before [currentIdx] in [ordered] backed by a different file, +/// collapsed to the first episode of its same-file group so a multi-episode +/// file is entered at the episode that fronts it (#1500). +MediaItem? previousEpisodeSkippingSameFile(List ordered, int currentIdx) { + final current = ordered[currentIdx]; + for (var i = currentIdx - 1; i >= 0; i--) { + final candidate = ordered[i]; + if (current.sharesFileWith(candidate)) continue; + var head = i; + while (head > 0 && candidate.sharesFileWith(ordered[head - 1])) { + head--; + } + return ordered[head]; + } + return null; +} + /// Find the season index matching an explicit navigation target or on-deck /// episode. With neither, fall back to the first season that still has /// unwatched episodes (so a partially-watched show removed from Continue diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 72e1e475..3ebf2495 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -394,6 +394,44 @@ sealed class MediaItem with _$MediaItem { /// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`. List get parentChain => [?parentId, ?grandparentId]; + /// Server-side file paths across every version of this item. Plex + /// represents a multi-episode file (`S02E24-E25.mkv`) as distinct episode + /// items whose parts have *different* part ids but the same file, so the + /// file path — not the part id — is the "same underlying file" signal + /// (#1500). + Set get allPartFiles => { + for (final version in mediaVersions ?? const []) + for (final part in version.parts) + if (part.file != null && part.file!.isNotEmpty) part.file!, + }; + + /// Whether [other] is backed by the same physical file as this item. + /// [playedPartId] — the part actually being played, when known — pins the + /// comparison to that part's file, so an episode with multiple versions + /// only matches against the file on screen; otherwise any file overlap + /// between the two items counts. Items without file metadata (Plex hides + /// paths from restricted users) or from a different server never match. + bool sharesFileWith(MediaItem other, {String? playedPartId}) { + if (other.serverId != serverId) return false; + final otherFiles = other.allPartFiles; + if (otherFiles.isEmpty) return false; + if (playedPartId != null) { + final playedFile = _filePathForPart(playedPartId); + if (playedFile != null) return otherFiles.contains(playedFile); + } + return allPartFiles.intersection(otherFiles).isNotEmpty; + } + + /// The file path of this item's part with [partId], or null when unknown. + String? _filePathForPart(String partId) { + for (final version in mediaVersions ?? const []) { + for (final part in version.parts) { + if (part.id == partId) return (part.file?.isEmpty ?? true) ? null : part.file; + } + } + return null; + } + /// Recency used to order the Continue Watching / On Deck shelf: when the item /// was last watched, falling back to when it was added for never-watched rows. /// Shared by the per-client merge and the cross-server sort so they agree. diff --git a/lib/media/media_part.dart b/lib/media/media_part.dart index 5afeb531..6e28d386 100644 --- a/lib/media/media_part.dart +++ b/lib/media/media_part.dart @@ -20,6 +20,14 @@ class MediaPart { /// appending auth. final String? streamPath; + /// The server-side file path backing this part. The identity signal for + /// "same underlying file": Plex represents a multi-episode file + /// (`S02E24-E25.mkv`) as distinct episodes whose parts have *different* + /// part ids but the same [file] (#1500). May be absent (e.g. Plex hides + /// paths from restricted users), in which case same-file detection + /// degrades gracefully. + final String? file; + @JsonKey(fromJson: flexibleInt) final int? sizeBytes; final String? container; @@ -33,6 +41,7 @@ class MediaPart { const MediaPart({ required this.id, this.streamPath, + this.file, this.sizeBytes, this.container, this.durationMs, diff --git a/lib/media/media_part.g.dart b/lib/media/media_part.g.dart index 94a796b8..f154d6e5 100644 --- a/lib/media/media_part.g.dart +++ b/lib/media/media_part.g.dart @@ -9,6 +9,7 @@ part of 'media_part.dart'; MediaPart _$MediaPartFromJson(Map json) => MediaPart( id: _stringFromJson(json['id']), streamPath: json['streamPath'] as String?, + file: json['file'] as String?, sizeBytes: flexibleInt(json['sizeBytes']), container: json['container'] as String?, durationMs: flexibleInt(json['durationMs']), @@ -19,6 +20,7 @@ MediaPart _$MediaPartFromJson(Map json) => MediaPart( Map _$MediaPartToJson(MediaPart instance) => { 'id': instance.id, 'streamPath': ?instance.streamPath, + 'file': ?instance.file, 'sizeBytes': ?instance.sizeBytes, 'container': ?instance.container, 'durationMs': ?instance.durationMs, diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 8a547590..acd0b486 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -243,7 +243,14 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted - Future getNextEpisode(String currentItemKey, {bool loopQueue = false}) async { + /// + /// Entries backed by the same file as the one playing are skipped: Plex + /// lists each episode of a multi-episode file (`S02E24-E25.mkv`) as its + /// own queue item, and advancing to the sibling would replay the file + /// from the start (#1500). [playedPartId] pins the comparison to the file + /// of the part actually playing when known; otherwise any file overlap + /// with the current item counts. + Future getNextEpisode(String currentItemKey, {bool loopQueue = false, String? playedPartId}) async { if (!_isQueueMode) { // For sequential mode, let the video player handle next episode return null; @@ -256,16 +263,70 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } return null; } - final currentIndex = indexResult.index!; + + final current = _loadedItems[indexResult.index!]; + var anchor = current; + // Bounded so a pathological all-same-file looping queue can't spin. + for (var steps = 0; steps <= _playQueueTotalCount; steps++) { + final candidate = await _itemAfter(anchor, loopQueue: loopQueue); + if (candidate == null || !current.sharesFileWith(candidate, playedPartId: playedPartId)) { + return candidate; + } + anchor = candidate; + } + return null; + } + + /// Gets the previous item in the playback queue. + /// Returns null if at the beginning of the queue or current item is not in queue. + /// + /// Mirrors [getNextEpisode]'s multi-episode-file handling (#1500): entries + /// backed by the file that's playing are skipped, and the result is + /// collapsed to the first episode of its same-file group so a + /// multi-episode file is entered at the episode that fronts it. + Future getPreviousEpisode(String currentItemKey, {String? playedPartId}) async { + if (!_isQueueMode) { + // For sequential mode, let the video player handle previous episode + return null; + } + + final currentIndex = (await _getCurrentIndex()).index; + if (currentIndex == null) return null; + + final current = _loadedItems[currentIndex]; + MediaItem? candidate = current; + for (var steps = 0; steps <= _playQueueTotalCount; steps++) { + candidate = await _itemBefore(candidate!); + if (candidate == null) return null; + if (!current.sharesFileWith(candidate, playedPartId: playedPartId)) break; + } + + // Collapse to the first episode of the candidate's same-file group. + for (var steps = 0; steps <= _playQueueTotalCount; steps++) { + final before = await _itemBefore(candidate!); + if (before == null || !candidate.sharesFileWith(before)) return candidate; + candidate = before; + } + return candidate; + } + + /// The queue item immediately after [anchor], extending the loaded window + /// when needed. Returns null at the end of the queue unless [loopQueue]. + /// Does not move the queue cursor — setCurrentItem does that when + /// playback of the returned item actually starts. + Future _itemAfter(MediaItem anchor, {bool loopQueue = false}) async { + final anchorId = playQueueItemIdFor(anchor); + if (anchorId == null) return null; + final anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return null; // Check if there's a next item in the loaded window - if (currentIndex + 1 < _loadedItems.length) { - // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts - return _loadedItems[currentIndex + 1]; + if (anchorIndex + 1 < _loadedItems.length) { + return _loadedItems[anchorIndex + 1]; } // Check if we're at the end of the entire queue - if (currentIndex + 1 >= _playQueueTotalCount) { + if (anchorIndex + 1 >= _playQueueTotalCount) { if (loopQueue && _playQueueTotalCount > 0) { // Loop back to beginning - load first item if (_windowFetcher != null && _playQueueId != null) { @@ -273,7 +334,6 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { if (response != null && response.items != null && response.items!.isNotEmpty) { // Items arrive pre-tagged with server info by the producing mapper. _loadedItems = response.items!; - // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts return _loadedItems.first; } } @@ -284,8 +344,8 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { // Need to load next window if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { - // Load next window centered on the item after current. Plex-only path - // — _windowFetcher != null implies queue items are PlexMediaItem. + // Load next window centered on the item after the anchor. Plex-only + // path — _windowFetcher != null implies queue items are PlexMediaItem. final last = _loadedItems.last; final nextItemID = last is PlexMediaItem ? last.playQueueItemId : null; if (nextItemID != null) { @@ -298,31 +358,22 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return null; } - /// Gets the previous item in the playback queue. - /// Returns null if at the beginning of the queue or current item is not in queue. - Future getPreviousEpisode(String currentItemKey) async { - if (!_isQueueMode) { - // For sequential mode, let the video player handle previous episode - return null; - } - - final currentIndex = (await _getCurrentIndex()).index; - if (currentIndex == null) return null; + /// The queue item immediately before [anchor], extending the loaded + /// window when needed. Returns null at the beginning of the queue. + Future _itemBefore(MediaItem anchor) async { + final anchorId = playQueueItemIdFor(anchor); + if (anchorId == null) return null; + final anchorIndex = _findLoadedIndex(anchorId); + if (anchorIndex == -1) return null; // Check if there's a previous item in the loaded window - if (currentIndex > 0) { - // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts - return _loadedItems[currentIndex - 1]; + if (anchorIndex > 0) { + return _loadedItems[anchorIndex - 1]; } - // Check if we're at the beginning of the entire queue - if (currentIndex == 0) { - return null; - } - - // Need to load previous window + // Need to load previous window. Plex-only path — _windowFetcher != null + // implies items are PlexMediaItem. if (_windowFetcher != null && _playQueueId != null && _loadedItems.isNotEmpty) { - // Plex-only path — _windowFetcher != null implies items are PlexMediaItem. final first = _loadedItems.first; final prevItemID = first is PlexMediaItem ? first.playQueueItemId : null; if (prevItemID != null && prevItemID > 0) { @@ -335,6 +386,21 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return null; } + /// Queue items backed by the same physical file as [current] — the other + /// episodes of a Plex multi-episode file (#1500), which should share its + /// watched state. Scans the loaded window only: in sequential order + /// same-file episodes are adjacent, so they are always co-resident. + /// [current] may be a different object instance than the queue's copy; + /// exclusion is by item id. + List sameFileSiblings(MediaItem current, {String? playedPartId}) { + if (!_isQueueMode) return const []; + final seenIds = {current.id}; + return [ + for (final item in _loadedItems) + if (seenIds.add(item.id) && current.sharesFileWith(item, playedPartId: playedPartId)) item, + ]; + } + /// Clears the playback queue and exits queue mode void clearShuffle() { _playQueueId = null; diff --git a/lib/screens/video_player/parts/episode_queue.dart b/lib/screens/video_player/parts/episode_queue.dart index 3b8e885f..037ef19c 100644 --- a/lib/screens/video_player/parts/episode_queue.dart +++ b/lib/screens/video_player/parts/episode_queue.dart @@ -92,6 +92,10 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( context: context, metadata: targetMetadata, + // The part actually being played, so the queue can skip sibling + // entries of a Plex multi-episode file (#1500). MediaSourceInfo + // carries the Plex numeric part id; MediaPart.id is its string form. + playedPartId: _currentMediaInfo?.partId?.toString(), ); if (mounted && _currentMetadata.globalKey == targetMetadata.globalKey && (attempt == null || attempt.isCurrent)) { @@ -130,9 +134,12 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState { if (currentIdx == -1) return; if (mounted) { + // Same-file siblings are skipped by file-path intersection of the + // stored metadata (#1500) — offline media info doesn't carry the + // server part id, so the helpers compare the items' own parts. _setPlayerState(() { - _previousEpisode = currentIdx > 0 ? sorted[currentIdx - 1] : null; - _nextEpisode = currentIdx < sorted.length - 1 ? sorted[currentIdx + 1] : null; + _previousEpisode = previousEpisodeSkippingSameFile(sorted, currentIdx); + _nextEpisode = nextEpisodeSkippingSameFile(sorted, currentIdx); }); } } catch (e) { diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index c9eec394..38bc6616 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -230,6 +230,10 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Local media still reports live when its server is online; only queue // locally when no reporting client is reachable. if (mediaClient != null) { + // Captured now (synchronously); resolved at scrobble time because the + // play queue holding the siblings is created fire-and-forget and may + // not exist yet when the tracker is wired. + final playbackState = context.read(); _progressTracker = PlaybackProgressTracker( client: mediaClient, metadata: metadata, @@ -239,6 +243,18 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'), playSessionId: playSessionId, mediaInfo: mediaInfo, + onScrobbled: () async { + // Other episodes of a Plex multi-episode file share this item's + // part — watching the file watched them too (#1500). Reusing + // markWatchedFromPlaybackStop keeps the local watched-event + // emission and the Jellyfin double-scrobble guard (#1287). + final siblings = playbackState.sameFileSiblings(metadata, playedPartId: mediaInfo?.partId?.toString()); + for (final sibling in siblings) { + if (sibling.isWatched) continue; + await mediaClient.markWatchedFromPlaybackStop(sibling); + appLogger.d('Scrobbled same-file sibling ${sibling.id} of ${metadata.id}'); + } + }, ); _progressTracker!.startTracking(); } else if (_isOfflinePlayback) { diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index 4bfccca0..2529af03 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -61,7 +61,15 @@ class EpisodeNavigationService { /// - Not applicable (e.g., movie content) /// - Next episode doesn't exist (end of season/series) /// - Previous episode doesn't exist (first episode) - Future loadAdjacentEpisodes({required BuildContext context, required MediaItem metadata}) async { + /// + /// [playedPartId] is the backend part id actually being played, when + /// known — it lets the queue skip sibling entries of a Plex + /// multi-episode file (#1500). + Future loadAdjacentEpisodes({ + required BuildContext context, + required MediaItem metadata, + String? playedPartId, + }) async { try { // Resolve providers up-front so we don't reach for `context` after // any of the awaits below — avoids the @@ -82,8 +90,8 @@ class EpisodeNavigationService { if (!playbackState.isQueueActive) { return AdjacentEpisodes(); } - final next = await playbackState.getNextEpisode(metadata.id, loopQueue: false); - final previous = await playbackState.getPreviousEpisode(metadata.id); + final next = await playbackState.getNextEpisode(metadata.id, loopQueue: false, playedPartId: playedPartId); + final previous = await playbackState.getPreviousEpisode(metadata.id, playedPartId: playedPartId); final mode = playbackState.isShuffleActive ? 'Shuffle' : 'Sequential'; appLogger.d('$mode mode - Next: ${next?.title}, Previous: ${previous?.title}'); return AdjacentEpisodes(next: next, previous: previous); diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index afb7e497..32277159 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -60,6 +60,7 @@ MediaVersion jellyfinMediaSourceToVersion( MediaPart( id: partId, streamPath: streamPath, + file: source['Path'] as String?, sizeBytes: flexibleInt(source['Size']), container: source['Container'] as String?, durationMs: includePartDuration ? jellyfinTicksToMs(source['RunTimeTicks']) : null, diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index e71b4a15..5c3ddbe0 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -55,6 +55,14 @@ class PlaybackProgressTracker { /// Jellyfin stream indexes in playback-progress reports. final MediaSourceInfo? mediaInfo; + /// Invoked once after the item is successfully scrobbled. The player wires + /// this to mark same-file sibling episodes of a Plex multi-episode file + /// watched (#1500) — resolved lazily here because the play queue holding + /// the siblings is created fire-and-forget and may not exist when this + /// tracker is constructed. Best-effort: failures are logged and never + /// un-scrobble the primary item. + final Future Function()? onScrobbled; + /// Timer for periodic progress updates Timer? _progressTimer; @@ -96,6 +104,7 @@ class PlaybackProgressTracker { this.playMethod, this.playSessionId, this.mediaInfo, + this.onScrobbled, this.updateInterval = const Duration(seconds: 10), }) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'), assert(isOffline || client != null, 'client is required when isOffline is false'), @@ -344,6 +353,16 @@ class PlaybackProgressTracker { appLogger.w('Failed to scrobble ${metadata.id}', error: e); _scrobbled = false; // Retry on next tick } + // After (and only after) the primary mark succeeded. A failure here + // must not reset _scrobbled — that would re-scrobble the primary + // item and inflate its view count. + if (_scrobbled && onScrobbled != null) { + try { + await onScrobbled!(); + } catch (e) { + appLogger.w('Post-scrobble hook failed for ${metadata.id}', error: e); + } + } } } } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index d73b3385..98e33020 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -79,6 +79,7 @@ MediaPart _mediaPartFromMap( return MediaPart( id: (json['id'] ?? fallbackId).toString(), streamPath: json['key']?.toString(), + file: json['file']?.toString(), sizeBytes: flexibleInt(json['size']), container: json['container']?.toString() ?? fallbackContainer, durationMs: flexibleInt(json['duration']), diff --git a/test/providers/playback_state_provider_test.dart b/test/providers/playback_state_provider_test.dart index b9139839..a40d2799 100644 --- a/test/providers/playback_state_provider_test.dart +++ b/test/providers/playback_state_provider_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.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_part.dart'; +import 'package:plezy/media/media_version.dart'; import 'package:plezy/media/play_queue.dart'; import 'package:plezy/models/plex/play_queue_response.dart'; import 'package:plezy/providers/playback_state_provider.dart'; @@ -13,6 +15,22 @@ PlexMediaItem _item(String ratingKey, int playQueueItemID) => PlexMediaItem( title: 'Episode $ratingKey', ); +/// Episode queue entry carrying file identity, as Plex play-queue items do. +/// Episodes of a multi-episode file (`S02E24-E25.mkv`) get *distinct* part +/// ids (`part-` here, mirroring real servers) but share [file]. +PlexMediaItem _itemWithFile(String ratingKey, int playQueueItemID, String file) => PlexMediaItem( + id: ratingKey, + kind: MediaKind.episode, + playQueueItemId: playQueueItemID, + title: 'Episode $ratingKey', + mediaVersions: [ + MediaVersion( + id: 'v-$ratingKey', + parts: [MediaPart(id: 'part-$ratingKey', file: file)], + ), + ], +); + PlexMediaItem _miItem(String id, int playQueueItemId) => PlexMediaItem(id: id, kind: MediaKind.episode, playQueueItemId: playQueueItemId); @@ -306,4 +324,151 @@ void main() { expect(p.isItemInActiveQueue(ep), isFalse); }); }); + + group('multi-episode files (#1500)', () { + // Plex lists each episode of a multi-episode file (S02E24-E25.mkv) as + // its own queue entry with a distinct ratingKey AND a distinct part id, + // but the same Part.file. e24/e25 share a file; e23 and e26 don't. + const fileA = '/tv/S02E24-E25.mkv'; + Future queueWithMultiEpisodeFile({int selectedItemID = 1002}) async { + final p = PlaybackStateProvider(); + final items = [ + _itemWithFile('e23', 1001, '/tv/S02E23.mkv'), + _itemWithFile('e24', 1002, fileA), + _itemWithFile('e25', 1003, fileA), + _itemWithFile('e26', 1004, '/tv/S02E26-E27.mkv'), + ]; + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: selectedItemID, totalCount: 4, items: items), + null, + ); + return p; + } + + test('getNextEpisode skips the same-file sibling using playedPartId', () async { + final p = await queueWithMultiEpisodeFile(); + addTearDown(p.dispose); + + final next = await p.getNextEpisode('e24', playedPartId: 'part-e24'); + expect(next!.id, 'e26'); + }); + + test('getNextEpisode skips the same-file sibling via file intersection without playedPartId', () async { + final p = await queueWithMultiEpisodeFile(); + addTearDown(p.dispose); + + final next = await p.getNextEpisode('e24'); + expect(next!.id, 'e26'); + }); + + test('getNextEpisode skips multiple siblings of a triple-episode file', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + await p.setPlaybackFromPlayQueue( + _queue( + playQueueID: 1, + selectedItemID: 1001, + totalCount: 4, + items: [ + _itemWithFile('e1', 1001, fileA), + _itemWithFile('e2', 1002, fileA), + _itemWithFile('e3', 1003, fileA), + _itemWithFile('e4', 1004, '/tv/S02E26-E27.mkv'), + ], + ), + null, + ); + + final next = await p.getNextEpisode('e1', playedPartId: 'part-e1'); + expect(next!.id, 'e4'); + }); + + test('getNextEpisode returns null when only same-file siblings remain', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + await p.setPlaybackFromPlayQueue( + _queue( + playQueueID: 1, + selectedItemID: 1001, + totalCount: 2, + items: [_itemWithFile('e24', 1001, fileA), _itemWithFile('e25', 1002, fileA)], + ), + null, + ); + + expect(await p.getNextEpisode('e24', playedPartId: 'part-e24'), isNull); + }); + + test('items without file data keep positional behavior even with playedPartId', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + final items = [_item('a', 1001), _item('b', 1002)]; + await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null); + + final next = await p.getNextEpisode('a', playedPartId: 'part-a'); + expect(next!.id, 'b'); + }); + + test('skip past the loaded window extends it and lands on the next distinct file', () async { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + // Window holds only the two same-file entries; e26 lives past it. + final windowItems = [_itemWithFile('e24', 1001, fileA), _itemWithFile('e25', 1002, fileA)]; + await p.setPlaybackFromPlayQueue( + _queue(playQueueID: 1, selectedItemID: 1001, totalCount: 3, items: windowItems), + null, + ); + + var fetchCount = 0; + p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async { + fetchCount++; + return _queue( + playQueueID: playQueueId, + selectedItemID: 1001, + totalCount: 3, + items: [...windowItems, _itemWithFile('e26', 1003, '/tv/S02E26-E27.mkv')], + ); + }); + + final next = await p.getNextEpisode('e24', playedPartId: 'part-e24'); + expect(next!.id, 'e26'); + expect(fetchCount, 1); + }); + + test('getPreviousEpisode collapses to the first episode of the same-file group', () async { + final p = await queueWithMultiEpisodeFile(selectedItemID: 1004); + addTearDown(p.dispose); + + // From e26, previous is the e24-e25 file, entered at e24 (not e25). + final prev = await p.getPreviousEpisode('e26', playedPartId: 'part-e26'); + expect(prev!.id, 'e24'); + }); + + test('getPreviousEpisode skips same-file siblings of the playing item', () async { + final p = await queueWithMultiEpisodeFile(selectedItemID: 1003); + addTearDown(p.dispose); + + // Playing the file as e25: previous must not land inside the same file. + final prev = await p.getPreviousEpisode('e25', playedPartId: 'part-e25'); + expect(prev!.id, 'e23'); + }); + + test('sameFileSiblings returns the other episodes of the playing file', () async { + final p = await queueWithMultiEpisodeFile(); + addTearDown(p.dispose); + + final current = p.loadedItems[1]; // e24 + final siblings = p.sameFileSiblings(current, playedPartId: 'part-e24'); + expect(siblings.map((s) => s.id), ['e25']); + + // Distinct-file episode has no siblings. + expect(p.sameFileSiblings(p.loadedItems.first, playedPartId: 'part-e23'), isEmpty); + }); + + test('sameFileSiblings is empty without an active queue', () { + final p = PlaybackStateProvider(); + addTearDown(p.dispose); + expect(p.sameFileSiblings(_itemWithFile('e24', 1, fileA), playedPartId: 'part-e24'), isEmpty); + }); + }); } diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 70a08e93..e12bbdbe 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -774,6 +774,94 @@ void main() { expect(precise.markWatchedAttempts, 2); expect(precise.markWatchedSuccesses, 1); }); + + test('onScrobbled fires once after a successful scrobble (#1500)', () async { + final client = _FakePlexClient(thresholdPercent: 90); + final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100)); + var hookCalls = 0; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + onScrobbled: () async => hookCalls++, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped'); + await tracker.sendProgress('stopped'); + + expect(client.markWatchedCalls, ['42']); + expect(hookCalls, 1); + }); + + test('onScrobbled is not invoked below threshold', () async { + final client = _FakePlexClient(thresholdPercent: 90); + final player = _FakePlayer(position: const Duration(seconds: 89), duration: const Duration(seconds: 100)); + var hookCalls = 0; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + onScrobbled: () async => hookCalls++, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped'); + expect(hookCalls, 0); + }); + + test('onScrobbled waits for a successful scrobble when the first attempt fails', () async { + final precise = _ScrobblePreciseClient(thresholdPercent: 90, failScrobbleFirstTime: true); + final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100)); + var hookCalls = 0; + final tracker = PlaybackProgressTracker( + client: precise, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + onScrobbled: () async => hookCalls++, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(precise.markWatchedAttempts, 1); + expect(hookCalls, 0); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(precise.markWatchedSuccesses, 1); + expect(hookCalls, 1); + }); + + test('a throwing onScrobbled does not reset the scrobble latch', () async { + // A sibling-mark failure must not re-scrobble the primary item — that + // would inflate its view count on the next progress tick. + final client = _FakePlexClient(thresholdPercent: 90); + final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100)); + var hookCalls = 0; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(ratingKey: '42'), + player: player, + isOffline: false, + onScrobbled: () async { + hookCalls++; + throw Exception('sibling mark failed'); + }, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + + expect(client.markWatchedCalls, hasLength(1)); + expect(hookCalls, 1); + }); }); // ============================================================ @@ -1007,6 +1095,14 @@ class _ScrobblePreciseClient implements PlexClient { _ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false}); final int thresholdPercent; + + /// markWatchedFromPlaybackStop resolves the event's cacheServerId from + /// [serverId] after the transport call — without this override the + /// notify step throws NoSuchMethodError and a successful markWatched + /// still registers as a failed scrobble. + @override + ServerId get serverId => ServerId('scrobbler'); + @override int get watchedThresholdPercent => thresholdPercent; diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index 1b02979d..347831c4 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -3,6 +3,7 @@ import 'package:plezy/media/library_query.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_part.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_version.dart'; import 'package:plezy/utils/download_version_utils.dart'; @@ -422,4 +423,41 @@ void main() { expect(client.childrenCalls, ['show-1']); expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]); }); + + group('same-file adjacency (#1500)', () { + // Episodes of a Plex multi-episode file (S02E24-E25.mkv) are distinct + // items with distinct part ids but the same Part.file: e24/e25 here. + // e23 and e26 are their own files. + MediaVersion version(String key, String file) => MediaVersion( + id: 'v-$key', + parts: [MediaPart(id: 'part-$key', file: file)], + ); + late final episodes = [ + _episode('e23', versions: [version('e23', '/tv/S02E23.mkv')]), + _episode('e24', versions: [version('e24', '/tv/S02E24-E25.mkv')]), + _episode('e25', versions: [version('e25', '/tv/S02E24-E25.mkv')]), + _episode('e26', versions: [version('e26', '/tv/S02E26-E27.mkv')]), + ]; + + test('nextEpisodeSkippingSameFile skips same-file siblings', () { + expect(nextEpisodeSkippingSameFile(episodes, 1)!.id, 'e26'); + expect(nextEpisodeSkippingSameFile(episodes, 0)!.id, 'e24'); + expect(nextEpisodeSkippingSameFile(episodes, 3), isNull); + // No same-file sibling left before the end → null. + expect(nextEpisodeSkippingSameFile(episodes.sublist(0, 3), 1), isNull); + }); + + test('nextEpisodeSkippingSameFile degrades to plain adjacency without part data', () { + final plain = [_episode('a'), _episode('b')]; + expect(nextEpisodeSkippingSameFile(plain, 0)!.id, 'b'); + }); + + test('previousEpisodeSkippingSameFile collapses to the group head', () { + // From e26, previous is the e24-e25 file, entered at e24. + expect(previousEpisodeSkippingSameFile(episodes, 3)!.id, 'e24'); + // From inside the group (e25), previous skips the same file entirely. + expect(previousEpisodeSkippingSameFile(episodes, 2)!.id, 'e23'); + expect(previousEpisodeSkippingSameFile(episodes, 0), isNull); + }); + }); }