Jellyfin: a watched episode leaves Continue (resume point cleared); watched at 70 %, or reaching the ending or last 15 % after 40 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-13 01:11:35 +03:00
1 parent 7487b4deb1
commit 197fe113a1
5 files changed
+39 -17

No files matched your search

+6 -1
View File
@@ -182,7 +182,12 @@ class JellyfinProgressReporter {
final report = JellyfinPlaybackReport( final report = JellyfinPlaybackReport(
itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks); itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks);
_send('Stopped', () => client.reportPlaybackStopped(report)); _send('Stopped', () => client.reportPlaybackStopped(report));
if (ticks == null) _send('played', () => client.markPlayed(itemId)); if (ticks == null) {
_send('played', () => client.markPlayed(itemId));
// Marking played keeps a resume point Progress left (Continue would
// still list it, "3 min left"): clear it.
_send('resume cleared', () => client.clearResumePosition(itemId));
}
return _chain = _chain.then((_) => onStopped?.call()); return _chain = _chain.then((_) => onStopped?.call());
} }
+9 -4
View File
@@ -45,6 +45,10 @@ FakeJellyfin playbackFake() {
reports.add((path: '/UserPlayedItems', body: const {})); reports.add((path: '/UserPlayedItems', body: const {}));
return const JellyfinHttpResponse(200, '{"Played":true}'); return const JellyfinHttpResponse(200, '{"Played":true}');
}); });
fake.on('POST', '/UserItems/$episodeId/UserData', (_, body) {
reports.add((path: '/UserData', body: jsonDecode(body!) as Map<String, dynamic>));
return const JellyfinHttpResponse(200, '{}');
});
return fake; return fake;
} }
@@ -277,9 +281,10 @@ void main() {
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false)); reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
} }
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 2), expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
['Stopped', 'UserPlayedItems']); ['Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[reports.length - 2].body.containsKey('PositionTicks'), isFalse); expect(reports[reports.length - 3].body.containsKey('PositionTicks'), isFalse);
expect(reports.last.body, {'PlaybackPositionTicks': 0}, reason: 'the resume point Progress left is cleared');
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body] final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond); .where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide'); expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
@@ -299,7 +304,7 @@ void main() {
r.update(const PlayerSnapshot(position: 701, paused: true)); r.update(const PlayerSnapshot(position: 701, paused: true));
await r.stop(const PlayerSnapshot(position: 701, paused: true)); await r.stop(const PlayerSnapshot(position: 701, paused: true));
expect(reports.map((r) => r.path.split('/').last), expect(reports.map((r) => r.path.split('/').last),
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems']); ['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems', 'UserData']);
expect(reports[2].body.containsKey('PositionTicks'), isFalse); expect(reports[2].body.containsKey('PositionTicks'), isFalse);
expect(reports[3].body.containsKey('PositionTicks'), isFalse); expect(reports[3].body.containsKey('PositionTicks'), isFalse);
}); });
+8 -5
View File
@@ -834,9 +834,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
union of the watched stretches is the coverage (rewatching counts once), union of the watched stretches is the coverage (rewatching counts once),
alongside the plain watched time, first / last / furthest position and alongside the plain watched time, first / last / furthest position and
start / end time. At a stop: played before this opening -> keep played; start / end time. At a stop: played before this opening -> keep played;
coverage >= 80 % of the runtime, or the ending reached (furthest position coverage >= 70 % of the runtime, or the ending reached (furthest position
past the Outro / ED start, else the real end) with coverage >= 50 % -> past the Outro / ED start or into the last 15 %, at most 4 min, whichever
played; coverage under max(2 min, 5 %) (at most a quarter of a short is earlier) with coverage >= 40 % -> played (someone who watched that
far and stops is done with it); coverage under max(2 min, 5 %) (at most a quarter of a short
item) -> a peek; else part way, resuming at the last position. An item) -> a peek; else part way, resuming at the last position. An
unknown runtime never makes it played. Jellyfin decides played from unknown runtime never makes it played. Jellyfin decides played from
positions alone (10.11): Playing sets a resumable item unplayed, a positions alone (10.11): Playing sets a resumable item unplayed, a
@@ -847,8 +848,10 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
- played (before, or by the rule at an earlier stop of this opening): - played (before, or by the rule at an earlier stop of this opening):
`UserPlayedItems` right after Playing, Progress without `UserPlayedItems` right after Playing, Progress without
`PositionTicks` (leaves the saved position and played state), Stopped `PositionTicks` (leaves the saved position and played state), Stopped
without `PositionTicks` (Jellyfin marks it played, position 0), then without `PositionTicks` (Jellyfin marks it played), then
`UserPlayedItems` again; `UserPlayedItems` again and the resume point cleared
(`/UserItems/{id}/UserData` `PlaybackPositionTicks: 0`): a position an
earlier Progress left would otherwise keep it in Continue;
- peek: Stopped with the resume point from before the opening (0 when - peek: Stopped with the resume point from before the opening (0 when
none), so the item is as it was; none), so the item is as it was;
- part way: Stopped at the last position, held under 85 % of the - part way: Stopped at the last position, held under 85 % of the
+7 -4
View File
@@ -110,8 +110,10 @@ class WatchSession {
/// The rule, for an item [runtime] seconds long whose ending starts at /// The rule, for an item [runtime] seconds long whose ending starts at
/// [endingStart] (an Outro segment or ED chapter; null = the real end): /// [endingStart] (an Outro segment or ED chapter; null = the real end):
/// - played before: [WatchOutcome.keepPlayed]; /// - played before: [WatchOutcome.keepPlayed];
/// - [covered] >= 80 % of the runtime, or the ending was reached /// - [covered] >= 70 % of the runtime, or the ending was reached
/// ([furthest]) with [covered] >= 50 %: [WatchOutcome.played]; /// ([furthest]) with [covered] >= 40 %: [WatchOutcome.played]. The
/// ending is the earlier of [endingStart] and the last 15 % (at most
/// 4 min): someone who watched that far and stops is done with it;
/// - [covered] under max(2 min, 5 %) (at most a quarter of a short /// - [covered] under max(2 min, 5 %) (at most a quarter of a short
/// item): [WatchOutcome.peek]; /// item): [WatchOutcome.peek];
/// - else [WatchOutcome.partial] at [lastPosition]. /// - else [WatchOutcome.partial] at [lastPosition].
@@ -120,8 +122,9 @@ class WatchSession {
if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed); if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed);
final c = covered; final c = covered;
if (runtime > 0) { if (runtime > 0) {
final end = endingStart != null && endingStart > 0 && endingStart < runtime ? endingStart : runtime - 2; final tail = runtime - math.min(240.0, 0.15 * runtime);
if (c >= 0.8 * runtime || (furthest >= end && c >= 0.5 * runtime)) { final end = endingStart != null && endingStart > 0 && endingStart < tail ? endingStart : tail;
if (c >= 0.7 * runtime || (furthest >= end && c >= 0.4 * runtime)) {
return const WatchVerdict(WatchOutcome.played); return const WatchVerdict(WatchOutcome.played);
} }
} }
+9 -3
View File
@@ -41,7 +41,7 @@ void main() {
expect(w.watched, closeTo(400, 1)); expect(w.watched, closeTo(400, 1));
}); });
test('played: 80 % covered, or the ending reached after 50 %', () { test('played: 70 % covered, or the ending (or the last 15 %) reached after 40 %', () {
final full = WatchSession(); final full = WatchSession();
play(full, 0, 0, 1200); // 83 % play(full, 0, 0, 1200); // 83 %
expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); expect(full.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
@@ -50,8 +50,14 @@ void main() {
final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED final t = play(skipped, 0, 0, 800); // 56 %, then skipped to the ED
play(skipped, t, ed, ed + 5); play(skipped, t, ed, ed + 5);
expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played);
// Without a known ending only the real end counts. // Without a known ending the last 15 % (at most 4 min) counts.
expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.partial); expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.played);
// Watched with the opening skipped, stopped 3 min before the end.
final early = WatchSession();
final u = play(early, 0, 0, 90);
play(early, u, 180, ep - 180);
expect(early.verdict(runtime: ep).outcome, WatchOutcome.played);
final jumped = WatchSession(); final jumped = WatchSession();
play(jumped, 0, 0, 400); // 28 %, then jumped to the end play(jumped, 0, 0, 400); // 28 %, then jumped to the end