fix(jellyfin): harden local discovery flow

This commit is contained in:
edde746
2026-05-31 14:33:31 +02:00
parent e3db54940d
commit 1b5334e72f
10 changed files with 397 additions and 81 deletions
+119 -40
View File
@@ -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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<String> _enteredUrls() {
return _urlController.text
.split(RegExp(r'[\n,]+'))
@@ -445,7 +472,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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<AddJellyfinScreen> 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),
],
),
),
),
),
);
}
}
@@ -49,7 +49,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
preferredUrl: widget.connection.baseUrl,
expectedMachineId: widget.connection.serverMachineId,
baseUrlsToPersist: input.explicitBaseUrls,
baseUrlsToValidate: input.explicitBaseUrls,
baseUrlValidationGroups: input.validationBaseUrlGroups,
);
final updated = widget.connection.copyWith(
baseUrl: endpoint.activeBaseUrl,
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'dart:io';
import '../../utils/app_logger.dart';
import '../../utils/udp_broadcast_sockets.dart';
import 'remote_auth_context.dart';
import 'remote_auth_service.dart';
@@ -40,7 +41,7 @@ class LanDiscoveryService {
static const int _beaconVersion = 1;
// Broadcaster state (host)
RawDatagramSocket? _broadcastSocket;
UdpBroadcastSocketSet? _broadcastSockets;
Timer? _broadcastTimer;
// Listener state (client)
@@ -97,8 +98,8 @@ class LanDiscoveryService {
if (contexts.isEmpty) return;
try {
_broadcastSocket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
_broadcastSocket!.broadcastEnabled = true;
_broadcastSockets = await UdpBroadcastSockets.bind();
if (_broadcastSockets!.isEmpty) return;
appLogger.d('LanDiscovery: Broadcasting started on port $discoveryPort');
@@ -118,7 +119,8 @@ class LanDiscoveryService {
}
void _sendBeacon(RemoteAuthContext context, String deviceName, String platform, int wsPort, List<String> 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<void> stopBroadcasting() async {
_broadcastTimer?.cancel();
_broadcastTimer = null;
_broadcastSocket?.close();
_broadcastSocket = null;
_broadcastSockets?.close();
_broadcastSockets = null;
appLogger.d('LanDiscovery: Broadcasting stopped');
}
+2
View File
@@ -77,6 +77,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
String? expectedMachineId,
Iterable<String>? baseUrlsToPersist,
Iterable<String>? baseUrlsToValidate,
Iterable<Iterable<String>>? baseUrlValidationGroups,
}) {
return _endpointDiscovery.raceEndpoints(
baseUrls,
@@ -84,6 +85,7 @@ class JellyfinConnectionAuthService implements ConnectionAuthService {
expectedMachineId: expectedMachineId,
baseUrlsToPersist: baseUrlsToPersist,
baseUrlsToValidate: baseUrlsToValidate,
baseUrlValidationGroups: baseUrlValidationGroups,
);
}
+60 -6
View File
@@ -49,8 +49,13 @@ class JellyfinEndpointCandidate {
class JellyfinEndpointUserInputCandidates {
final List<String> probeBaseUrls;
final List<String> explicitBaseUrls;
final List<List<String>> 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<String>? baseUrlsToPersist,
Iterable<String>? baseUrlsToValidate,
Iterable<Iterable<String>>? 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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.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<JellyfinEndpointCandidate, JellyfinEndpointProbeResult> results, {
required String? expectedMachineId,
}) {
if (expectedMachineId?.isNotEmpty == true) {
final matchingResults = Map<JellyfinEndpointCandidate, JellyfinEndpointProbeResult>.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<String> input) {
final probeBaseUrls = <String>[];
final explicitBaseUrls = <String>[];
final validationBaseUrlGroups = <List<String>>[];
final seenProbe = <String>{};
final seenExplicit = <String>{};
@@ -282,9 +320,15 @@ class JellyfinEndpointDiscovery {
if (_hasScheme(normalized)) {
addProbe(normalized);
addExplicit(normalized);
validationBaseUrlGroups.add([normalized]);
} else {
final group = <String>[];
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<List<String>> _normalizeBaseUrlGroups(Iterable<Iterable<String>> groups) {
final result = <List<String>>[];
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<String> _activeFirst(String activeBaseUrl, List<String> urls) {
@@ -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<List<DiscoveredJellyfinServer>> discover({
Duration timeout = const Duration(seconds: 2),
Duration responseWindow = const Duration(seconds: 2),
InternetAddress? broadcastAddress,
}) async {
RawDatagramSocket? socket;
StreamSubscription<RawSocketEvent>? subscription;
UdpBroadcastSocketSet? socketSet;
final subscriptions = <StreamSubscription<RawSocketEvent>>[];
final discovered = <String, DiscoveredJellyfinServer>{};
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<void>.delayed(const Duration(milliseconds: 350));
socket.send(data, target, discoveryPort);
await Future<void>.delayed(timeout);
socketSet.send(data, target, discoveryPort);
await Future<void>.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<DiscoveredJellyfinServer> sortDiscoveredServers(Iterable<DiscoveredJellyfinServer> 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<int> data) {
+84
View File
@@ -0,0 +1,84 @@
import 'dart:async';
import 'dart:io';
import 'app_logger.dart';
class UdpBroadcastSocketSet {
final List<RawDatagramSocket> _sockets;
const UdpBroadcastSocketSet._(this._sockets);
bool get isEmpty => _sockets.isEmpty;
Iterable<RawDatagramSocket> get sockets => _sockets;
void send(List<int> 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<UdpBroadcastSocketSet> bind({int port = 0}) async {
final sockets = <RawDatagramSocket>[];
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<List<InternetAddress>> _localIPv4Addresses() async {
try {
final interfaces = await NetworkInterface.list(
includeLinkLocal: false,
includeLoopback: false,
type: InternetAddressType.IPv4,
);
final addresses = <InternetAddress>[];
final seen = <String>{};
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<RawDatagramSocket?> _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;
}
}
}
@@ -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);
@@ -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<MediaServerUrlException>()),
);
});
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');
@@ -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']);
});
});
}