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 (controller, created) = session.EnsureController( s => new LmsSessionController( _logger, _lmsClient, player, s)); 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); } // Report playback progress if the controller has an active item if (controller is LmsSessionController lmsController) { await ReportPlaybackProgressAsync(sessionManager, session, lmsController).ConfigureAwait(false); } } private async Task ReportPlaybackProgressAsync( ISessionManager sessionManager, SessionInfo session, LmsSessionController controller) { if (!controller.IsPlaying || !controller.CurrentItemId.HasValue) { return; } try { // Get current playback status from LMS var status = await _lmsClient.GetPlayerStatusAsync(controller.PlayerMac).ConfigureAwait(false); if (status == null) { return; } // Get the item from Jellyfin library var libraryManager = _serviceProvider.GetService(); if (libraryManager == null) { return; } var item = libraryManager.GetItemById(controller.CurrentItemId.Value); if (item == null) { return; } // Calculate position in ticks var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond); // Determine if paused var isPaused = status.Mode == "pause"; // Create playback progress info var progressInfo = new PlaybackProgressInfo { ItemId = controller.CurrentItemId.Value, SessionId = session.Id, IsPaused = isPaused, PositionTicks = positionTicks, PlayMethod = PlayMethod.DirectStream, CanSeek = true, IsMuted = status.Volume == 0, VolumeLevel = status.Volume }; // Report progress to session manager await sessionManager.OnPlaybackProgress(progressInfo).ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Error reporting playback progress for player {Name}", controller.PlayerMac); } } /// 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; } }