feat(player): start Plex transcodes at the resume position (#1817)

A Plex transcode session always starts producing at zero: the decision
request never sent offset=, so any non-zero open - resuming a
transcoded title, or switching from Direct Play to a transcoded
quality mid-playback - opened a session whose produced window begins
at the start of the file and seeked it. mpv immediately requests a
segment the transcoder has not produced, PMS answers 404 for it and
every subsequent segment, and playback buffers forever.

Send offset=<seconds> (6dp) with the decision and start request - the
view offset on initial open, the resolved resume position on every
in-place reload - so the session begins producing at the position the
player consumes first. The playlist timeline is unchanged: an offset
session's media playlist still covers the full title from segment
zero, so the player keeps opening with start: at the resume position
and in-stream seeks work as before.

Before a native player opens an offset playlist, waitForTranscodeReady
walks the master playlist, the media playlist, and the segment
containing the offset, because PMS can publish a manifest before that
segment is fetchable and mpv treats the 404 as an HLS error. The probe
is best-effort: it never fails an open, hands off immediately on HTTP
500 (on the response and exception paths alike) so the server-limit
dialog stays prompt, stops on cancellation, skips itself when the
playlist durations never reach the offset, and stays out of the
endpoint-failover cascade. In-place reloads resolve the replacement
source only after the old stop report has gone out, so Plex cannot use
that stop to terminate the replacement transcode.

close #1840
This commit is contained in:
toluLikesToCode
2026-08-09 06:28:59 +02:00
committed by edde746
parent bb3762ed63
commit 8740a19f36
10 changed files with 694 additions and 13 deletions
+39
View File
@@ -177,6 +177,45 @@ class MediaServerHttpClient {
);
}
/// Issue a GET and return only status and headers, draining the body
/// unread — the shape for probes that ask "does this answer?" rather than
/// "what does it say?".
///
/// Unlike [getBytes] the status code is surfaced instead of only logged.
/// Unlike [get] nothing is ever decoded, so a body that fails decoding
/// cannot convert a status into an exception, and — because
/// [FailoverHttpClient] overrides [get] alone — this method structurally
/// never enters the endpoint-failover cascade. Non-2xx is returned, not
/// thrown, matching [get].
Future<MediaServerResponse> getStatus(
String url, {
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) {
return _perform<MediaServerResponse>(
'GET',
url,
headers: headers,
timeout: timeout,
abort: abort,
consume: (streamed, scope) async {
final effectiveUri = switch (streamed) {
http.BaseResponseWithUrl(:final url) => url,
_ => scope.uri,
};
await scope.receive(streamed.stream.drain<void>());
scope.logResponse(streamed.statusCode);
return MediaServerResponse(
statusCode: streamed.statusCode,
headers: streamed.headers,
requestUri: scope.uri,
effectiveUri: effectiveUri,
);
},
);
}
/// Stream-download a URL directly into a file.
Future<void> downloadFile(
String url,