diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 5ca8accf..a7da25af 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -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 baseUrls; final JellyfinServerInfo serverInfo; final Map _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 raceEndpoints( Iterable baseUrls, { String? preferredUrl, @@ -288,14 +289,12 @@ class JellyfinEndpointDiscovery { } final effectiveUrls = {}; - final matchingBaseUrls = {}; final machineMismatchBaseUrls = {}; 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._( diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index 139acbf5..a77e6cb4 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -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 = []; 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',