fix(jellyfin): harden local discovery flow
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user