fix(player): handle Plex multi-episode files in up-next and watch state

close #1500
This commit is contained in:
edde746
2026-07-06 18:39:45 +02:00
parent ef75887504
commit 4a3295b7fd
14 changed files with 530 additions and 34 deletions
+30
View File
@@ -179,6 +179,36 @@ String? _airDateKey(MediaItem episode) {
/// ordering rationale.
void sortEpisodesByWatchOrder(List<MediaItem> 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<MediaItem> 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<MediaItem> 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
+38
View File
@@ -394,6 +394,44 @@ sealed class MediaItem with _$MediaItem {
/// `[seasonId, showId]`. For a season: `[showId]`. For a movie: `[]`.
List<String> 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<String> get allPartFiles => {
for (final version in mediaVersions ?? const <MediaVersion>[])
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 <MediaVersion>[]) {
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.
+9
View File
@@ -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,
+2
View File
@@ -9,6 +9,7 @@ part of 'media_part.dart';
MediaPart _$MediaPartFromJson(Map<String, dynamic> 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<String, dynamic> json) => MediaPart(
Map<String, dynamic> _$MediaPartToJson(MediaPart instance) => <String, dynamic>{
'id': instance.id,
'streamPath': ?instance.streamPath,
'file': ?instance.file,
'sizeBytes': ?instance.sizeBytes,
'container': ?instance.container,
'durationMs': ?instance.durationMs,
+95 -29
View File
@@ -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<MediaItem?> 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<MediaItem?> 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<MediaItem?> 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<MediaItem?> _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<MediaItem?> 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<MediaItem?> _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<MediaItem> sameFileSiblings(MediaItem current, {String? playedPartId}) {
if (!_isQueueMode) return const [];
final seenIds = <String>{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;
@@ -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) {
@@ -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<PlaybackStateProvider>();
_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) {
+11 -3
View File
@@ -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<AdjacentEpisodes> 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<AdjacentEpisodes> 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);
+1
View File
@@ -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,
@@ -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<void> 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);
}
}
}
}
}
+1
View File
@@ -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']),
@@ -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-<ratingKey>` 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<PlaybackStateProvider> 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);
});
});
}
@@ -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<void>.delayed(Duration.zero);
expect(precise.markWatchedAttempts, 1);
expect(hookCalls, 0);
await tracker.sendProgress('playing');
await Future<void>.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<void>.delayed(Duration.zero);
await tracker.sendProgress('playing');
await Future<void>.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;
+38
View File
@@ -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);
});
});
}