From 1b5334e72fcd078f4b96e2d767efd673b3707c62 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 31 May 2026 14:33:31 +0200 Subject: [PATCH] fix(jellyfin): harden local discovery flow --- lib/screens/settings/add_jellyfin_screen.dart | 159 +++++++++++++----- .../edit_jellyfin_connection_screen.dart | 2 +- .../lan_discovery_service.dart | 16 +- lib/services/jellyfin_auth_service.dart | 2 + lib/services/jellyfin_endpoint_discovery.dart | 66 +++++++- .../jellyfin_lan_discovery_service.dart | 60 ++++--- lib/utils/udp_broadcast_sockets.dart | 84 +++++++++ .../settings/add_jellyfin_screen_test.dart | 47 ++++++ .../jellyfin_endpoint_discovery_test.dart | 32 +++- .../jellyfin_lan_discovery_service_test.dart | 10 ++ 10 files changed, 397 insertions(+), 81 deletions(-) create mode 100644 lib/utils/udp_broadcast_sockets.dart diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index d5901788..2ea1b4e7 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -11,6 +11,7 @@ import '../../connection/connection.dart'; import '../../exceptions/media_server_exceptions.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; +import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../profiles/active_profile_binder.dart'; @@ -135,7 +136,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final factory = widget._localDiscoveryFactory; final servers = factory != null ? await factory() - : await JellyfinLanDiscoveryService().discover(timeout: const Duration(milliseconds: 1300)); + : await JellyfinLanDiscoveryService().discover(responseWindow: const Duration(milliseconds: 1300)); if (!mounted || attemptId != _localDiscoveryAttemptId) return; final focusFirstServer = servers.isNotEmpty && _urlController.text.trim().isEmpty && _serverInfo == null && PlatformDetector.isTV(); @@ -198,7 +199,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta final endpoint = await auth.raceEndpoints( input.probeBaseUrls, baseUrlsToPersist: input.explicitBaseUrls, - baseUrlsToValidate: input.explicitBaseUrls, + baseUrlValidationGroups: input.validationBaseUrlGroups, ); final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl); if (!mounted) return; @@ -213,6 +214,8 @@ class _AddJellyfinScreenState extends State with AsyncFormSta // PlatformDetector.isTV() default in add_plex_account_screen.dart. if (qcEnabled && PlatformDetector.isTV()) { unawaited(_startQuickConnect()); + } else { + _requestFocusAfterFrame(_usernameFocus); } }, errorMapper: (e) => @@ -271,6 +274,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta // Show the waiting panel without a spinner — opt-out of busy mid-flow // so the user-visible state matches "we're polling, nothing for you to do". setState(() => _qcInitiation = initiation); + _requestFocusAfterFrame(_cancelQuickConnectFocus); setBusy(false); final connection = await auth.authenticateByQuickConnect( @@ -316,6 +320,29 @@ class _AddJellyfinScreenState extends State with AsyncFormSta setBusy(false); } + void _requestFocusAfterFrame(FocusNode node) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !node.canRequestFocus) return; + node.requestFocus(); + }); + } + + void _focusFirstDiscoveredServerOrFind() { + if (_localServers.isEmpty) { + _findServerFocus.requestFocus(); + return; + } + _discoveredServerFocusNodes[_localServers.first.id]?.requestFocus(); + } + + void _focusLastDiscoveredServerOrUrl() { + if (_localServers.isEmpty) { + _urlFocus.requestFocus(); + return; + } + _discoveredServerFocusNodes[_localServers.last.id]?.requestFocus(); + } + List _enteredUrls() { return _urlController.text .split(RegExp(r'[\n,]+')) @@ -445,7 +472,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta } return [ Text(t.addServer.jellyfinUrlsIntro, style: theme.textTheme.bodyMedium), - if (_serverInfo == null) ..._buildLocalDiscoverySection(theme), const SizedBox(height: 16), FocusableTextFormField( controller: _urlController, @@ -463,9 +489,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _clearResolvedServer(); }); }, - onNavigateDown: _serverInfo == null - ? () => _findServerFocus.requestFocus() - : () => _usernameFocus.requestFocus(), + onNavigateDown: _serverInfo == null ? _focusFirstDiscoveredServerOrFind : () => _usernameFocus.requestFocus(), textInputAction: TextInputAction.go, onFieldSubmitted: busy ? null : (_) => _probe(), decoration: InputDecoration( @@ -475,10 +499,12 @@ class _AddJellyfinScreenState extends State with AsyncFormSta validator: (_) => _enteredUrls().isEmpty ? t.addServer.required : null, ), if (_serverInfo == null) ...[ + ..._buildLocalDiscoverySection(theme), const SizedBox(height: 16), FocusableButton( focusNode: _findServerFocus, useBackgroundFocus: true, + onNavigateUp: _focusLastDiscoveredServerOrUrl, onPressed: busy ? null : _probe, child: FilledButton.icon( onPressed: busy ? null : _probe, @@ -615,41 +641,26 @@ class _AddJellyfinScreenState extends State with AsyncFormSta Text(t.addServer.localServers, style: theme.textTheme.titleSmall), const SizedBox(height: 8), for (final server in _localServers) ...[ - FocusableButton( + _DiscoveredJellyfinServerTile( + server: server, focusNode: _discoveredServerFocusNodes[server.id], - useBackgroundFocus: true, - onPressed: busy ? null : () => unawaited(_useDiscoveredServer(server)), - child: OutlinedButton( - onPressed: busy ? null : () => unawaited(_useDiscoveredServer(server)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - const AppIcon(Symbols.dns_rounded, fill: 1), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(server.name), - Text( - server.address, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - const SizedBox(width: 12), - const AppIcon(Symbols.chevron_right_rounded), - ], - ), - ), - ), + onNavigateUp: () { + final index = _localServers.indexOf(server); + if (index <= 0) { + _urlFocus.requestFocus(); + return; + } + _discoveredServerFocusNodes[_localServers[index - 1].id]?.requestFocus(); + }, + onNavigateDown: () { + final index = _localServers.indexOf(server); + if (index < 0 || index == _localServers.length - 1) { + _findServerFocus.requestFocus(); + return; + } + _discoveredServerFocusNodes[_localServers[index + 1].id]?.requestFocus(); + }, + onTap: busy ? null : () => unawaited(_useDiscoveredServer(server)), ), const SizedBox(height: 8), ], @@ -709,3 +720,71 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ]; } } + +class _DiscoveredJellyfinServerTile extends StatelessWidget { + final DiscoveredJellyfinServer server; + final FocusNode? focusNode; + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateDown; + final VoidCallback? onTap; + + const _DiscoveredJellyfinServerTile({ + required this.server, + required this.focusNode, + required this.onNavigateUp, + required this.onNavigateDown, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FocusableWrapper( + focusNode: focusNode, + disableScale: true, + borderRadius: 12, + useBackgroundFocus: true, + descendantsAreFocusable: false, + onSelect: onTap, + onNavigateUp: onNavigateUp, + onNavigateDown: onNavigateDown, + child: Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const AppIcon(Symbols.dns_rounded, fill: 1), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(server.name, style: theme.textTheme.titleSmall), + const SizedBox(height: 2), + Text( + server.address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + const AppIcon(Symbols.chevron_right_rounded, fill: 1), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 2ddbe161..66651776 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -49,7 +49,7 @@ class _EditJellyfinConnectionScreenState extends State ips) { - if (_broadcastSocket == null) return; + final broadcastSockets = _broadcastSockets; + if (broadcastSockets == null || broadcastSockets.isEmpty) return; try { final auth = RemoteAuthService.instance; @@ -150,7 +152,7 @@ class LanDiscoveryService { }); final data = utf8.encode(packet); - _broadcastSocket!.send(data, InternetAddress('255.255.255.255'), discoveryPort); + broadcastSockets.send(data, UdpBroadcastSockets.limitedBroadcastAddress, discoveryPort); } catch (e) { appLogger.e('LanDiscovery: Failed to send beacon', error: e); } @@ -159,8 +161,8 @@ class LanDiscoveryService { Future stopBroadcasting() async { _broadcastTimer?.cancel(); _broadcastTimer = null; - _broadcastSocket?.close(); - _broadcastSocket = null; + _broadcastSockets?.close(); + _broadcastSockets = null; appLogger.d('LanDiscovery: Broadcasting stopped'); } diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index 5d2b6a20..352ea86c 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -77,6 +77,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { String? expectedMachineId, Iterable? baseUrlsToPersist, Iterable? baseUrlsToValidate, + Iterable>? baseUrlValidationGroups, }) { return _endpointDiscovery.raceEndpoints( baseUrls, @@ -84,6 +85,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { expectedMachineId: expectedMachineId, baseUrlsToPersist: baseUrlsToPersist, baseUrlsToValidate: baseUrlsToValidate, + baseUrlValidationGroups: baseUrlValidationGroups, ); } diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 410ba769..b2adac6a 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -49,8 +49,13 @@ class JellyfinEndpointCandidate { class JellyfinEndpointUserInputCandidates { final List probeBaseUrls; final List explicitBaseUrls; + final List> validationBaseUrlGroups; - const JellyfinEndpointUserInputCandidates({required this.probeBaseUrls, required this.explicitBaseUrls}); + const JellyfinEndpointUserInputCandidates({ + required this.probeBaseUrls, + required this.explicitBaseUrls, + required this.validationBaseUrlGroups, + }); } class JellyfinEndpointDiscovery { @@ -102,6 +107,7 @@ class JellyfinEndpointDiscovery { String? expectedMachineId, Iterable? baseUrlsToPersist, Iterable? baseUrlsToValidate, + Iterable>? baseUrlValidationGroups, }) async { final urls = normalizeBaseUrls(baseUrls); if (urls.isEmpty) { @@ -111,6 +117,7 @@ class JellyfinEndpointDiscovery { final persistUrls = baseUrlsToPersist == null ? urls : normalizeBaseUrls(baseUrlsToPersist); final validateUrls = baseUrlsToValidate == null ? urls : normalizeBaseUrls(baseUrlsToValidate); final validateUrlSet = validateUrls.toSet(); + final validationGroups = baseUrlValidationGroups == null ? null : _normalizeBaseUrlGroups(baseUrlValidationGroups); final preferred = preferredUrl == null || preferredUrl.trim().isEmpty ? null : normalizeBaseUrl(preferredUrl); final candidates = [for (var i = 0; i < urls.length; i++) JellyfinEndpointCandidate(url: urls[i], index: i)]; @@ -166,11 +173,27 @@ class JellyfinEndpointDiscovery { } final expected = hasExpectedMachineId ? expectedMachineIdTrimmed! : selectedInfo.machineId; - for (final entry in successfulResults.entries) { - if (!validateUrlSet.contains(entry.key.url)) continue; - final info = entry.value.serverInfo; - if (info != null && info.machineId != expected) { - throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + if (validationGroups != null) { + if (validationGroups.length > 1) { + for (final group in validationGroups) { + final groupSet = group.toSet(); + final groupResults = Map.fromEntries( + successfulResults.entries.where((entry) => groupSet.contains(entry.key.url)), + ); + final candidate = _selectValidationCandidate(groupResults, expectedMachineId: expectedMachineIdTrimmed); + final info = candidate == null ? null : groupResults[candidate]?.serverInfo; + if (info != null && info.machineId != expected) { + throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + } + } + } + } else { + for (final entry in successfulResults.entries) { + if (!validateUrlSet.contains(entry.key.url)) continue; + final info = entry.value.serverInfo; + if (info != null && info.machineId != expected) { + throw MediaServerUrlException('The URLs point to different Jellyfin servers'); + } } } @@ -225,6 +248,20 @@ class JellyfinEndpointDiscovery { return entries.first.key; } + JellyfinEndpointCandidate? _selectValidationCandidate( + Map results, { + required String? expectedMachineId, + }) { + if (expectedMachineId?.isNotEmpty == true) { + final matchingResults = Map.fromEntries( + results.entries.where((entry) => entry.value.serverInfo?.machineId == expectedMachineId), + ); + final match = _selectLowestLatencyCandidate(matchingResults); + if (match != null) return match; + } + return _selectLowestLatencyCandidate(results); + } + /// Normalizes a concrete Jellyfin base URL without inventing a scheme or port. static String normalizeBaseUrl(String input) => stripTrailingSlash(input); @@ -261,6 +298,7 @@ class JellyfinEndpointDiscovery { static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { final probeBaseUrls = []; final explicitBaseUrls = []; + final validationBaseUrlGroups = >[]; final seenProbe = {}; final seenExplicit = {}; @@ -282,9 +320,15 @@ class JellyfinEndpointDiscovery { if (_hasScheme(normalized)) { addProbe(normalized); addExplicit(normalized); + validationBaseUrlGroups.add([normalized]); } else { + final group = []; for (final candidate in expandInputToBaseUrls(normalized)) { addProbe(candidate); + group.add(candidate); + } + if (group.isNotEmpty) { + validationBaseUrlGroups.add(List.unmodifiable(group)); } } } @@ -292,6 +336,7 @@ class JellyfinEndpointDiscovery { return JellyfinEndpointUserInputCandidates( probeBaseUrls: List.unmodifiable(probeBaseUrls), explicitBaseUrls: List.unmodifiable(explicitBaseUrls), + validationBaseUrlGroups: List.unmodifiable(validationBaseUrlGroups), ); } @@ -306,6 +351,15 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } + static List> _normalizeBaseUrlGroups(Iterable> groups) { + final result = >[]; + for (final group in groups) { + final normalized = normalizeBaseUrls(group); + if (normalized.isNotEmpty) result.add(normalized); + } + return List.unmodifiable(result); + } + static bool _hasScheme(String input) => RegExp(r'^[a-zA-Z][a-zA-Z\d+.-]*://').hasMatch(input); static List _activeFirst(String activeBaseUrl, List urls) { diff --git a/lib/services/jellyfin_lan_discovery_service.dart b/lib/services/jellyfin_lan_discovery_service.dart index d8415618..09410a10 100644 --- a/lib/services/jellyfin_lan_discovery_service.dart +++ b/lib/services/jellyfin_lan_discovery_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import '../utils/app_logger.dart'; +import '../utils/udp_broadcast_sockets.dart'; import 'jellyfin_endpoint_discovery.dart'; class DiscoveredJellyfinServer { @@ -17,46 +18,59 @@ class JellyfinLanDiscoveryService { static const int discoveryPort = 7359; static const String discoveryMessage = 'who is JellyfinServer?'; + /// Sends two discovery packets 350 ms apart, then listens for + /// [responseWindow] after the second packet. Future> discover({ - Duration timeout = const Duration(seconds: 2), + Duration responseWindow = const Duration(seconds: 2), InternetAddress? broadcastAddress, }) async { - RawDatagramSocket? socket; - StreamSubscription? subscription; + UdpBroadcastSocketSet? socketSet; + final subscriptions = >[]; final discovered = {}; try { - socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); - socket.broadcastEnabled = true; - subscription = socket.listen((event) { - if (event != RawSocketEvent.read) return; - Datagram? datagram; - while ((datagram = socket?.receive()) != null) { - final server = parseDiscoveryResponse(datagram!.data); - if (server == null) continue; - discovered.putIfAbsent(server.id, () => server); - } - }); + socketSet = await UdpBroadcastSockets.bind(); + for (final socket in socketSet.sockets) { + subscriptions.add( + socket.listen((event) { + if (event != RawSocketEvent.read) return; + Datagram? datagram; + while ((datagram = socket.receive()) != null) { + final server = parseDiscoveryResponse(datagram!.data); + if (server == null) continue; + discovered.putIfAbsent(server.id, () => server); + } + }), + ); + } final data = utf8.encode(discoveryMessage); - final target = broadcastAddress ?? InternetAddress('255.255.255.255'); - socket.send(data, target, discoveryPort); + final target = broadcastAddress ?? UdpBroadcastSockets.limitedBroadcastAddress; + socketSet.send(data, target, discoveryPort); await Future.delayed(const Duration(milliseconds: 350)); - socket.send(data, target, discoveryPort); - await Future.delayed(timeout); + socketSet.send(data, target, discoveryPort); + await Future.delayed(responseWindow); } catch (e, st) { appLogger.w('Jellyfin LAN discovery failed', error: e, stackTrace: st); } finally { - await subscription?.cancel(); - socket?.close(); + for (final subscription in subscriptions) { + await subscription.cancel(); + } + socketSet?.close(); } - final servers = discovered.values.toList() + return sortDiscoveredServers(discovered.values); + } + + static List sortDiscoveredServers(Iterable servers) { + final sorted = servers.toList() ..sort((a, b) { final name = a.name.toLowerCase().compareTo(b.name.toLowerCase()); if (name != 0) return name; - return a.address.compareTo(b.address); + final address = a.address.compareTo(b.address); + if (address != 0) return address; + return a.id.compareTo(b.id); }); - return List.unmodifiable(servers); + return List.unmodifiable(sorted); } static DiscoveredJellyfinServer? parseDiscoveryResponse(List data) { diff --git a/lib/utils/udp_broadcast_sockets.dart b/lib/utils/udp_broadcast_sockets.dart new file mode 100644 index 00000000..0e951217 --- /dev/null +++ b/lib/utils/udp_broadcast_sockets.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:io'; + +import 'app_logger.dart'; + +class UdpBroadcastSocketSet { + final List _sockets; + + const UdpBroadcastSocketSet._(this._sockets); + + bool get isEmpty => _sockets.isEmpty; + + Iterable get sockets => _sockets; + + void send(List data, InternetAddress address, int port) { + for (final socket in _sockets) { + try { + socket.send(data, address, port); + } catch (e, st) { + appLogger.w('UDP broadcast send failed from ${socket.address.address}', error: e, stackTrace: st); + } + } + } + + void close() { + for (final socket in _sockets) { + socket.close(); + } + } +} + +class UdpBroadcastSockets { + UdpBroadcastSockets._(); + + static final InternetAddress limitedBroadcastAddress = InternetAddress('255.255.255.255'); + + static Future bind({int port = 0}) async { + final sockets = []; + for (final address in await _localIPv4Addresses()) { + final socket = await _tryBind(address, port); + if (socket != null) sockets.add(socket); + } + + if (sockets.isEmpty) { + final socket = await _tryBind(InternetAddress.anyIPv4, port); + if (socket != null) sockets.add(socket); + } + + return UdpBroadcastSocketSet._(List.unmodifiable(sockets)); + } + + static Future> _localIPv4Addresses() async { + try { + final interfaces = await NetworkInterface.list( + includeLinkLocal: false, + includeLoopback: false, + type: InternetAddressType.IPv4, + ); + final addresses = []; + final seen = {}; + for (final interface in interfaces) { + for (final address in interface.addresses) { + if (address.isLoopback || address.type != InternetAddressType.IPv4 || !seen.add(address.address)) continue; + addresses.add(address); + } + } + return List.unmodifiable(addresses); + } catch (e, st) { + appLogger.w('Failed to enumerate IPv4 interfaces for UDP broadcast', error: e, stackTrace: st); + return const []; + } + } + + static Future _tryBind(InternetAddress address, int port) async { + try { + final socket = await RawDatagramSocket.bind(address, port); + socket.broadcastEnabled = true; + return socket; + } catch (e, st) { + appLogger.w('Failed to bind UDP broadcast socket on ${address.address}:$port', error: e, stackTrace: st); + return null; + } + } +} diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 80b26d07..472f869e 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -132,6 +132,8 @@ void main() { await tester.testTextInput.receiveAction(TextInputAction.go); await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username'); + await tester.tap(find.byType(TextField).first); await tester.pump(); @@ -191,6 +193,51 @@ void main() { expect(find.text('Jellyfin 10.9.0'), findsOneWidget); }); + testWidgets('D-pad can navigate through discovered Jellyfin servers', (tester) async { + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp( + home: AddJellyfinScreen( + localDiscoveryFactory: () async => [ + DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + DiscoveredJellyfinServer(address: 'http://192.168.1.30:8096', id: 'srv-2', name: 'Office'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Office'), findsOneWidget); + expect(find.byType(OutlinedButton), findsNothing); + + await tester.tap(find.byType(TextField).first); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-2'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:FindServer'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-2'); + }); + group('Jellyfin profile binding decisions', () { test('creates a local profile only on true first-run with no profiles', () { expect(shouldCreateLocalJellyfinProfile(targetProfile: null, activeProfile: null, hasProfiles: false), isTrue); diff --git a/test/services/jellyfin_endpoint_discovery_test.dart b/test/services/jellyfin_endpoint_discovery_test.dart index 9db11698..af4679c2 100644 --- a/test/services/jellyfin_endpoint_discovery_test.dart +++ b/test/services/jellyfin_endpoint_discovery_test.dart @@ -58,7 +58,7 @@ void main() { final result = await discovery.raceEndpoints( input.probeBaseUrls, baseUrlsToPersist: input.explicitBaseUrls, - baseUrlsToValidate: input.explicitBaseUrls, + baseUrlValidationGroups: input.validationBaseUrlGroups, ); expect(result.activeBaseUrl, 'http://jf.example.com:8096'); @@ -79,7 +79,7 @@ void main() { final result = await discovery.raceEndpoints( input.probeBaseUrls, baseUrlsToPersist: input.explicitBaseUrls, - baseUrlsToValidate: input.explicitBaseUrls, + baseUrlValidationGroups: input.validationBaseUrlGroups, ); expect(result.baseUrls, ['http://192.168.1.10:8096']); @@ -102,12 +102,36 @@ void main() { final result = await discovery.raceEndpoints( input.probeBaseUrls, baseUrlsToPersist: input.explicitBaseUrls, - baseUrlsToValidate: 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()), + ); + }); + test('retains explicit user-entered failover URLs when using input candidates', () async { final discovery = JellyfinEndpointDiscovery( testHttpClientFactory: () => MockClient((req) async { @@ -125,7 +149,7 @@ void main() { final result = await discovery.raceEndpoints( input.probeBaseUrls, baseUrlsToPersist: input.explicitBaseUrls, - baseUrlsToValidate: input.explicitBaseUrls, + baseUrlValidationGroups: input.validationBaseUrlGroups, ); expect(result.activeBaseUrl, 'https://jf.example.com'); diff --git a/test/services/jellyfin_lan_discovery_service_test.dart b/test/services/jellyfin_lan_discovery_service_test.dart index 93ecb069..008376f1 100644 --- a/test/services/jellyfin_lan_discovery_service_test.dart +++ b/test/services/jellyfin_lan_discovery_service_test.dart @@ -31,5 +31,15 @@ void main() { isNull, ); }); + + test('sorts discovered servers deterministically', () { + final sorted = JellyfinLanDiscoveryService.sortDiscoveredServers([ + DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-2', name: 'Home'), + DiscoveredJellyfinServer(address: 'http://192.168.1.10:8096', id: 'srv-3', name: 'Office'), + DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + ]); + + expect(sorted.map((server) => server.id), ['srv-1', 'srv-2', 'srv-3']); + }); }); }