fix(jellyfin): keep unreachable endpoints when saving a connection

Persist every user-entered URL that is not positively known to belong to a
different server, matching reconcilePreviouslyStoredBaseUrls. Requiring a
successful identity probe deleted a stored LAN endpoint whenever the box was
asleep or the user saved from outside the network.
This commit is contained in:
edde746
2026-07-27 17:44:52 +02:00
parent db593e1255
commit b202c62641
2 changed files with 41 additions and 20 deletions
@@ -25,8 +25,9 @@ class JellyfinServerInfo {
class JellyfinEndpointRaceResult {
final String activeBaseUrl;
/// Trusted, active-first endpoints. Every fallback completed an
/// unauthenticated public probe and reported [serverInfo]'s exact machine ID.
/// Active-first endpoints selected for persistence. Candidates that reported
/// another machine ID are excluded; candidates that returned no trustworthy
/// identity are retained for a later retry.
final List<String> baseUrls;
final JellyfinServerInfo serverInfo;
final Map<String, String> _verifiedEffectiveBaseUrls;
@@ -159,11 +160,11 @@ class JellyfinEndpointDiscovery {
}
}
/// Races public Jellyfin probes and returns only identity-verified endpoints.
/// Races public Jellyfin probes and returns persistence-safe endpoints.
///
/// [baseUrlsToPersist] contains caller-selected persistence candidates, not
/// pre-trusted URLs. Unreachable candidates and candidates for another
/// machine are never included in the returned [JellyfinEndpointRaceResult].
/// [baseUrlsToPersist] contains caller-selected persistence candidates.
/// Candidates that reported another machine are excluded; candidates that
/// returned no trustworthy identity are retained for a later retry.
Future<JellyfinEndpointRaceResult> raceEndpoints(
Iterable<String> baseUrls, {
String? preferredUrl,
@@ -288,14 +289,12 @@ class JellyfinEndpointDiscovery {
}
final effectiveUrls = <String, String>{};
final matchingBaseUrls = <String>{};
final machineMismatchBaseUrls = <String>{};
for (final entry in identityResults.entries) {
if (entry.value.serverInfo?.machineId != expected) {
machineMismatchBaseUrls.add(entry.key.url);
continue;
}
matchingBaseUrls.add(entry.key.url);
final effectiveBaseUrl = entry.value.effectiveBaseUrl;
if (effectiveBaseUrl != null) {
effectiveUrls[entry.key.url] = effectiveBaseUrl;
@@ -305,7 +304,7 @@ class JellyfinEndpointDiscovery {
effectiveUrls[selectedCandidate.url] = activeBaseUrl;
final persistedUrls = [
for (final url in persistUrls)
if (matchingBaseUrls.contains(url)) effectiveUrls[url] ?? url,
if (!machineMismatchBaseUrls.contains(url)) effectiveUrls[url] ?? url,
];
return JellyfinEndpointRaceResult._(
@@ -157,7 +157,7 @@ void main() {
);
});
test('persists only explicit URLs that proved the selected machine identity', () async {
test('persists explicit URLs while probing without authentication', () async {
final probeRequests = <http.Request>[];
final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((req) async {
@@ -180,7 +180,7 @@ void main() {
);
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['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());
@@ -209,20 +209,42 @@ void main() {
expect(result.serverInfo.machineId, 'srv-1');
});
test('excludes unreachable URLs from the trusted failover list', () async {
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((req) async {
if (req.url.host == 'offline.example.com') {
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://offline.example.com', 'https://jf.example.com']);
final result = await discovery.raceEndpoints([offlineUrl, activeUrl], baseUrlsToPersist: [offlineUrl, activeUrl]);
expect(result.activeBaseUrl, 'https://jf.example.com');
expect(result.baseUrls, ['https://jf.example.com']);
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 {
@@ -249,7 +271,7 @@ void main() {
);
});
test('expected machine ID keeps only reachable matching candidates', () async {
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') {
@@ -265,7 +287,7 @@ void main() {
], expectedMachineId: 'srv-1');
expect(result.activeBaseUrl, 'https://matching.example.com');
expect(result.baseUrls, ['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 {
@@ -285,7 +307,7 @@ void main() {
baseUrlsToValidate: const [],
);
expect(result.baseUrls, ['https://active.example.com']);
expect(result.baseUrls, ['https://active.example.com', 'https://offline.example.com']);
expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [
'https://active.example.com',
'https://offline.example.com',