385 lines
11 KiB
C#
385 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
|
|
{
|
|
_logger.LogInformation("LMS SeekAsync: player {Mac}, position {Seconds}s", playerMac, positionSeconds);
|
|
await SendCommandAsync<object>(playerMac, ["time", positionSeconds.ToString("F1", CultureInfo.InvariantCulture)])
|
|
.ConfigureAwait(false);
|
|
_logger.LogInformation("LMS SeekAsync: command sent successfully");
|
|
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;
|
|
}
|
|
}
|