diff --git a/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs b/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
index e5c4dea..751c810 100644
--- a/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
+++ b/Jellyfin.Plugin.JellyLMS/Api/JellyLmsController.cs
@@ -28,7 +28,6 @@ public class JellyLmsController : ControllerBase
{
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
- private readonly LmsSessionManager _sessionManager;
private readonly ILibraryManager _libraryManager;
///
@@ -36,17 +35,14 @@ public class JellyLmsController : ControllerBase
///
/// The LMS API client.
/// The player manager.
- /// The session manager.
/// The library manager.
public JellyLmsController(
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
- LmsSessionManager sessionManager,
ILibraryManager libraryManager)
{
_lmsClient = lmsClient;
_playerManager = playerManager;
- _sessionManager = sessionManager;
_libraryManager = libraryManager;
}
@@ -192,110 +188,6 @@ public class JellyLmsController : ControllerBase
return success ? Ok() : BadRequest("Failed to dissolve sync group");
}
- ///
- /// Gets all active playback sessions.
- ///
- /// List of active sessions.
- [HttpGet("Sessions")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult> GetSessions()
- {
- return Ok(_sessionManager.GetActiveSessions());
- }
-
- ///
- /// Starts playback of a Jellyfin item on LMS players.
- ///
- /// The playback request.
- /// The created session.
- [HttpPost("Sessions/Play")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- public async Task> StartPlayback([FromBody] StartPlaybackRequest request)
- {
- var session = await _sessionManager.StartPlaybackAsync(request.ItemId, request.PlayerMacs, request.UserId)
- .ConfigureAwait(false);
-
- if (session == null)
- {
- return BadRequest("Failed to start playback");
- }
-
- return Ok(session);
- }
-
- ///
- /// Pauses a playback session.
- ///
- /// The session ID.
- /// Success status.
- [HttpPost("Sessions/{sessionId}/Pause")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task PauseSession(string sessionId)
- {
- var success = await _sessionManager.PauseSessionAsync(sessionId).ConfigureAwait(false);
- return success ? Ok() : NotFound();
- }
-
- ///
- /// Resumes a paused playback session.
- ///
- /// The session ID.
- /// Success status.
- [HttpPost("Sessions/{sessionId}/Resume")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task ResumeSession(string sessionId)
- {
- var success = await _sessionManager.ResumeSessionAsync(sessionId).ConfigureAwait(false);
- return success ? Ok() : NotFound();
- }
-
- ///
- /// Stops a playback session.
- ///
- /// The session ID.
- /// Success status.
- [HttpPost("Sessions/{sessionId}/Stop")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task StopSession(string sessionId)
- {
- var success = await _sessionManager.StopSessionAsync(sessionId).ConfigureAwait(false);
- return success ? Ok() : NotFound();
- }
-
- ///
- /// Seeks to a position in the playback session.
- ///
- /// The session ID.
- /// The seek request.
- /// Success status.
- [HttpPost("Sessions/{sessionId}/Seek")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task SeekSession(string sessionId, [FromBody] SeekRequest request)
- {
- var success = await _sessionManager.SeekAsync(sessionId, request.PositionTicks).ConfigureAwait(false);
- return success ? Ok() : NotFound();
- }
-
- ///
- /// Sets the volume for all players in a session.
- ///
- /// The session ID.
- /// The volume request.
- /// Success status.
- [HttpPost("Sessions/{sessionId}/Volume")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task SetSessionVolume(string sessionId, [FromBody] VolumeRequest request)
- {
- var success = await _sessionManager.SetVolumeAsync(sessionId, request.Volume).ConfigureAwait(false);
- return success ? Ok() : NotFound();
- }
-
///
/// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access.
@@ -407,37 +299,3 @@ public class CreateSyncGroupRequest
[Required]
public List SlaveMacs { get; set; } = [];
}
-
-///
-/// Request to start playback.
-///
-public class StartPlaybackRequest
-{
- ///
- /// Gets or sets the Jellyfin item ID.
- ///
- [Required]
- public Guid ItemId { get; set; }
-
- ///
- /// Gets or sets the LMS player MAC addresses.
- ///
- [Required]
- public List PlayerMacs { get; set; } = [];
-
- ///
- /// Gets or sets the optional user ID.
- ///
- public Guid? UserId { get; set; }
-}
-
-///
-/// Request to seek to a position.
-///
-public class SeekRequest
-{
- ///
- /// Gets or sets the position in ticks.
- ///
- public long PositionTicks { get; set; }
-}
diff --git a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
index 0d2e128..6585201 100644
--- a/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
+++ b/Jellyfin.Plugin.JellyLMS/Configuration/PluginConfiguration.cs
@@ -106,6 +106,26 @@ public class PluginConfiguration : BasePluginConfiguration
///
public List PathMappings { get; set; } = new();
+ ///
+ /// Gets or sets the timeout in seconds when waiting for LMS to start playback.
+ ///
+ public int LoadingTimeoutSeconds { get; set; } = 5;
+
+ ///
+ /// Gets or sets the timeout in seconds when waiting for a seek operation to complete.
+ ///
+ public int SeekTimeoutSeconds { get; set; } = 3;
+
+ ///
+ /// Gets or sets the polling interval in milliseconds during state transitions.
+ ///
+ public int TransitionPollIntervalMs { get; set; } = 300;
+
+ ///
+ /// Gets or sets the number of automatic retries for transient failures.
+ ///
+ public int MaxAutoRetries { get; set; } = 2;
+
///
/// Gets all effective path mappings, including legacy single mapping if configured.
///
diff --git a/Jellyfin.Plugin.JellyLMS/Models/LmsPlaybackSession.cs b/Jellyfin.Plugin.JellyLMS/Models/LmsPlaybackSession.cs
index 60dd4eb..a97488e 100644
--- a/Jellyfin.Plugin.JellyLMS/Models/LmsPlaybackSession.cs
+++ b/Jellyfin.Plugin.JellyLMS/Models/LmsPlaybackSession.cs
@@ -9,9 +9,14 @@ namespace Jellyfin.Plugin.JellyLMS.Models;
public enum PlaybackState
{
///
- /// Playback is stopped.
+ /// Device connected, no media loaded.
///
- Stopped,
+ Idle,
+
+ ///
+ /// Play command sent, waiting for LMS to confirm playback started.
+ ///
+ Loading,
///
/// Playback is active.
@@ -21,7 +26,84 @@ public enum PlaybackState
///
/// Playback is paused.
///
- Paused
+ Paused,
+
+ ///
+ /// Position change in progress.
+ ///
+ Seeking,
+
+ ///
+ /// Playback failed with an error.
+ ///
+ Error,
+
+ ///
+ /// Playback has ended.
+ ///
+ Stopped
+}
+
+///
+/// Types of playback errors.
+///
+public enum PlaybackErrorType
+{
+ ///
+ /// No error.
+ ///
+ None,
+
+ ///
+ /// Operation timed out waiting for LMS response.
+ ///
+ Timeout,
+
+ ///
+ /// Network error communicating with LMS.
+ ///
+ NetworkError,
+
+ ///
+ /// LMS returned an error.
+ ///
+ LmsError,
+
+ ///
+ /// Error with the audio stream from Jellyfin.
+ ///
+ StreamError,
+
+ ///
+ /// Unknown error.
+ ///
+ Unknown
+}
+
+///
+/// Contains details about a playback error.
+///
+public class PlaybackErrorInfo
+{
+ ///
+ /// Gets or sets the type of error.
+ ///
+ public PlaybackErrorType ErrorType { get; set; }
+
+ ///
+ /// Gets or sets the error message.
+ ///
+ public string Message { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets when the error occurred.
+ ///
+ public DateTime OccurredAt { get; set; }
+
+ ///
+ /// Gets or sets the number of retry attempts made.
+ ///
+ public int RetryCount { get; set; }
}
///
@@ -67,7 +149,12 @@ public class LmsPlaybackSession
///
/// Gets or sets the current playback state.
///
- public PlaybackState State { get; set; } = PlaybackState.Stopped;
+ public PlaybackState State { get; set; } = PlaybackState.Idle;
+
+ ///
+ /// Gets or sets the last error that occurred during playback.
+ ///
+ public PlaybackErrorInfo? LastError { get; set; }
///
/// Gets or sets the current playback position in ticks.
diff --git a/Jellyfin.Plugin.JellyLMS/PluginServiceRegistrator.cs b/Jellyfin.Plugin.JellyLMS/PluginServiceRegistrator.cs
index 4ecdc7e..c46d076 100644
--- a/Jellyfin.Plugin.JellyLMS/PluginServiceRegistrator.cs
+++ b/Jellyfin.Plugin.JellyLMS/PluginServiceRegistrator.cs
@@ -15,8 +15,6 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
{
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
- serviceCollection.AddSingleton();
- serviceCollection.AddHostedService(sp => sp.GetRequiredService());
// Device discovery service - registers LMS players as Jellyfin sessions for casting
// Use AddHostedService directly to let DI handle construction
diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs
index 03830eb..ddd4d53 100644
--- a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs
+++ b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionController.cs
@@ -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;
///
/// Initializes a new instance of the 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();
+
///
/// Gets or sets the currently playing item ID.
///
public Guid? CurrentItemId { get; set; }
///
- /// Gets or sets a value indicating whether playback is currently active.
+ /// Gets a value indicating whether playback is currently active.
///
- public bool IsPlaying { get; set; }
+ public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
+ || _stateMachine.CurrentState == PlaybackState.Loading
+ || _stateMachine.CurrentState == PlaybackState.Seeking;
///
- /// Gets or sets a value indicating whether playback is paused.
+ /// Gets a value indicating whether playback is paused.
///
- public bool IsPaused { get; set; }
+ public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
+
+ ///
+ /// Gets the current playback state.
+ ///
+ public PlaybackState State => _stateMachine.CurrentState;
+
+ ///
+ /// Gets the last error that occurred during playback.
+ ///
+ public PlaybackErrorInfo? LastError => _lastError;
///
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;
diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionManager.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsSessionManager.cs
deleted file mode 100644
index ebec9ae..0000000
--- a/Jellyfin.Plugin.JellyLMS/Services/LmsSessionManager.cs
+++ /dev/null
@@ -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;
-
-///
-/// Manages active playback sessions between Jellyfin and LMS.
-///
-public class LmsSessionManager : IHostedService
-{
- private readonly ILogger _logger;
- private readonly ILmsApiClient _lmsClient;
- private readonly LmsPlayerManager _playerManager;
- private readonly ILibraryManager _libraryManager;
- private readonly ConcurrentDictionary _sessions = new();
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The logger instance.
- /// The LMS API client.
- /// The player manager.
- /// The Jellyfin library manager.
- public LmsSessionManager(
- ILogger logger,
- ILmsApiClient lmsClient,
- LmsPlayerManager playerManager,
- ILibraryManager libraryManager)
- {
- _logger = logger;
- _lmsClient = lmsClient;
- _playerManager = playerManager;
- _libraryManager = libraryManager;
- }
-
- private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
-
- ///
- /// Gets all active sessions.
- ///
- /// List of active sessions.
- public List GetActiveSessions()
- {
- return _sessions.Values.Where(s => s.State != PlaybackState.Stopped).ToList();
- }
-
- ///
- /// Gets a session by ID.
- ///
- /// The session ID.
- /// The session, or null if not found.
- public LmsPlaybackSession? GetSession(string sessionId)
- {
- return _sessions.GetValueOrDefault(sessionId);
- }
-
- ///
- /// Starts playback of a Jellyfin item on LMS players.
- ///
- /// The Jellyfin item ID.
- /// The LMS player MAC addresses.
- /// Optional user ID.
- /// The created session.
- public async Task StartPlaybackAsync(
- Guid itemId,
- IEnumerable 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;
- }
-
- ///
- /// Pauses a playback session.
- ///
- /// The session ID.
- /// True if successful.
- public async Task 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;
- }
-
- ///
- /// Resumes a paused playback session.
- ///
- /// The session ID.
- /// True if successful.
- public async Task 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;
- }
-
- ///
- /// Stops a playback session.
- ///
- /// The session ID.
- /// True if successful.
- public async Task 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;
- }
-
- ///
- /// Seeks to a position in the playback session.
- ///
- /// The session ID.
- /// The position in ticks.
- /// True if successful.
- public async Task 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;
- }
-
- ///
- /// Sets the volume for all players in a session.
- ///
- /// The session ID.
- /// The volume level (0-100).
- /// True if successful.
- public async Task 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;
- }
-
- ///
- /// Updates the session state from LMS.
- ///
- /// The session ID.
- /// A task representing the operation.
- 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";
- }
-
- ///
- public Task StartAsync(CancellationToken cancellationToken)
- {
- _logger.LogInformation("LMS Session Manager started");
- return Task.CompletedTask;
- }
-
- ///
- 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;
- }
-}
diff --git a/Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs b/Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs
new file mode 100644
index 0000000..f0eda8f
--- /dev/null
+++ b/Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs
@@ -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;
+
+///
+/// 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;
+ }
+}
diff --git a/Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs b/Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs
new file mode 100644
index 0000000..2c827c6
--- /dev/null
+++ b/Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Plugin.JellyLMS.Models;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.JellyLMS.Services;
+
+///
+/// Event args for state transitions.
+///
+public class StateTransitionEventArgs : EventArgs
+{
+ ///
+ /// Gets the state before the transition.
+ ///
+ public PlaybackState FromState { get; init; }
+
+ ///
+ /// Gets the state after the transition.
+ ///
+ public PlaybackState ToState { get; init; }
+
+ ///
+ /// Gets the reason for the transition.
+ ///
+ public string? Reason { get; init; }
+}
+
+///
+/// Manages playback state transitions with validation.
+///
+public class PlaybackStateMachine
+{
+ private readonly object _lock = new();
+ private readonly ILogger? _logger;
+ private PlaybackState _currentState = PlaybackState.Idle;
+ private PlaybackState _stateBeforeSeek = PlaybackState.Idle;
+
+ ///
+ /// Valid state transitions. Key is the current state, value is the array of valid next states.
+ ///
+ private static readonly Dictionary 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]
+ };
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Optional logger for state transitions.
+ public PlaybackStateMachine(ILogger? logger = null)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Fired when the state changes.
+ ///
+ public event EventHandler? StateChanged;
+
+ ///
+ /// Gets the current playback state.
+ ///
+ public PlaybackState CurrentState
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _currentState;
+ }
+ }
+ }
+
+ ///
+ /// Gets the state before the current seek operation (if in Seeking state).
+ /// Used to restore the correct state after seeking completes.
+ ///
+ public PlaybackState StateBeforeSeek
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _stateBeforeSeek;
+ }
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether playback is active (Playing or Paused).
+ ///
+ public bool IsPlaybackActive
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _currentState == PlaybackState.Playing
+ || _currentState == PlaybackState.Paused
+ || _currentState == PlaybackState.Seeking
+ || _currentState == PlaybackState.Loading;
+ }
+ }
+ }
+
+ ///
+ /// Attempts to transition to a new state.
+ ///
+ /// The target state.
+ /// Optional reason for the transition (for logging).
+ /// True if the transition was valid and completed.
+ 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;
+ }
+ }
+
+ ///
+ /// Forces a state change without validation. Use with caution.
+ /// Intended for error recovery scenarios.
+ ///
+ /// The target state.
+ /// Reason for the forced transition.
+ 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");
+ }
+ }
+ }
+
+ ///
+ /// Resets the state machine to Idle.
+ ///
+ public void Reset()
+ {
+ ForceState(PlaybackState.Idle, "Reset");
+ }
+
+ ///
+ /// Checks if a transition from one state to another is valid.
+ ///
+ /// The current state.
+ /// The target state.
+ /// True if the transition is valid.
+ public static bool IsValidTransition(PlaybackState from, PlaybackState to)
+ {
+ if (!ValidTransitions.TryGetValue(from, out var validTargets))
+ {
+ return false;
+ }
+
+ return Array.IndexOf(validTargets, to) >= 0;
+ }
+}
diff --git a/README.md b/README.md
index 67bf34c..5e5b2b5 100644
--- a/README.md
+++ b/README.md
@@ -31,15 +31,15 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
│ │ Library │──┼────────►│ LmsApiClient │────────►│ │ Players │ │
│ │ (Audio) │ │ │ │ │ │ (Zones) │ │
│ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │
-│ │ │ │ Session │ │ │ │
-│ ┌───────────┐ │ │ │ Manager │ │ │ ┌───────────┐ │
-│ │ Queue │──┼────────►│ └───────────┘ │────────►│ │ Sync │ │
-│ │ │ │ │ │ │ │ Groups │ │
-│ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │
-│ │ │ │ REST API │ │ │ │
-│ ┌───────────┐ │ │ │Controller │ │ │ │
-│ │ Playback │──┼────────►│ └───────────┘ │ │ │
-│ │ Controls │ │ │ │ │ │
+│ │ │ │ Session │ │ │ │
+│ ┌───────────┐ │ │ │Controller │ │ │ ┌───────────┐ │
+│ │ Queue │──┼────────►│ │ (State │ │────────►│ │ Sync │ │
+│ │ │ │ │ │ Machine) │ │ │ │ Groups │ │
+│ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │
+│ │ │ │ │ │
+│ ┌───────────┐ │ │ ┌───────────┐ │ │ │
+│ │ Playback │──┼────────►│ │ REST API │ │ │ │
+│ │ Controls │ │ │ └───────────┘ │ │ │
│ └───────────┘ │ └─────────────────┘ └─────────────────┘
└─────────────────┘
```
@@ -48,8 +48,9 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
- **Player Discovery**: Automatically discovers all LMS players/zones
- **Multi-Room Sync**: Create and manage sync groups for synchronized playback across multiple rooms
-- **Playback Control**: Play, pause, stop, seek, and volume control forwarded to LMS
+- **Playback Control**: Play, pause, stop, seek, and volume control via Jellyfin's "Play On" (cast) interface
- **Stream Bridging**: Generates audio stream URLs from Jellyfin for LMS to consume
+- **Robust State Machine**: Ensures proper sequencing of playback operations with automatic retry and timeout handling
## Screenshots
@@ -76,9 +77,62 @@ Create and manage synchronized playback groups for multi-room audio:
## Requirements
- Jellyfin Server 10.10.0 or later
-- .NET 8.0 Runtime
+- .NET 9.0 Runtime
- Logitech Media Server (LMS) with JSON-RPC API enabled (default on port 9000)
+## Playback Architecture
+
+JellyLMS uses Jellyfin's native "Play On" (cast) interface to control LMS players. When you select an LMS player from Jellyfin's cast menu, playback is managed through a robust state machine that ensures reliable operation.
+
+### State Machine
+
+The playback controller uses a state machine to ensure proper sequencing of operations:
+
+```
+ ┌────────┐
+ │ Idle │ (device connected, no media)
+ └───┬────┘
+ │ Play command
+ ▼
+ ┌────────┐
+ ┌────►│Loading │◄────┐
+ │ └───┬────┘ │
+ │ │ │ Seek (HTTP streaming
+ │ LMS confirms │ restarts stream)
+ │ mode="play" │
+ │ ▼ │
+┌───────┐ │ ┌────────┐ │
+│ Error │◄────┼─────│Playing │─────┘
+└───┬───┘ │ └───┬────┘
+ │ │ │ Pause
+ retry │ ▼
+ │ │ ┌────────┐
+ └─────────┼─────│ Paused │
+ │ └───┬────┘
+ │ │ Seek (native LMS)
+ │ ▼
+ │ ┌────────┐
+ └─────│Seeking │
+ └────────┘
+
+From any state: Stop → Stopped
+```
+
+### How Playback Works
+
+1. **Cast Request**: User selects an LMS player from Jellyfin's "Play On" menu
+2. **Loading**: Plugin sends play command to LMS and transitions to Loading state
+3. **Confirmation**: Plugin polls LMS until playback is confirmed (mode="play")
+4. **Playing**: Playback is active; progress is synced between Jellyfin and LMS
+5. **Controls**: Play, pause, seek, and volume commands are forwarded to LMS
+
+### Error Handling
+
+The state machine includes automatic retry with exponential backoff:
+- **Timeout errors**: Auto-retry up to 2 times (500ms → 1s delay)
+- **Network errors**: Auto-retry up to 2 times
+- **LMS errors**: No retry, transition to Error state
+
## Installation
### Manual Installation
@@ -101,7 +155,7 @@ cd jellyLMS
dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
# The DLL will be in:
-# Jellyfin.Plugin.JellyLMS/bin/Release/net8.0/
+# Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/
```
## Configuration
@@ -116,41 +170,45 @@ dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
| Connection Timeout | Timeout for LMS API calls (seconds) | `10` |
| Enable Auto Sync | Automatically sync players when creating groups | `true` |
| Default Player | MAC address of the default player | (none) |
+| Use Direct File Path | Enable direct file access instead of HTTP streaming | `false` |
+
+### Advanced Settings (State Machine)
+
+| Setting | Description | Default |
+|---------|-------------|---------|
+| Loading Timeout | Max time to wait for LMS to start playback (seconds) | `5` |
+| Seek Timeout | Max time to wait for seek to complete (seconds) | `3` |
+| Transition Poll Interval | How often to poll LMS during state transitions (ms) | `300` |
+| Max Auto Retries | Number of automatic retries for transient failures | `2` |
3. Click "Test Connection" to verify connectivity to LMS
4. Use "Discover Players" to see available LMS players
## API Endpoints
-The plugin exposes REST API endpoints under `/JellyLms/`:
+The plugin exposes REST API endpoints under `/JellyLms/` for player and sync group management.
+
+**Note:** Playback control (play, pause, seek, volume) is handled through Jellyfin's native "Play On" (cast) interface, not through REST endpoints.
### Players
- `GET /JellyLms/Players` - List all LMS players
- `GET /JellyLms/Players/{mac}` - Get specific player details
-- `POST /JellyLms/Players/Refresh` - Refresh player list from LMS
+- `POST /JellyLms/Players/{mac}/PowerOn` - Power on a player
+- `POST /JellyLms/Players/{mac}/PowerOff` - Power off a player
+- `POST /JellyLms/Players/{mac}/Volume` - Set player volume
### Sync Groups
- `GET /JellyLms/SyncGroups` - List all sync groups
- `POST /JellyLms/SyncGroups` - Create a new sync group
- `DELETE /JellyLms/SyncGroups/{masterMac}` - Dissolve a sync group
-- `DELETE /JellyLms/SyncGroups/{masterMac}/Players/{slaveMac}` - Remove player from group
+- `DELETE /JellyLms/SyncGroups/Players/{mac}` - Remove player from its sync group
-### Sessions
+### Utilities
-- `GET /JellyLms/Sessions` - List active playback sessions
-- `POST /JellyLms/Sessions` - Start a new playback session
-- `POST /JellyLms/Sessions/{id}/Pause` - Pause playback
-- `POST /JellyLms/Sessions/{id}/Resume` - Resume playback
-- `POST /JellyLms/Sessions/{id}/Stop` - Stop playback
-- `POST /JellyLms/Sessions/{id}/Seek` - Seek to position
-- `POST /JellyLms/Sessions/{id}/Volume` - Set volume
-
-### Status
-
-- `GET /JellyLms/Status` - Get LMS connection status
- `POST /JellyLms/TestConnection` - Test LMS connectivity
+- `GET /JellyLms/DiscoverPaths` - Discover file paths for direct file access configuration
## LMS Setup
@@ -218,15 +276,17 @@ Jellyfin.Plugin.JellyLMS/
│ ├── PluginConfiguration.cs # Plugin settings
│ └── configPage.html # Dashboard configuration UI
├── Api/
-│ └── JellyLmsController.cs # REST API endpoints
+│ └── JellyLmsController.cs # REST API endpoints (players, sync groups)
├── Services/
│ ├── ILmsApiClient.cs # LMS API interface
│ ├── LmsApiClient.cs # LMS JSON-RPC client
│ ├── LmsPlayerManager.cs # Player discovery & sync
-│ └── LmsSessionManager.cs # Playback session management
+│ ├── LmsSessionController.cs # Playback control (ISessionController)
+│ ├── PlaybackStateMachine.cs # State machine for playback lifecycle
+│ └── LmsStatusPoller.cs # Polls LMS to confirm state transitions
└── Models/
├── LmsPlayer.cs # Player model
- ├── LmsPlaybackSession.cs # Session state model
+ ├── LmsPlaybackSession.cs # Session state (incl. PlaybackState enum)
└── LmsApiModels.cs # JSON-RPC DTOs
```
@@ -237,7 +297,7 @@ Jellyfin.Plugin.JellyLMS/
dotnet build Jellyfin.Plugin.JellyLMS.sln
# Copy to Jellyfin plugins directory
-cp Jellyfin.Plugin.JellyLMS/bin/Debug/net8.0/Jellyfin.Plugin.JellyLMS.dll \
+cp Jellyfin.Plugin.JellyLMS/bin/Debug/net9.0/Jellyfin.Plugin.JellyLMS.dll \
~/.local/share/jellyfin/plugins/JellyLMS/
# Restart Jellyfin to load the plugin