fix(plex): report transcode session on the timeline (#1381)

Send the playback X-Plex-Session-Identifier on /:/timeline so the server reports Transcode instead of Direct Play.
This commit is contained in:
Huzama Ahmad
2026-06-20 03:32:02 +02:00
committed by GitHub
parent 0d78020fbf
commit 19abd7831f
3 changed files with 216 additions and 2 deletions
+21 -2
View File
@@ -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<void> 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<void> 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,
);
@@ -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 {}
@@ -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<http.Response> 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<http.Request> requests}) makeRecordingClient() {
final requests = <http.Request>[];
final client = makeClient((request) async {
requests.add(request);
return http.Response('', 200);
});
return (client: client, requests: requests);
}
http.Request timelineRequest(List<http.Request> 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');
});
});
}