Multi-target the plugin against net9.0 (Jellyfin 10.11.x) and net10.0 (Jellyfin 12.0.x), with per-framework Jellyfin.Controller/Model references. Jellyfin 12 added a controlling-session parameter to ISessionManager.ReportCapabilities; an empty value skips the AssertCanControl check, which is what a server-side registration needs. Each release now ships two packages, jellylms_<version>_jf11.zip and jellylms_<version>_jf12.zip, with a manifest entry each. Jellyfin filters by targetAbi, so both can share a version number. Also corrects the manifest targetAbi for new releases from 10.10.0.0 to 10.11.0.0 - the plugin has referenced 10.11 packages since 1.0.0. jprm only reads ./build.yaml, so build-plugin.sh swaps targetAbi/framework per variant and restores the file afterwards. The builder image moves to the .NET 10 SDK, which targets both frameworks. CA1873 (new in that SDK) is disabled alongside CA1848, same rationale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
8.5 KiB
C#
261 lines
8.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class LmsDeviceDiscoveryService : IHostedService, IDisposable
|
|
{
|
|
private const string AppName = "JellyLMS";
|
|
private const string AppVersion = "1.0.0";
|
|
|
|
private readonly ILogger<LmsDeviceDiscoveryService> _logger;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly ILmsApiClient _lmsClient;
|
|
private readonly LmsPlayerManager _playerManager;
|
|
private readonly ConcurrentDictionary<string, string> _registeredDeviceIds = new();
|
|
private ISessionManager? _sessionManager;
|
|
private Timer? _discoveryTimer;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LmsDeviceDiscoveryService"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">The logger instance.</param>
|
|
/// <param name="serviceProvider">The service provider for lazy resolution.</param>
|
|
/// <param name="lmsClient">The LMS API client.</param>
|
|
/// <param name="playerManager">The LMS player manager.</param>
|
|
public LmsDeviceDiscoveryService(
|
|
ILogger<LmsDeviceDiscoveryService> logger,
|
|
IServiceProvider serviceProvider,
|
|
ILmsApiClient lmsClient,
|
|
LmsPlayerManager playerManager)
|
|
{
|
|
_logger = logger;
|
|
_serviceProvider = serviceProvider;
|
|
_lmsClient = lmsClient;
|
|
_playerManager = playerManager;
|
|
_logger.LogInformation("LMS Device Discovery Service constructed");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<ISessionManager>();
|
|
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<ILibraryManager>();
|
|
var (controller, created) = session.EnsureController<LmsSessionController>(
|
|
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
|
|
};
|
|
|
|
#if NET10_0_OR_GREATER
|
|
// Jellyfin 12 added a controlling session parameter; an empty value skips the
|
|
// "can control" assertion, which is what we want for a server-side registration.
|
|
sessionManager.ReportCapabilities(string.Empty, session.Id, capabilities);
|
|
#else
|
|
sessionManager.ReportCapabilities(session.Id, capabilities);
|
|
#endif
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes managed resources.
|
|
/// </summary>
|
|
/// <param name="disposing">Whether to dispose managed resources.</param>
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposing)
|
|
{
|
|
_discoveryTimer?.Dispose();
|
|
}
|
|
|
|
_disposed = true;
|
|
}
|
|
}
|