remove redundant restAPI
Build Plugin / build (push) Successful in 2m46s
Release Plugin / build-and-release (push) Successful in 2m44s

playback is controlled by state machine
This commit is contained in:
2025-12-30 14:37:27 +01:00
parent 29cd6dfaeb
commit a199fe452c
9 changed files with 824 additions and 523 deletions
@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -23,12 +23,16 @@ public class LmsSessionController : ISessionController, IDisposable
private readonly SessionInfo _session;
private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager;
private readonly PlaybackStateMachine _stateMachine;
private readonly LmsStatusPoller _statusPoller;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Timer? _progressTimer;
private bool _disposed;
private BaseItem? _currentItem;
private Guid[] _playlist = [];
private int _playlistIndex;
private long _seekOffsetTicks; // Offset from transcoded stream start position
private PlaybackErrorInfo? _lastError;
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class.
@@ -53,22 +57,38 @@ public class LmsSessionController : ISessionController, IDisposable
_session = session;
_sessionManager = sessionManager;
_libraryManager = libraryManager;
_stateMachine = new PlaybackStateMachine(logger);
_statusPoller = new LmsStatusPoller(lmsClient, logger);
}
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Gets or sets the currently playing item ID.
/// </summary>
public Guid? CurrentItemId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether playback is currently active.
/// Gets a value indicating whether playback is currently active.
/// </summary>
public bool IsPlaying { get; set; }
public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
|| _stateMachine.CurrentState == PlaybackState.Loading
|| _stateMachine.CurrentState == PlaybackState.Seeking;
/// <summary>
/// Gets or sets a value indicating whether playback is paused.
/// Gets a value indicating whether playback is paused.
/// </summary>
public bool IsPaused { get; set; }
public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState State => _stateMachine.CurrentState;
/// <summary>
/// Gets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError => _lastError;
/// <inheritdoc />
public bool IsSessionActive => _player.IsConnected;
@@ -185,12 +205,51 @@ public class LmsSessionController : ISessionController, IDisposable
useDirectPath,
streamUrl);
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Transition to Loading state before sending command
_stateMachine.TryTransition(PlaybackState.Loading, "Starting playback");
// Send play command with retry logic
var playSuccess = await _statusPoller.ExecuteWithRetryAsync(
async () => await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false),
Config.MaxAutoRetries,
retry => _logger.LogInformation("Retrying play command (attempt {Retry})", retry),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!playSuccess)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.LmsError,
Message = "Failed to send play command to LMS after retries",
OccurredAt = DateTime.UtcNow,
RetryCount = Config.MaxAutoRetries
};
_stateMachine.TryTransition(PlaybackState.Error, "Play command failed");
return;
}
// Wait for LMS to confirm playback started
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!started)
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = $"LMS did not start playing within {loadingTimeout.TotalSeconds}s",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "Loading timeout");
return;
}
// Track current playback state
CurrentItemId = itemId;
IsPlaying = true;
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "LMS confirmed playback");
// Track the seek offset so we report the correct position
// When using direct file paths, LMS handles seeking natively so no offset needed
@@ -252,7 +311,14 @@ public class LmsSessionController : ISessionController, IDisposable
private async Task ReportPlaybackProgressAsync()
{
if (!IsPlaying || !CurrentItemId.HasValue)
// Don't report during Loading, Seeking, or Error states
var currentState = _stateMachine.CurrentState;
if (currentState == PlaybackState.Loading
|| currentState == PlaybackState.Seeking
|| currentState == PlaybackState.Error
|| currentState == PlaybackState.Stopped
|| currentState == PlaybackState.Idle
|| !CurrentItemId.HasValue)
{
return;
}
@@ -269,19 +335,29 @@ public class LmsSessionController : ISessionController, IDisposable
// we're playing a transcoded stream that starts at the seek position.
// Add the seek offset to get the actual track position.
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks;
var isPaused = status.Mode == "pause";
var lmsIsPaused = status.Mode == "pause";
// Update our local state from LMS
IsPaused = isPaused;
// Sync state machine with LMS state (handles external pause/play)
if (lmsIsPaused && currentState == PlaybackState.Playing)
{
_stateMachine.TryTransition(PlaybackState.Paused, "LMS reported pause");
}
else if (!lmsIsPaused && status.Mode == "play" && currentState == PlaybackState.Paused)
{
_stateMachine.TryTransition(PlaybackState.Playing, "LMS reported play");
}
// Check if playback has stopped on LMS side (track ended)
// Only advance if we're not paused - LMS can briefly report "stop" during transitions
if (status.Mode == "stop" && !IsPaused)
if (status.Mode == "stop" && currentState == PlaybackState.Playing)
{
// Double-check by getting status again after a brief delay to avoid false positives
await Task.Delay(500).ConfigureAwait(false);
var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (confirmStatus?.Mode != "stop")
// Confirm stop with a quick poll instead of fixed delay
var stillStopped = await _statusPoller.WaitForModeAsync(
_player.MacAddress,
"stop",
TimeSpan.FromMilliseconds(500),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!stillStopped)
{
_logger.LogDebug("LMS mode changed from stop, ignoring");
return;
@@ -311,7 +387,7 @@ public class LmsSessionController : ISessionController, IDisposable
{
ItemId = CurrentItemId.Value,
SessionId = _session.Id,
IsPaused = isPaused,
IsPaused = _stateMachine.CurrentState == PlaybackState.Paused,
PositionTicks = positionTicks,
PlayMethod = PlayMethod.DirectStream,
CanSeek = true,
@@ -347,8 +423,7 @@ public class LmsSessionController : ISessionController, IDisposable
_logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value);
await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false);
IsPlaying = false;
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Stopped, "Playback stopped");
CurrentItemId = null;
}
catch (Exception ex)
@@ -382,29 +457,28 @@ public class LmsSessionController : ISessionController, IDisposable
case PlaystateCommand.Pause:
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Pause command result: {Result}", pauseResult);
IsPaused = true;
_stateMachine.TryTransition(PlaybackState.Paused, "Pause command");
break;
case PlaystateCommand.Unpause:
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Unpause/Play command result: {Result}", playResult);
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "Unpause command");
break;
case PlaystateCommand.PlayPause:
// Toggle play/pause - check current state first
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
if (currentState?.Mode == "play")
// Toggle play/pause based on current state
if (_stateMachine.CurrentState == PlaybackState.Playing)
{
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
IsPaused = true;
_stateMachine.TryTransition(PlaybackState.Paused, "PlayPause toggle to pause");
}
else
{
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
IsPaused = false;
_stateMachine.TryTransition(PlaybackState.Playing, "PlayPause toggle to play");
}
break;
@@ -419,7 +493,10 @@ public class LmsSessionController : ISessionController, IDisposable
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
{
var positionTicks = playstateRequest.SeekPositionTicks.Value;
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
var positionSeconds = (double)(positionTicks / TimeSpan.TicksPerSecond);
// Transition to Seeking state (state machine remembers previous state)
_stateMachine.TryTransition(PlaybackState.Seeking, "Seek command");
// Check if we're using direct file path mode - if so, LMS can seek natively
if (CanSeekNatively())
@@ -427,13 +504,37 @@ public class LmsSessionController : ISessionController, IDisposable
// Use native LMS seeking - much smoother!
_logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds);
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
// Wait for seek to complete
var seekTimeout = TimeSpan.FromSeconds(Config.SeekTimeoutSeconds);
var seekComplete = await _statusPoller.WaitForSeekCompleteAsync(
_player.MacAddress,
positionSeconds,
toleranceSeconds: 2.0,
seekTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
// No seek offset needed - LMS handles position tracking natively
_seekOffsetTicks = 0;
// Restore previous state
var previousState = _stateMachine.StateBeforeSeek;
if (seekComplete)
{
_stateMachine.TryTransition(previousState, "Seek completed");
}
else
{
_logger.LogWarning("Seek may not have completed within timeout, restoring state anyway");
_stateMachine.TryTransition(previousState, "Seek timeout - restoring state");
}
}
else
{
// For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks
// Build a new URL with the seek position and restart playback
// This is essentially a new playback, so transition to Loading
_stateMachine.TryTransition(PlaybackState.Loading, "HTTP stream seek - restarting");
var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
_logger.LogInformation(
"Seeking by restarting stream at position {Seconds}s: {Url}",
@@ -442,9 +543,30 @@ public class LmsSessionController : ISessionController, IDisposable
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
// Wait for playback to start
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
var started = await _statusPoller.WaitForPlaybackStartAsync(
_player.MacAddress,
loadingTimeout,
_cancellationTokenSource.Token).ConfigureAwait(false);
// Track the seek offset so we report the correct position
// The transcoded stream starts at 0, but we need to report the actual track position
_seekOffsetTicks = positionTicks;
if (started)
{
_stateMachine.TryTransition(PlaybackState.Playing, "HTTP stream seek completed");
}
else
{
_lastError = new PlaybackErrorInfo
{
ErrorType = PlaybackErrorType.Timeout,
Message = "Stream restart after seek failed to start",
OccurredAt = DateTime.UtcNow
};
_stateMachine.TryTransition(PlaybackState.Error, "HTTP stream seek failed");
}
}
_logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond);
@@ -686,7 +808,14 @@ public class LmsSessionController : ISessionController, IDisposable
if (disposing)
{
// Cancel any pending operations
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
StopProgressTimer();
// Reset state machine
_stateMachine.Reset();
}
_disposed = true;
@@ -1,313 +0,0 @@
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;
}
}
@@ -0,0 +1,228 @@
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;
}
}
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JellyLMS.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Event args for state transitions.
/// </summary>
public class StateTransitionEventArgs : EventArgs
{
/// <summary>
/// Gets the state before the transition.
/// </summary>
public PlaybackState FromState { get; init; }
/// <summary>
/// Gets the state after the transition.
/// </summary>
public PlaybackState ToState { get; init; }
/// <summary>
/// Gets the reason for the transition.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Manages playback state transitions with validation.
/// </summary>
public class PlaybackStateMachine
{
private readonly object _lock = new();
private readonly ILogger? _logger;
private PlaybackState _currentState = PlaybackState.Idle;
private PlaybackState _stateBeforeSeek = PlaybackState.Idle;
/// <summary>
/// Valid state transitions. Key is the current state, value is the array of valid next states.
/// </summary>
private static readonly Dictionary<PlaybackState, PlaybackState[]> ValidTransitions = new()
{
[PlaybackState.Idle] = [PlaybackState.Loading, PlaybackState.Stopped],
[PlaybackState.Loading] = [PlaybackState.Playing, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Playing] = [PlaybackState.Paused, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Paused] = [PlaybackState.Playing, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Seeking] = [PlaybackState.Playing, PlaybackState.Paused, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Error] = [PlaybackState.Loading, PlaybackState.Idle, PlaybackState.Stopped],
[PlaybackState.Stopped] = [PlaybackState.Idle, PlaybackState.Loading]
};
/// <summary>
/// Initializes a new instance of the <see cref="PlaybackStateMachine"/> class.
/// </summary>
/// <param name="logger">Optional logger for state transitions.</param>
public PlaybackStateMachine(ILogger? logger = null)
{
_logger = logger;
}
/// <summary>
/// Fired when the state changes.
/// </summary>
public event EventHandler<StateTransitionEventArgs>? StateChanged;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState CurrentState
{
get
{
lock (_lock)
{
return _currentState;
}
}
}
/// <summary>
/// Gets the state before the current seek operation (if in Seeking state).
/// Used to restore the correct state after seeking completes.
/// </summary>
public PlaybackState StateBeforeSeek
{
get
{
lock (_lock)
{
return _stateBeforeSeek;
}
}
}
/// <summary>
/// Gets a value indicating whether playback is active (Playing or Paused).
/// </summary>
public bool IsPlaybackActive
{
get
{
lock (_lock)
{
return _currentState == PlaybackState.Playing
|| _currentState == PlaybackState.Paused
|| _currentState == PlaybackState.Seeking
|| _currentState == PlaybackState.Loading;
}
}
}
/// <summary>
/// Attempts to transition to a new state.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Optional reason for the transition (for logging).</param>
/// <returns>True if the transition was valid and completed.</returns>
public bool TryTransition(PlaybackState newState, string? reason = null)
{
lock (_lock)
{
if (_currentState == newState)
{
return true; // Already in this state
}
if (!IsValidTransition(_currentState, newState))
{
_logger?.LogWarning(
"Invalid state transition attempted: {From} -> {To} (reason: {Reason})",
_currentState,
newState,
reason ?? "none");
return false;
}
// Store state before seek for restoration
if (newState == PlaybackState.Seeking)
{
_stateBeforeSeek = _currentState;
}
var oldState = _currentState;
_currentState = newState;
_logger?.LogInformation(
"State transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason ?? "none");
// Fire event outside the lock to prevent deadlocks
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = reason
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
return true;
}
}
/// <summary>
/// Forces a state change without validation. Use with caution.
/// Intended for error recovery scenarios.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Reason for the forced transition.</param>
public void ForceState(PlaybackState newState, string reason)
{
lock (_lock)
{
var oldState = _currentState;
_currentState = newState;
_logger?.LogWarning(
"Forced state transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason);
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = $"FORCED: {reason}"
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
}
}
/// <summary>
/// Resets the state machine to Idle.
/// </summary>
public void Reset()
{
ForceState(PlaybackState.Idle, "Reset");
}
/// <summary>
/// Checks if a transition from one state to another is valid.
/// </summary>
/// <param name="from">The current state.</param>
/// <param name="to">The target state.</param>
/// <returns>True if the transition is valid.</returns>
public static bool IsValidTransition(PlaybackState from, PlaybackState to)
{
if (!ValidTransitions.TryGetValue(from, out var validTargets))
{
return false;
}
return Array.IndexOf(validTargets, to) >= 0;
}
}