From a199fe452c4cc6eb26180b5d148f59ed045a86d7 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 30 Dec 2025 14:37:27 +0100 Subject: [PATCH] remove redundant restAPI playback is controlled by state machine --- .../Api/JellyLmsController.cs | 142 -------- .../Configuration/PluginConfiguration.cs | 20 ++ .../Models/LmsPlaybackSession.cs | 95 +++++- .../PluginServiceRegistrator.cs | 2 - .../Services/LmsSessionController.cs | 191 +++++++++-- .../Services/LmsSessionManager.cs | 313 ------------------ .../Services/LmsStatusPoller.cs | 228 +++++++++++++ .../Services/PlaybackStateMachine.cs | 234 +++++++++++++ README.md | 122 +++++-- 9 files changed, 824 insertions(+), 523 deletions(-) delete mode 100644 Jellyfin.Plugin.JellyLMS/Services/LmsSessionManager.cs create mode 100644 Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs create mode 100644 Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs 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