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:
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user