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:
1 parent
7487b4deb1
commit
197fe113a1
5 files changed
+39
-17
No files matched your search
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user