using System; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.JellyLMS.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.JellyLMS.Services; /// /// Polls LMS player status to confirm state transitions. /// public class LmsStatusPoller { private readonly ILmsApiClient _lmsClient; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The LMS API client. /// The logger instance. public LmsStatusPoller(ILmsApiClient lmsClient, ILogger logger) { _lmsClient = lmsClient; _logger = logger; } private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// /// Waits for LMS to report that playback has started (mode="play"). /// /// The player's MAC address. /// Maximum time to wait. /// Cancellation token. /// True if playback started within the timeout. public async Task WaitForPlaybackStartAsync( string playerMac, TimeSpan timeout, CancellationToken cancellationToken = default) { return await WaitForModeAsync(playerMac, "play", timeout, cancellationToken).ConfigureAwait(false); } /// /// Waits for LMS to report a specific playback mode. /// /// The player's MAC address. /// The expected mode ("play", "pause", "stop"). /// Maximum time to wait. /// Cancellation token. /// True if the expected mode was detected within the timeout. public async Task 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; } /// /// Waits for LMS to report a position within tolerance of the target. /// Used to confirm seek operations completed. /// /// The player's MAC address. /// The target position in seconds. /// Acceptable tolerance (default 2 seconds). /// Maximum time to wait. /// Cancellation token. /// True if the position was reached within the timeout. public async Task 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; } /// /// Executes an action with automatic retry on failure. /// /// The async action to execute. /// Maximum number of retries. /// Optional callback when retrying. /// Cancellation token. /// True if the action succeeded within retry limit. public async Task ExecuteWithRetryAsync( Func> action, int maxRetries, Action? 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; } }