First POC with working playback
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net.Mime;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JellyLMS.Models;
|
||||
using Jellyfin.Plugin.JellyLMS.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Api;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for JellyLMS operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("JellyLms")]
|
||||
[Authorize]
|
||||
[Produces(MediaTypeNames.Application.Json)]
|
||||
public class JellyLmsController : ControllerBase
|
||||
{
|
||||
private readonly ILmsApiClient _lmsClient;
|
||||
private readonly LmsPlayerManager _playerManager;
|
||||
private readonly LmsSessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JellyLmsController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="lmsClient">The LMS API client.</param>
|
||||
/// <param name="playerManager">The player manager.</param>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
public JellyLmsController(
|
||||
ILmsApiClient lmsClient,
|
||||
LmsPlayerManager playerManager,
|
||||
LmsSessionManager sessionManager)
|
||||
{
|
||||
_lmsClient = lmsClient;
|
||||
_playerManager = playerManager;
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection to the LMS server.
|
||||
/// </summary>
|
||||
/// <returns>The connection status.</returns>
|
||||
[HttpPost("TestConnection")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LmsServerStatus>> TestConnection()
|
||||
{
|
||||
var status = await _lmsClient.TestConnectionAsync().ConfigureAwait(false);
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all LMS players.
|
||||
/// </summary>
|
||||
/// <param name="refresh">Force refresh from LMS.</param>
|
||||
/// <returns>List of players.</returns>
|
||||
[HttpGet("Players")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<LmsPlayer>>> GetPlayers([FromQuery] bool refresh = false)
|
||||
{
|
||||
var players = await _playerManager.GetPlayersAsync(refresh).ConfigureAwait(false);
|
||||
return Ok(players);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific player by MAC address.
|
||||
/// </summary>
|
||||
/// <param name="mac">The player's MAC address.</param>
|
||||
/// <returns>The player details.</returns>
|
||||
[HttpGet("Players/{mac}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LmsPlayer>> GetPlayer(string mac)
|
||||
{
|
||||
var player = await _playerManager.GetPlayerAsync(mac).ConfigureAwait(false);
|
||||
if (player == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(player);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Powers on a player.
|
||||
/// </summary>
|
||||
/// <param name="mac">The player's MAC address.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Players/{mac}/PowerOn")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> PowerOn(string mac)
|
||||
{
|
||||
var success = await _lmsClient.PowerOnAsync(mac).ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to power on player");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Powers off a player.
|
||||
/// </summary>
|
||||
/// <param name="mac">The player's MAC address.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Players/{mac}/PowerOff")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> PowerOff(string mac)
|
||||
{
|
||||
var success = await _lmsClient.PowerOffAsync(mac).ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to power off player");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the volume on a player.
|
||||
/// </summary>
|
||||
/// <param name="mac">The player's MAC address.</param>
|
||||
/// <param name="request">The volume request.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Players/{mac}/Volume")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> SetVolume(string mac, [FromBody] VolumeRequest request)
|
||||
{
|
||||
var success = await _lmsClient.SetVolumeAsync(mac, request.Volume).ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to set volume");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sync groups.
|
||||
/// </summary>
|
||||
/// <returns>List of sync groups.</returns>
|
||||
[HttpGet("SyncGroups")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<SyncGroup>>> GetSyncGroups()
|
||||
{
|
||||
var groups = await _playerManager.GetSyncGroupsAsync().ConfigureAwait(false);
|
||||
return Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sync group.
|
||||
/// </summary>
|
||||
/// <param name="request">The sync request.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("SyncGroups")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> CreateSyncGroup([FromBody] CreateSyncGroupRequest request)
|
||||
{
|
||||
var success = await _playerManager.CreateSyncGroupAsync(request.MasterMac, request.SlaveMacs)
|
||||
.ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to create sync group");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a player from its sync group.
|
||||
/// </summary>
|
||||
/// <param name="mac">The player's MAC address.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpDelete("SyncGroups/Players/{mac}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> UnsyncPlayer(string mac)
|
||||
{
|
||||
var success = await _playerManager.UnsyncPlayerAsync(mac).ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to unsync player");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dissolves an entire sync group.
|
||||
/// </summary>
|
||||
/// <param name="masterMac">The master player's MAC address.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpDelete("SyncGroups/{masterMac}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DissolveSyncGroup(string masterMac)
|
||||
{
|
||||
var success = await _playerManager.DissolveSyncGroupAsync(masterMac).ConfigureAwait(false);
|
||||
return success ? Ok() : BadRequest("Failed to dissolve sync group");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active playback sessions.
|
||||
/// </summary>
|
||||
/// <returns>List of active sessions.</returns>
|
||||
[HttpGet("Sessions")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<List<LmsPlaybackSession>> GetSessions()
|
||||
{
|
||||
return Ok(_sessionManager.GetActiveSessions());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts playback of a Jellyfin item on LMS players.
|
||||
/// </summary>
|
||||
/// <param name="request">The playback request.</param>
|
||||
/// <returns>The created session.</returns>
|
||||
[HttpPost("Sessions/Play")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<LmsPlaybackSession>> StartPlayback([FromBody] StartPlaybackRequest request)
|
||||
{
|
||||
var session = await _sessionManager.StartPlaybackAsync(request.ItemId, request.PlayerMacs, request.UserId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
return BadRequest("Failed to start playback");
|
||||
}
|
||||
|
||||
return Ok(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Pause")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> PauseSession(string sessionId)
|
||||
{
|
||||
var success = await _sessionManager.PauseSessionAsync(sessionId).ConfigureAwait(false);
|
||||
return success ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Resume")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> ResumeSession(string sessionId)
|
||||
{
|
||||
var success = await _sessionManager.ResumeSessionAsync(sessionId).ConfigureAwait(false);
|
||||
return success ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Stop")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> StopSession(string sessionId)
|
||||
{
|
||||
var success = await _sessionManager.StopSessionAsync(sessionId).ConfigureAwait(false);
|
||||
return success ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeks to a position in the playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <param name="request">The seek request.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Seek")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> SeekSession(string sessionId, [FromBody] SeekRequest request)
|
||||
{
|
||||
var success = await _sessionManager.SeekAsync(sessionId, request.PositionTicks).ConfigureAwait(false);
|
||||
return success ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the volume for all players in a session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <param name="request">The volume request.</param>
|
||||
/// <returns>Success status.</returns>
|
||||
[HttpPost("Sessions/{sessionId}/Volume")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> SetSessionVolume(string sessionId, [FromBody] VolumeRequest request)
|
||||
{
|
||||
var success = await _sessionManager.SetVolumeAsync(sessionId, request.Volume).ConfigureAwait(false);
|
||||
return success ? Ok() : NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set volume.
|
||||
/// </summary>
|
||||
public class VolumeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the volume level (0-100).
|
||||
/// </summary>
|
||||
[Range(0, 100)]
|
||||
public int Volume { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a sync group.
|
||||
/// </summary>
|
||||
public class CreateSyncGroupRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the master player MAC address.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string MasterMac { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the slave player MAC addresses.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public List<string> SlaveMacs { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start playback.
|
||||
/// </summary>
|
||||
public class StartPlaybackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin item ID.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LMS player MAC addresses.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public List<string> PlayerMacs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional user ID.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to seek to a position.
|
||||
/// </summary>
|
||||
public class SeekRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the position in ticks.
|
||||
/// </summary>
|
||||
public long PositionTicks { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin configuration for JellyLMS.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
public PluginConfiguration()
|
||||
{
|
||||
LmsServerUrl = "http://localhost:9000";
|
||||
LmsUsername = string.Empty;
|
||||
LmsPassword = string.Empty;
|
||||
JellyfinServerUrl = "http://localhost:8096";
|
||||
ConnectionTimeoutSeconds = 10;
|
||||
EnableAutoSync = true;
|
||||
DefaultPlayerMac = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LMS server URL (e.g., http://192.168.1.100:9000).
|
||||
/// </summary>
|
||||
public string LmsServerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LMS username (if authentication is enabled).
|
||||
/// </summary>
|
||||
public string LmsUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the LMS password (if authentication is enabled).
|
||||
/// </summary>
|
||||
public string LmsPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin server URL that LMS will use to stream audio.
|
||||
/// This should be accessible from the LMS server.
|
||||
/// </summary>
|
||||
public string JellyfinServerUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the connection timeout in seconds.
|
||||
/// </summary>
|
||||
public int ConnectionTimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to automatically sync players
|
||||
/// when playing to multiple devices.
|
||||
/// </summary>
|
||||
public bool EnableAutoSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default player MAC address to use when none is specified.
|
||||
/// </summary>
|
||||
public string DefaultPlayerMac { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin API key for authenticating stream requests from LMS.
|
||||
/// </summary>
|
||||
public string JellyfinApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>JellyLMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="JellyLmsConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<h2>JellyLMS Configuration</h2>
|
||||
<p>Configure the connection between Jellyfin and Logitech Media Server (LMS) for multi-room audio playback.</p>
|
||||
|
||||
<form id="JellyLmsConfigForm">
|
||||
<div class="verticalSection">
|
||||
<h3>LMS Server Settings</h3>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LmsServerUrl">LMS Server URL</label>
|
||||
<input id="LmsServerUrl" name="LmsServerUrl" type="url" is="emby-input" />
|
||||
<div class="fieldDescription">The URL of your LMS server (e.g., http://192.168.1.100:9000)</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LmsUsername">LMS Username (optional)</label>
|
||||
<input id="LmsUsername" name="LmsUsername" type="text" is="emby-input" />
|
||||
<div class="fieldDescription">Username if LMS authentication is enabled</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="LmsPassword">LMS Password (optional)</label>
|
||||
<input id="LmsPassword" name="LmsPassword" type="password" is="emby-input" />
|
||||
<div class="fieldDescription">Password if LMS authentication is enabled</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="btnTestConnection" class="raised button-alt block emby-button">
|
||||
<span>Test Connection</span>
|
||||
</button>
|
||||
<div id="connectionStatus" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3>Jellyfin Server Settings</h3>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="JellyfinServerUrl">Jellyfin Server URL</label>
|
||||
<input id="JellyfinServerUrl" name="JellyfinServerUrl" type="url" is="emby-input" />
|
||||
<div class="fieldDescription">The URL that LMS will use to stream audio from Jellyfin. This must be accessible from the LMS server (e.g., http://192.168.1.4:8096).</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="JellyfinApiKey">Jellyfin API Key</label>
|
||||
<input id="JellyfinApiKey" name="JellyfinApiKey" type="password" is="emby-input" />
|
||||
<div class="fieldDescription">API key for LMS to authenticate with Jellyfin. Create one in Dashboard > API Keys.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3>Playback Settings</h3>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ConnectionTimeoutSeconds">Connection Timeout (seconds)</label>
|
||||
<input id="ConnectionTimeoutSeconds" name="ConnectionTimeoutSeconds" type="number" is="emby-input" min="5" max="60" />
|
||||
<div class="fieldDescription">Timeout for LMS API requests</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="EnableAutoSync" name="EnableAutoSync" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable Auto-Sync</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">Automatically sync players when playing to multiple devices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3>LMS Players</h3>
|
||||
<div id="playersList">
|
||||
<p>Click "Refresh Players" to discover LMS players.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="btnRefreshPlayers" class="raised button-alt block emby-button">
|
||||
<span>Refresh Players</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer" style="margin-top: 15px;">
|
||||
<label class="inputLabel inputLabelUnfocused" for="DefaultPlayerMac">Default Player</label>
|
||||
<select is="emby-select" id="DefaultPlayerMac" name="DefaultPlayerMac" class="emby-select-withcolor emby-select">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
<div class="fieldDescription">Default player to use when none is specified</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
var JellyLmsConfig = {
|
||||
pluginUniqueId: 'a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d'
|
||||
};
|
||||
|
||||
function loadPlayers() {
|
||||
var playersList = document.querySelector('#playersList');
|
||||
var defaultSelect = document.querySelector('#DefaultPlayerMac');
|
||||
var currentDefault = defaultSelect.value;
|
||||
|
||||
playersList.innerHTML = '<p>Loading players...</p>';
|
||||
|
||||
ApiClient.ajax({
|
||||
url: ApiClient.getUrl('JellyLms/Players', { refresh: true }),
|
||||
type: 'GET',
|
||||
dataType: 'json'
|
||||
}).then(function(players) {
|
||||
defaultSelect.innerHTML = '<option value="">None</option>';
|
||||
|
||||
if (!players || players.length === 0) {
|
||||
playersList.innerHTML = '<p>No players found. Make sure LMS is running and has connected players.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<table class="tblGenres detailTable" style="width: 100%;">';
|
||||
html += '<thead><tr><th>Name</th><th>Model</th><th>Status</th><th>Volume</th><th>Synced</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
players.forEach(function(player) {
|
||||
var status = player.IsConnected ? (player.IsPoweredOn ? 'On' : 'Standby') : 'Disconnected';
|
||||
var statusColor = player.IsConnected ? (player.IsPoweredOn ? 'green' : 'orange') : 'red';
|
||||
var syncStatus = player.IsSynced ? 'Yes' : 'No';
|
||||
|
||||
html += '<tr>';
|
||||
html += '<td>' + player.Name + '</td>';
|
||||
html += '<td>' + player.Model + '</td>';
|
||||
html += '<td style="color: ' + statusColor + ';">' + status + '</td>';
|
||||
html += '<td>' + player.Volume + '%</td>';
|
||||
html += '<td>' + syncStatus + '</td>';
|
||||
html += '</tr>';
|
||||
|
||||
var option = document.createElement('option');
|
||||
option.value = player.MacAddress;
|
||||
option.text = player.Name;
|
||||
if (player.MacAddress === currentDefault) {
|
||||
option.selected = true;
|
||||
}
|
||||
defaultSelect.appendChild(option);
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
playersList.innerHTML = html;
|
||||
}).catch(function(err) {
|
||||
playersList.innerHTML = '<p style="color: red;">Error loading players. Check LMS connection.</p>';
|
||||
console.error('Error loading players:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
var statusDiv = document.querySelector('#connectionStatus');
|
||||
statusDiv.innerHTML = '<span style="color: orange;">Testing connection...</span>';
|
||||
|
||||
ApiClient.ajax({
|
||||
url: ApiClient.getUrl('JellyLms/TestConnection'),
|
||||
type: 'POST',
|
||||
dataType: 'json'
|
||||
}).then(function(result) {
|
||||
if (result.IsConnected) {
|
||||
statusDiv.innerHTML = '<span style="color: green;">Connected! Found ' + result.PlayerCount + ' player(s).</span>';
|
||||
loadPlayers();
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span style="color: red;">Connection failed: ' + (result.LastError || 'Unknown error') + '</span>';
|
||||
}
|
||||
}).catch(function(err) {
|
||||
statusDiv.innerHTML = '<span style="color: red;">Connection failed. Check the server URL.</span>';
|
||||
console.error('Connection test failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector('#JellyLmsConfigPage')
|
||||
.addEventListener('pageshow', function() {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
|
||||
document.querySelector('#LmsServerUrl').value = config.LmsServerUrl || 'http://localhost:9000';
|
||||
document.querySelector('#LmsUsername').value = config.LmsUsername || '';
|
||||
document.querySelector('#LmsPassword').value = config.LmsPassword || '';
|
||||
document.querySelector('#JellyfinServerUrl').value = config.JellyfinServerUrl || 'http://localhost:8096';
|
||||
document.querySelector('#JellyfinApiKey').value = config.JellyfinApiKey || '';
|
||||
document.querySelector('#ConnectionTimeoutSeconds').value = config.ConnectionTimeoutSeconds || 10;
|
||||
document.querySelector('#EnableAutoSync').checked = config.EnableAutoSync !== false;
|
||||
document.querySelector('#DefaultPlayerMac').value = config.DefaultPlayerMac || '';
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#btnTestConnection')
|
||||
.addEventListener('click', function() {
|
||||
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
|
||||
config.LmsServerUrl = document.querySelector('#LmsServerUrl').value;
|
||||
config.LmsUsername = document.querySelector('#LmsUsername').value;
|
||||
config.LmsPassword = document.querySelector('#LmsPassword').value;
|
||||
ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function() {
|
||||
testConnection();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#btnRefreshPlayers')
|
||||
.addEventListener('click', function() {
|
||||
loadPlayers();
|
||||
});
|
||||
|
||||
document.querySelector('#JellyLmsConfigForm')
|
||||
.addEventListener('submit', function(e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(JellyLmsConfig.pluginUniqueId).then(function (config) {
|
||||
config.LmsServerUrl = document.querySelector('#LmsServerUrl').value;
|
||||
config.LmsUsername = document.querySelector('#LmsUsername').value;
|
||||
config.LmsPassword = document.querySelector('#LmsPassword').value;
|
||||
config.JellyfinServerUrl = document.querySelector('#JellyfinServerUrl').value;
|
||||
config.JellyfinApiKey = document.querySelector('#JellyfinApiKey').value;
|
||||
config.ConnectionTimeoutSeconds = parseInt(document.querySelector('#ConnectionTimeoutSeconds').value) || 10;
|
||||
config.EnableAutoSync = document.querySelector('#EnableAutoSync').checked;
|
||||
config.DefaultPlayerMac = document.querySelector('#DefaultPlayerMac').value;
|
||||
ApiClient.updatePluginConfiguration(JellyLmsConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.JellyLMS</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" >
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.0">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
|
||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
|
||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Models;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-RPC request for LMS API.
|
||||
/// </summary>
|
||||
public class LmsJsonRpcRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the request ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the method name (always "slim.request").
|
||||
/// </summary>
|
||||
[JsonPropertyName("method")]
|
||||
public string Method { get; set; } = "slim.request";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parameters [playerMac, [command, args...]].
|
||||
/// </summary>
|
||||
[JsonPropertyName("params")]
|
||||
public object[] Params { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON-RPC response from LMS API.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
public class LmsJsonRpcResponse<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the request ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the result data.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public T? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets any error message.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player count response.
|
||||
/// </summary>
|
||||
public class PlayerCountResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the count value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("_count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Players list response.
|
||||
/// </summary>
|
||||
public class PlayersListResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the player count.
|
||||
/// </summary>
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of players.
|
||||
/// </summary>
|
||||
[JsonPropertyName("players_loop")]
|
||||
public List<LmsPlayerInfo> Players { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player info from LMS API.
|
||||
/// </summary>
|
||||
public class LmsPlayerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the player name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player ID (MAC address).
|
||||
/// </summary>
|
||||
[JsonPropertyName("playerid")]
|
||||
public string PlayerId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player IP address.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ip")]
|
||||
public string Ip { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the player is connected.
|
||||
/// </summary>
|
||||
[JsonPropertyName("connected")]
|
||||
public int Connected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the power state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("power")]
|
||||
public int Power { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player model.
|
||||
/// </summary>
|
||||
[JsonPropertyName("model")]
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player model name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("modelname")]
|
||||
public string ModelName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Player status response.
|
||||
/// </summary>
|
||||
public class PlayerStatusResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the player name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("player_name")]
|
||||
public string PlayerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player connected state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("player_connected")]
|
||||
public int PlayerConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the power state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("power")]
|
||||
public int Power { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playback mode (play, pause, stop).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current time position in seconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("time")]
|
||||
public double Time { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mixer volume (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("mixer volume")]
|
||||
public int Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total duration in seconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public double Duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sync master MAC address.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sync_master")]
|
||||
public string? SyncMaster { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of synced player MACs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sync_slaves")]
|
||||
public string? SyncSlaves { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current track title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("current_title")]
|
||||
public string? CurrentTitle { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LMS server status.
|
||||
/// </summary>
|
||||
public class LmsServerStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the server version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player count.
|
||||
/// </summary>
|
||||
public int PlayerCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the server is reachable.
|
||||
/// </summary>
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last error message.
|
||||
/// </summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sync group information.
|
||||
/// </summary>
|
||||
public class SyncGroup
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the master player MAC address.
|
||||
/// </summary>
|
||||
public string MasterMac { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the master player name.
|
||||
/// </summary>
|
||||
public string MasterName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the slave player MAC addresses.
|
||||
/// </summary>
|
||||
public List<string> SlaveMacs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the slave player names.
|
||||
/// </summary>
|
||||
public List<string> SlaveNames { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of players in this sync group.
|
||||
/// </summary>
|
||||
public int PlayerCount => 1 + SlaveMacs.Count;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current playback state.
|
||||
/// </summary>
|
||||
public enum PlaybackState
|
||||
{
|
||||
/// <summary>
|
||||
/// Playback is stopped.
|
||||
/// </summary>
|
||||
Stopped,
|
||||
|
||||
/// <summary>
|
||||
/// Playback is active.
|
||||
/// </summary>
|
||||
Playing,
|
||||
|
||||
/// <summary>
|
||||
/// Playback is paused.
|
||||
/// </summary>
|
||||
Paused
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an active playback session bridging Jellyfin to LMS.
|
||||
/// </summary>
|
||||
public class LmsPlaybackSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique session identifier.
|
||||
/// </summary>
|
||||
public string SessionId { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin session ID.
|
||||
/// </summary>
|
||||
public string? JellyfinSessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin item ID being played.
|
||||
/// </summary>
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item name for display.
|
||||
/// </summary>
|
||||
public string ItemName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artist name (for audio).
|
||||
/// </summary>
|
||||
public string? Artist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the album name (for audio).
|
||||
/// </summary>
|
||||
public string? Album { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MAC addresses of LMS players in this session.
|
||||
/// </summary>
|
||||
public List<string> PlayerMacs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current playback state.
|
||||
/// </summary>
|
||||
public PlaybackState State { get; set; } = PlaybackState.Stopped;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current playback position in ticks.
|
||||
/// </summary>
|
||||
public long PositionTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total runtime in ticks.
|
||||
/// </summary>
|
||||
public long RuntimeTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this session started.
|
||||
/// </summary>
|
||||
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the audio stream URL being played on LMS.
|
||||
/// </summary>
|
||||
public string? StreamUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin user ID who initiated playback.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an LMS player/zone.
|
||||
/// </summary>
|
||||
public class LmsPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the player's display name.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player's MAC address (unique identifier).
|
||||
/// </summary>
|
||||
public string MacAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player's IP address.
|
||||
/// </summary>
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the player is connected to LMS.
|
||||
/// </summary>
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the player is powered on.
|
||||
/// </summary>
|
||||
public bool IsPoweredOn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player's current volume (0-100).
|
||||
/// </summary>
|
||||
public int Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the player model name.
|
||||
/// </summary>
|
||||
public string Model { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MAC address of the sync master, if this player is synced.
|
||||
/// </summary>
|
||||
public string? SyncMaster { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of synced player MAC addresses (if this is a sync master).
|
||||
/// </summary>
|
||||
public List<string> SyncSlaves { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this player is part of a sync group.
|
||||
/// </summary>
|
||||
public bool IsSynced => !string.IsNullOrEmpty(SyncMaster) || SyncSlaves.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JellyLMS.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS;
|
||||
|
||||
/// <summary>
|
||||
/// The main JellyLMS plugin class.
|
||||
/// Bridges Jellyfin audio playback to LMS for multi-room synchronized playback.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "JellyLMS";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("a5b8c9d0-1e2f-3a4b-5c6d-7e8f9a0b1c2d");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Stream Jellyfin audio to Logitech Media Server (LMS) for multi-room playback";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Jellyfin.Plugin.JellyLMS.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS;
|
||||
|
||||
/// <summary>
|
||||
/// Registers plugin services with Jellyfin's DI container.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>();
|
||||
serviceCollection.AddSingleton<LmsPlayerManager>();
|
||||
serviceCollection.AddSingleton<LmsSessionManager>();
|
||||
serviceCollection.AddHostedService(sp => sp.GetRequiredService<LmsSessionManager>());
|
||||
|
||||
// Device discovery service - registers LMS players as Jellyfin sessions for casting
|
||||
// Use AddHostedService directly to let DI handle construction
|
||||
serviceCollection.AddHostedService<LmsDeviceDiscoveryService>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user