Files
plezy/lib/utils/media_server_http_client.dart
T
edde746 3a704a2b9b fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.

The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2026-08-10 23:04:35 +02:00

490 lines
16 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'app_logger.dart';
import 'future_extensions.dart';
import 'isolate_helper.dart';
import 'log_redaction_manager.dart';
import 'managed_http_client.dart';
import 'url_utils.dart';
import '../exceptions/media_server_exceptions.dart';
// Platform-specific imports are conditional
import 'platform_http_client_stub.dart' if (dart.library.io) 'platform_http_client_io.dart' as platform;
/// Response from [MediaServerHttpClient] requests.
class MediaServerResponse {
final int statusCode;
/// Parsed JSON body (`Map<String, dynamic>` or `List`), or raw `String`
/// for non-JSON responses.
final dynamic data;
final Map<String, String> headers;
final Uri? requestUri;
/// Final response URI after redirects, or [requestUri] when the transport
/// does not expose redirect metadata.
final Uri? effectiveUri;
MediaServerResponse({required this.statusCode, this.data, required this.headers, this.requestUri, Uri? effectiveUri})
: effectiveUri = effectiveUri ?? requestUri;
}
/// Throw [MediaServerHttpException] for non-2xx responses so callers don't blindly
/// cast HTML/text error bodies to `Map<String, dynamic>`.
void throwIfHttpError(MediaServerResponse r) {
if (r.statusCode >= 400) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: r.statusCode,
responseData: r.data,
requestUri: r.requestUri,
message: 'HTTP ${r.statusCode}',
);
}
}
/// Abort controller for cancelling in-flight HTTP requests.
///
/// Uses the `package:http` [AbortableRequest] mechanism so the underlying
/// transport (IOClient, CronetClient, CupertinoClient) actually cancels
/// the network operation.
class AbortController {
final _completer = Completer<void>();
Future<void> get trigger => _completer.future;
bool get isAborted => _completer.isCompleted;
void abort() {
if (!_completer.isCompleted) _completer.complete();
}
/// Stop a paged operation before it starts or commits more work.
///
/// The exception deliberately carries no request URI or response payload.
void throwIfAborted() {
if (isAborted) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'Operation cancelled');
}
}
}
/// HTTP client wrapper providing base URL, default headers, JSON parsing,
/// timeouts, logging, and optional endpoint failover.
class MediaServerHttpClient {
final http.Client _client;
/// Requests owned by this client, aborted at the transport on shutdown so an
/// in-flight body raises [http.RequestAbortedException] instead of truncating.
final Set<AbortController> _activeAborts = <AbortController>{};
/// Not delegated to [ManagedHttpClient]'s own closing guard: that reports
/// shutdown as an [http.ClientException], which maps to
/// [MediaServerHttpErrorType.connectionError] and so reads as transient.
/// Failover, pagination and download retry all branch on
/// [MediaServerHttpException.isCancellation].
bool _closing = false;
MediaServerHttpClient({
http.Client? client,
this.baseUrl = '',
Map<String, String> defaultHeaders = const {},
this.connectTimeout = const Duration(seconds: 10),
this.receiveTimeout = const Duration(seconds: 120),
// Plex home loads fan out many HTTP/1.1 calls on Linux. Keep that tuning
// opt-in so generic tracker/auth clients stay disposable and closeable.
bool usePlexApiClient = false,
}) : _client = client ?? (usePlexApiClient ? platform.createPlexApiClient() : platform.createPlatformClient()),
defaultHeaders = Map.of(defaultHeaders);
/// The underlying [http.Client] for direct streaming / multipart requests.
http.Client get inner => _client;
String baseUrl;
Map<String, String> defaultHeaders;
Duration connectTimeout;
Duration receiveTimeout;
Future<MediaServerResponse> get(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) => _send('GET', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
Future<MediaServerResponse> post(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) => _send(
'POST',
path,
queryParameters: queryParameters,
headers: headers,
body: body,
timeout: timeout,
abort: abort,
);
Future<MediaServerResponse> put(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) => _send(
'PUT',
path,
queryParameters: queryParameters,
headers: headers,
body: body,
timeout: timeout,
abort: abort,
);
Future<MediaServerResponse> delete(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
/// Fetch raw bytes (e.g. images, BIF files, subtitles).
Future<Uint8List> getBytes(String url, {Map<String, String>? headers, Duration? timeout, AbortController? abort}) {
return _perform<Uint8List>(
'GET',
url,
headers: headers,
timeout: timeout,
abort: abort,
consume: (streamed, scope) async {
final bytes = await scope.receive(streamed.stream.toBytes());
scope.logResponse(streamed.statusCode);
return bytes;
},
);
}
/// Stream-download a URL directly into a file.
Future<void> downloadFile(
String url,
String filePath, {
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) {
final tempFile = File('$filePath.download');
return _perform<void>(
'GET',
url,
label: 'download',
headers: headers,
timeout: timeout,
abort: abort,
// Also clears a temp file left by an earlier attempt when this one never
// got past connect.
onError: () async {
if (await tempFile.exists()) {
try {
await tempFile.delete();
} catch (_) {}
}
},
consume: (streamed, scope) async {
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
await streamed.stream.drain<void>();
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: streamed.statusCode,
requestUri: scope.uri,
message: 'HTTP ${streamed.statusCode}',
);
}
final file = File(filePath);
await file.parent.create(recursive: true);
if (await tempFile.exists()) await tempFile.delete();
final sink = tempFile.openWrite();
try {
await scope.receive(streamed.stream.pipe(sink));
} finally {
await sink.close();
}
if (await file.exists()) await file.delete();
await tempFile.rename(filePath);
},
);
}
void close() {
_closing = true;
_abortActiveRequests();
_client.close();
}
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async {
_closing = true;
_abortActiveRequests();
if (_client case final ManagedHttpClient managed) {
await managed.closeGracefully(drainTimeout: drainTimeout);
} else {
_client.close();
}
}
Future<MediaServerResponse> _send(
String method,
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) {
return _perform<MediaServerResponse>(
method,
path,
queryParameters: queryParameters,
headers: headers,
body: body,
timeout: timeout,
abort: abort,
consume: (streamed, scope) async {
final effectiveUri = switch (streamed) {
http.BaseResponseWithUrl(:final url) => url,
_ => scope.uri,
};
final bytes = await scope.receive(streamed.stream.toBytes());
scope.logResponse(streamed.statusCode);
dynamic data;
try {
data = await _decodeBody(bytes, streamed.headers);
} catch (e) {
final body = await _decodeTextBody(bytes);
throw MediaServerHttpException(
type: MediaServerHttpErrorType.unknown,
statusCode: streamed.statusCode,
responseData: body,
requestUri: scope.uri,
message: 'Failed to decode response body: $e',
);
}
return MediaServerResponse(
statusCode: streamed.statusCode,
data: data,
headers: streamed.headers,
requestUri: scope.uri,
effectiveUri: effectiveUri,
);
},
);
}
/// Run one request: closing guard, abort registration, connect phase and
/// failure wrapping. [consume] reads the body through its scope, which
/// carries the same timeout and abort wiring into the receive phase;
/// [onError] runs after the abort and before the failure is wrapped. Every
/// exit path deregisters the request from [_activeAborts].
Future<T> _perform<T>(
String method,
String url, {
String? label,
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
Future<void> Function()? onError,
required Future<T> Function(http.StreamedResponse streamed, _RequestScope scope) consume,
}) async {
if (_closing) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing');
}
final uri = _resolveUri(url, queryParameters);
final operation = label ?? method;
final requestAbort = AbortController();
_activeAborts.add(requestAbort);
final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort));
request.headers.addAll({...defaultHeaders, ...?headers});
_setBody(request, body);
final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout);
try {
final streamed = await _withAbortOnTimeout(
_client.send(request),
timeout ?? connectTimeout,
operation: '$operation ${uri.path} connect',
abort: requestAbort,
);
return await consume(streamed, scope);
} catch (e) {
requestAbort.abort();
await onError?.call();
throw MediaServerHttpException.from(e, uri: uri);
} finally {
_activeAborts.remove(requestAbort);
}
}
void _abortActiveRequests() {
for (final abort in _activeAborts.toList()) {
abort.abort();
}
}
Future<void> _abortTrigger(AbortController owned, AbortController? external) {
final externalTrigger = external?.trigger;
return externalTrigger == null ? owned.trigger : Future.any<void>([owned.trigger, externalTrigger]);
}
Future<T> _withAbortOnTimeout<T>(
Future<T> future,
Duration timeLimit, {
required String operation,
required AbortController abort,
}) async {
try {
return await future.namedTimeout(timeLimit, operation: operation);
} on TimeoutException {
abort.abort();
rethrow;
}
}
/// Build a full URI from [baseUrl] + [path] + [queryParameters].
/// Use this from callers that need to construct URLs with the client's
/// current (possibly failover-switched) base, rather than reading
/// `config.baseUrl` directly.
Uri buildUri(String path, {Map<String, dynamic>? queryParameters}) => _buildUri(path, queryParameters);
/// Build a full URI from [baseUrl] + [path] + [queryParameters].
/// Uses [Uri.encodeComponent] which encodes spaces as `%20` (not `+`).
Uri _buildUri(String path, Map<String, dynamic>? queryParameters) {
final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
// [path] may already carry a query string (e.g. Plex home hub keys like
// `/hubs/home/recentlyAdded?type=2&sectionID=2`). Merge via [_appendQuery] —
// the same path used for absolute URLs in [_send] — so extra params join with
// `&` instead of producing a malformed double-`?` URL that corrupts the
// existing params (e.g. sectionID).
return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters);
}
/// Resolve a request target: absolute URLs keep their own host and query,
/// relative paths go through [baseUrl].
Uri _resolveUri(String url, Map<String, dynamic>? queryParameters) =>
_isAbsoluteUrl(url) ? _appendQuery(Uri.parse(url), queryParameters) : _buildUri(url, queryParameters);
/// Append query parameters to an already-parsed URI.
Uri _appendQuery(Uri uri, Map<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return uri;
final query = encodeQueryParameters(queryParameters);
if (query.isEmpty) return uri;
final existing = uri.query;
final combined = existing.isEmpty ? query : '$existing&$query';
return uri.replace(query: combined);
}
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
/// Set the request body, choosing encoding based on the body type.
void _setBody(http.Request request, Object? body) {
if (body == null) return;
if (body is List<int>) {
request.bodyBytes = Uint8List.fromList(body);
return;
}
if (body is String) {
request.body = body;
return;
}
// Content type comes from the caller's headers (Jellyfin/Plex put
// `application/json` in their defaults); `request.body` falls back to
// text/plain. Don't add one here — `request.headers` is case-insensitive,
// and the setter above has already filled the key in either way.
request.body = jsonEncode(body);
}
/// Decode the response body: lenient UTF-8, then JSON parse if applicable.
/// Large payloads are decoded in a background isolate.
Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async {
if (bytes.isEmpty) return null;
final contentType = (_headerValue(headers, 'content-type') ?? '').toLowerCase();
final isJson = contentType.contains('json');
// For large JSON payloads, do both UTF-8 decode and JSON parse in a
// single isolate roundtrip to avoid two context switches.
if (isJson && bytes.length > 50 * 1024) {
return await tryIsolateRun(() => jsonDecode(utf8.decode(bytes, allowMalformed: true)));
}
final body = await _decodeTextBody(bytes);
return isJson ? jsonDecode(body) : body;
}
Future<String> _decodeTextBody(List<int> bytes) async {
return bytes.length > 50 * 1024
? await tryIsolateRun(() => utf8.decode(bytes, allowMalformed: true))
: utf8.decode(bytes, allowMalformed: true);
}
static String? _headerValue(Map<String, String> headers, String name) {
final lowerName = name.toLowerCase();
for (final entry in headers.entries) {
if (entry.key.toLowerCase() == lowerName) return entry.value;
}
return null;
}
void _logResponse(String method, Uri uri, int statusCode, int ms) {
appLogger.d('$method ${LogRedactionManager.redact(uri.toString())}$statusCode (${ms}ms)');
}
}
/// The live request handed to a [MediaServerHttpClient._perform] body handler.
/// Its stopwatch starts with the connect phase, so [logResponse] reports the
/// full round trip regardless of how the body was read.
class _RequestScope {
_RequestScope(this._owner, this.uri, this._operation, this._abort, this._receiveTimeout);
final MediaServerHttpClient _owner;
final Uri uri;
final String _operation;
final AbortController _abort;
final Duration _receiveTimeout;
final Stopwatch _sw = Stopwatch()..start();
Future<T> receive<T>(Future<T> future) =>
_owner._withAbortOnTimeout(future, _receiveTimeout, operation: '$_operation ${uri.path} receive', abort: _abort);
void logResponse(int statusCode) {
_sw.stop();
_owner._logResponse(_operation, uri, statusCode, _sw.elapsedMilliseconds);
}
}
/// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks,
/// log uploads, image fetches, etc). No base URL or default Plex headers.
final httpClient = MediaServerHttpClient();