Files
plezy/lib/services/companion_remote/lan_discovery_service.dart
T

332 lines
9.4 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import '../../utils/app_logger.dart';
import '../../utils/udp_broadcast_sockets.dart';
import 'remote_auth_context.dart';
import 'remote_auth_service.dart';
/// A host discovered on the LAN via UDP broadcast.
class DiscoveredHost {
final String authContextId;
final String clientId;
final String name;
final String platform;
final int port;
final List<String> ips;
DateTime lastSeen;
DiscoveredHost({
required this.authContextId,
required this.clientId,
required this.name,
required this.platform,
required this.port,
required this.ips,
DateTime? lastSeen,
}) : lastSeen = lastSeen ?? DateTime.now();
List<String> get addresses => ips.map((ip) => '$ip:$port').toList();
}
/// UDP-based LAN discovery for companion remote.
///
/// Hosts broadcast authenticated beacons; clients listen and filter
/// by matching Plex home membership.
class LanDiscoveryService {
static const int discoveryPort = 48633;
static const int _broadcastIntervalSeconds = 3;
static const int _staleTimeoutSeconds = 10;
static const int _beaconVersion = 1;
// Broadcaster state (host)
UdpBroadcastSocketSet? _broadcastSockets;
Timer? _broadcastTimer;
// Listener state (client)
RawDatagramSocket? _listenSocket;
StreamSubscription<RawSocketEvent>? _listenSubscription;
Timer? _staleCleanupTimer;
int _listenGeneration = 0;
final Map<String, DiscoveredHost> _discoveredHosts = {};
final _hostsController = StreamController<List<DiscoveredHost>>.broadcast();
/// Whether the broadcaster is currently active.
bool get isBroadcasting => _broadcastTimer != null;
/// Whether the listener is currently active.
bool get isListening => _listenSocket != null;
// ── Host: Broadcasting ──
Future<void> startBroadcastingForContexts({
required List<RemoteAuthContext> contexts,
required String deviceName,
required String platform,
required int wsPort,
required List<String> ips,
}) async {
await stopBroadcasting();
if (contexts.isEmpty) return;
try {
_broadcastSockets = await UdpBroadcastSockets.bind();
if (_broadcastSockets!.isEmpty) return;
appLogger.d('LanDiscovery: Broadcasting started on port $discoveryPort');
// Send immediately, then periodically
for (final context in contexts) {
_sendBeacon(context, deviceName, platform, wsPort, ips);
}
_broadcastTimer = Timer.periodic(
const Duration(seconds: _broadcastIntervalSeconds),
(_) {
for (final context in contexts) {
_sendBeacon(context, deviceName, platform, wsPort, ips);
}
},
);
} catch (e) {
appLogger.e('LanDiscovery: Failed to start broadcasting', error: e);
await stopBroadcasting();
}
}
void _sendBeacon(
RemoteAuthContext context,
String deviceName,
String platform,
int wsPort,
List<String> ips,
) {
final broadcastSockets = _broadcastSockets;
if (broadcastSockets == null || broadcastSockets.isEmpty) return;
try {
final auth = RemoteAuthService.instance;
final discoveryKey = context.discoveryKey;
final clientId = context.clientIdentifier;
final homeHash = auth.computeDiscoveryTag(discoveryKey);
final beaconHmac = auth.computeBeaconHmac(
discoveryKey: discoveryKey,
version: _beaconVersion,
homeHash: homeHash,
name: deviceName,
platform: platform,
clientId: clientId,
port: wsPort,
ips: ips,
);
final packet = jsonEncode({
'app': 'plezy',
'v': _beaconVersion,
'homeHash': homeHash,
'name': deviceName,
'platform': platform,
'clientId': clientId,
'port': wsPort,
'ips': ips,
'hmac': beaconHmac,
});
final data = utf8.encode(packet);
broadcastSockets.send(
data,
UdpBroadcastSockets.limitedBroadcastAddress,
discoveryPort,
);
} catch (e) {
appLogger.e('LanDiscovery: Failed to send beacon', error: e);
}
}
Future<void> stopBroadcasting() async {
_broadcastTimer?.cancel();
_broadcastTimer = null;
await _broadcastSockets?.close();
_broadcastSockets = null;
appLogger.d('LanDiscovery: Broadcasting stopped');
}
// ── Client: Listening ──
Stream<List<DiscoveredHost>> startListeningForContexts(
List<RemoteAuthContext> contexts,
) {
_stopListeningInternal();
_discoveredHosts.clear();
final generation = _listenGeneration;
unawaited(_bindListener(contexts, generation));
// Periodically remove stale hosts
_staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
final now = DateTime.now();
final staleIds = <String>[];
for (final entry in _discoveredHosts.entries) {
if (now.difference(entry.value.lastSeen).inSeconds >
_staleTimeoutSeconds) {
staleIds.add(entry.key);
}
}
if (staleIds.isNotEmpty) {
for (final id in staleIds) {
_discoveredHosts.remove(id);
}
_emitHosts();
}
});
return _hostsController.stream;
}
Future<void> _bindListener(
List<RemoteAuthContext> contexts,
int generation,
) async {
try {
final socket = await RawDatagramSocket.bind(
InternetAddress.anyIPv4,
discoveryPort,
reuseAddress: true,
reusePort: true,
);
if (generation != _listenGeneration) {
socket.close();
return;
}
_listenSocket = socket;
appLogger.d('LanDiscovery: Listening on port $discoveryPort');
_listenSubscription = socket.listenDatagrams(
(datagram) => _handleDatagram(datagram, contexts),
debugLabel: 'LanDiscovery listener',
);
} catch (e) {
if (generation == _listenGeneration) {
appLogger.e('LanDiscovery: Failed to bind listener', error: e);
}
}
}
void _handleDatagram(Datagram datagram, List<RemoteAuthContext> contexts) {
try {
final packet = utf8.decode(datagram.data);
final json = jsonDecode(packet) as Map<String, dynamic>;
if (json['app'] != 'plezy') return;
final version = json['v'] as int? ?? 0;
final homeHash = json['homeHash'] as String? ?? '';
final name = json['name'] as String? ?? '';
final platform = json['platform'] as String? ?? '';
final clientId = json['clientId'] as String? ?? '';
final port = json['port'] as int? ?? 0;
final ips = (json['ips'] as List<dynamic>?)?.cast<String>() ?? [];
final hmac = json['hmac'] as String? ?? '';
final auth = RemoteAuthService.instance;
RemoteAuthContext? matchedContext;
for (final context in contexts) {
final discoveryKey = context.discoveryKey;
if (!auth.verifyBeaconHmac(
receivedHmac: hmac,
discoveryKey: discoveryKey,
version: version,
homeHash: homeHash,
name: name,
platform: platform,
clientId: clientId,
port: port,
ips: ips,
)) {
continue;
}
if (!auth.matchesDiscoveryTag(homeHash, discoveryKey)) {
continue;
}
matchedContext = context;
break;
}
if (matchedContext == null) {
return; // Different home
}
// Valid beacon from same home. Normalize only after authentication so
// stored endpoint order matches the canonical HMAC representation.
final normalizedIps = List<String>.from(ips)..sort();
final lastSeen = DateTime.now();
final hostKey = clientId;
final existing = _discoveredHosts[hostKey];
if (existing != null) {
final hostChanged =
existing.name != name ||
existing.platform != platform ||
existing.port != port ||
!const ListEquality<String>().equals(existing.ips, normalizedIps);
if (hostChanged) {
_discoveredHosts[hostKey] = DiscoveredHost(
authContextId: existing.authContextId,
clientId: clientId,
name: name,
platform: platform,
port: port,
ips: normalizedIps,
lastSeen: lastSeen,
);
_emitHosts();
} else {
existing.lastSeen = lastSeen;
}
} else {
_discoveredHosts[hostKey] = DiscoveredHost(
authContextId: matchedContext.id,
clientId: clientId,
name: name,
platform: platform,
port: port,
ips: normalizedIps,
lastSeen: lastSeen,
);
appLogger.d(
'LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port',
);
_emitHosts();
}
} catch (e) {
// Ignore malformed packets
}
}
void _emitHosts() {
_hostsController.add(_discoveredHosts.values.toList());
}
void stopListening() {
_stopListeningInternal();
_discoveredHosts.clear();
_emitHosts();
}
void _stopListeningInternal() {
++_listenGeneration;
_staleCleanupTimer?.cancel();
_staleCleanupTimer = null;
_listenSubscription?.cancel();
_listenSubscription = null;
_listenSocket?.close();
_listenSocket = null;
appLogger.d('LanDiscovery: Listening stopped');
}
void dispose() {
stopBroadcasting();
stopListening();
_hostsController.close();
}
}