From ddb7520ce81db3ac79a10f2d8c5c8e7e446f0ba9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:05:57 +0200 Subject: [PATCH] fix(jellyfin): promote redirected server URLs --- lib/services/jellyfin_endpoint_discovery.dart | 326 ++++++++++++++---- lib/utils/media_server_http_client.dart | 112 ++++-- .../jellyfin_endpoint_discovery_test.dart | 75 ++++ 3 files changed, 435 insertions(+), 78 deletions(-) diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 68e86aa2..04743df9 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -19,7 +19,11 @@ class JellyfinServerInfo { /// Server's reported version string. 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 { @@ -27,16 +31,27 @@ class JellyfinEndpointRaceResult { final List baseUrls; 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 { final bool success; final int latencyMs; final JellyfinServerInfo? serverInfo; + final String? effectiveBaseUrl; 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 { @@ -67,16 +82,35 @@ class JellyfinEndpointDiscovery { MediaServerHttpClient _buildHttpClient({required String 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. - Future probe(String baseUrl, {Duration timeout = MediaServerTimeouts.jellyfinProbe}) async { + Future 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 client = _buildHttpClient(baseUrl: normalised); try { - final response = await client.get('/System/Info/Public', timeout: timeout); + final response = await client.get( + '/System/Info/Public', + timeout: timeout, + ); throwIfHttpError(response); + final effectiveBaseUrl = _resolveEffectiveBaseUrl(normalised, response); + if (effectiveBaseUrl != normalised) { + LogRedactionManager.registerServerUrl(effectiveBaseUrl); + } final data = response.data; if (data is! Map) { throw MediaServerUrlException('Server response was not JSON'); @@ -84,9 +118,18 @@ class JellyfinEndpointDiscovery { final id = data['Id']; final name = data['ServerName'] ?? data['LocalAddress']; 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 { rethrow; } on MediaServerHttpException catch (e) { @@ -113,28 +156,57 @@ class JellyfinEndpointDiscovery { throw MediaServerUrlException('Enter at least one Jellyfin server URL'); } - final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist); - final validateUrls = baseUrlsToValidate == null ? urls : normalizeBaseUrls(baseUrlsToValidate); + final persistUrls = baseUrlsToPersist == null + ? urls + : normalizeBaseUrls(baseUrlsToPersist); + final validateUrls = baseUrlsToValidate == null + ? urls + : normalizeBaseUrls(baseUrlsToValidate); 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 candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)]; + final preferred = preferredUrl == null || preferredUrl.trim().isEmpty + ? null + : normalizeBaseUrl(preferredUrl); + final candidates = [ + for (var i = 0; i < urls.length; i++) + JellyfinEndpointCandidate(url: urls[i], index: i), + ]; - EndpointRaceSelection? firstSelection; - EndpointRaceSelection? bestSelection; + EndpointRaceSelection< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >? + firstSelection; + EndpointRaceSelection< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >? + bestSelection; - await for (final selection in raceEndpointCandidates( - label: 'Jellyfin server URL', - candidates: candidates, - preferredUrl: preferred, - urlOf: (candidate) => candidate.url, - failureLogFields: (candidate, result) => {'error': result.error, 'latencyMs': result.latencyMs}, - probe: (candidate, timeout) => _probeWithLatency(candidate.url, timeout: timeout), - measure: (candidate) => _probeWithAverageLatency(candidate.url, attempts: 2), - isSuccess: (result) => result.success, - selectBestCandidate: (results) => _selectLowestLatencyCandidate(results), - )) { + await for (final selection + in raceEndpointCandidates< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >( + label: 'Jellyfin server URL', + candidates: candidates, + preferredUrl: preferred, + urlOf: (candidate) => candidate.url, + failureLogFields: (candidate, result) => { + 'error': result.error, + 'latencyMs': result.latencyMs, + }, + probe: (candidate, timeout) => + _probeWithLatency(candidate.url, timeout: timeout), + measure: (candidate) => + _probeWithAverageLatency(candidate.url, attempts: 2), + isSuccess: (result) => result.success, + selectBestCandidate: (results) => + _selectLowestLatencyCandidate(results), + )) { if (selection.phase == EndpointRacePhase.first) { firstSelection = selection; } else { @@ -147,19 +219,31 @@ class JellyfinEndpointDiscovery { throw MediaServerUrlException('No reachable Jellyfin server found'); } - final Map successfulResults = - bestSelection?.successfulResults ?? firstSelection?.successfulResults ?? const {}; + final Map + successfulResults = + bestSelection?.successfulResults ?? + firstSelection?.successfulResults ?? + const {}; var selectedCandidate = selected.candidate; var selectedResult = selected.result; final expectedMachineIdTrimmed = expectedMachineId?.trim(); final hasExpectedMachineId = expectedMachineIdTrimmed?.isNotEmpty == true; if (hasExpectedMachineId) { - final matchingResults = Map.fromEntries( - successfulResults.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineIdTrimmed), - ); + final matchingResults = + Map< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >.fromEntries( + successfulResults.entries.where( + (entry) => + entry.value.serverInfo?.machineId == expectedMachineIdTrimmed, + ), + ); final matchingCandidate = _selectLowestLatencyCandidate(matchingResults); - final matchingResult = matchingCandidate == null ? null : matchingResults[matchingCandidate]; + final matchingResult = matchingCandidate == null + ? null + : matchingResults[matchingCandidate]; if (matchingCandidate != null && matchingResult != null) { selectedCandidate = matchingCandidate; selectedResult = matchingResult; @@ -171,18 +255,33 @@ class JellyfinEndpointDiscovery { throw MediaServerUrlException('No reachable Jellyfin server found'); } - final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId; + final expected = hasExpectedMachineId + ? expectedMachineIdTrimmed! + : selectedInfo.machineId; if (validationGroups != null) { if (validationGroups.length > 1) { for (final group in validationGroups) { final groupSet = group.toSet(); - final groupResults = Map.fromEntries( - successfulResults.entries.where((entry) => groupSet.contains(entry.key.url)), + final groupResults = + Map< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >.fromEntries( + successfulResults.entries.where( + (entry) => groupSet.contains(entry.key.url), + ), + ); + final candidate = _selectValidationCandidate( + groupResults, + expectedMachineId: expectedMachineIdTrimmed, ); - final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed); - final info = candidate == null ? null : groupResults[candidate]?.serverInfo; + final info = candidate == null + ? null + : groupResults[candidate]?.serverInfo; 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; final info = entry.value.serverInfo; 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) { - throw MediaServerUrlException('The URL does not match this Jellyfin server'); + throw MediaServerUrlException( + 'The URL does not match this Jellyfin server', + ); } + final effectiveUrls = {}; + 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( - activeBaseUrl: selectedCandidate.url, - baseUrls: _activeFirst(selectedCandidate.url, persistUrls), + activeBaseUrl: activeBaseUrl, + baseUrls: _activeFirst(activeBaseUrl, persistedUrls), serverInfo: selectedInfo, ); } - Future _probeWithLatency(String baseUrl, {required Duration timeout}) async { + Future _probeWithLatency( + String baseUrl, { + required Duration timeout, + }) async { final stopwatch = Stopwatch()..start(); try { - final info = await probe(baseUrl, timeout: timeout); + final probe = await _probeServer(baseUrl, timeout: timeout); 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) { stopwatch.stop(); - return JellyfinEndpointProbeResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString()); + return JellyfinEndpointProbeResult( + success: false, + latencyMs: stopwatch.elapsedMilliseconds, + error: e.toString(), + ); } } - Future _probeWithAverageLatency(String baseUrl, {required int attempts}) async { + Future _probeWithAverageLatency( + String baseUrl, { + required int attempts, + }) async { final results = []; JellyfinServerInfo? info; + String? effectiveBaseUrl; 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) { - return JellyfinEndpointProbeResult(success: false, latencyMs: result.latencyMs, error: result.error); + return JellyfinEndpointProbeResult( + success: false, + latencyMs: result.latencyMs, + error: result.error, + ); } info = result.serverInfo; + effectiveBaseUrl = result.effectiveBaseUrl; results.add(result); } - final avgLatency = results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/ results.length; - return JellyfinEndpointProbeResult(success: true, latencyMs: avgLatency, serverInfo: info); + final avgLatency = + results.map((result) => result.latencyMs).reduce((a, b) => a + b) ~/ + results.length; + return JellyfinEndpointProbeResult( + success: true, + latencyMs: avgLatency, + serverInfo: info, + effectiveBaseUrl: effectiveBaseUrl, + ); } JellyfinEndpointCandidate? _selectLowestLatencyCandidate( @@ -252,15 +400,70 @@ class JellyfinEndpointDiscovery { required String? expectedMachineId, }) { if (expectedMachineId?.isNotEmpty == true) { - final matchingResults = Map.fromEntries( - results.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineId), - ); + final matchingResults = + Map< + JellyfinEndpointCandidate, + JellyfinEndpointProbeResult + >.fromEntries( + results.entries.where( + (entry) => entry.value.serverInfo?.machineId == expectedMachineId, + ), + ); final match = _selectLowestLatencyCandidate(matchingResults); if (match != null) return match; } 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. static String normalizeBaseUrl(String input) => canonicalizeBaseUrl(input); @@ -277,7 +480,9 @@ class JellyfinEndpointDiscovery { final result = []; final seen = {}; 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; result.add(normalized); } @@ -294,7 +499,9 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } - static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { + static JellyfinEndpointUserInputCandidates buildUserInputCandidates( + Iterable input, + ) { final probeBaseUrls = []; final explicitBaseUrls = []; final validationBaseUrlGroups = >[]; @@ -350,7 +557,9 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } - static List> _normalizeBaseUrlGroups(Iterable> groups) { + static List> _normalizeBaseUrlGroups( + Iterable> groups, + ) { final result = >[]; for (final group in groups) { final normalized = normalizeBaseUrls(group); @@ -359,7 +568,8 @@ class JellyfinEndpointDiscovery { 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 _activeFirst(String activeBaseUrl, List urls) { final result = []; diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index bb160d8d..c9d05318 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -13,7 +13,9 @@ import 'managed_http_client.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; +import 'platform_http_client_stub.dart' + if (dart.library.io) 'platform_http_client_io.dart' + as platform; /// Response from [MediaServerHttpClient] requests. class MediaServerResponse { @@ -26,7 +28,17 @@ class MediaServerResponse { final Map headers; 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 @@ -76,7 +88,11 @@ class MediaServerHttpClient { // 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()), + }) : _client = + client ?? + (usePlexApiClient + ? platform.createPlexApiClient() + : platform.createPlatformClient()), defaultHeaders = Map.of(defaultHeaders); /// The underlying [http.Client] for direct streaming / multipart requests. @@ -93,7 +109,14 @@ class MediaServerHttpClient { Map? headers, Duration? timeout, 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 post( String path, { @@ -135,7 +158,14 @@ class MediaServerHttpClient { Map? headers, Duration? timeout, 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). Future getBytes( @@ -145,13 +175,20 @@ class MediaServerHttpClient { AbortController? abort, }) async { 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 requestAbort = AbortController(); _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}); final sw = Stopwatch()..start(); @@ -191,13 +228,20 @@ class MediaServerHttpClient { AbortController? abort, }) async { 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 requestAbort = AbortController(); _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}); try { @@ -255,7 +299,9 @@ class MediaServerHttpClient { _client.close(); } - Future closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) async { + Future closeGracefully({ + Duration drainTimeout = const Duration(seconds: 2), + }) async { _closing = true; _abortActiveRequests(); if (_client case final ManagedHttpClient managed) { @@ -275,7 +321,10 @@ class MediaServerHttpClient { AbortController? abort, }) async { 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) @@ -286,7 +335,11 @@ class MediaServerHttpClient { final requestAbort = AbortController(); _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); _setBody(request, body); @@ -298,6 +351,10 @@ class MediaServerHttpClient { operation: '$method ${uri.path} connect', abort: requestAbort, ); + final effectiveUri = switch (streamed) { + http.BaseResponseWithUrl(:final url) => url, + _ => uri, + }; final bytes = await _withAbortOnTimeout( streamed.stream.toBytes(), @@ -327,6 +384,7 @@ class MediaServerHttpClient { data: data, headers: streamed.headers, requestUri: uri, + effectiveUri: effectiveUri, ); } catch (e) { requestAbort.abort(); @@ -345,7 +403,9 @@ class MediaServerHttpClient { Future _abortTrigger(AbortController owned, AbortController? external) { final externalTrigger = external?.trigger; - return externalTrigger == null ? owned.trigger : Future.any([owned.trigger, externalTrigger]); + return externalTrigger == null + ? owned.trigger + : Future.any([owned.trigger, externalTrigger]); } Future _withAbortOnTimeout( @@ -366,7 +426,8 @@ class MediaServerHttpClient { /// 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? queryParameters}) => _buildUri(path, queryParameters); + Uri buildUri(String path, {Map? queryParameters}) => + _buildUri(path, queryParameters); /// Build a full URI from [baseUrl] + [path] + [queryParameters]. /// Uses [Uri.encodeComponent] which encodes spaces as `%20` (not `+`). @@ -417,7 +478,8 @@ class MediaServerHttpClient { 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. void _setBody(http.Request request, Object? body) { @@ -437,7 +499,9 @@ class MediaServerHttpClient { // http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415 // if both `Content-Type` (from defaults) and `content-type` (added below) // 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) { request.headers['content-type'] = 'application/json'; } @@ -445,16 +509,22 @@ class MediaServerHttpClient { /// Decode the response body: lenient UTF-8, then JSON parse if applicable. /// Large payloads are decoded in a background isolate. - Future _decodeBody(List bytes, Map headers) async { + Future _decodeBody( + List bytes, + Map headers, + ) async { if (bytes.isEmpty) return null; - final contentType = (_headerValue(headers, 'content-type') ?? '').toLowerCase(); + 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))); + return await tryIsolateRun( + () => jsonDecode(utf8.decode(bytes, allowMalformed: true)), + ); } final body = await _decodeTextBody(bytes); @@ -477,7 +547,9 @@ class MediaServerHttpClient { } 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)', + ); } } diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index af4679c2..f8ac6ce2 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -13,6 +13,31 @@ http.Response _info({required String id, String name = 'Home'}) => http.Response headers: {'content-type': 'application/json'}, ); +class _RedirectedInfoClient extends http.BaseClient { + _RedirectedInfoClient(this.resolveUrl); + + final Uri Function(Uri requestedUrl) resolveUrl; + + @override + Future send(http.BaseRequest request) async { + await request.finalize().drain(); + return _ResponseWithUrl( + Stream>.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() { group('JellyfinEndpointDiscovery', () { test('normalizes and deduplicates endpoint URLs', () { @@ -214,5 +239,55 @@ void main() { throwsA(isA()), ); }); + 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().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().having((error) => error.message, 'message', contains('insecure URL'))), + ); + }); }); }