Duncan Tourolle 2f5a182afd
Some checks failed
🏗️ 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
First POC with working playback
2025-12-13 23:54:33 +01:00

383 lines
11 KiB
C#

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;
}
}