using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.JellyLMS.Models;
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);
}
}
///
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;
}
}