fix(jellyfin): promote redirected server URLs
This commit is contained in:
@@ -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,28 +156,57 @@ 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
|
||||||
label: 'Jellyfin server URL',
|
in raceEndpointCandidates<
|
||||||
candidates: candidates,
|
JellyfinEndpointCandidate,
|
||||||
preferredUrl: preferred,
|
JellyfinEndpointProbeResult
|
||||||
urlOf: (candidate) => candidate.url,
|
>(
|
||||||
failureLogFields: (candidate, result) => {'error': result.error, 'latencyMs': result.latencyMs},
|
label: 'Jellyfin server URL',
|
||||||
probe: (candidate, timeout) => _probeWithLatency(candidate.url, timeout: timeout),
|
candidates: candidates,
|
||||||
measure: (candidate) => _probeWithAverageLatency(candidate.url, attempts: 2),
|
preferredUrl: preferred,
|
||||||
isSuccess: (result) => result.success,
|
urlOf: (candidate) => candidate.url,
|
||||||
selectBestCandidate: (results) => _selectLowestLatencyCandidate(results),
|
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) {
|
if (selection.phase == EndpointRacePhase.first) {
|
||||||
firstSelection = selection;
|
firstSelection = selection;
|
||||||
} else {
|
} else {
|
||||||
@@ -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(groupResults, expectedMachineId: expectedMachineIdTrimmed);
|
final info = candidate == null
|
||||||
final info = candidate == null ? null : groupResults[candidate]?.serverInfo;
|
? 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,15 +400,70 @@ 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;
|
||||||
}
|
}
|
||||||
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>[];
|
||||||
|
|||||||
@@ -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'))),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user