First POC with working playback
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s

This commit is contained in:
2025-12-13 23:54:33 +01:00
parent 7a9dbdafcc
commit 2f5a182afd
21 changed files with 2777 additions and 540 deletions
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Models;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Interface for LMS JSON-RPC API communication.
/// </summary>
public interface ILmsApiClient
{
/// <summary>
/// Tests the connection to the LMS server.
/// </summary>
/// <returns>Server status with connection result.</returns>
Task<LmsServerStatus> TestConnectionAsync();
/// <summary>
/// Gets all players connected to LMS.
/// </summary>
/// <returns>List of LMS players.</returns>
Task<List<LmsPlayer>> GetPlayersAsync();
/// <summary>
/// Gets the status of a specific player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>The player status.</returns>
Task<PlayerStatusResult?> GetPlayerStatusAsync(string playerMac);
/// <summary>
/// Plays a URL on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="url">The audio URL to play.</param>
/// <param name="title">Optional title for display.</param>
/// <returns>True if successful.</returns>
Task<bool> PlayUrlAsync(string playerMac, string url, string? title = null);
/// <summary>
/// Pauses playback on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> PauseAsync(string playerMac);
/// <summary>
/// Resumes playback on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> PlayAsync(string playerMac);
/// <summary>
/// Stops playback on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> StopAsync(string playerMac);
/// <summary>
/// Sets the volume on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="volume">Volume level (0-100).</param>
/// <returns>True if successful.</returns>
Task<bool> SetVolumeAsync(string playerMac, int volume);
/// <summary>
/// Seeks to a position on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <param name="positionSeconds">Position in seconds.</param>
/// <returns>True if successful.</returns>
Task<bool> SeekAsync(string playerMac, double positionSeconds);
/// <summary>
/// Powers on the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> PowerOnAsync(string playerMac);
/// <summary>
/// Powers off the specified player.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> PowerOffAsync(string playerMac);
/// <summary>
/// Syncs a slave player to a master player.
/// </summary>
/// <param name="masterMac">The master player's MAC address.</param>
/// <param name="slaveMac">The slave player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> SyncPlayerAsync(string masterMac, string slaveMac);
/// <summary>
/// Removes a player from its sync group.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
Task<bool> UnsyncPlayerAsync(string playerMac);
/// <summary>
/// Gets all current sync groups.
/// </summary>
/// <returns>List of sync groups.</returns>
Task<List<SyncGroup>> GetSyncGroupsAsync();
}
@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// HTTP client for LMS JSON-RPC API communication.
/// </summary>
public class LmsApiClient : ILmsApiClient, IDisposable
{
private readonly ILogger<LmsApiClient> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="LmsApiClient"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public LmsApiClient(ILogger<LmsApiClient> logger)
{
_logger = logger;
_httpClient = new HttpClient();
}
private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
private string JsonRpcEndpoint => $"{Config.LmsServerUrl.TrimEnd('/')}/jsonrpc.js";
/// <inheritdoc />
public async Task<LmsServerStatus> TestConnectionAsync()
{
var status = new LmsServerStatus();
try
{
var result = await SendCommandAsync<PlayersListResult>("-", ["players", "0", "1"]).ConfigureAwait(false);
status.IsConnected = result != null;
status.PlayerCount = result?.Count ?? 0;
status.Version = "Connected";
}
catch (Exception ex)
{
status.IsConnected = false;
status.LastError = ex.Message;
_logger.LogError(ex, "Failed to connect to LMS at {Endpoint}", JsonRpcEndpoint);
}
return status;
}
/// <inheritdoc />
public async Task<List<LmsPlayer>> GetPlayersAsync()
{
var players = new List<LmsPlayer>();
try
{
// First get player count
var countResult = await SendCommandAsync<PlayerCountResult>("-", ["player", "count", "?"]).ConfigureAwait(false);
var count = countResult?.Count ?? 0;
if (count == 0)
{
return players;
}
// Then get player list
var listResult = await SendCommandAsync<PlayersListResult>("-", ["players", "0", count.ToString(CultureInfo.InvariantCulture)]).ConfigureAwait(false);
if (listResult?.Players == null)
{
return players;
}
foreach (var p in listResult.Players)
{
var player = new LmsPlayer
{
Name = p.Name,
MacAddress = p.PlayerId,
IpAddress = p.Ip.Split(':')[0], // Remove port if present
IsConnected = p.Connected == 1,
IsPoweredOn = p.Power == 1,
Model = p.ModelName
};
// Get additional status for sync info
var status = await GetPlayerStatusAsync(p.PlayerId).ConfigureAwait(false);
if (status != null)
{
player.Volume = status.Volume;
player.SyncMaster = status.SyncMaster;
if (!string.IsNullOrEmpty(status.SyncSlaves))
{
player.SyncSlaves = status.SyncSlaves.Split(',').ToList();
}
}
players.Add(player);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get players from LMS");
}
return players;
}
/// <inheritdoc />
public async Task<PlayerStatusResult?> GetPlayerStatusAsync(string playerMac)
{
try
{
return await SendCommandAsync<PlayerStatusResult>(playerMac, ["status", "-", "1", "tags:"])
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get status for player {Mac}", playerMac);
return null;
}
}
/// <inheritdoc />
public async Task<bool> PlayUrlAsync(string playerMac, string url, string? title = null)
{
try
{
// First clear the playlist and add the URL
await SendCommandAsync<object>(playerMac, ["playlist", "clear"]).ConfigureAwait(false);
await SendCommandAsync<object>(playerMac, ["playlist", "add", url]).ConfigureAwait(false);
// Set title if provided
if (!string.IsNullOrEmpty(title))
{
await SendCommandAsync<object>(playerMac, ["playlist", "title", title]).ConfigureAwait(false);
}
// Start playback
await SendCommandAsync<object>(playerMac, ["play"]).ConfigureAwait(false);
_logger.LogInformation("Started playback of {Url} on player {Mac}", url, playerMac);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to play URL on player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> PauseAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["pause", "1"]).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pause player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> PlayAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["play"]).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to resume player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> StopAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["stop"]).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stop player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> SetVolumeAsync(string playerMac, int volume)
{
try
{
volume = Math.Clamp(volume, 0, 100);
await SendCommandAsync<object>(playerMac, ["mixer", "volume", volume.ToString(CultureInfo.InvariantCulture)])
.ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to set volume on player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> SeekAsync(string playerMac, double positionSeconds)
{
try
{
await SendCommandAsync<object>(playerMac, ["time", positionSeconds.ToString("F1", CultureInfo.InvariantCulture)])
.ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to seek on player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> PowerOnAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["power", "1"]).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to power on player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> PowerOffAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["power", "0"]).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to power off player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> SyncPlayerAsync(string masterMac, string slaveMac)
{
try
{
await SendCommandAsync<object>(masterMac, ["sync", slaveMac]).ConfigureAwait(false);
_logger.LogInformation("Synced player {Slave} to master {Master}", slaveMac, masterMac);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync player {Slave} to {Master}", slaveMac, masterMac);
return false;
}
}
/// <inheritdoc />
public async Task<bool> UnsyncPlayerAsync(string playerMac)
{
try
{
await SendCommandAsync<object>(playerMac, ["sync", "-"]).ConfigureAwait(false);
_logger.LogInformation("Unsynced player {Mac}", playerMac);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to unsync player {Mac}", playerMac);
return false;
}
}
/// <inheritdoc />
public async Task<List<SyncGroup>> GetSyncGroupsAsync()
{
var groups = new List<SyncGroup>();
var players = await GetPlayersAsync().ConfigureAwait(false);
// Find all masters (players with slaves)
var masters = players.Where(p => p.SyncSlaves.Count > 0).ToList();
foreach (var master in masters)
{
var group = new SyncGroup
{
MasterMac = master.MacAddress,
MasterName = master.Name,
SlaveMacs = master.SyncSlaves
};
// Resolve slave names
foreach (var slaveMac in master.SyncSlaves)
{
var slave = players.FirstOrDefault(p => p.MacAddress == slaveMac);
if (slave != null)
{
group.SlaveNames.Add(slave.Name);
}
}
groups.Add(group);
}
return groups;
}
private async Task<T?> SendCommandAsync<T>(string playerMac, string[] command)
{
var request = new LmsJsonRpcRequest
{
Params = [playerMac, command]
};
var response = await _httpClient.PostAsJsonAsync(JsonRpcEndpoint, request).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var result = JsonSerializer.Deserialize<LmsJsonRpcResponse<T>>(content);
if (!string.IsNullOrEmpty(result?.Error))
{
throw new InvalidOperationException($"LMS API error: {result.Error}");
}
return result != null ? result.Result : default;
}
/// <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)
{
_httpClient.Dispose();
}
_disposed = true;
}
}
@@ -0,0 +1,249 @@
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;
/// <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 (controller, created) = session.EnsureController<LmsSessionController>(
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);
}
}
/// <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;
}
}
@@ -0,0 +1,163 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Manages LMS player discovery and state tracking.
/// </summary>
public class LmsPlayerManager
{
private readonly ILogger<LmsPlayerManager> _logger;
private readonly ILmsApiClient _lmsClient;
private readonly ConcurrentDictionary<string, LmsPlayer> _players = new();
private DateTime _lastRefresh = DateTime.MinValue;
private readonly TimeSpan _cacheExpiry = TimeSpan.FromSeconds(30);
/// <summary>
/// Initializes a new instance of the <see cref="LmsPlayerManager"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="lmsClient">The LMS API client.</param>
public LmsPlayerManager(ILogger<LmsPlayerManager> logger, ILmsApiClient lmsClient)
{
_logger = logger;
_lmsClient = lmsClient;
}
/// <summary>
/// Gets all known LMS players, refreshing if cache is stale.
/// </summary>
/// <param name="forceRefresh">Force a refresh from LMS.</param>
/// <returns>List of LMS players.</returns>
public async Task<List<LmsPlayer>> GetPlayersAsync(bool forceRefresh = false)
{
if (!forceRefresh && DateTime.UtcNow - _lastRefresh < _cacheExpiry && _players.Count > 0)
{
return _players.Values.ToList();
}
await RefreshPlayersAsync().ConfigureAwait(false);
return _players.Values.ToList();
}
/// <summary>
/// Gets a specific player by MAC address.
/// </summary>
/// <param name="macAddress">The player's MAC address.</param>
/// <returns>The player, or null if not found.</returns>
public async Task<LmsPlayer?> GetPlayerAsync(string macAddress)
{
if (_players.TryGetValue(macAddress, out var player))
{
return player;
}
await RefreshPlayersAsync().ConfigureAwait(false);
return _players.GetValueOrDefault(macAddress);
}
/// <summary>
/// Refreshes the player list from LMS.
/// </summary>
/// <returns>A task representing the operation.</returns>
public async Task RefreshPlayersAsync()
{
try
{
var players = await _lmsClient.GetPlayersAsync().ConfigureAwait(false);
_players.Clear();
foreach (var player in players)
{
_players[player.MacAddress] = player;
}
_lastRefresh = DateTime.UtcNow;
_logger.LogDebug("Refreshed {Count} players from LMS", players.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh players from LMS");
}
}
/// <summary>
/// Gets all current sync groups.
/// </summary>
/// <returns>List of sync groups.</returns>
public async Task<List<SyncGroup>> GetSyncGroupsAsync()
{
return await _lmsClient.GetSyncGroupsAsync().ConfigureAwait(false);
}
/// <summary>
/// Creates a sync group with the specified players.
/// </summary>
/// <param name="masterMac">The master player's MAC address.</param>
/// <param name="slaveMacs">The slave players' MAC addresses.</param>
/// <returns>True if successful.</returns>
public async Task<bool> CreateSyncGroupAsync(string masterMac, IEnumerable<string> slaveMacs)
{
var success = true;
foreach (var slaveMac in slaveMacs)
{
if (!await _lmsClient.SyncPlayerAsync(masterMac, slaveMac).ConfigureAwait(false))
{
_logger.LogWarning("Failed to sync player {Slave} to master {Master}", slaveMac, masterMac);
success = false;
}
}
// Refresh player state to update sync info
await RefreshPlayersAsync().ConfigureAwait(false);
return success;
}
/// <summary>
/// Removes a player from its sync group.
/// </summary>
/// <param name="playerMac">The player's MAC address.</param>
/// <returns>True if successful.</returns>
public async Task<bool> UnsyncPlayerAsync(string playerMac)
{
var result = await _lmsClient.UnsyncPlayerAsync(playerMac).ConfigureAwait(false);
await RefreshPlayersAsync().ConfigureAwait(false);
return result;
}
/// <summary>
/// Dissolves an entire sync group (unsyncs all members).
/// </summary>
/// <param name="masterMac">The master player's MAC address.</param>
/// <returns>True if successful.</returns>
public async Task<bool> DissolveSyncGroupAsync(string masterMac)
{
var player = await GetPlayerAsync(masterMac).ConfigureAwait(false);
if (player == null)
{
return false;
}
var success = true;
// Unsync all slaves
foreach (var slaveMac in player.SyncSlaves)
{
if (!await _lmsClient.UnsyncPlayerAsync(slaveMac).ConfigureAwait(false))
{
success = false;
}
}
await RefreshPlayersAsync().ConfigureAwait(false);
return success;
}
}
@@ -0,0 +1,253 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Session controller for LMS player devices.
/// Enables Jellyfin to send playback commands to LMS players via the cast interface.
/// </summary>
public class LmsSessionController : ISessionController
{
private readonly ILogger _logger;
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayer _player;
private readonly SessionInfo _session;
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="player">The LMS player this controller manages.</param>
/// <param name="session">The Jellyfin session associated with this controller.</param>
public LmsSessionController(
ILogger logger,
ILmsApiClient lmsClient,
LmsPlayer player,
SessionInfo session)
{
_logger = logger;
_lmsClient = lmsClient;
_player = player;
_session = session;
}
/// <inheritdoc />
public bool IsSessionActive => _player.IsConnected && _player.IsPoweredOn;
/// <inheritdoc />
public bool SupportsMediaControl => true;
/// <summary>
/// Gets the MAC address of the LMS player.
/// </summary>
public string PlayerMac => _player.MacAddress;
/// <inheritdoc />
public async Task SendMessage<T>(
SessionMessageType name,
Guid messageId,
T data,
CancellationToken cancellationToken)
{
_logger.LogDebug(
"LMS Session Controller received message {MessageType} for player {PlayerName}",
name,
_player.Name);
try
{
switch (name)
{
case SessionMessageType.Play:
await HandlePlayCommandAsync(data, cancellationToken).ConfigureAwait(false);
break;
case SessionMessageType.Playstate:
await HandlePlaystateCommandAsync(data, cancellationToken).ConfigureAwait(false);
break;
case SessionMessageType.GeneralCommand:
await HandleGeneralCommandAsync(data, cancellationToken).ConfigureAwait(false);
break;
default:
_logger.LogDebug("Unhandled message type: {MessageType}", name);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling message {MessageType} for player {PlayerName}", name, _player.Name);
}
}
private async Task HandlePlayCommandAsync<T>(T data, CancellationToken cancellationToken)
{
if (data is not PlayRequest playRequest)
{
_logger.LogWarning("Expected PlayRequest but got {Type}", data?.GetType().Name);
return;
}
_logger.LogInformation(
"Play command received for player {PlayerName}: {ItemCount} items",
_player.Name,
playRequest.ItemIds.Length);
// Power on the player if needed
if (!_player.IsPoweredOn)
{
await _lmsClient.PowerOnAsync(_player.MacAddress).ConfigureAwait(false);
}
// For now, play the first item
// TODO: Support playlists/queues
if (playRequest.ItemIds.Length > 0)
{
var itemId = playRequest.ItemIds[0];
var streamUrl = BuildStreamUrl(itemId);
_logger.LogInformation("Playing stream URL: {Url}", streamUrl);
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Seek to start position if specified
if (playRequest.StartPositionTicks.HasValue && playRequest.StartPositionTicks.Value > 0)
{
var positionSeconds = playRequest.StartPositionTicks.Value / TimeSpan.TicksPerSecond;
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
}
}
}
private async Task HandlePlaystateCommandAsync<T>(T data, CancellationToken cancellationToken)
{
if (data is not PlaystateRequest playstateRequest)
{
_logger.LogWarning("Expected PlaystateRequest but got {Type}", data?.GetType().Name);
return;
}
_logger.LogDebug(
"Playstate command {Command} for player {PlayerName}",
playstateRequest.Command,
_player.Name);
switch (playstateRequest.Command)
{
case PlaystateCommand.Stop:
await _lmsClient.StopAsync(_player.MacAddress).ConfigureAwait(false);
break;
case PlaystateCommand.Pause:
await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
break;
case PlaystateCommand.Unpause:
await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
break;
case PlaystateCommand.Seek:
if (playstateRequest.SeekPositionTicks.HasValue)
{
var positionSeconds = playstateRequest.SeekPositionTicks.Value / TimeSpan.TicksPerSecond;
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
}
break;
case PlaystateCommand.NextTrack:
case PlaystateCommand.PreviousTrack:
// TODO: Implement playlist navigation
_logger.LogDebug("Track navigation not yet implemented");
break;
default:
_logger.LogDebug("Unhandled playstate command: {Command}", playstateRequest.Command);
break;
}
}
private async Task HandleGeneralCommandAsync<T>(T data, CancellationToken cancellationToken)
{
if (data is not GeneralCommand command)
{
_logger.LogWarning("Expected GeneralCommand but got {Type}", data?.GetType().Name);
return;
}
_logger.LogDebug(
"General command {CommandName} for player {PlayerName}",
command.Name,
_player.Name);
switch (command.Name)
{
case GeneralCommandType.SetVolume:
if (command.Arguments.TryGetValue("Volume", out var volumeStr) &&
int.TryParse(volumeStr, out var volume))
{
await _lmsClient.SetVolumeAsync(_player.MacAddress, volume).ConfigureAwait(false);
}
break;
case GeneralCommandType.VolumeUp:
var currentStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (currentStatus != null)
{
var newVolume = Math.Min(100, currentStatus.Volume + 5);
await _lmsClient.SetVolumeAsync(_player.MacAddress, newVolume).ConfigureAwait(false);
}
break;
case GeneralCommandType.VolumeDown:
var status = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (status != null)
{
var newVolume = Math.Max(0, status.Volume - 5);
await _lmsClient.SetVolumeAsync(_player.MacAddress, newVolume).ConfigureAwait(false);
}
break;
case GeneralCommandType.Mute:
await _lmsClient.SetVolumeAsync(_player.MacAddress, 0).ConfigureAwait(false);
break;
case GeneralCommandType.ToggleMute:
// TODO: Track mute state to toggle properly
break;
default:
_logger.LogDebug("Unhandled general command: {Command}", command.Name);
break;
}
}
private string BuildStreamUrl(Guid itemId)
{
var config = Plugin.Instance?.Configuration;
var jellyfinUrl = config?.JellyfinServerUrl?.TrimEnd('/') ?? "http://localhost:8096";
var apiKey = config?.JellyfinApiKey ?? string.Empty;
// Build audio stream URL that LMS can fetch
// Use direct stream endpoint - simpler and more compatible
var url = $"{jellyfinUrl}/Audio/{itemId}/stream?static=true";
if (!string.IsNullOrEmpty(apiKey))
{
url += $"&api_key={apiKey}";
}
return url;
}
}
@@ -0,0 +1,313 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Manages active playback sessions between Jellyfin and LMS.
/// </summary>
public class LmsSessionManager : IHostedService
{
private readonly ILogger<LmsSessionManager> _logger;
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
private readonly ConcurrentDictionary<string, LmsPlaybackSession> _sessions = new();
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionManager"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param>
/// <param name="libraryManager">The Jellyfin library manager.</param>
public LmsSessionManager(
ILogger<LmsSessionManager> logger,
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
ILibraryManager libraryManager)
{
_logger = logger;
_lmsClient = lmsClient;
_playerManager = playerManager;
_libraryManager = libraryManager;
}
private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>List of active sessions.</returns>
public List<LmsPlaybackSession> GetActiveSessions()
{
return _sessions.Values.Where(s => s.State != PlaybackState.Stopped).ToList();
}
/// <summary>
/// Gets a session by ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session, or null if not found.</returns>
public LmsPlaybackSession? GetSession(string sessionId)
{
return _sessions.GetValueOrDefault(sessionId);
}
/// <summary>
/// Starts playback of a Jellyfin item on LMS players.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <param name="playerMacs">The LMS player MAC addresses.</param>
/// <param name="userId">Optional user ID.</param>
/// <returns>The created session.</returns>
public async Task<LmsPlaybackSession?> StartPlaybackAsync(
Guid itemId,
IEnumerable<string> playerMacs,
Guid? userId = null)
{
var macList = playerMacs.ToList();
if (macList.Count == 0)
{
_logger.LogWarning("No players specified for playback");
return null;
}
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
_logger.LogWarning("Item {ItemId} not found", itemId);
return null;
}
// Build the audio stream URL
var streamUrl = BuildStreamUrl(itemId);
var session = new LmsPlaybackSession
{
ItemId = itemId,
ItemName = item.Name,
PlayerMacs = macList,
State = PlaybackState.Playing,
StreamUrl = streamUrl,
UserId = userId,
RuntimeTicks = item.RunTimeTicks ?? 0
};
// If multiple players, sync them first
if (macList.Count > 1)
{
var masterMac = macList[0];
var slaveMacs = macList.Skip(1);
await _playerManager.CreateSyncGroupAsync(masterMac, slaveMacs).ConfigureAwait(false);
}
// Start playback on the first player (others will sync)
var targetMac = macList[0];
var success = await _lmsClient.PlayUrlAsync(targetMac, streamUrl, item.Name).ConfigureAwait(false);
if (!success)
{
_logger.LogError("Failed to start playback on player {Mac}", targetMac);
return null;
}
_sessions[session.SessionId] = session;
_logger.LogInformation(
"Started playback session {SessionId} for {Item} on {Count} players",
session.SessionId,
item.Name,
macList.Count);
return session;
}
/// <summary>
/// Pauses a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> PauseSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
// Pause the master player (synced players will follow)
var success = await _lmsClient.PauseAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Paused;
}
return success;
}
/// <summary>
/// Resumes a paused playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> ResumeSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.PlayAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Playing;
}
return success;
}
/// <summary>
/// Stops a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> StopSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.StopAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Stopped;
// Unsync players if there were multiple
if (session.PlayerMacs.Count > 1)
{
await _playerManager.DissolveSyncGroupAsync(session.PlayerMacs[0]).ConfigureAwait(false);
}
}
// Remove the session
_sessions.TryRemove(sessionId, out _);
return success;
}
/// <summary>
/// Seeks to a position in the playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="positionTicks">The position in ticks.</param>
/// <returns>True if successful.</returns>
public async Task<bool> SeekAsync(string sessionId, long positionTicks)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
var success = await _lmsClient.SeekAsync(session.PlayerMacs[0], positionSeconds).ConfigureAwait(false);
if (success)
{
session.PositionTicks = positionTicks;
}
return success;
}
/// <summary>
/// Sets the volume for all players in a session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="volume">The volume level (0-100).</param>
/// <returns>True if successful.</returns>
public async Task<bool> SetVolumeAsync(string sessionId, int volume)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = true;
foreach (var mac in session.PlayerMacs)
{
if (!await _lmsClient.SetVolumeAsync(mac, volume).ConfigureAwait(false))
{
success = false;
}
}
return success;
}
/// <summary>
/// Updates the session state from LMS.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>A task representing the operation.</returns>
public async Task RefreshSessionStateAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return;
}
var status = await _lmsClient.GetPlayerStatusAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (status == null)
{
return;
}
session.PositionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
session.State = status.Mode switch
{
"play" => PlaybackState.Playing,
"pause" => PlaybackState.Paused,
_ => PlaybackState.Stopped
};
}
private string BuildStreamUrl(Guid itemId)
{
var baseUrl = Config.JellyfinServerUrl.TrimEnd('/');
// Direct stream URL - LMS will pull audio from Jellyfin
return $"{baseUrl}/Audio/{itemId}/stream.mp3";
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager started");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager stopping");
// Stop all active sessions
foreach (var sessionId in _sessions.Keys.ToList())
{
_ = StopSessionAsync(sessionId);
}
return Task.CompletedTask;
}
}