fix(jellyfin): promote redirected server URLs

This commit is contained in:
edde746
2026-07-14 23:05:57 +02:00
parent abf1027b77
commit ddb7520ce8
3 changed files with 435 additions and 78 deletions
+260 -50
View File
@@ -19,7 +19,11 @@ class JellyfinServerInfo {
/// Server's reported version string. /// Server's reported version string.
final String version; final String version;
const JellyfinServerInfo({required this.serverName, required this.machineId, required this.version}); const JellyfinServerInfo({
required this.serverName,
required this.machineId,
required this.version,
});
} }
class JellyfinEndpointRaceResult { class JellyfinEndpointRaceResult {
@@ -27,16 +31,27 @@ class JellyfinEndpointRaceResult {
final List<String> baseUrls; final List<String> baseUrls;
final JellyfinServerInfo serverInfo; final JellyfinServerInfo serverInfo;
const JellyfinEndpointRaceResult({required this.activeBaseUrl, required this.baseUrls, required this.serverInfo}); const JellyfinEndpointRaceResult({
required this.activeBaseUrl,
required this.baseUrls,
required this.serverInfo,
});
} }
class JellyfinEndpointProbeResult { class JellyfinEndpointProbeResult {
final bool success; final bool success;
final int latencyMs; final int latencyMs;
final JellyfinServerInfo? serverInfo; final JellyfinServerInfo? serverInfo;
final String? effectiveBaseUrl;
final String? error; final String? error;
const JellyfinEndpointProbeResult({required this.success, required this.latencyMs, this.serverInfo, this.error}); const JellyfinEndpointProbeResult({
required this.success,
required this.latencyMs,
this.serverInfo,
this.effectiveBaseUrl,
this.error,
});
} }
class JellyfinEndpointCandidate { class JellyfinEndpointCandidate {
@@ -67,16 +82,35 @@ class JellyfinEndpointDiscovery {
MediaServerHttpClient _buildHttpClient({required String baseUrl}) { MediaServerHttpClient _buildHttpClient({required String baseUrl}) {
LogRedactionManager.registerServerUrl(baseUrl); LogRedactionManager.registerServerUrl(baseUrl);
return MediaServerHttpClient(baseUrl: baseUrl, client: _testHttpClientFactory?.call()); return MediaServerHttpClient(
baseUrl: baseUrl,
client: _testHttpClientFactory?.call(),
);
} }
/// Probe the server identified by [baseUrl] without authenticating. /// Probe the server identified by [baseUrl] without authenticating.
Future<JellyfinServerInfo> probe(String baseUrl, {Duration timeout = MediaServerTimeouts.jellyfinProbe}) async { Future<JellyfinServerInfo> probe(
String baseUrl, {
Duration timeout = MediaServerTimeouts.jellyfinProbe,
}) async {
final result = await _probeServer(baseUrl, timeout: timeout);
return result.serverInfo;
}
Future<({JellyfinServerInfo serverInfo, String effectiveBaseUrl})>
_probeServer(String baseUrl, {required Duration timeout}) async {
final normalised = normalizeBaseUrl(baseUrl); final normalised = normalizeBaseUrl(baseUrl);
final client = _buildHttpClient(baseUrl: normalised); final client = _buildHttpClient(baseUrl: normalised);
try { try {
final response = await client.get('/System/Info/Public', timeout: timeout); final response = await client.get(
'/System/Info/Public',
timeout: timeout,
);
throwIfHttpError(response); throwIfHttpError(response);
final effectiveBaseUrl = _resolveEffectiveBaseUrl(normalised, response);
if (effectiveBaseUrl != normalised) {
LogRedactionManager.registerServerUrl(effectiveBaseUrl);
}
final data = response.data; final data = response.data;
if (data is! Map<String, dynamic>) { if (data is! Map<String, dynamic>) {
throw MediaServerUrlException('Server response was not JSON'); throw MediaServerUrlException('Server response was not JSON');
@@ -84,9 +118,18 @@ class JellyfinEndpointDiscovery {
final id = data['Id']; final id = data['Id'];
final name = data['ServerName'] ?? data['LocalAddress']; final name = data['ServerName'] ?? data['LocalAddress'];
if (id is! String || name is! String) { if (id is! String || name is! String) {
throw MediaServerUrlException('Server response missing Id/ServerName — not a Jellyfin server?'); throw MediaServerUrlException(
'Server response missing Id/ServerName — not a Jellyfin server?',
);
} }
return JellyfinServerInfo(serverName: name, machineId: id, version: data['Version'] as String? ?? ''); return (
serverInfo: JellyfinServerInfo(
serverName: name,
machineId: id,
version: data['Version'] as String? ?? '',
),
effectiveBaseUrl: effectiveBaseUrl,
);
} on MediaServerUrlException { } on MediaServerUrlException {
rethrow; rethrow;
} on MediaServerHttpException catch (e) { } on MediaServerHttpException catch (e) {
@@ -113,27 +156,56 @@ class JellyfinEndpointDiscovery {
throw MediaServerUrlException('Enter at least one Jellyfin server URL'); throw MediaServerUrlException('Enter at least one Jellyfin server URL');
} }
final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist); final persistUrls = baseUrlsToPersist == null
final validateUrls = baseUrlsToValidate == null ? urls : normalizeBaseUrls(baseUrlsToValidate); ? urls
: normalizeBaseUrls(baseUrlsToPersist);
final validateUrls = baseUrlsToValidate == null
? urls
: normalizeBaseUrls(baseUrlsToValidate);
final validateUrlSet = validateUrls.toSet(); final validateUrlSet = validateUrls.toSet();
final validationGroups = baseUrlValidationGroups == null ? null : _normalizeBaseUrlGroups(baseUrlValidationGroups); final validationGroups = baseUrlValidationGroups == null
? null
: _normalizeBaseUrlGroups(baseUrlValidationGroups);
final preferred = preferredUrl == null || preferredUrl.trim().isEmpty ? null : normalizeBaseUrl(preferredUrl); final preferred = preferredUrl == null || preferredUrl.trim().isEmpty
final candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)]; ? null
: normalizeBaseUrl(preferredUrl);
final candidates = [
for (var i = 0; i < urls.length; i++)
JellyfinEndpointCandidate(url: urls[i], index: i),
];
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? firstSelection; EndpointRaceSelection<
EndpointRaceSelection<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>? bestSelection; JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>?
firstSelection;
EndpointRaceSelection<
JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>?
bestSelection;
await for (final selection in raceEndpointCandidates<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>( await for (final selection
in raceEndpointCandidates<
JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>(
label: 'Jellyfin server URL', label: 'Jellyfin server URL',
candidates: candidates, candidates: candidates,
preferredUrl: preferred, preferredUrl: preferred,
urlOf: (candidate) => candidate.url, urlOf: (candidate) => candidate.url,
failureLogFields: (candidate, result) => {'error': result.error, 'latencyMs': result.latencyMs}, failureLogFields: (candidate, result) => {
probe: (candidate, timeout) => _probeWithLatency(candidate.url, timeout: timeout), 'error': result.error,
measure: (candidate) => _probeWithAverageLatency(candidate.url, attempts: 2), 'latencyMs': result.latencyMs,
},
probe: (candidate, timeout) =>
_probeWithLatency(candidate.url, timeout: timeout),
measure: (candidate) =>
_probeWithAverageLatency(candidate.url, attempts: 2),
isSuccess: (result) => result.success, isSuccess: (result) => result.success,
selectBestCandidate: (results) => _selectLowestLatencyCandidate(results), selectBestCandidate: (results) =>
_selectLowestLatencyCandidate(results),
)) { )) {
if (selection.phase == EndpointRacePhase.first) { if (selection.phase == EndpointRacePhase.first) {
firstSelection = selection; firstSelection = selection;
@@ -147,19 +219,31 @@ class JellyfinEndpointDiscovery {
throw MediaServerUrlException('No reachable Jellyfin server found'); throw MediaServerUrlException('No reachable Jellyfin server found');
} }
final Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> successfulResults = final Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>
bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {}; successfulResults =
bestSelection?.successfulResults ??
firstSelection?.successfulResults ??
const {};
var selectedCandidate = selected.candidate; var selectedCandidate = selected.candidate;
var selectedResult = selected.result; var selectedResult = selected.result;
final expectedMachineIdTrimmed = expectedMachineId?.trim(); final expectedMachineIdTrimmed = expectedMachineId?.trim();
final hasExpectedMachineId = expectedMachineIdTrimmed?.isNotEmpty == true; final hasExpectedMachineId = expectedMachineIdTrimmed?.isNotEmpty == true;
if (hasExpectedMachineId) { if (hasExpectedMachineId) {
final matchingResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries( final matchingResults =
successfulResults.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineIdTrimmed), Map<
JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>.fromEntries(
successfulResults.entries.where(
(entry) =>
entry.value.serverInfo?.machineId == expectedMachineIdTrimmed,
),
); );
final matchingCandidate = _selectLowestLatencyCandidate(matchingResults); final matchingCandidate = _selectLowestLatencyCandidate(matchingResults);
final matchingResult = matchingCandidate == null ? null : matchingResults[matchingCandidate]; final matchingResult = matchingCandidate == null
? null
: matchingResults[matchingCandidate];
if (matchingCandidate != null && matchingResult != null) { if (matchingCandidate != null && matchingResult != null) {
selectedCandidate = matchingCandidate; selectedCandidate = matchingCandidate;
selectedResult = matchingResult; selectedResult = matchingResult;
@@ -171,18 +255,33 @@ class JellyfinEndpointDiscovery {
throw MediaServerUrlException('No reachable Jellyfin server found'); throw MediaServerUrlException('No reachable Jellyfin server found');
} }
final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId; final expected = hasExpectedMachineId
? expectedMachineIdTrimmed!
: selectedInfo.machineId;
if (validationGroups != null) { if (validationGroups != null) {
if (validationGroups.length > 1) { if (validationGroups.length > 1) {
for (final group in validationGroups) { for (final group in validationGroups) {
final groupSet = group.toSet(); final groupSet = group.toSet();
final groupResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries( final groupResults =
successfulResults.entries.where((entry) => groupSet.contains(entry.key.url)), Map<
JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>.fromEntries(
successfulResults.entries.where(
(entry) => groupSet.contains(entry.key.url),
),
); );
final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed); final candidate = _selectValidationCandidate(
final info = candidate == null ? null : groupResults[candidate]?.serverInfo; groupResults,
expectedMachineId: expectedMachineIdTrimmed,
);
final info = candidate == null
? null
: groupResults[candidate]?.serverInfo;
if (info != null && info.machineId != expected) { if (info != null && info.machineId != expected) {
throw MediaServerUrlException('The URLs point to different Jellyfin servers'); throw MediaServerUrlException(
'The URLs point to different Jellyfin servers',
);
} }
} }
} }
@@ -191,47 +290,96 @@ class JellyfinEndpointDiscovery {
if (!validateUrlSet.contains(entry.key.url)) continue; if (!validateUrlSet.contains(entry.key.url)) continue;
final info = entry.value.serverInfo; final info = entry.value.serverInfo;
if (info != null && info.machineId != expected) { if (info != null && info.machineId != expected) {
throw MediaServerUrlException('The URLs point to different Jellyfin servers'); throw MediaServerUrlException(
'The URLs point to different Jellyfin servers',
);
} }
} }
} }
if (selectedInfo.machineId != expected) { if (selectedInfo.machineId != expected) {
throw MediaServerUrlException('The URL does not match this Jellyfin server'); throw MediaServerUrlException(
'The URL does not match this Jellyfin server',
);
} }
final effectiveUrls = <String, String>{};
for (final entry in successfulResults.entries) {
final effectiveBaseUrl = entry.value.effectiveBaseUrl;
if (effectiveBaseUrl != null) {
effectiveUrls[entry.key.url] = effectiveBaseUrl;
}
}
final activeBaseUrl =
selectedResult.effectiveBaseUrl ?? selectedCandidate.url;
effectiveUrls[selectedCandidate.url] = activeBaseUrl;
final persistedUrls = [
for (final url in persistUrls) effectiveUrls[url] ?? url,
];
return JellyfinEndpointRaceResult( return JellyfinEndpointRaceResult(
activeBaseUrl: selectedCandidate.url, activeBaseUrl: activeBaseUrl,
baseUrls: _activeFirst(selectedCandidate.url, persistUrls), baseUrls: _activeFirst(activeBaseUrl, persistedUrls),
serverInfo: selectedInfo, serverInfo: selectedInfo,
); );
} }
Future<JellyfinEndpointProbeResult> _probeWithLatency(String baseUrl, {required Duration timeout}) async { Future<JellyfinEndpointProbeResult> _probeWithLatency(
String baseUrl, {
required Duration timeout,
}) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
try { try {
final info = await probe(baseUrl, timeout: timeout); final probe = await _probeServer(baseUrl, timeout: timeout);
stopwatch.stop(); stopwatch.stop();
return JellyfinEndpointProbeResult(success: true, latencyMs: stopwatch.elapsedMilliseconds, serverInfo: info); return JellyfinEndpointProbeResult(
success: true,
latencyMs: stopwatch.elapsedMilliseconds,
serverInfo: probe.serverInfo,
effectiveBaseUrl: probe.effectiveBaseUrl,
);
} catch (e) { } catch (e) {
stopwatch.stop(); stopwatch.stop();
return JellyfinEndpointProbeResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString()); return JellyfinEndpointProbeResult(
success: false,
latencyMs: stopwatch.elapsedMilliseconds,
error: e.toString(),
);
} }
} }
Future<JellyfinEndpointProbeResult> _probeWithAverageLatency(String baseUrl, {required int attempts}) async { Future<JellyfinEndpointProbeResult> _probeWithAverageLatency(
String baseUrl, {
required int attempts,
}) async {
final results = <JellyfinEndpointProbeResult>[]; final results = <JellyfinEndpointProbeResult>[];
JellyfinServerInfo? info; JellyfinServerInfo? info;
String? effectiveBaseUrl;
for (var i = 0; i < attempts; i++) { for (var i = 0; i < attempts; i++) {
final result = await _probeWithLatency(baseUrl, timeout: MediaServerTimeouts.connectionRace); final result = await _probeWithLatency(
baseUrl,
timeout: MediaServerTimeouts.connectionRace,
);
if (!result.success) { if (!result.success) {
return JellyfinEndpointProbeResult(success: false, latencyMs: result.latencyMs, error: result.error); return JellyfinEndpointProbeResult(
success: false,
latencyMs: result.latencyMs,
error: result.error,
);
} }
info = result.serverInfo; info = result.serverInfo;
effectiveBaseUrl = result.effectiveBaseUrl;
results.add(result); results.add(result);
} }
final avgLatency = results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/ results.length; final avgLatency =
return JellyfinEndpointProbeResult(success: true, latencyMs: avgLatency, serverInfo: info); results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/
results.length;
return JellyfinEndpointProbeResult(
success: true,
latencyMs: avgLatency,
serverInfo: info,
effectiveBaseUrl: effectiveBaseUrl,
);
} }
JellyfinEndpointCandidate? _selectLowestLatencyCandidate( JellyfinEndpointCandidate? _selectLowestLatencyCandidate(
@@ -252,8 +400,14 @@ class JellyfinEndpointDiscovery {
required String? expectedMachineId, required String? expectedMachineId,
}) { }) {
if (expectedMachineId?.isNotEmpty == true) { if (expectedMachineId?.isNotEmpty == true) {
final matchingResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.fromEntries( final matchingResults =
results.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineId), Map<
JellyfinEndpointCandidate,
JellyfinEndpointProbeResult
>.fromEntries(
results.entries.where(
(entry) => entry.value.serverInfo?.machineId == expectedMachineId,
),
); );
final match = _selectLowestLatencyCandidate(matchingResults); final match = _selectLowestLatencyCandidate(matchingResults);
if (match != null) return match; if (match != null) return match;
@@ -261,6 +415,55 @@ class JellyfinEndpointDiscovery {
return _selectLowestLatencyCandidate(results); return _selectLowestLatencyCandidate(results);
} }
static String _resolveEffectiveBaseUrl(
String requestedBaseUrl,
MediaServerResponse response,
) {
final requestedUri = response.requestUri;
final effectiveUri = response.effectiveUri;
if (requestedUri == null ||
effectiveUri == null ||
effectiveUri == requestedUri) {
return requestedBaseUrl;
}
final requestedBaseUri = Uri.tryParse(requestedBaseUrl);
final effectiveScheme = effectiveUri.scheme.toLowerCase();
if (requestedBaseUri == null ||
requestedBaseUri.host.isEmpty ||
(effectiveScheme != 'http' && effectiveScheme != 'https')) {
throw MediaServerUrlException('Server redirected to an unsupported URL');
}
if (requestedBaseUri.host.toLowerCase() !=
effectiveUri.host.toLowerCase()) {
throw MediaServerUrlException(
'Server redirected to a different host. Enter the final Jellyfin URL directly',
);
}
if (requestedBaseUri.scheme.toLowerCase() == 'https' &&
effectiveScheme != 'https') {
throw MediaServerUrlException(
'Server redirected from HTTPS to an insecure URL',
);
}
const publicInfoPath = '/System/Info/Public';
if (!effectiveUri.path.endsWith(publicInfoPath)) {
throw MediaServerUrlException(
'Server redirected to an unsupported URL. Enter the final Jellyfin URL directly',
);
}
final basePath = effectiveUri.path.substring(
0,
effectiveUri.path.length - publicInfoPath.length,
);
return normalizeBaseUrl(
effectiveUri
.replace(path: basePath, query: null, fragment: null)
.toString(),
);
}
/// Normalizes a concrete Jellyfin base URL without inventing a scheme or port. /// Normalizes a concrete Jellyfin base URL without inventing a scheme or port.
static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input); static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input);
@@ -277,7 +480,9 @@ class JellyfinEndpointDiscovery {
final result = <String>[]; final result = <String>[];
final seen = <String>{}; final seen = <String>{};
void add(Uri uri) { void add(Uri uri) {
final normalized = stripTrailingSlash(uri.replace(query: null, fragment: null).toString()); final normalized = stripTrailingSlash(
uri.replace(query: null, fragment: null).toString(),
);
if (normalized.isEmpty || !seen.add(normalized)) return; if (normalized.isEmpty || !seen.add(normalized)) return;
result.add(normalized); result.add(normalized);
} }
@@ -294,7 +499,9 @@ class JellyfinEndpointDiscovery {
return List.unmodifiable(result); return List.unmodifiable(result);
} }
static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable<String> input) { static JellyfinEndpointUserInputCandidates buildUserInputCandidates(
Iterable<String> input,
) {
final probeBaseUrls = <String>[]; final probeBaseUrls = <String>[];
final explicitBaseUrls = <String>[]; final explicitBaseUrls = <String>[];
final validationBaseUrlGroups = <List<String>>[]; final validationBaseUrlGroups = <List<String>>[];
@@ -350,7 +557,9 @@ class JellyfinEndpointDiscovery {
return List.unmodifiable(result); return List.unmodifiable(result);
} }
static List<List<String>> _normalizeBaseUrlGroups(Iterable<Iterable<String>> groups) { static List<List<String>> _normalizeBaseUrlGroups(
Iterable<Iterable<String>> groups,
) {
final result = <List<String>>[]; final result = <List<String>>[];
for (final group in groups) { for (final group in groups) {
final normalized = normalizeBaseUrls(group); final normalized = normalizeBaseUrls(group);
@@ -359,7 +568,8 @@ class JellyfinEndpointDiscovery {
return List.unmodifiable(result); return List.unmodifiable(result);
} }
static bool _hasScheme(String input) => RegExp(r'^[a-zA-Z][a-zA-Z\d+.-]*://').hasMatch(input); static bool _hasScheme(String input) =>
RegExp(r'^[a-zA-Z][a-zA-Z\d+.-]*://').hasMatch(input);
static List<String> _activeFirst(String activeBaseUrl, List<String> urls) { static List<String> _activeFirst(String activeBaseUrl, List<String> urls) {
final result = <String>[]; final result = <String>[];
+92 -20
View File
@@ -13,7 +13,9 @@ import 'managed_http_client.dart';
import '../exceptions/media_server_exceptions.dart'; import '../exceptions/media_server_exceptions.dart';
// Platform-specific imports are conditional // Platform-specific imports are conditional
import 'platform_http_client_stub.dart' if (dart.library.io) 'platform_http_client_io.dart' as platform; import 'platform_http_client_stub.dart'
if (dart.library.io) 'platform_http_client_io.dart'
as platform;
/// Response from [MediaServerHttpClient] requests. /// Response from [MediaServerHttpClient] requests.
class MediaServerResponse { class MediaServerResponse {
@@ -26,7 +28,17 @@ class MediaServerResponse {
final Map<String, String> headers; final Map<String, String> headers;
final Uri? requestUri; final Uri? requestUri;
MediaServerResponse({required this.statusCode, this.data, required this.headers, this.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 /// Throw [MediaServerHttpException] for non-2xx responses so callers don't blindly
@@ -76,7 +88,11 @@ class MediaServerHttpClient {
// Plex home loads fan out many HTTP/1.1 calls on Linux. Keep that tuning // 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. // opt-in so generic tracker/auth clients stay disposable and closeable.
bool usePlexApiClient = false, bool usePlexApiClient = false,
}) : _client = client ?? (usePlexApiClient ? platform.createPlexApiClient() : platform.createPlatformClient()), }) : _client =
client ??
(usePlexApiClient
? platform.createPlexApiClient()
: platform.createPlatformClient()),
defaultHeaders = Map.of(defaultHeaders); defaultHeaders = Map.of(defaultHeaders);
/// The underlying [http.Client] for direct streaming / multipart requests. /// The underlying [http.Client] for direct streaming / multipart requests.
@@ -93,7 +109,14 @@ class MediaServerHttpClient {
Map<String, String>? headers, Map<String, String>? headers,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => _send('GET', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); }) => _send(
'GET',
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
Future<MediaServerResponse> post( Future<MediaServerResponse> post(
String path, { String path, {
@@ -135,7 +158,14 @@ class MediaServerHttpClient {
Map<String, String>? headers, Map<String, String>? headers,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); }) => _send(
'DELETE',
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
/// Fetch raw bytes (e.g. images, BIF files, subtitles). /// Fetch raw bytes (e.g. images, BIF files, subtitles).
Future<Uint8List> getBytes( Future<Uint8List> getBytes(
@@ -145,13 +175,20 @@ class MediaServerHttpClient {
AbortController? abort, AbortController? abort,
}) async { }) async {
if (_closing) { if (_closing) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); throw MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: 'HTTP client is closing',
);
} }
final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null);
final requestAbort = AbortController(); final requestAbort = AbortController();
_activeAborts.add(requestAbort); _activeAborts.add(requestAbort);
final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); final request = http.AbortableRequest(
'GET',
uri,
abortTrigger: _abortTrigger(requestAbort, abort),
);
request.headers.addAll({...defaultHeaders, ...?headers}); request.headers.addAll({...defaultHeaders, ...?headers});
final sw = Stopwatch()..start(); final sw = Stopwatch()..start();
@@ -191,13 +228,20 @@ class MediaServerHttpClient {
AbortController? abort, AbortController? abort,
}) async { }) async {
if (_closing) { if (_closing) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); throw MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: 'HTTP client is closing',
);
} }
final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null);
final requestAbort = AbortController(); final requestAbort = AbortController();
_activeAborts.add(requestAbort); _activeAborts.add(requestAbort);
final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); final request = http.AbortableRequest(
'GET',
uri,
abortTrigger: _abortTrigger(requestAbort, abort),
);
request.headers.addAll({...defaultHeaders, ...?headers}); request.headers.addAll({...defaultHeaders, ...?headers});
try { try {
@@ -255,7 +299,9 @@ class MediaServerHttpClient {
_client.close(); _client.close();
} }
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async { Future<void> closeGracefully({
Duration drainTimeout = const Duration(seconds: 2),
}) async {
_closing = true; _closing = true;
_abortActiveRequests(); _abortActiveRequests();
if (_client case final ManagedHttpClient managed) { if (_client case final ManagedHttpClient managed) {
@@ -275,7 +321,10 @@ class MediaServerHttpClient {
AbortController? abort, AbortController? abort,
}) async { }) async {
if (_closing) { if (_closing) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); throw MediaServerHttpException(
type: MediaServerHttpErrorType.cancelled,
message: 'HTTP client is closing',
);
} }
final uri = _isAbsoluteUrl(path) final uri = _isAbsoluteUrl(path)
@@ -286,7 +335,11 @@ class MediaServerHttpClient {
final requestAbort = AbortController(); final requestAbort = AbortController();
_activeAborts.add(requestAbort); _activeAborts.add(requestAbort);
final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort)); final request = http.AbortableRequest(
method,
uri,
abortTrigger: _abortTrigger(requestAbort, abort),
);
request.headers.addAll(mergedHeaders); request.headers.addAll(mergedHeaders);
_setBody(request, body); _setBody(request, body);
@@ -298,6 +351,10 @@ class MediaServerHttpClient {
operation: '$method ${uri.path} connect', operation: '$method ${uri.path} connect',
abort: requestAbort, abort: requestAbort,
); );
final effectiveUri = switch (streamed) {
http.BaseResponseWithUrl(:final url) => url,
_ => uri,
};
final bytes = await _withAbortOnTimeout( final bytes = await _withAbortOnTimeout(
streamed.stream.toBytes(), streamed.stream.toBytes(),
@@ -327,6 +384,7 @@ class MediaServerHttpClient {
data: data, data: data,
headers: streamed.headers, headers: streamed.headers,
requestUri: uri, requestUri: uri,
effectiveUri: effectiveUri,
); );
} catch (e) { } catch (e) {
requestAbort.abort(); requestAbort.abort();
@@ -345,7 +403,9 @@ class MediaServerHttpClient {
Future<void> _abortTrigger(AbortController owned, AbortController? external) { Future<void> _abortTrigger(AbortController owned, AbortController? external) {
final externalTrigger = external?.trigger; final externalTrigger = external?.trigger;
return externalTrigger == null ? owned.trigger : Future.any<void>([owned.trigger, externalTrigger]); return externalTrigger == null
? owned.trigger
: Future.any<void>([owned.trigger, externalTrigger]);
} }
Future<T> _withAbortOnTimeout<T>( Future<T> _withAbortOnTimeout<T>(
@@ -366,7 +426,8 @@ class MediaServerHttpClient {
/// Use this from callers that need to construct URLs with the client's /// Use this from callers that need to construct URLs with the client's
/// current (possibly failover-switched) base, rather than reading /// current (possibly failover-switched) base, rather than reading
/// `config.baseUrl` directly. /// `config.baseUrl` directly.
Uri buildUri(String path, {Map<String, dynamic>? queryParameters}) => _buildUri(path, queryParameters); Uri buildUri(String path, {Map<String, dynamic>? queryParameters}) =>
_buildUri(path, queryParameters);
/// Build a full URI from [baseUrl] + [path] + [queryParameters]. /// Build a full URI from [baseUrl] + [path] + [queryParameters].
/// Uses [Uri.encodeComponent] which encodes spaces as `%20` (not `+`). /// Uses [Uri.encodeComponent] which encodes spaces as `%20` (not `+`).
@@ -417,7 +478,8 @@ class MediaServerHttpClient {
return parts.join('&'); return parts.join('&');
} }
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://'); static bool _isAbsoluteUrl(String url) =>
url.startsWith('http://') || url.startsWith('https://');
/// Set the request body, choosing encoding based on the body type. /// Set the request body, choosing encoding based on the body type.
void _setBody(http.Request request, Object? body) { void _setBody(http.Request request, Object? body) {
@@ -437,7 +499,9 @@ class MediaServerHttpClient {
// http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415 // http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415
// if both `Content-Type` (from defaults) and `content-type` (added below) // if both `Content-Type` (from defaults) and `content-type` (added below)
// end up coexisting, so check both casings before adding. // end up coexisting, so check both casings before adding.
final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type'); final hasContentType = request.headers.keys.any(
(k) => k.toLowerCase() == 'content-type',
);
if (!hasContentType) { if (!hasContentType) {
request.headers['content-type'] = 'application/json'; request.headers['content-type'] = 'application/json';
} }
@@ -445,16 +509,22 @@ class MediaServerHttpClient {
/// Decode the response body: lenient UTF-8, then JSON parse if applicable. /// Decode the response body: lenient UTF-8, then JSON parse if applicable.
/// Large payloads are decoded in a background isolate. /// Large payloads are decoded in a background isolate.
Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async { Future<dynamic> _decodeBody(
List<int> bytes,
Map<String, String> headers,
) async {
if (bytes.isEmpty) return null; if (bytes.isEmpty) return null;
final contentType = (_headerValue(headers, 'content-type') ?? '').toLowerCase(); final contentType = (_headerValue(headers, 'content-type') ?? '')
.toLowerCase();
final isJson = contentType.contains('json'); final isJson = contentType.contains('json');
// For large JSON payloads, do both UTF-8 decode and JSON parse in a // For large JSON payloads, do both UTF-8 decode and JSON parse in a
// single isolate roundtrip to avoid two context switches. // single isolate roundtrip to avoid two context switches.
if (isJson && bytes.length > 50 * 1024) { if (isJson && bytes.length > 50 * 1024) {
return await tryIsolateRun(() => jsonDecode(utf8.decode(bytes, allowMalformed: true))); return await tryIsolateRun(
() => jsonDecode(utf8.decode(bytes, allowMalformed: true)),
);
} }
final body = await _decodeTextBody(bytes); final body = await _decodeTextBody(bytes);
@@ -477,7 +547,9 @@ class MediaServerHttpClient {
} }
void _logResponse(String method, Uri uri, int statusCode, int ms) { void _logResponse(String method, Uri uri, int statusCode, int ms) {
appLogger.d('$method ${LogRedactionManager.redact(uri.toString())}$statusCode (${ms}ms)'); appLogger.d(
'$method ${LogRedactionManager.redact(uri.toString())}$statusCode (${ms}ms)',
);
} }
} }
@@ -13,6 +13,31 @@ http.Response _info({required String id, String name = 'Home'}) => http.Response
headers: {'content-type': 'application/json'}, headers: {'content-type': 'application/json'},
); );
class _RedirectedInfoClient extends http.BaseClient {
_RedirectedInfoClient(this.resolveUrl);
final Uri Function(Uri requestedUrl) resolveUrl;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
await request.finalize().drain<void>();
return _ResponseWithUrl(
Stream<List<int>>.value(utf8.encode(jsonEncode({'Id': 'srv-1', 'ServerName': 'Home', 'Version': '10.11.11'}))),
200,
url: resolveUrl(request.url),
request: request,
headers: const {'content-type': 'application/json'},
);
}
}
class _ResponseWithUrl extends http.StreamedResponse implements http.BaseResponseWithUrl {
_ResponseWithUrl(super.stream, super.statusCode, {required this.url, super.request, super.headers});
@override
final Uri url;
}
void main() { void main() {
group('JellyfinEndpointDiscovery', () { group('JellyfinEndpointDiscovery', () {
test('normalizes and deduplicates endpoint URLs', () { test('normalizes and deduplicates endpoint URLs', () {
@@ -214,5 +239,55 @@ void main() {
throwsA(isA<MediaServerUrlException>()), throwsA(isA<MediaServerUrlException>()),
); );
}); });
test('promotes a same-host HTTPS redirect before persisting the endpoint', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => _RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'https')),
);
final result = await discovery.raceEndpoints(
['http://jf.example.com'],
baseUrlsToPersist: ['http://jf.example.com'],
);
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['https://jf.example.com']);
});
test('preserves a Jellyfin base path when promoting a redirect', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => _RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'https')),
);
final result = await discovery.raceEndpoints(
['http://jf.example.com/jellyfin'],
baseUrlsToPersist: ['http://jf.example.com/jellyfin'],
);
expect(result.activeBaseUrl, 'https://jf.example.com/jellyfin');
expect(result.baseUrls, ['https://jf.example.com/jellyfin']);
});
test('rejects a probe redirect to a different host', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () =>
_RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'https', host: 'login.example.com')),
);
await expectLater(
discovery.probe('http://jf.example.com'),
throwsA(isA<MediaServerUrlException>().having((error) => error.message, 'message', contains('different host'))),
);
});
test('rejects a probe redirect that downgrades HTTPS', () async {
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => _RedirectedInfoClient((requestedUrl) => requestedUrl.replace(scheme: 'http')),
);
await expectLater(
discovery.probe('https://jf.example.com'),
throwsA(isA<MediaServerUrlException>().having((error) => error.message, 'message', contains('insecure URL'))),
);
});
}); });
} }