using System; using System.Collections.Concurrent; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Plugin.JellyLMS.Models; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.JellyLMS.Services; /// /// Background service that discovers LMS players and registers them as Jellyfin sessions. /// This enables LMS players to appear in Jellyfin's "Cast to" device picker. /// public class LmsDeviceDiscoveryService : IHostedService, IDisposable { private const string AppName = "JellyLMS"; private const string AppVersion = "1.0.0"; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly ILmsApiClient _lmsClient; private readonly LmsPlayerManager _playerManager; private readonly ConcurrentDictionary _registeredDeviceIds = new(); private ISessionManager? _sessionManager; private Timer? _discoveryTimer; private bool _disposed; /// /// Initializes a new instance of the class. /// /// The logger instance. /// The service provider for lazy resolution. /// The LMS API client. /// The LMS player manager. public LmsDeviceDiscoveryService( ILogger logger, IServiceProvider serviceProvider, ILmsApiClient lmsClient, LmsPlayerManager playerManager) { _logger = logger; _serviceProvider = serviceProvider; _lmsClient = lmsClient; _playerManager = playerManager; _logger.LogInformation("LMS Device Discovery Service constructed"); } /// public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("LMS Device Discovery Service starting"); // Run initial discovery after a delay, then every 15 seconds _discoveryTimer = new Timer( async _ => await DiscoverAndRegisterPlayersAsync().ConfigureAwait(false), null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(15)); return Task.CompletedTask; } /// public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("LMS Device Discovery Service stopping"); _discoveryTimer?.Change(Timeout.Infinite, 0); _registeredDeviceIds.Clear(); return Task.CompletedTask; } private ISessionManager? GetSessionManager() { if (_sessionManager != null) { return _sessionManager; } try { _sessionManager = _serviceProvider.GetService(); if (_sessionManager == null) { _logger.LogWarning("ISessionManager not available yet"); } } catch (Exception ex) { _logger.LogError(ex, "Failed to resolve ISessionManager"); } return _sessionManager; } private async Task DiscoverAndRegisterPlayersAsync() { try { var sessionManager = GetSessionManager(); if (sessionManager == null) { _logger.LogDebug("Session manager not available, skipping discovery"); return; } // First test connection var status = await _lmsClient.TestConnectionAsync().ConfigureAwait(false); if (!status.IsConnected) { _logger.LogDebug("LMS server not connected, skipping player discovery"); return; } // Get all players from LMS var players = await _playerManager.GetPlayersAsync(forceRefresh: true).ConfigureAwait(false); _logger.LogDebug("Discovered {Count} LMS players", players.Count); foreach (var player in players) { try { await RegisterOrRefreshPlayerSessionAsync(sessionManager, player).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Failed to register player {Name} ({Mac})", player.Name, player.MacAddress); } } // Clean up tracked device IDs for players that no longer exist foreach (var mac in _registeredDeviceIds.Keys) { if (!players.Exists(p => p.MacAddress == mac)) { _registeredDeviceIds.TryRemove(mac, out _); _logger.LogDebug("Removed tracking for disconnected player {Mac}", mac); } } } catch (Exception ex) { _logger.LogError(ex, "Error during LMS player discovery"); } } private async Task RegisterOrRefreshPlayerSessionAsync(ISessionManager sessionManager, LmsPlayer player) { // Create a unique device ID for this player var deviceId = $"lms-{player.MacAddress}"; // Always call LogSessionActivity to keep the session alive // This creates a new session if one doesn't exist, or refreshes the existing one var session = await sessionManager.LogSessionActivity( appName: AppName, appVersion: AppVersion, deviceId: deviceId, deviceName: player.Name, remoteEndPoint: player.IpAddress, user: null).ConfigureAwait(false); if (session == null) { _logger.LogWarning("Failed to create/refresh session for player {Name}", player.Name); return; } // Check if this is a new registration var isNew = !_registeredDeviceIds.ContainsKey(player.MacAddress); // Add our controller to the session using EnsureController pattern var libraryManager = _serviceProvider.GetRequiredService(); var (controller, created) = session.EnsureController( s => new LmsSessionController( _logger, _lmsClient, player, s, sessionManager, libraryManager)); if (created) { _logger.LogInformation("Created LmsSessionController for player {Name}", player.Name); } // Always report capabilities to ensure they're set // This is critical for the device to appear in "Play On" menu var capabilities = new ClientCapabilities { PlayableMediaTypes = [MediaType.Audio], SupportedCommands = [ GeneralCommandType.VolumeUp, GeneralCommandType.VolumeDown, GeneralCommandType.Mute, GeneralCommandType.Unmute, GeneralCommandType.SetVolume, GeneralCommandType.ToggleMute ], SupportsMediaControl = true, SupportsPersistentIdentifier = true }; sessionManager.ReportCapabilities(session.Id, capabilities); // Track this device _registeredDeviceIds[player.MacAddress] = deviceId; if (isNew) { _logger.LogInformation( "Registered LMS player {Name} ({Mac}) as session {SessionId}", player.Name, player.MacAddress, session.Id); } } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Disposes managed resources. /// /// Whether to dispose managed resources. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _discoveryTimer?.Dispose(); } _disposed = true; } }