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

playback is controlled by state machine
This commit is contained in:
Duncan Tourolle 2025-12-30 14:37:27 +01:00
parent 29cd6dfaeb
commit a199fe452c
9 changed files with 824 additions and 523 deletions

View File

@ -28,7 +28,6 @@ public class JellyLmsController : ControllerBase
{ {
private readonly ILmsApiClient _lmsClient; private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager; private readonly LmsPlayerManager _playerManager;
private readonly LmsSessionManager _sessionManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
@ -36,17 +35,14 @@ public class JellyLmsController : ControllerBase
/// </summary> /// </summary>
/// <param name="lmsClient">The LMS API client.</param> /// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param> /// <param name="playerManager">The player manager.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="libraryManager">The library manager.</param> /// <param name="libraryManager">The library manager.</param>
public JellyLmsController( public JellyLmsController(
ILmsApiClient lmsClient, ILmsApiClient lmsClient,
LmsPlayerManager playerManager, LmsPlayerManager playerManager,
LmsSessionManager sessionManager,
ILibraryManager libraryManager) ILibraryManager libraryManager)
{ {
_lmsClient = lmsClient; _lmsClient = lmsClient;
_playerManager = playerManager; _playerManager = playerManager;
_sessionManager = sessionManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
} }
@ -192,110 +188,6 @@ public class JellyLmsController : ControllerBase
return success ? Ok() : BadRequest("Failed to dissolve sync group"); return success ? Ok() : BadRequest("Failed to dissolve sync group");
} }
/// <summary>
/// Gets all active playback sessions.
/// </summary>
/// <returns>List of active sessions.</returns>
[HttpGet("Sessions")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<List<LmsPlaybackSession>> GetSessions()
{
return Ok(_sessionManager.GetActiveSessions());
}
/// <summary>
/// Starts playback of a Jellyfin item on LMS players.
/// </summary>
/// <param name="request">The playback request.</param>
/// <returns>The created session.</returns>
[HttpPost("Sessions/Play")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<LmsPlaybackSession>> 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);
}
/// <summary>
/// Pauses a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Pause")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> PauseSession(string sessionId)
{
var success = await _sessionManager.PauseSessionAsync(sessionId).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary>
/// Resumes a paused playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Resume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> ResumeSession(string sessionId)
{
var success = await _sessionManager.ResumeSessionAsync(sessionId).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary>
/// Stops a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Stop")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> StopSession(string sessionId)
{
var success = await _sessionManager.StopSessionAsync(sessionId).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary>
/// Seeks to a position in the playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="request">The seek request.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Seek")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SeekSession(string sessionId, [FromBody] SeekRequest request)
{
var success = await _sessionManager.SeekAsync(sessionId, request.PositionTicks).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary>
/// Sets the volume for all players in a session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="request">The volume request.</param>
/// <returns>Success status.</returns>
[HttpPost("Sessions/{sessionId}/Volume")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> SetSessionVolume(string sessionId, [FromBody] VolumeRequest request)
{
var success = await _sessionManager.SetVolumeAsync(sessionId, request.Volume).ConfigureAwait(false);
return success ? Ok() : NotFound();
}
/// <summary> /// <summary>
/// Discovers file paths used by Jellyfin's music libraries. /// Discovers file paths used by Jellyfin's music libraries.
/// Helps users configure path mappings for direct file access. /// Helps users configure path mappings for direct file access.
@ -407,37 +299,3 @@ public class CreateSyncGroupRequest
[Required] [Required]
public List<string> SlaveMacs { get; set; } = []; public List<string> SlaveMacs { get; set; } = [];
} }
/// <summary>
/// Request to start playback.
/// </summary>
public class StartPlaybackRequest
{
/// <summary>
/// Gets or sets the Jellyfin item ID.
/// </summary>
[Required]
public Guid ItemId { get; set; }
/// <summary>
/// Gets or sets the LMS player MAC addresses.
/// </summary>
[Required]
public List<string> PlayerMacs { get; set; } = [];
/// <summary>
/// Gets or sets the optional user ID.
/// </summary>
public Guid? UserId { get; set; }
}
/// <summary>
/// Request to seek to a position.
/// </summary>
public class SeekRequest
{
/// <summary>
/// Gets or sets the position in ticks.
/// </summary>
public long PositionTicks { get; set; }
}

View File

@ -106,6 +106,26 @@ public class PluginConfiguration : BasePluginConfiguration
/// </summary> /// </summary>
public List<PathMapping> PathMappings { get; set; } = new(); public List<PathMapping> PathMappings { get; set; } = new();
/// <summary>
/// Gets or sets the timeout in seconds when waiting for LMS to start playback.
/// </summary>
public int LoadingTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Gets or sets the timeout in seconds when waiting for a seek operation to complete.
/// </summary>
public int SeekTimeoutSeconds { get; set; } = 3;
/// <summary>
/// Gets or sets the polling interval in milliseconds during state transitions.
/// </summary>
public int TransitionPollIntervalMs { get; set; } = 300;
/// <summary>
/// Gets or sets the number of automatic retries for transient failures.
/// </summary>
public int MaxAutoRetries { get; set; } = 2;
/// <summary> /// <summary>
/// Gets all effective path mappings, including legacy single mapping if configured. /// Gets all effective path mappings, including legacy single mapping if configured.
/// </summary> /// </summary>

View File

@ -9,9 +9,14 @@ namespace Jellyfin.Plugin.JellyLMS.Models;
public enum PlaybackState public enum PlaybackState
{ {
/// <summary> /// <summary>
/// Playback is stopped. /// Device connected, no media loaded.
/// </summary> /// </summary>
Stopped, Idle,
/// <summary>
/// Play command sent, waiting for LMS to confirm playback started.
/// </summary>
Loading,
/// <summary> /// <summary>
/// Playback is active. /// Playback is active.
@ -21,7 +26,84 @@ public enum PlaybackState
/// <summary> /// <summary>
/// Playback is paused. /// Playback is paused.
/// </summary> /// </summary>
Paused Paused,
/// <summary>
/// Position change in progress.
/// </summary>
Seeking,
/// <summary>
/// Playback failed with an error.
/// </summary>
Error,
/// <summary>
/// Playback has ended.
/// </summary>
Stopped
}
/// <summary>
/// Types of playback errors.
/// </summary>
public enum PlaybackErrorType
{
/// <summary>
/// No error.
/// </summary>
None,
/// <summary>
/// Operation timed out waiting for LMS response.
/// </summary>
Timeout,
/// <summary>
/// Network error communicating with LMS.
/// </summary>
NetworkError,
/// <summary>
/// LMS returned an error.
/// </summary>
LmsError,
/// <summary>
/// Error with the audio stream from Jellyfin.
/// </summary>
StreamError,
/// <summary>
/// Unknown error.
/// </summary>
Unknown
}
/// <summary>
/// Contains details about a playback error.
/// </summary>
public class PlaybackErrorInfo
{
/// <summary>
/// Gets or sets the type of error.
/// </summary>
public PlaybackErrorType ErrorType { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// Gets or sets when the error occurred.
/// </summary>
public DateTime OccurredAt { get; set; }
/// <summary>
/// Gets or sets the number of retry attempts made.
/// </summary>
public int RetryCount { get; set; }
} }
/// <summary> /// <summary>
@ -67,7 +149,12 @@ public class LmsPlaybackSession
/// <summary> /// <summary>
/// Gets or sets the current playback state. /// Gets or sets the current playback state.
/// </summary> /// </summary>
public PlaybackState State { get; set; } = PlaybackState.Stopped; public PlaybackState State { get; set; } = PlaybackState.Idle;
/// <summary>
/// Gets or sets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError { get; set; }
/// <summary> /// <summary>
/// Gets or sets the current playback position in ticks. /// Gets or sets the current playback position in ticks.

View File

@ -15,8 +15,6 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
{ {
serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>(); serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>();
serviceCollection.AddSingleton<LmsPlayerManager>(); serviceCollection.AddSingleton<LmsPlayerManager>();
serviceCollection.AddSingleton<LmsSessionManager>();
serviceCollection.AddHostedService(sp => sp.GetRequiredService<LmsSessionManager>());
// Device discovery service - registers LMS players as Jellyfin sessions for casting // Device discovery service - registers LMS players as Jellyfin sessions for casting
// Use AddHostedService directly to let DI handle construction // Use AddHostedService directly to let DI handle construction

View File

@ -1,7 +1,7 @@
using System; using System;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models; using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
@ -23,12 +23,16 @@ public class LmsSessionController : ISessionController, IDisposable
private readonly SessionInfo _session; private readonly SessionInfo _session;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly PlaybackStateMachine _stateMachine;
private readonly LmsStatusPoller _statusPoller;
private readonly CancellationTokenSource _cancellationTokenSource = new();
private Timer? _progressTimer; private Timer? _progressTimer;
private bool _disposed; private bool _disposed;
private BaseItem? _currentItem; private BaseItem? _currentItem;
private Guid[] _playlist = []; private Guid[] _playlist = [];
private int _playlistIndex; private int _playlistIndex;
private long _seekOffsetTicks; // Offset from transcoded stream start position private long _seekOffsetTicks; // Offset from transcoded stream start position
private PlaybackErrorInfo? _lastError;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="LmsSessionController"/> class. /// Initializes a new instance of the <see cref="LmsSessionController"/> class.
@ -53,22 +57,38 @@ public class LmsSessionController : ISessionController, IDisposable
_session = session; _session = session;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
_stateMachine = new PlaybackStateMachine(logger);
_statusPoller = new LmsStatusPoller(lmsClient, logger);
} }
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary> /// <summary>
/// Gets or sets the currently playing item ID. /// Gets or sets the currently playing item ID.
/// </summary> /// </summary>
public Guid? CurrentItemId { get; set; } public Guid? CurrentItemId { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether playback is currently active. /// Gets a value indicating whether playback is currently active.
/// </summary> /// </summary>
public bool IsPlaying { get; set; } public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
|| _stateMachine.CurrentState == PlaybackState.Loading
|| _stateMachine.CurrentState == PlaybackState.Seeking;
/// <summary> /// <summary>
/// Gets or sets a value indicating whether playback is paused. /// Gets a value indicating whether playback is paused.
/// </summary> /// </summary>
public bool IsPaused { get; set; } public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState State => _stateMachine.CurrentState;
/// <summary>
/// Gets the last error that occurred during playback.
/// </summary>
public PlaybackErrorInfo? LastError => _lastError;
/// <inheritdoc /> /// <inheritdoc />
public bool IsSessionActive => _player.IsConnected; public bool IsSessionActive => _player.IsConnected;
@ -185,12 +205,51 @@ public class LmsSessionController : ISessionController, IDisposable
useDirectPath, useDirectPath,
streamUrl); 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 // Track current playback state
CurrentItemId = itemId; CurrentItemId = itemId;
IsPlaying = true; _stateMachine.TryTransition(PlaybackState.Playing, "LMS confirmed playback");
IsPaused = false;
// Track the seek offset so we report the correct position // Track the seek offset so we report the correct position
// When using direct file paths, LMS handles seeking natively so no offset needed // 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() 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; return;
} }
@ -269,19 +335,29 @@ public class LmsSessionController : ISessionController, IDisposable
// we're playing a transcoded stream that starts at the seek position. // we're playing a transcoded stream that starts at the seek position.
// Add the seek offset to get the actual track position. // Add the seek offset to get the actual track position.
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks; var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks;
var isPaused = status.Mode == "pause"; var lmsIsPaused = status.Mode == "pause";
// Update our local state from LMS // Sync state machine with LMS state (handles external pause/play)
IsPaused = isPaused; 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) // 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" && currentState == PlaybackState.Playing)
if (status.Mode == "stop" && !IsPaused)
{ {
// Double-check by getting status again after a brief delay to avoid false positives // Confirm stop with a quick poll instead of fixed delay
await Task.Delay(500).ConfigureAwait(false); var stillStopped = await _statusPoller.WaitForModeAsync(
var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false); _player.MacAddress,
if (confirmStatus?.Mode != "stop") "stop",
TimeSpan.FromMilliseconds(500),
_cancellationTokenSource.Token).ConfigureAwait(false);
if (!stillStopped)
{ {
_logger.LogDebug("LMS mode changed from stop, ignoring"); _logger.LogDebug("LMS mode changed from stop, ignoring");
return; return;
@ -311,7 +387,7 @@ public class LmsSessionController : ISessionController, IDisposable
{ {
ItemId = CurrentItemId.Value, ItemId = CurrentItemId.Value,
SessionId = _session.Id, SessionId = _session.Id,
IsPaused = isPaused, IsPaused = _stateMachine.CurrentState == PlaybackState.Paused,
PositionTicks = positionTicks, PositionTicks = positionTicks,
PlayMethod = PlayMethod.DirectStream, PlayMethod = PlayMethod.DirectStream,
CanSeek = true, CanSeek = true,
@ -347,8 +423,7 @@ public class LmsSessionController : ISessionController, IDisposable
_logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value); _logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value);
await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false); await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false);
IsPlaying = false; _stateMachine.TryTransition(PlaybackState.Stopped, "Playback stopped");
IsPaused = false;
CurrentItemId = null; CurrentItemId = null;
} }
catch (Exception ex) catch (Exception ex)
@ -382,29 +457,28 @@ public class LmsSessionController : ISessionController, IDisposable
case PlaystateCommand.Pause: case PlaystateCommand.Pause:
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false); var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Pause command result: {Result}", pauseResult); _logger.LogInformation("Pause command result: {Result}", pauseResult);
IsPaused = true; _stateMachine.TryTransition(PlaybackState.Paused, "Pause command");
break; break;
case PlaystateCommand.Unpause: case PlaystateCommand.Unpause:
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false); var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("Unpause/Play command result: {Result}", playResult); _logger.LogInformation("Unpause/Play command result: {Result}", playResult);
IsPaused = false; _stateMachine.TryTransition(PlaybackState.Playing, "Unpause command");
break; break;
case PlaystateCommand.PlayPause: case PlaystateCommand.PlayPause:
// Toggle play/pause - check current state first // Toggle play/pause based on current state
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false); if (_stateMachine.CurrentState == PlaybackState.Playing)
if (currentState?.Mode == "play")
{ {
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false); var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult); _logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
IsPaused = true; _stateMachine.TryTransition(PlaybackState.Paused, "PlayPause toggle to pause");
} }
else else
{ {
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false); var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult); _logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
IsPaused = false; _stateMachine.TryTransition(PlaybackState.Playing, "PlayPause toggle to play");
} }
break; break;
@ -419,7 +493,10 @@ public class LmsSessionController : ISessionController, IDisposable
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue) if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
{ {
var positionTicks = playstateRequest.SeekPositionTicks.Value; 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 // Check if we're using direct file path mode - if so, LMS can seek natively
if (CanSeekNatively()) if (CanSeekNatively())
@ -427,13 +504,37 @@ public class LmsSessionController : ISessionController, IDisposable
// Use native LMS seeking - much smoother! // Use native LMS seeking - much smoother!
_logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds); _logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds);
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false); 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 // No seek offset needed - LMS handles position tracking natively
_seekOffsetTicks = 0; _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 else
{ {
// For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks // 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); var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
_logger.LogInformation( _logger.LogInformation(
"Seeking by restarting stream at position {Seconds}s: {Url}", "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); 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 // 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; _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); _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) if (disposing)
{ {
// Cancel any pending operations
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
StopProgressTimer(); StopProgressTimer();
// Reset state machine
_stateMachine.Reset();
} }
_disposed = true; _disposed = true;

View File

@ -1,313 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JellyLMS.Configuration;
using Jellyfin.Plugin.JellyLMS.Models;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Manages active playback sessions between Jellyfin and LMS.
/// </summary>
public class LmsSessionManager : IHostedService
{
private readonly ILogger<LmsSessionManager> _logger;
private readonly ILmsApiClient _lmsClient;
private readonly LmsPlayerManager _playerManager;
private readonly ILibraryManager _libraryManager;
private readonly ConcurrentDictionary<string, LmsPlaybackSession> _sessions = new();
/// <summary>
/// Initializes a new instance of the <see cref="LmsSessionManager"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="lmsClient">The LMS API client.</param>
/// <param name="playerManager">The player manager.</param>
/// <param name="libraryManager">The Jellyfin library manager.</param>
public LmsSessionManager(
ILogger<LmsSessionManager> logger,
ILmsApiClient lmsClient,
LmsPlayerManager playerManager,
ILibraryManager libraryManager)
{
_logger = logger;
_lmsClient = lmsClient;
_playerManager = playerManager;
_libraryManager = libraryManager;
}
private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>List of active sessions.</returns>
public List<LmsPlaybackSession> GetActiveSessions()
{
return _sessions.Values.Where(s => s.State != PlaybackState.Stopped).ToList();
}
/// <summary>
/// Gets a session by ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session, or null if not found.</returns>
public LmsPlaybackSession? GetSession(string sessionId)
{
return _sessions.GetValueOrDefault(sessionId);
}
/// <summary>
/// Starts playback of a Jellyfin item on LMS players.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <param name="playerMacs">The LMS player MAC addresses.</param>
/// <param name="userId">Optional user ID.</param>
/// <returns>The created session.</returns>
public async Task<LmsPlaybackSession?> StartPlaybackAsync(
Guid itemId,
IEnumerable<string> playerMacs,
Guid? userId = null)
{
var macList = playerMacs.ToList();
if (macList.Count == 0)
{
_logger.LogWarning("No players specified for playback");
return null;
}
var item = _libraryManager.GetItemById(itemId);
if (item == null)
{
_logger.LogWarning("Item {ItemId} not found", itemId);
return null;
}
// Build the audio stream URL
var streamUrl = BuildStreamUrl(itemId);
var session = new LmsPlaybackSession
{
ItemId = itemId,
ItemName = item.Name,
PlayerMacs = macList,
State = PlaybackState.Playing,
StreamUrl = streamUrl,
UserId = userId,
RuntimeTicks = item.RunTimeTicks ?? 0
};
// If multiple players, sync them first
if (macList.Count > 1)
{
var masterMac = macList[0];
var slaveMacs = macList.Skip(1);
await _playerManager.CreateSyncGroupAsync(masterMac, slaveMacs).ConfigureAwait(false);
}
// Start playback on the first player (others will sync)
var targetMac = macList[0];
var success = await _lmsClient.PlayUrlAsync(targetMac, streamUrl, item.Name).ConfigureAwait(false);
if (!success)
{
_logger.LogError("Failed to start playback on player {Mac}", targetMac);
return null;
}
_sessions[session.SessionId] = session;
_logger.LogInformation(
"Started playback session {SessionId} for {Item} on {Count} players",
session.SessionId,
item.Name,
macList.Count);
return session;
}
/// <summary>
/// Pauses a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> PauseSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
// Pause the master player (synced players will follow)
var success = await _lmsClient.PauseAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Paused;
}
return success;
}
/// <summary>
/// Resumes a paused playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> ResumeSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.PlayAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Playing;
}
return success;
}
/// <summary>
/// Stops a playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>True if successful.</returns>
public async Task<bool> StopSessionAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = await _lmsClient.StopAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (success)
{
session.State = PlaybackState.Stopped;
// Unsync players if there were multiple
if (session.PlayerMacs.Count > 1)
{
await _playerManager.DissolveSyncGroupAsync(session.PlayerMacs[0]).ConfigureAwait(false);
}
}
// Remove the session
_sessions.TryRemove(sessionId, out _);
return success;
}
/// <summary>
/// Seeks to a position in the playback session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="positionTicks">The position in ticks.</param>
/// <returns>True if successful.</returns>
public async Task<bool> SeekAsync(string sessionId, long positionTicks)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
var success = await _lmsClient.SeekAsync(session.PlayerMacs[0], positionSeconds).ConfigureAwait(false);
if (success)
{
session.PositionTicks = positionTicks;
}
return success;
}
/// <summary>
/// Sets the volume for all players in a session.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <param name="volume">The volume level (0-100).</param>
/// <returns>True if successful.</returns>
public async Task<bool> SetVolumeAsync(string sessionId, int volume)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return false;
}
var success = true;
foreach (var mac in session.PlayerMacs)
{
if (!await _lmsClient.SetVolumeAsync(mac, volume).ConfigureAwait(false))
{
success = false;
}
}
return success;
}
/// <summary>
/// Updates the session state from LMS.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>A task representing the operation.</returns>
public async Task RefreshSessionStateAsync(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
{
return;
}
var status = await _lmsClient.GetPlayerStatusAsync(session.PlayerMacs[0]).ConfigureAwait(false);
if (status == null)
{
return;
}
session.PositionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
session.State = status.Mode switch
{
"play" => PlaybackState.Playing,
"pause" => PlaybackState.Paused,
_ => PlaybackState.Stopped
};
}
private string BuildStreamUrl(Guid itemId)
{
var baseUrl = Config.JellyfinServerUrl.TrimEnd('/');
// Direct stream URL - LMS will pull audio from Jellyfin
return $"{baseUrl}/Audio/{itemId}/stream.mp3";
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager started");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("LMS Session Manager stopping");
// Stop all active sessions
foreach (var sessionId in _sessions.Keys.ToList())
{
_ = StopSessionAsync(sessionId);
}
return Task.CompletedTask;
}
}

View File

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

View File

@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JellyLMS.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JellyLMS.Services;
/// <summary>
/// Event args for state transitions.
/// </summary>
public class StateTransitionEventArgs : EventArgs
{
/// <summary>
/// Gets the state before the transition.
/// </summary>
public PlaybackState FromState { get; init; }
/// <summary>
/// Gets the state after the transition.
/// </summary>
public PlaybackState ToState { get; init; }
/// <summary>
/// Gets the reason for the transition.
/// </summary>
public string? Reason { get; init; }
}
/// <summary>
/// Manages playback state transitions with validation.
/// </summary>
public class PlaybackStateMachine
{
private readonly object _lock = new();
private readonly ILogger? _logger;
private PlaybackState _currentState = PlaybackState.Idle;
private PlaybackState _stateBeforeSeek = PlaybackState.Idle;
/// <summary>
/// Valid state transitions. Key is the current state, value is the array of valid next states.
/// </summary>
private static readonly Dictionary<PlaybackState, PlaybackState[]> ValidTransitions = new()
{
[PlaybackState.Idle] = [PlaybackState.Loading, PlaybackState.Stopped],
[PlaybackState.Loading] = [PlaybackState.Playing, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Playing] = [PlaybackState.Paused, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Paused] = [PlaybackState.Playing, PlaybackState.Seeking, PlaybackState.Loading, PlaybackState.Stopped, PlaybackState.Error],
[PlaybackState.Seeking] = [PlaybackState.Playing, PlaybackState.Paused, PlaybackState.Error, PlaybackState.Stopped],
[PlaybackState.Error] = [PlaybackState.Loading, PlaybackState.Idle, PlaybackState.Stopped],
[PlaybackState.Stopped] = [PlaybackState.Idle, PlaybackState.Loading]
};
/// <summary>
/// Initializes a new instance of the <see cref="PlaybackStateMachine"/> class.
/// </summary>
/// <param name="logger">Optional logger for state transitions.</param>
public PlaybackStateMachine(ILogger? logger = null)
{
_logger = logger;
}
/// <summary>
/// Fired when the state changes.
/// </summary>
public event EventHandler<StateTransitionEventArgs>? StateChanged;
/// <summary>
/// Gets the current playback state.
/// </summary>
public PlaybackState CurrentState
{
get
{
lock (_lock)
{
return _currentState;
}
}
}
/// <summary>
/// Gets the state before the current seek operation (if in Seeking state).
/// Used to restore the correct state after seeking completes.
/// </summary>
public PlaybackState StateBeforeSeek
{
get
{
lock (_lock)
{
return _stateBeforeSeek;
}
}
}
/// <summary>
/// Gets a value indicating whether playback is active (Playing or Paused).
/// </summary>
public bool IsPlaybackActive
{
get
{
lock (_lock)
{
return _currentState == PlaybackState.Playing
|| _currentState == PlaybackState.Paused
|| _currentState == PlaybackState.Seeking
|| _currentState == PlaybackState.Loading;
}
}
}
/// <summary>
/// Attempts to transition to a new state.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Optional reason for the transition (for logging).</param>
/// <returns>True if the transition was valid and completed.</returns>
public bool TryTransition(PlaybackState newState, string? reason = null)
{
lock (_lock)
{
if (_currentState == newState)
{
return true; // Already in this state
}
if (!IsValidTransition(_currentState, newState))
{
_logger?.LogWarning(
"Invalid state transition attempted: {From} -> {To} (reason: {Reason})",
_currentState,
newState,
reason ?? "none");
return false;
}
// Store state before seek for restoration
if (newState == PlaybackState.Seeking)
{
_stateBeforeSeek = _currentState;
}
var oldState = _currentState;
_currentState = newState;
_logger?.LogInformation(
"State transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason ?? "none");
// Fire event outside the lock to prevent deadlocks
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = reason
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
return true;
}
}
/// <summary>
/// Forces a state change without validation. Use with caution.
/// Intended for error recovery scenarios.
/// </summary>
/// <param name="newState">The target state.</param>
/// <param name="reason">Reason for the forced transition.</param>
public void ForceState(PlaybackState newState, string reason)
{
lock (_lock)
{
var oldState = _currentState;
_currentState = newState;
_logger?.LogWarning(
"Forced state transition: {From} -> {To} (reason: {Reason})",
oldState,
newState,
reason);
var args = new StateTransitionEventArgs
{
FromState = oldState,
ToState = newState,
Reason = $"FORCED: {reason}"
};
try
{
StateChanged?.Invoke(this, args);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in StateChanged event handler");
}
}
}
/// <summary>
/// Resets the state machine to Idle.
/// </summary>
public void Reset()
{
ForceState(PlaybackState.Idle, "Reset");
}
/// <summary>
/// Checks if a transition from one state to another is valid.
/// </summary>
/// <param name="from">The current state.</param>
/// <param name="to">The target state.</param>
/// <returns>True if the transition is valid.</returns>
public static bool IsValidTransition(PlaybackState from, PlaybackState to)
{
if (!ValidTransitions.TryGetValue(from, out var validTargets))
{
return false;
}
return Array.IndexOf(validTargets, to) >= 0;
}
}

122
README.md
View File

@ -31,15 +31,15 @@ JellyLMS enables Jellyfin to stream audio to LMS, which acts as a multi-room spe
│ │ Library │──┼────────►│ LmsApiClient │────────►│ │ Players │ │ │ │ Library │──┼────────►│ LmsApiClient │────────►│ │ Players │ │
│ │ (Audio) │ │ │ │ │ │ (Zones) │ │ │ │ (Audio) │ │ │ │ │ │ (Zones) │ │
│ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │ │ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │
│ │ │ │ Session │ │ │ │ │ │ │ │ Session │ │ │ │
│ ┌───────────┐ │ │ │ Manager │ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │Controller │ │ │ ┌───────────┐ │
│ │ Queue │──┼────────►│ └───────────┘ │────────►│ │ Sync │ │ │ │ Queue │──┼────────►│ │ (State │ │────────►│ │ Sync │ │
│ │ │ │ │ │ │ │ Groups │ │ │ │ │ │ │ │ Machine) │ │ │ │ Groups │ │
│ └───────────┘ │ │ ┌───────────┐ │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │
│ │ │ │ REST API │ │ │ │ │ │ │ │ │ │
│ ┌───────────┐ │ │ │Controller │ │ │ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ │
│ │ Playback │──┼────────►│ └───────────┘ │ │ │ │ │ Playback │──┼────────►│ │ REST API │ │ │ │
│ │ Controls │ │ │ │ │ │ │ │ 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 - **Player Discovery**: Automatically discovers all LMS players/zones
- **Multi-Room Sync**: Create and manage sync groups for synchronized playback across multiple rooms - **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 - **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 ## Screenshots
@ -76,9 +77,62 @@ Create and manage synchronized playback groups for multi-room audio:
## Requirements ## Requirements
- Jellyfin Server 10.10.0 or later - 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) - 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 ## Installation
### Manual Installation ### Manual Installation
@ -101,7 +155,7 @@ cd jellyLMS
dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
# The DLL will be in: # The DLL will be in:
# Jellyfin.Plugin.JellyLMS/bin/Release/net8.0/ # Jellyfin.Plugin.JellyLMS/bin/Release/net9.0/
``` ```
## Configuration ## Configuration
@ -116,41 +170,45 @@ dotnet build Jellyfin.Plugin.JellyLMS.sln -c Release
| Connection Timeout | Timeout for LMS API calls (seconds) | `10` | | Connection Timeout | Timeout for LMS API calls (seconds) | `10` |
| Enable Auto Sync | Automatically sync players when creating groups | `true` | | Enable Auto Sync | Automatically sync players when creating groups | `true` |
| Default Player | MAC address of the default player | (none) | | 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 3. Click "Test Connection" to verify connectivity to LMS
4. Use "Discover Players" to see available LMS players 4. Use "Discover Players" to see available LMS players
## API Endpoints ## 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 ### Players
- `GET /JellyLms/Players` - List all LMS players - `GET /JellyLms/Players` - List all LMS players
- `GET /JellyLms/Players/{mac}` - Get specific player details - `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 ### Sync Groups
- `GET /JellyLms/SyncGroups` - List all sync groups - `GET /JellyLms/SyncGroups` - List all sync groups
- `POST /JellyLms/SyncGroups` - Create a new sync group - `POST /JellyLms/SyncGroups` - Create a new sync group
- `DELETE /JellyLms/SyncGroups/{masterMac}` - Dissolve a 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 - `POST /JellyLms/TestConnection` - Test LMS connectivity
- `GET /JellyLms/DiscoverPaths` - Discover file paths for direct file access configuration
## LMS Setup ## LMS Setup
@ -218,15 +276,17 @@ Jellyfin.Plugin.JellyLMS/
│ ├── PluginConfiguration.cs # Plugin settings │ ├── PluginConfiguration.cs # Plugin settings
│ └── configPage.html # Dashboard configuration UI │ └── configPage.html # Dashboard configuration UI
├── Api/ ├── Api/
│ └── JellyLmsController.cs # REST API endpoints │ └── JellyLmsController.cs # REST API endpoints (players, sync groups)
├── Services/ ├── Services/
│ ├── ILmsApiClient.cs # LMS API interface │ ├── ILmsApiClient.cs # LMS API interface
│ ├── LmsApiClient.cs # LMS JSON-RPC client │ ├── LmsApiClient.cs # LMS JSON-RPC client
│ ├── LmsPlayerManager.cs # Player discovery & sync │ ├── 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/ └── Models/
├── LmsPlayer.cs # Player model ├── LmsPlayer.cs # Player model
├── LmsPlaybackSession.cs # Session state model ├── LmsPlaybackSession.cs # Session state (incl. PlaybackState enum)
└── LmsApiModels.cs # JSON-RPC DTOs └── LmsApiModels.cs # JSON-RPC DTOs
``` ```
@ -237,7 +297,7 @@ Jellyfin.Plugin.JellyLMS/
dotnet build Jellyfin.Plugin.JellyLMS.sln dotnet build Jellyfin.Plugin.JellyLMS.sln
# Copy to Jellyfin plugins directory # 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/ ~/.local/share/jellyfin/plugins/JellyLMS/
# Restart Jellyfin to load the plugin # Restart Jellyfin to load the plugin