Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
504 lines
19 KiB
Dart
504 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
|
import 'package:plezy/media/media_browser_dialect.dart';
|
|
import 'package:plezy/services/jellyfin_endpoint_discovery.dart';
|
|
|
|
http.Response _info({required String id, String name = 'Home'}) => http.Response(
|
|
jsonEncode({'Id': id, 'ServerName': name, 'Version': '10.9.0'}),
|
|
200,
|
|
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() {
|
|
group('JellyfinEndpointDiscovery', () {
|
|
test('normalizes and deduplicates endpoint URLs', () {
|
|
expect(
|
|
JellyfinEndpointDiscovery.normalizeBaseUrls([
|
|
' https://jf.example.com/ ',
|
|
'https://jf.example.com',
|
|
'',
|
|
'https://jf.lan:8096/',
|
|
]),
|
|
['https://jf.example.com', 'https://jf.lan:8096'],
|
|
);
|
|
expect(JellyfinEndpointDiscovery.normalizeBaseUrl('jf.example.com/'), 'jf.example.com');
|
|
});
|
|
|
|
test('Jellyfin bare host expansion preserves its ordered URL candidates', () {
|
|
const expected = ['http://host.lan:8096', 'https://host.lan', 'https://host.lan:8096', 'http://host.lan'];
|
|
|
|
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan'), expected);
|
|
expect(JellyfinEndpointDiscovery.buildUserInputCandidates(['host.lan']).probeBaseUrls, expected);
|
|
});
|
|
|
|
test('Emby bare host expansion includes the 8920 HTTPS candidate in order', () {
|
|
const expected = [
|
|
'http://host.lan:8096',
|
|
'https://host.lan',
|
|
'https://host.lan:8920',
|
|
'https://host.lan:8096',
|
|
'http://host.lan',
|
|
];
|
|
|
|
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('host.lan', dialect: MediaBrowserDialect.emby), expected);
|
|
expect(
|
|
JellyfinEndpointDiscovery.buildUserInputCandidates([
|
|
'host.lan',
|
|
], dialect: MediaBrowserDialect.emby).probeBaseUrls,
|
|
expected,
|
|
);
|
|
});
|
|
|
|
test('explicit URLs are never expanded for either dialect', () {
|
|
const explicitUrl = 'https://host.lan:9443/emby';
|
|
|
|
for (final dialect in MediaBrowserDialect.values) {
|
|
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls(explicitUrl, dialect: dialect), [
|
|
explicitUrl,
|
|
], reason: dialect.id);
|
|
final candidates = JellyfinEndpointDiscovery.buildUserInputCandidates([explicitUrl], dialect: dialect);
|
|
expect(candidates.probeBaseUrls, [explicitUrl], reason: dialect.id);
|
|
expect(candidates.explicitBaseUrls, [explicitUrl], reason: dialect.id);
|
|
}
|
|
});
|
|
|
|
test('probe records Jellyfin, Emby, and unknown public-info dialects', () async {
|
|
Future<JellyfinServerInfo> probe(Map<String, Object?> publicInfo) {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((request) async {
|
|
expect(request.url.path, '/System/Info/Public');
|
|
return http.Response(jsonEncode(publicInfo), 200, headers: {'content-type': 'application/json'});
|
|
}),
|
|
);
|
|
return discovery.probe('https://server.example.com');
|
|
}
|
|
|
|
final jellyfin = await probe({
|
|
'Id': 'jellyfin-server',
|
|
'ServerName': 'Jellyfin Home',
|
|
'Version': '10.10.7',
|
|
'ProductName': 'Jellyfin Server',
|
|
});
|
|
final emby = await probe({
|
|
'Id': 'emby-server',
|
|
'ServerName': 'Emby Home',
|
|
'Version': '4.9.5.0',
|
|
'LocalAddresses': <String>[],
|
|
'RemoteAddresses': <String>[],
|
|
});
|
|
final unknown = await probe({'Id': 'unknown-server', 'ServerName': 'Unknown Home', 'Version': '1.0.0'});
|
|
|
|
expect(jellyfin.dialect, MediaBrowserDialect.jellyfin);
|
|
expect(emby.dialect, MediaBrowserDialect.emby);
|
|
expect(unknown.dialect, isNull);
|
|
});
|
|
|
|
test('expands host and port input without changing the port', () {
|
|
expect(JellyfinEndpointDiscovery.expandInputToBaseUrls('192.168.1.10:8096'), [
|
|
'http://192.168.1.10:8096',
|
|
'https://192.168.1.10:8096',
|
|
]);
|
|
});
|
|
|
|
test('races expanded bare host candidates', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
if (req.url.scheme == 'http' && req.url.host == 'jf.example.com' && req.url.port == 8096) {
|
|
return _info(id: 'srv-1');
|
|
}
|
|
throw TimeoutException('offline');
|
|
}),
|
|
);
|
|
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['jf.example.com']);
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
input.probeBaseUrls,
|
|
baseUrlsToPersist: input.explicitBaseUrls,
|
|
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
|
);
|
|
|
|
expect(result.activeBaseUrl, 'http://jf.example.com:8096');
|
|
expect(result.baseUrls, ['http://jf.example.com:8096']);
|
|
});
|
|
|
|
test('does not persist failed shorthand guesses as failover URLs', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
if (req.url.scheme == 'http' && req.url.host == '192.168.1.10' && req.url.port == 8096) {
|
|
return _info(id: 'srv-1');
|
|
}
|
|
throw TimeoutException('offline');
|
|
}),
|
|
);
|
|
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['192.168.1.10']);
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
input.probeBaseUrls,
|
|
baseUrlsToPersist: input.explicitBaseUrls,
|
|
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
|
);
|
|
|
|
expect(result.baseUrls, ['http://192.168.1.10:8096']);
|
|
});
|
|
|
|
test('does not reject different servers found only through shorthand guesses', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
if (req.url.scheme == 'http' && req.url.host == 'jf.example.com' && req.url.port == 8096) {
|
|
return _info(id: 'srv-1');
|
|
}
|
|
if (req.url.scheme == 'https' && req.url.host == 'jf.example.com' && !req.url.hasPort) {
|
|
return _info(id: 'srv-2');
|
|
}
|
|
throw TimeoutException('offline');
|
|
}),
|
|
);
|
|
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['jf.example.com']);
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
input.probeBaseUrls,
|
|
baseUrlsToPersist: input.explicitBaseUrls,
|
|
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
|
);
|
|
|
|
expect(result.baseUrls, [result.activeBaseUrl]);
|
|
});
|
|
|
|
test('rejects different servers reached from separate shorthand entries', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
if (req.url.scheme == 'http' && req.url.host == 'one.example.com' && req.url.port == 8096) {
|
|
return _info(id: 'srv-1');
|
|
}
|
|
if (req.url.scheme == 'http' && req.url.host == 'two.example.com' && req.url.port == 8096) {
|
|
return _info(id: 'srv-2');
|
|
}
|
|
throw TimeoutException('offline');
|
|
}),
|
|
);
|
|
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(['one.example.com', 'two.example.com']);
|
|
|
|
await expectLater(
|
|
discovery.raceEndpoints(
|
|
input.probeBaseUrls,
|
|
baseUrlsToPersist: input.explicitBaseUrls,
|
|
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
|
),
|
|
throwsA(isA<MediaServerUrlException>()),
|
|
);
|
|
});
|
|
|
|
test('persists explicit URLs while probing without authentication', () async {
|
|
final probeRequests = <http.Request>[];
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
probeRequests.add(req);
|
|
if (req.url.host == 'offline.example.com') {
|
|
throw TimeoutException('offline');
|
|
}
|
|
return _info(id: 'srv-1');
|
|
}),
|
|
);
|
|
final input = JellyfinEndpointDiscovery.buildUserInputCandidates([
|
|
'https://offline.example.com',
|
|
'https://jf.example.com',
|
|
]);
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
input.probeBaseUrls,
|
|
baseUrlsToPersist: input.explicitBaseUrls,
|
|
baseUrlValidationGroups: input.validationBaseUrlGroups,
|
|
);
|
|
|
|
expect(result.activeBaseUrl, 'https://jf.example.com');
|
|
expect(result.baseUrls, ['https://jf.example.com', 'https://offline.example.com']);
|
|
expect(probeRequests, isNotEmpty);
|
|
for (final request in probeRequests) {
|
|
final headerNames = request.headers.keys.map((name) => name.toLowerCase());
|
|
expect(headerNames, isNot(contains('authorization')));
|
|
expect(headerNames, isNot(contains('x-emby-token')));
|
|
expect(request.url.queryParameters.keys.map((name) => name.toLowerCase()), isNot(contains('api_key')));
|
|
}
|
|
});
|
|
|
|
test('races URLs and selects the lowest-latency reachable endpoint', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
if (req.url.host == 'slow.example.com') {
|
|
await Future<void>.delayed(const Duration(milliseconds: 35));
|
|
} else {
|
|
await Future<void>.delayed(const Duration(milliseconds: 1));
|
|
}
|
|
return _info(id: 'srv-1');
|
|
}),
|
|
);
|
|
|
|
final result = await discovery.raceEndpoints(['https://slow.example.com', 'https://fast.example.com']);
|
|
|
|
expect(result.activeBaseUrl, 'https://fast.example.com');
|
|
expect(result.baseUrls, ['https://fast.example.com', 'https://slow.example.com']);
|
|
expect(result.serverInfo.machineId, 'srv-1');
|
|
});
|
|
|
|
test('an unreachable persisted endpoint survives a save', () async {
|
|
const offlineUrl = 'https://offline.example.com';
|
|
const activeUrl = 'https://jf.example.com';
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((request) async {
|
|
if (request.url.host == 'offline.example.com') {
|
|
throw TimeoutException('offline');
|
|
}
|
|
return _info(id: 'srv-1');
|
|
}),
|
|
);
|
|
|
|
final result = await discovery.raceEndpoints([offlineUrl, activeUrl], baseUrlsToPersist: [offlineUrl, activeUrl]);
|
|
|
|
expect(result.activeBaseUrl, activeUrl);
|
|
expect(result.baseUrls, [activeUrl, offlineUrl]);
|
|
});
|
|
|
|
test('a different-machine persisted endpoint remains excluded', () async {
|
|
const activeUrl = 'https://jf.example.com';
|
|
const differentMachineUrl = 'https://other.example.com';
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () =>
|
|
MockClient((request) async => _info(id: request.url.host == 'other.example.com' ? 'srv-2' : 'srv-1')),
|
|
);
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
[activeUrl, differentMachineUrl],
|
|
expectedMachineId: 'srv-1',
|
|
baseUrlsToPersist: [activeUrl, differentMachineUrl],
|
|
baseUrlsToValidate: const [],
|
|
);
|
|
|
|
expect(result.activeBaseUrl, activeUrl);
|
|
expect(result.baseUrls, [activeUrl]);
|
|
expect(result.reconcilePreviouslyStoredBaseUrls([activeUrl, differentMachineUrl]), [activeUrl]);
|
|
});
|
|
|
|
test('rejects reachable URLs that point to different Jellyfin servers', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((req) async {
|
|
return _info(id: req.url.host == 'one.example.com' ? 'srv-1' : 'srv-2');
|
|
}),
|
|
);
|
|
|
|
await expectLater(
|
|
discovery.raceEndpoints(['https://one.example.com', 'https://two.example.com']),
|
|
throwsA(isA<MediaServerUrlException>()),
|
|
);
|
|
});
|
|
|
|
test('rejects URLs that do not match an expected existing server id', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((_) async => _info(id: 'srv-2')),
|
|
);
|
|
|
|
await expectLater(
|
|
discovery.raceEndpoints(['https://jf.example.com'], expectedMachineId: 'srv-1'),
|
|
throwsA(isA<MediaServerUrlException>()),
|
|
);
|
|
});
|
|
|
|
test('Emby empty-input errors name the selected product', () async {
|
|
final discovery = JellyfinEndpointDiscovery(dialect: MediaBrowserDialect.emby);
|
|
|
|
await expectLater(
|
|
discovery.raceEndpoints(const []),
|
|
throwsA(
|
|
isA<MediaServerUrlException>().having(
|
|
(exception) => exception.message,
|
|
'message',
|
|
'Enter at least one Emby server URL',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('Emby machine-id mismatch errors name the selected product', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
dialect: MediaBrowserDialect.emby,
|
|
testHttpClientFactory: () => MockClient((_) async => _info(id: 'srv-2')),
|
|
);
|
|
|
|
await expectLater(
|
|
discovery.raceEndpoints(['https://emby.example.com'], expectedMachineId: 'srv-1', baseUrlsToValidate: const []),
|
|
throwsA(
|
|
isA<MediaServerUrlException>().having(
|
|
(exception) => exception.message,
|
|
'message',
|
|
'The URL does not match this Emby server',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('expected machine ID retains candidates that returned no identity', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((request) async {
|
|
if (request.url.host == 'offline.example.com') {
|
|
throw TimeoutException('offline');
|
|
}
|
|
return _info(id: 'srv-1');
|
|
}),
|
|
);
|
|
|
|
final result = await discovery.raceEndpoints([
|
|
'https://matching.example.com',
|
|
'https://offline.example.com',
|
|
], expectedMachineId: 'srv-1');
|
|
|
|
expect(result.activeBaseUrl, 'https://matching.example.com');
|
|
expect(result.baseUrls, ['https://matching.example.com', 'https://offline.example.com']);
|
|
});
|
|
|
|
test('reconciles stored endpoints without pruning candidates that returned no identity', () async {
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((request) async {
|
|
if (request.url.host == 'offline.example.com') {
|
|
throw TimeoutException('offline');
|
|
}
|
|
return _info(id: request.url.host == 'wrong.example.com' ? 'srv-2' : 'srv-1');
|
|
}),
|
|
);
|
|
const storedBaseUrls = ['https://active.example.com', 'https://offline.example.com', 'https://wrong.example.com'];
|
|
|
|
final result = await discovery.raceEndpoints(
|
|
storedBaseUrls,
|
|
expectedMachineId: 'srv-1',
|
|
baseUrlsToValidate: const [],
|
|
);
|
|
|
|
expect(result.baseUrls, ['https://active.example.com', 'https://offline.example.com']);
|
|
expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [
|
|
'https://active.example.com',
|
|
'https://offline.example.com',
|
|
]);
|
|
});
|
|
|
|
test('waits for a late phase-one identity before filtering persisted fallbacks', () async {
|
|
final allowLateIdentity = Completer<void>();
|
|
final lateProbeStarted = Completer<void>();
|
|
final phaseTwoFallbackFinished = Completer<void>();
|
|
var fallbackRequests = 0;
|
|
final discovery = JellyfinEndpointDiscovery(
|
|
testHttpClientFactory: () => MockClient((request) async {
|
|
if (request.url.host != 'fallback.example.com') {
|
|
return _info(id: 'srv-1');
|
|
}
|
|
fallbackRequests++;
|
|
if (fallbackRequests == 1) {
|
|
lateProbeStarted.complete();
|
|
await allowLateIdentity.future;
|
|
return _info(id: 'srv-1');
|
|
}
|
|
phaseTwoFallbackFinished.complete();
|
|
throw TimeoutException('phase-two fallback probe failed');
|
|
}),
|
|
);
|
|
|
|
var raceCompleted = false;
|
|
final raceFuture = discovery
|
|
.raceEndpoints(['https://active.example.com', 'https://fallback.example.com'], expectedMachineId: 'srv-1')
|
|
.then((result) {
|
|
raceCompleted = true;
|
|
return result;
|
|
});
|
|
|
|
await lateProbeStarted.future;
|
|
await phaseTwoFallbackFinished.future;
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(raceCompleted, isFalse);
|
|
|
|
allowLateIdentity.complete();
|
|
final result = await raceFuture;
|
|
expect(result.activeBaseUrl, 'https://active.example.com');
|
|
expect(result.baseUrls, ['https://active.example.com', 'https://fallback.example.com']);
|
|
});
|
|
|
|
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']);
|
|
expect(result.reconcilePreviouslyStoredBaseUrls(['http://jf.example.com']), ['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'))),
|
|
);
|
|
});
|
|
});
|
|
}
|