229 lines
8.4 KiB
C#
229 lines
8.4 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.JellyLMS.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.JellyLMS.Services;
|
|
|
|
/// <summary>
|
|
/// Polls LMS player status to confirm state transitions.
|
|
/// </summary>
|
|
public class LmsStatusPoller
|
|
{
|
|
private readonly ILmsApiClient _lmsClient;
|
|
private readonly ILogger _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="LmsStatusPoller"/> class.
|
|
/// </summary>
|
|
/// <param name="lmsClient">The LMS API client.</param>
|
|
/// <param name="logger">The logger instance.</param>
|
|
public LmsStatusPoller(ILmsApiClient lmsClient, ILogger logger)
|
|
{
|
|
_lmsClient = lmsClient;
|
|
_logger = logger;
|
|
}
|
|
|
|
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
|
|
|
/// <summary>
|
|
/// Waits for LMS to report that playback has started (mode="play").
|
|
/// </summary>
|
|
/// <param name="playerMac">The player's MAC address.</param>
|
|
/// <param name="timeout">Maximum time to wait.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if playback started within the timeout.</returns>
|
|
public async Task<bool> WaitForPlaybackStartAsync(
|
|
string playerMac,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await WaitForModeAsync(playerMac, "play", timeout, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for LMS to report a specific playback mode.
|
|
/// </summary>
|
|
/// <param name="playerMac">The player's MAC address.</param>
|
|
/// <param name="expectedMode">The expected mode ("play", "pause", "stop").</param>
|
|
/// <param name="timeout">Maximum time to wait.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if the expected mode was detected within the timeout.</returns>
|
|
public async Task<bool> WaitForModeAsync(
|
|
string playerMac,
|
|
string expectedMode,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var pollInterval = TimeSpan.FromMilliseconds(Config.TransitionPollIntervalMs);
|
|
var startTime = DateTime.UtcNow;
|
|
|
|
_logger.LogDebug(
|
|
"Waiting for player {Mac} to reach mode '{Mode}' (timeout: {Timeout}s)",
|
|
playerMac,
|
|
expectedMode,
|
|
timeout.TotalSeconds);
|
|
|
|
while (DateTime.UtcNow - startTime < timeout)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
var status = await _lmsClient.GetPlayerStatusAsync(playerMac).ConfigureAwait(false);
|
|
if (status?.Mode == expectedMode)
|
|
{
|
|
_logger.LogDebug(
|
|
"Player {Mac} reached mode '{Mode}' after {Elapsed}ms",
|
|
playerMac,
|
|
expectedMode,
|
|
(DateTime.UtcNow - startTime).TotalMilliseconds);
|
|
return true;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Player {Mac} current mode: '{CurrentMode}', waiting for '{ExpectedMode}'",
|
|
playerMac,
|
|
status?.Mode ?? "unknown",
|
|
expectedMode);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Error polling player {Mac} status", playerMac);
|
|
}
|
|
|
|
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Timeout waiting for player {Mac} to reach mode '{Mode}' after {Timeout}s",
|
|
playerMac,
|
|
expectedMode,
|
|
timeout.TotalSeconds);
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Waits for LMS to report a position within tolerance of the target.
|
|
/// Used to confirm seek operations completed.
|
|
/// </summary>
|
|
/// <param name="playerMac">The player's MAC address.</param>
|
|
/// <param name="targetPositionSeconds">The target position in seconds.</param>
|
|
/// <param name="toleranceSeconds">Acceptable tolerance (default 2 seconds).</param>
|
|
/// <param name="timeout">Maximum time to wait.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if the position was reached within the timeout.</returns>
|
|
public async Task<bool> WaitForSeekCompleteAsync(
|
|
string playerMac,
|
|
double targetPositionSeconds,
|
|
double toleranceSeconds,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var pollInterval = TimeSpan.FromMilliseconds(Config.TransitionPollIntervalMs);
|
|
var startTime = DateTime.UtcNow;
|
|
|
|
_logger.LogDebug(
|
|
"Waiting for player {Mac} to seek to {Target}s (tolerance: {Tolerance}s, timeout: {Timeout}s)",
|
|
playerMac,
|
|
targetPositionSeconds,
|
|
toleranceSeconds,
|
|
timeout.TotalSeconds);
|
|
|
|
while (DateTime.UtcNow - startTime < timeout)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
var status = await _lmsClient.GetPlayerStatusAsync(playerMac).ConfigureAwait(false);
|
|
if (status != null)
|
|
{
|
|
var positionDiff = Math.Abs(status.Time - targetPositionSeconds);
|
|
if (positionDiff <= toleranceSeconds)
|
|
{
|
|
_logger.LogDebug(
|
|
"Player {Mac} reached position {Position}s (target: {Target}s) after {Elapsed}ms",
|
|
playerMac,
|
|
status.Time,
|
|
targetPositionSeconds,
|
|
(DateTime.UtcNow - startTime).TotalMilliseconds);
|
|
return true;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Player {Mac} at position {Position}s, waiting for {Target}s (diff: {Diff}s)",
|
|
playerMac,
|
|
status.Time,
|
|
targetPositionSeconds,
|
|
positionDiff);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Error polling player {Mac} status during seek", playerMac);
|
|
}
|
|
|
|
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Timeout waiting for player {Mac} to seek to {Target}s after {Timeout}s",
|
|
playerMac,
|
|
targetPositionSeconds,
|
|
timeout.TotalSeconds);
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes an action with automatic retry on failure.
|
|
/// </summary>
|
|
/// <param name="action">The async action to execute.</param>
|
|
/// <param name="maxRetries">Maximum number of retries.</param>
|
|
/// <param name="onRetry">Optional callback when retrying.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if the action succeeded within retry limit.</returns>
|
|
public async Task<bool> ExecuteWithRetryAsync(
|
|
Func<Task<bool>> action,
|
|
int maxRetries,
|
|
Action<int>? onRetry = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var retryCount = 0;
|
|
var baseDelayMs = 500;
|
|
|
|
while (retryCount <= maxRetries)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
try
|
|
{
|
|
if (await action().ConfigureAwait(false))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
catch (Exception ex) when (retryCount < maxRetries)
|
|
{
|
|
_logger.LogWarning(ex, "Action failed, will retry ({Retry}/{Max})", retryCount + 1, maxRetries);
|
|
}
|
|
|
|
retryCount++;
|
|
if (retryCount <= maxRetries)
|
|
{
|
|
onRetry?.Invoke(retryCount);
|
|
|
|
// Exponential backoff: 500ms, 1000ms, 2000ms, etc.
|
|
var delayMs = baseDelayMs * (int)Math.Pow(2, retryCount - 1);
|
|
_logger.LogDebug("Retrying in {Delay}ms (attempt {Retry}/{Max})", delayMs, retryCount, maxRetries);
|
|
await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|