diff --git a/apps/desktop/lib/src/jellyfin/progress.dart b/apps/desktop/lib/src/jellyfin/progress.dart index 8579081..771cc33 100644 --- a/apps/desktop/lib/src/jellyfin/progress.dart +++ b/apps/desktop/lib/src/jellyfin/progress.dart @@ -182,7 +182,12 @@ class JellyfinProgressReporter { final report = JellyfinPlaybackReport( itemId: itemId, mediaSourceId: source.id, playSessionId: playSessionId, positionTicks: ticks); _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()); } diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index ee62aba..b31be24 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -45,6 +45,10 @@ FakeJellyfin playbackFake() { reports.add((path: '/UserPlayedItems', body: const {})); return const JellyfinHttpResponse(200, '{"Played":true}'); }); + fake.on('POST', '/UserItems/$episodeId/UserData', (_, body) { + reports.add((path: '/UserData', body: jsonDecode(body!) as Map)); + return const JellyfinHttpResponse(200, '{}'); + }); return fake; } @@ -277,9 +281,10 @@ void main() { reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false)); } await reporter.stop(const PlayerSnapshot(position: 1420, paused: true)); - expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 2), - ['Stopped', 'UserPlayedItems']); - expect(reports[reports.length - 2].body.containsKey('PositionTicks'), isFalse); + expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3), + ['Stopped', 'UserPlayedItems', 'UserData']); + 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] .where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond); 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)); await r.stop(const PlayerSnapshot(position: 701, paused: true)); 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[3].body.containsKey('PositionTicks'), isFalse); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3789bb8..2e0ea60 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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), alongside the plain watched time, first / last / furthest position and start / end time. At a stop: played before this opening -> keep played; - coverage >= 80 % of the runtime, or the ending reached (furthest position - past the Outro / ED start, else the real end) with coverage >= 50 % -> - played; coverage under max(2 min, 5 %) (at most a quarter of a short + coverage >= 70 % of the runtime, or the ending reached (furthest position + past the Outro / ED start or into the last 15 %, at most 4 min, whichever + 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 unknown runtime never makes it played. Jellyfin decides played from 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): `UserPlayedItems` right after Playing, Progress without `PositionTicks` (leaves the saved position and played state), Stopped - without `PositionTicks` (Jellyfin marks it played, position 0), then - `UserPlayedItems` again; + without `PositionTicks` (Jellyfin marks it played), then + `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 none), so the item is as it was; - part way: Stopped at the last position, held under 85 % of the diff --git a/packages/core/lib/src/watch_session.dart b/packages/core/lib/src/watch_session.dart index b3a9ac7..ecfebd7 100644 --- a/packages/core/lib/src/watch_session.dart +++ b/packages/core/lib/src/watch_session.dart @@ -110,8 +110,10 @@ class WatchSession { /// The rule, for an item [runtime] seconds long whose ending starts at /// [endingStart] (an Outro segment or ED chapter; null = the real end): /// - played before: [WatchOutcome.keepPlayed]; - /// - [covered] >= 80 % of the runtime, or the ending was reached - /// ([furthest]) with [covered] >= 50 %: [WatchOutcome.played]; + /// - [covered] >= 70 % of the runtime, or the ending was reached + /// ([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 /// item): [WatchOutcome.peek]; /// - else [WatchOutcome.partial] at [lastPosition]. @@ -120,8 +122,9 @@ class WatchSession { if (playedBefore) return const WatchVerdict(WatchOutcome.keepPlayed); final c = covered; if (runtime > 0) { - final end = endingStart != null && endingStart > 0 && endingStart < runtime ? endingStart : runtime - 2; - if (c >= 0.8 * runtime || (furthest >= end && c >= 0.5 * runtime)) { + final tail = runtime - math.min(240.0, 0.15 * 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); } } diff --git a/packages/core/test/watch_session_test.dart b/packages/core/test/watch_session_test.dart index 8f369dc..0f24855 100644 --- a/packages/core/test/watch_session_test.dart +++ b/packages/core/test/watch_session_test.dart @@ -41,7 +41,7 @@ void main() { 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(); play(full, 0, 0, 1200); // 83 % 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 play(skipped, t, ed, ed + 5); expect(skipped.verdict(runtime: ep, endingStart: ed).outcome, WatchOutcome.played); - // Without a known ending only the real end counts. - expect(skipped.verdict(runtime: ep).outcome, WatchOutcome.partial); + // Without a known ending the last 15 % (at most 4 min) counts. + 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(); play(jumped, 0, 0, 400); // 28 %, then jumped to the end