diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 358160ff..609b6282 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1633,12 +1633,19 @@ class PlexClient ); } - /// Update playback progress + /// Update playback progress. + /// + /// [sessionIdentifier] is the playback's `X-Plex-Session-Identifier` — the + /// same value the (transcode) stream request carries. Sending it on the + /// timeline lets the server correlate this report with the active session, + /// so the dashboard reports the real stream decision (e.g. Transcode) instead + /// of falling back to a generic Direct Play / Original entry. Future updateProgress( String ratingKey, { required int time, required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, + String? sessionIdentifier, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { final response = await _http.post( @@ -1653,6 +1660,7 @@ class PlexClient if (report.recordedAt != null) 'updated': report.recordedAt!.millisecondsSinceEpoch ~/ 1000, if (report.willContinue != null) 'continuing': report.willContinue! ? 1 : 0, }, + headers: {'X-Plex-Session-Identifier': ?sessionIdentifier}, ); // Surface non-2xx instead of swallowing — progress is the cornerstone // of resume/Continue Watching, so silent failures hurt the user later. @@ -3425,6 +3433,7 @@ class PlexClient isTranscoding: true, activeAudioStreamId: resolvedAudioId, playMethod: 'Transcode', + playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, ); } @@ -3444,6 +3453,7 @@ class PlexClient isTranscoding: false, fallbackReason: fallbackReason, playMethod: 'DirectPlay', + playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, ); } @@ -3455,6 +3465,7 @@ class PlexClient externalSubtitles: _buildExternalSubtitles(data.mediaInfo), isOffline: false, playMethod: 'DirectPlay', + playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, ); } catch (e) { @@ -4089,7 +4100,13 @@ class PlexClient String? mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex, - }) => updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); + }) => updateProgress( + itemId, + time: position.inMilliseconds, + state: 'playing', + duration: duration?.inMilliseconds, + sessionIdentifier: playSessionId, + ); @override Future reportPlaybackProgress({ @@ -4107,6 +4124,7 @@ class PlexClient time: position.inMilliseconds, state: isPaused ? 'paused' : 'playing', duration: duration.inMilliseconds, + sessionIdentifier: playSessionId, ); @override @@ -4122,6 +4140,7 @@ class PlexClient time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds, + sessionIdentifier: playSessionId, report: report, ); diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 2fb979e5..70a08e93 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -146,6 +146,7 @@ class _FakePlexClient implements PlexClient { required int time, required String state, int? duration, + String? sessionIdentifier, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async { if (throwOnNextCall != null) { @@ -1025,6 +1026,7 @@ class _ScrobblePreciseClient implements PlexClient { required int time, required String state, int? duration, + String? sessionIdentifier, PlaybackReportMetadata report = const PlaybackReportMetadata.live(), }) async {} diff --git a/test/services/plex_timeline_session_test.dart b/test/services/plex_timeline_session_test.dart new file mode 100644 index 00000000..c476fa79 --- /dev/null +++ b/test/services/plex_timeline_session_test.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.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/models/plex/plex_config.dart'; +import 'package:plezy/models/transcode_quality_preset.dart'; +import 'package:plezy/services/playback_initialization_types.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; + +/// Regression coverage for the Plex transcode reporting bug: while +/// transcoding, the `/:/timeline` reports must carry the playback's +/// `X-Plex-Session-Identifier` so the server correlates the timeline with the +/// active transcode session and reports "Transcode" instead of falling back to +/// a generic Direct Play / Original entry. +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + PlexClient makeClient(Future Function(http.Request request) handler) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: ServerId('server-id'), + httpClient: MockClient(handler), + ); + } + + /// Captures every request and answers `/:/timeline` with 200 so + /// [PlexClient.updateProgress]'s `throwIfHttpError` is satisfied. + ({PlexClient client, List requests}) makeRecordingClient() { + final requests = []; + final client = makeClient((request) async { + requests.add(request); + return http.Response('', 200); + }); + return (client: client, requests: requests); + } + + http.Request timelineRequest(List requests) { + return requests.firstWhere((r) => r.url.path == '/:/timeline'); + } + + group('Plex timeline carries the session identifier', () { + test('reportPlaybackStarted sends X-Plex-Session-Identifier when a play session id is set', () async { + final harness = makeRecordingClient(); + addTearDown(harness.client.close); + + await harness.client.reportPlaybackStarted( + itemId: '42', + position: const Duration(seconds: 12), + duration: const Duration(minutes: 90), + playSessionId: 'session-abc', + playMethod: 'Transcode', + ); + + final request = timelineRequest(harness.requests); + expect(request.method, 'POST'); + expect(request.url.queryParameters['state'], 'playing'); + expect(request.headers['X-Plex-Session-Identifier'], 'session-abc'); + }); + + test('reportPlaybackProgress sends X-Plex-Session-Identifier when a play session id is set', () async { + final harness = makeRecordingClient(); + addTearDown(harness.client.close); + + await harness.client.reportPlaybackProgress( + itemId: '42', + position: const Duration(minutes: 5), + duration: const Duration(minutes: 90), + playSessionId: 'session-abc', + playMethod: 'Transcode', + ); + + final request = timelineRequest(harness.requests); + expect(request.method, 'POST'); + expect(request.url.queryParameters['state'], 'playing'); + expect(request.headers['X-Plex-Session-Identifier'], 'session-abc'); + }); + + test('reportPlaybackStopped sends X-Plex-Session-Identifier when a play session id is set', () async { + final harness = makeRecordingClient(); + addTearDown(harness.client.close); + + await harness.client.reportPlaybackStopped( + itemId: '42', + position: const Duration(minutes: 5), + duration: const Duration(minutes: 90), + playSessionId: 'session-abc', + ); + + final request = timelineRequest(harness.requests); + expect(request.method, 'POST'); + expect(request.url.queryParameters['state'], 'stopped'); + expect(request.headers['X-Plex-Session-Identifier'], 'session-abc'); + }); + + test('timeline omits X-Plex-Session-Identifier when there is no play session id', () async { + final harness = makeRecordingClient(); + addTearDown(harness.client.close); + + await harness.client.reportPlaybackProgress( + itemId: '42', + position: const Duration(minutes: 5), + duration: const Duration(minutes: 90), + ); + + final request = timelineRequest(harness.requests); + expect(request.headers.containsKey('X-Plex-Session-Identifier'), isFalse); + }); + }); + + group('getPlaybackInitialization threads the session identifier onto the result', () { + PlexClient makeMetadataClient() { + return makeClient((request) async { + if (request.url.path != '/library/metadata/42') { + return http.Response('not found', 404); + } + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': '42', + 'type': 'movie', + 'title': 'Movie', + 'Media': [ + { + 'id': 7, + 'container': 'mkv', + 'Part': [ + { + 'id': 99, + 'key': '/library/parts/99/file.mkv', + 'Stream': [ + {'streamType': 1, 'id': 300, 'codec': 'h264'}, + {'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'eng', 'selected': true}, + ], + }, + ], + }, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + } + + test('direct-play result carries the session identifier as playSessionId', () async { + final client = makeMetadataClient(); + addTearDown(client.close); + + final result = await client.getPlaybackInitialization( + PlaybackInitializationOptions( + metadata: MediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'), + selectedMediaIndex: 0, + // Original preset stays on the direct-play branch (no transcode + // decision round-trip needed for this assertion). + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'session-abc', + transcodeSessionId: 'transcode-abc', + ), + ); + + expect(result.isTranscoding, isFalse); + expect(result.playSessionId, 'session-abc'); + }); + }); +}