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 { class JellyfinEndpointRaceResult {
final String activeBaseUrl; final String activeBaseUrl;
/// Trusted, active-first endpoints. Every fallback completed an /// Active-first endpoints selected for persistence. Candidates that reported
/// unauthenticated public probe and reported [serverInfo]'s exact machine ID. /// another machine ID are excluded; candidates that returned no trustworthy
/// identity are retained for a later retry.
final List<String> baseUrls; final List<String> baseUrls;
final JellyfinServerInfo serverInfo; final JellyfinServerInfo serverInfo;
final Map<String, String> _verifiedEffectiveBaseUrls; 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 /// [baseUrlsToPersist] contains caller-selected persistence candidates.
/// pre-trusted URLs. Unreachable candidates and candidates for another /// Candidates that reported another machine are excluded; candidates that
/// machine are never included in the returned [JellyfinEndpointRaceResult]. /// returned no trustworthy identity are retained for a later retry.
Future<JellyfinEndpointRaceResult> raceEndpoints( Future<JellyfinEndpointRaceResult> raceEndpoints(
Iterable<String> baseUrls, { Iterable<String> baseUrls, {
String? preferredUrl, String? preferredUrl,
@@ -288,14 +289,12 @@ class JellyfinEndpointDiscovery {
} }
final effectiveUrls = <String, String>{}; final effectiveUrls = <String, String>{};
final matchingBaseUrls = <String>{};
final machineMismatchBaseUrls = <String>{}; final machineMismatchBaseUrls = <String>{};
for (final entry in identityResults.entries) { for (final entry in identityResults.entries) {
if (entry.value.serverInfo?.machineId != expected) { if (entry.value.serverInfo?.machineId != expected) {
machineMismatchBaseUrls.add(entry.key.url); machineMismatchBaseUrls.add(entry.key.url);
continue; continue;
} }
matchingBaseUrls.add(entry.key.url);
final effectiveBaseUrl = entry.value.effectiveBaseUrl; final effectiveBaseUrl = entry.value.effectiveBaseUrl;
if (effectiveBaseUrl != null) { if (effectiveBaseUrl != null) {
effectiveUrls[entry.key.url] = effectiveBaseUrl; effectiveUrls[entry.key.url] = effectiveBaseUrl;
@@ -305,7 +304,7 @@ class JellyfinEndpointDiscovery {
effectiveUrls[selectedCandidate.url] = activeBaseUrl; effectiveUrls[selectedCandidate.url] = activeBaseUrl;
final persistedUrls = [ final persistedUrls = [
for (final url in persistUrls) for (final url in persistUrls)
if (matchingBaseUrls.contains(url)) effectiveUrls[url] ?? url, if (!machineMismatchBaseUrls.contains(url)) effectiveUrls[url] ?? url,
]; ];
return JellyfinEndpointRaceResult._( 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 probeRequests = <http.Request>[];
final discovery = JellyfinEndpointDiscovery( final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((req) async { testHttpClientFactory: () => MockClient((req) async {
@@ -180,7 +180,7 @@ void main() {
); );
expect(result.activeBaseUrl, 'https://jf.example.com'); 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); expect(probeRequests, isNotEmpty);
for (final request in probeRequests) { for (final request in probeRequests) {
final headerNames = request.headers.keys.map((name) => name.toLowerCase()); final headerNames = request.headers.keys.map((name) => name.toLowerCase());
@@ -209,20 +209,42 @@ void main() {
expect(result.serverInfo.machineId, 'srv-1'); 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( final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((req) async { testHttpClientFactory: () => MockClient((request) async {
if (req.url.host == 'offline.example.com') { if (request.url.host == 'offline.example.com') {
throw TimeoutException('offline'); throw TimeoutException('offline');
} }
return _info(id: 'srv-1'); 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.activeBaseUrl, activeUrl);
expect(result.baseUrls, ['https://jf.example.com']); 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 { 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( final discovery = JellyfinEndpointDiscovery(
testHttpClientFactory: () => MockClient((request) async { testHttpClientFactory: () => MockClient((request) async {
if (request.url.host == 'offline.example.com') { if (request.url.host == 'offline.example.com') {
@@ -265,7 +287,7 @@ void main() {
], expectedMachineId: 'srv-1'); ], expectedMachineId: 'srv-1');
expect(result.activeBaseUrl, 'https://matching.example.com'); 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 { test('reconciles stored endpoints without pruning candidates that returned no identity', () async {
@@ -285,7 +307,7 @@ void main() {
baseUrlsToValidate: const [], baseUrlsToValidate: const [],
); );
expect(result.baseUrls, ['https://active.example.com']); expect(result.baseUrls, ['https://active.example.com', 'https://offline.example.com']);
expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [ expect(result.reconcilePreviouslyStoredBaseUrls(storedBaseUrls), [
'https://active.example.com', 'https://active.example.com',
'https://offline.example.com', 'https://offline.example.com',