remove redundant restAPI
playback is controlled by state machine
This commit is contained in:
parent
29cd6dfaeb
commit
a199fe452c
@ -28,7 +28,6 @@ public class JellyLmsController : ControllerBase
|
||||
{
|
||||
private readonly ILmsApiClient _lmsClient;
|
||||
private readonly LmsPlayerManager _playerManager;
|
||||
private readonly LmsSessionManager _sessionManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
@ -36,17 +35,14 @@ public class JellyLmsController : ControllerBase
|
||||
/// </summary>
|
||||
/// <param name="lmsClient">The LMS API client.</param>
|
||||
/// <param name="playerManager">The player manager.</param>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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<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; }
|
||||
}
|
||||
|
||||
@ -106,6 +106,26 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// </summary>
|
||||
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>
|
||||
/// Gets all effective path mappings, including legacy single mapping if configured.
|
||||
/// </summary>
|
||||
|
||||
@ -9,9 +9,14 @@ namespace Jellyfin.Plugin.JellyLMS.Models;
|
||||
public enum PlaybackState
|
||||
{
|
||||
/// <summary>
|
||||
/// Playback is stopped.
|
||||
/// Device connected, no media loaded.
|
||||
/// </summary>
|
||||
Stopped,
|
||||
Idle,
|
||||
|
||||
/// <summary>
|
||||
/// Play command sent, waiting for LMS to confirm playback started.
|
||||
/// </summary>
|
||||
Loading,
|
||||
|
||||
/// <summary>
|
||||
/// Playback is active.
|
||||
@ -21,7 +26,84 @@ public enum PlaybackState
|
||||
/// <summary>
|
||||
/// Playback is paused.
|
||||
/// </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>
|
||||
@ -67,7 +149,12 @@ public class LmsPlaybackSession
|
||||
/// <summary>
|
||||
/// Gets or sets the current playback state.
|
||||
/// </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>
|
||||
/// Gets or sets the current playback position in ticks.
|
||||
|
||||
@ -15,8 +15,6 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
serviceCollection.AddSingleton<ILmsApiClient, LmsApiClient>();
|
||||
serviceCollection.AddSingleton<LmsPlayerManager>();
|
||||
serviceCollection.AddSingleton<LmsSessionManager>();
|
||||
serviceCollection.AddHostedService(sp => sp.GetRequiredService<LmsSessionManager>());
|
||||
|
||||
// Device discovery service - registers LMS players as Jellyfin sessions for casting
|
||||
// Use AddHostedService directly to let DI handle construction
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JellyLMS.Configuration;
|
||||
using Jellyfin.Plugin.JellyLMS.Models;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@ -23,12 +23,16 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
private readonly SessionInfo _session;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly PlaybackStateMachine _stateMachine;
|
||||
private readonly LmsStatusPoller _statusPoller;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource = new();
|
||||
private Timer? _progressTimer;
|
||||
private bool _disposed;
|
||||
private BaseItem? _currentItem;
|
||||
private Guid[] _playlist = [];
|
||||
private int _playlistIndex;
|
||||
private long _seekOffsetTicks; // Offset from transcoded stream start position
|
||||
private PlaybackErrorInfo? _lastError;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LmsSessionController"/> class.
|
||||
@ -53,22 +57,38 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
_session = session;
|
||||
_sessionManager = sessionManager;
|
||||
_libraryManager = libraryManager;
|
||||
_stateMachine = new PlaybackStateMachine(logger);
|
||||
_statusPoller = new LmsStatusPoller(lmsClient, logger);
|
||||
}
|
||||
|
||||
private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the currently playing item ID.
|
||||
/// </summary>
|
||||
public Guid? CurrentItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playback is currently active.
|
||||
/// Gets a value indicating whether playback is currently active.
|
||||
/// </summary>
|
||||
public bool IsPlaying { get; set; }
|
||||
public bool IsPlaying => _stateMachine.CurrentState == PlaybackState.Playing
|
||||
|| _stateMachine.CurrentState == PlaybackState.Loading
|
||||
|| _stateMachine.CurrentState == PlaybackState.Seeking;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playback is paused.
|
||||
/// Gets a value indicating whether playback is paused.
|
||||
/// </summary>
|
||||
public bool IsPaused { get; set; }
|
||||
public bool IsPaused => _stateMachine.CurrentState == PlaybackState.Paused;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current playback state.
|
||||
/// </summary>
|
||||
public PlaybackState State => _stateMachine.CurrentState;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error that occurred during playback.
|
||||
/// </summary>
|
||||
public PlaybackErrorInfo? LastError => _lastError;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSessionActive => _player.IsConnected;
|
||||
@ -185,12 +205,51 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
useDirectPath,
|
||||
streamUrl);
|
||||
|
||||
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
|
||||
// Transition to Loading state before sending command
|
||||
_stateMachine.TryTransition(PlaybackState.Loading, "Starting playback");
|
||||
|
||||
// Send play command with retry logic
|
||||
var playSuccess = await _statusPoller.ExecuteWithRetryAsync(
|
||||
async () => await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false),
|
||||
Config.MaxAutoRetries,
|
||||
retry => _logger.LogInformation("Retrying play command (attempt {Retry})", retry),
|
||||
_cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
if (!playSuccess)
|
||||
{
|
||||
_lastError = new PlaybackErrorInfo
|
||||
{
|
||||
ErrorType = PlaybackErrorType.LmsError,
|
||||
Message = "Failed to send play command to LMS after retries",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
RetryCount = Config.MaxAutoRetries
|
||||
};
|
||||
_stateMachine.TryTransition(PlaybackState.Error, "Play command failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for LMS to confirm playback started
|
||||
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
|
||||
var started = await _statusPoller.WaitForPlaybackStartAsync(
|
||||
_player.MacAddress,
|
||||
loadingTimeout,
|
||||
_cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
if (!started)
|
||||
{
|
||||
_lastError = new PlaybackErrorInfo
|
||||
{
|
||||
ErrorType = PlaybackErrorType.Timeout,
|
||||
Message = $"LMS did not start playing within {loadingTimeout.TotalSeconds}s",
|
||||
OccurredAt = DateTime.UtcNow
|
||||
};
|
||||
_stateMachine.TryTransition(PlaybackState.Error, "Loading timeout");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track current playback state
|
||||
CurrentItemId = itemId;
|
||||
IsPlaying = true;
|
||||
IsPaused = false;
|
||||
_stateMachine.TryTransition(PlaybackState.Playing, "LMS confirmed playback");
|
||||
|
||||
// Track the seek offset so we report the correct position
|
||||
// When using direct file paths, LMS handles seeking natively so no offset needed
|
||||
@ -252,7 +311,14 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
|
||||
private async Task ReportPlaybackProgressAsync()
|
||||
{
|
||||
if (!IsPlaying || !CurrentItemId.HasValue)
|
||||
// Don't report during Loading, Seeking, or Error states
|
||||
var currentState = _stateMachine.CurrentState;
|
||||
if (currentState == PlaybackState.Loading
|
||||
|| currentState == PlaybackState.Seeking
|
||||
|| currentState == PlaybackState.Error
|
||||
|| currentState == PlaybackState.Stopped
|
||||
|| currentState == PlaybackState.Idle
|
||||
|| !CurrentItemId.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -269,19 +335,29 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
// we're playing a transcoded stream that starts at the seek position.
|
||||
// Add the seek offset to get the actual track position.
|
||||
var positionTicks = (long)(status.Time * TimeSpan.TicksPerSecond) + _seekOffsetTicks;
|
||||
var isPaused = status.Mode == "pause";
|
||||
var lmsIsPaused = status.Mode == "pause";
|
||||
|
||||
// Update our local state from LMS
|
||||
IsPaused = isPaused;
|
||||
// Sync state machine with LMS state (handles external pause/play)
|
||||
if (lmsIsPaused && currentState == PlaybackState.Playing)
|
||||
{
|
||||
_stateMachine.TryTransition(PlaybackState.Paused, "LMS reported pause");
|
||||
}
|
||||
else if (!lmsIsPaused && status.Mode == "play" && currentState == PlaybackState.Paused)
|
||||
{
|
||||
_stateMachine.TryTransition(PlaybackState.Playing, "LMS reported play");
|
||||
}
|
||||
|
||||
// Check if playback has stopped on LMS side (track ended)
|
||||
// Only advance if we're not paused - LMS can briefly report "stop" during transitions
|
||||
if (status.Mode == "stop" && !IsPaused)
|
||||
if (status.Mode == "stop" && currentState == PlaybackState.Playing)
|
||||
{
|
||||
// Double-check by getting status again after a brief delay to avoid false positives
|
||||
await Task.Delay(500).ConfigureAwait(false);
|
||||
var confirmStatus = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
if (confirmStatus?.Mode != "stop")
|
||||
// Confirm stop with a quick poll instead of fixed delay
|
||||
var stillStopped = await _statusPoller.WaitForModeAsync(
|
||||
_player.MacAddress,
|
||||
"stop",
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
_cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
if (!stillStopped)
|
||||
{
|
||||
_logger.LogDebug("LMS mode changed from stop, ignoring");
|
||||
return;
|
||||
@ -311,7 +387,7 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
{
|
||||
ItemId = CurrentItemId.Value,
|
||||
SessionId = _session.Id,
|
||||
IsPaused = isPaused,
|
||||
IsPaused = _stateMachine.CurrentState == PlaybackState.Paused,
|
||||
PositionTicks = positionTicks,
|
||||
PlayMethod = PlayMethod.DirectStream,
|
||||
CanSeek = true,
|
||||
@ -347,8 +423,7 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
_logger.LogInformation("Reporting playback stopped for item {ItemId}", CurrentItemId.Value);
|
||||
await _sessionManager.OnPlaybackStopped(stopInfo).ConfigureAwait(false);
|
||||
|
||||
IsPlaying = false;
|
||||
IsPaused = false;
|
||||
_stateMachine.TryTransition(PlaybackState.Stopped, "Playback stopped");
|
||||
CurrentItemId = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -382,29 +457,28 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
case PlaystateCommand.Pause:
|
||||
var pauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
_logger.LogInformation("Pause command result: {Result}", pauseResult);
|
||||
IsPaused = true;
|
||||
_stateMachine.TryTransition(PlaybackState.Paused, "Pause command");
|
||||
break;
|
||||
|
||||
case PlaystateCommand.Unpause:
|
||||
var playResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
_logger.LogInformation("Unpause/Play command result: {Result}", playResult);
|
||||
IsPaused = false;
|
||||
_stateMachine.TryTransition(PlaybackState.Playing, "Unpause command");
|
||||
break;
|
||||
|
||||
case PlaystateCommand.PlayPause:
|
||||
// Toggle play/pause - check current state first
|
||||
var currentState = await _lmsClient.GetPlayerStatusAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
if (currentState?.Mode == "play")
|
||||
// Toggle play/pause based on current state
|
||||
if (_stateMachine.CurrentState == PlaybackState.Playing)
|
||||
{
|
||||
var togglePauseResult = await _lmsClient.PauseAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
_logger.LogInformation("PlayPause toggle (pause) result: {Result}", togglePauseResult);
|
||||
IsPaused = true;
|
||||
_stateMachine.TryTransition(PlaybackState.Paused, "PlayPause toggle to pause");
|
||||
}
|
||||
else
|
||||
{
|
||||
var togglePlayResult = await _lmsClient.PlayAsync(_player.MacAddress).ConfigureAwait(false);
|
||||
_logger.LogInformation("PlayPause toggle (play) result: {Result}", togglePlayResult);
|
||||
IsPaused = false;
|
||||
_stateMachine.TryTransition(PlaybackState.Playing, "PlayPause toggle to play");
|
||||
}
|
||||
|
||||
break;
|
||||
@ -419,7 +493,10 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
if (playstateRequest.SeekPositionTicks.HasValue && CurrentItemId.HasValue)
|
||||
{
|
||||
var positionTicks = playstateRequest.SeekPositionTicks.Value;
|
||||
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
|
||||
var positionSeconds = (double)(positionTicks / TimeSpan.TicksPerSecond);
|
||||
|
||||
// Transition to Seeking state (state machine remembers previous state)
|
||||
_stateMachine.TryTransition(PlaybackState.Seeking, "Seek command");
|
||||
|
||||
// Check if we're using direct file path mode - if so, LMS can seek natively
|
||||
if (CanSeekNatively())
|
||||
@ -427,13 +504,37 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
// Use native LMS seeking - much smoother!
|
||||
_logger.LogInformation("Seeking natively to {Seconds}s using LMS time command", positionSeconds);
|
||||
await _lmsClient.SeekAsync(_player.MacAddress, positionSeconds).ConfigureAwait(false);
|
||||
|
||||
// Wait for seek to complete
|
||||
var seekTimeout = TimeSpan.FromSeconds(Config.SeekTimeoutSeconds);
|
||||
var seekComplete = await _statusPoller.WaitForSeekCompleteAsync(
|
||||
_player.MacAddress,
|
||||
positionSeconds,
|
||||
toleranceSeconds: 2.0,
|
||||
seekTimeout,
|
||||
_cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
// No seek offset needed - LMS handles position tracking natively
|
||||
_seekOffsetTicks = 0;
|
||||
|
||||
// Restore previous state
|
||||
var previousState = _stateMachine.StateBeforeSeek;
|
||||
if (seekComplete)
|
||||
{
|
||||
_stateMachine.TryTransition(previousState, "Seek completed");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Seek may not have completed within timeout, restoring state anyway");
|
||||
_stateMachine.TryTransition(previousState, "Seek timeout - restoring state");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For HTTP streams, LMS can't seek directly - we need to restart with startTimeTicks
|
||||
// Build a new URL with the seek position and restart playback
|
||||
// This is essentially a new playback, so transition to Loading
|
||||
_stateMachine.TryTransition(PlaybackState.Loading, "HTTP stream seek - restarting");
|
||||
|
||||
var streamUrl = BuildStreamUrlWithPosition(CurrentItemId.Value, positionTicks);
|
||||
_logger.LogInformation(
|
||||
"Seeking by restarting stream at position {Seconds}s: {Url}",
|
||||
@ -442,9 +543,30 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
|
||||
await _lmsClient.PlayUrlAsync(_player.MacAddress, streamUrl).ConfigureAwait(false);
|
||||
|
||||
// Wait for playback to start
|
||||
var loadingTimeout = TimeSpan.FromSeconds(Config.LoadingTimeoutSeconds);
|
||||
var started = await _statusPoller.WaitForPlaybackStartAsync(
|
||||
_player.MacAddress,
|
||||
loadingTimeout,
|
||||
_cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Track the seek offset so we report the correct position
|
||||
// The transcoded stream starts at 0, but we need to report the actual track position
|
||||
_seekOffsetTicks = positionTicks;
|
||||
|
||||
if (started)
|
||||
{
|
||||
_stateMachine.TryTransition(PlaybackState.Playing, "HTTP stream seek completed");
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastError = new PlaybackErrorInfo
|
||||
{
|
||||
ErrorType = PlaybackErrorType.Timeout,
|
||||
Message = "Stream restart after seek failed to start",
|
||||
OccurredAt = DateTime.UtcNow
|
||||
};
|
||||
_stateMachine.TryTransition(PlaybackState.Error, "HTTP stream seek failed");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Seek offset is now {Ticks} ticks ({Seconds}s)", _seekOffsetTicks, _seekOffsetTicks / TimeSpan.TicksPerSecond);
|
||||
@ -686,7 +808,14 @@ public class LmsSessionController : ISessionController, IDisposable
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
// Cancel any pending operations
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
|
||||
StopProgressTimer();
|
||||
|
||||
// Reset state machine
|
||||
_stateMachine.Reset();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
@ -1,313 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.JellyLMS.Configuration;
|
||||
using Jellyfin.Plugin.JellyLMS.Models;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.JellyLMS.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages active playback sessions between Jellyfin and LMS.
|
||||
/// </summary>
|
||||
public class LmsSessionManager : IHostedService
|
||||
{
|
||||
private readonly ILogger<LmsSessionManager> _logger;
|
||||
private readonly ILmsApiClient _lmsClient;
|
||||
private readonly LmsPlayerManager _playerManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ConcurrentDictionary<string, LmsPlaybackSession> _sessions = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LmsSessionManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="lmsClient">The LMS API client.</param>
|
||||
/// <param name="playerManager">The player manager.</param>
|
||||
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
||||
public LmsSessionManager(
|
||||
ILogger<LmsSessionManager> logger,
|
||||
ILmsApiClient lmsClient,
|
||||
LmsPlayerManager playerManager,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_lmsClient = lmsClient;
|
||||
_playerManager = playerManager;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
private PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active sessions.
|
||||
/// </summary>
|
||||
/// <returns>List of active sessions.</returns>
|
||||
public List<LmsPlaybackSession> GetActiveSessions()
|
||||
{
|
||||
return _sessions.Values.Where(s => s.State != PlaybackState.Stopped).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a session by ID.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>The session, or null if not found.</returns>
|
||||
public LmsPlaybackSession? GetSession(string sessionId)
|
||||
{
|
||||
return _sessions.GetValueOrDefault(sessionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts playback of a Jellyfin item on LMS players.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item ID.</param>
|
||||
/// <param name="playerMacs">The LMS player MAC addresses.</param>
|
||||
/// <param name="userId">Optional user ID.</param>
|
||||
/// <returns>The created session.</returns>
|
||||
public async Task<LmsPlaybackSession?> StartPlaybackAsync(
|
||||
Guid itemId,
|
||||
IEnumerable<string> playerMacs,
|
||||
Guid? userId = null)
|
||||
{
|
||||
var macList = playerMacs.ToList();
|
||||
if (macList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No players specified for playback");
|
||||
return null;
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (item == null)
|
||||
{
|
||||
_logger.LogWarning("Item {ItemId} not found", itemId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the audio stream URL
|
||||
var streamUrl = BuildStreamUrl(itemId);
|
||||
|
||||
var session = new LmsPlaybackSession
|
||||
{
|
||||
ItemId = itemId,
|
||||
ItemName = item.Name,
|
||||
PlayerMacs = macList,
|
||||
State = PlaybackState.Playing,
|
||||
StreamUrl = streamUrl,
|
||||
UserId = userId,
|
||||
RuntimeTicks = item.RunTimeTicks ?? 0
|
||||
};
|
||||
|
||||
// If multiple players, sync them first
|
||||
if (macList.Count > 1)
|
||||
{
|
||||
var masterMac = macList[0];
|
||||
var slaveMacs = macList.Skip(1);
|
||||
await _playerManager.CreateSyncGroupAsync(masterMac, slaveMacs).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Start playback on the first player (others will sync)
|
||||
var targetMac = macList[0];
|
||||
var success = await _lmsClient.PlayUrlAsync(targetMac, streamUrl, item.Name).ConfigureAwait(false);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogError("Failed to start playback on player {Mac}", targetMac);
|
||||
return null;
|
||||
}
|
||||
|
||||
_sessions[session.SessionId] = session;
|
||||
_logger.LogInformation(
|
||||
"Started playback session {SessionId} for {Item} on {Count} players",
|
||||
session.SessionId,
|
||||
item.Name,
|
||||
macList.Count);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses a playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>True if successful.</returns>
|
||||
public async Task<bool> PauseSessionAsync(string sessionId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pause the master player (synced players will follow)
|
||||
var success = await _lmsClient.PauseAsync(session.PlayerMacs[0]).ConfigureAwait(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
session.State = PlaybackState.Paused;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>True if successful.</returns>
|
||||
public async Task<bool> ResumeSessionAsync(string sessionId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var success = await _lmsClient.PlayAsync(session.PlayerMacs[0]).ConfigureAwait(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
session.State = PlaybackState.Playing;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>True if successful.</returns>
|
||||
public async Task<bool> StopSessionAsync(string sessionId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var success = await _lmsClient.StopAsync(session.PlayerMacs[0]).ConfigureAwait(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
session.State = PlaybackState.Stopped;
|
||||
|
||||
// Unsync players if there were multiple
|
||||
if (session.PlayerMacs.Count > 1)
|
||||
{
|
||||
await _playerManager.DissolveSyncGroupAsync(session.PlayerMacs[0]).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the session
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeks to a position in the playback session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <param name="positionTicks">The position in ticks.</param>
|
||||
/// <returns>True if successful.</returns>
|
||||
public async Task<bool> SeekAsync(string sessionId, long positionTicks)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var positionSeconds = positionTicks / TimeSpan.TicksPerSecond;
|
||||
var success = await _lmsClient.SeekAsync(session.PlayerMacs[0], positionSeconds).ConfigureAwait(false);
|
||||
|
||||
if (success)
|
||||
{
|
||||
session.PositionTicks = positionTicks;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the volume for all players in a session.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <param name="volume">The volume level (0-100).</param>
|
||||
/// <returns>True if successful.</returns>
|
||||
public async Task<bool> SetVolumeAsync(string sessionId, int volume)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var success = true;
|
||||
foreach (var mac in session.PlayerMacs)
|
||||
{
|
||||
if (!await _lmsClient.SetVolumeAsync(mac, volume).ConfigureAwait(false))
|
||||
{
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the session state from LMS.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
public async Task RefreshSessionStateAsync(string sessionId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var status = await _lmsClient.GetPlayerStatusAsync(session.PlayerMacs[0]).ConfigureAwait(false);
|
||||
if (status == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session.PositionTicks = (long)(status.Time * TimeSpan.TicksPerSecond);
|
||||
session.State = status.Mode switch
|
||||
{
|
||||
"play" => PlaybackState.Playing,
|
||||
"pause" => PlaybackState.Paused,
|
||||
_ => PlaybackState.Stopped
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildStreamUrl(Guid itemId)
|
||||
{
|
||||
var baseUrl = Config.JellyfinServerUrl.TrimEnd('/');
|
||||
// Direct stream URL - LMS will pull audio from Jellyfin
|
||||
return $"{baseUrl}/Audio/{itemId}/stream.mp3";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("LMS Session Manager started");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("LMS Session Manager stopping");
|
||||
|
||||
// Stop all active sessions
|
||||
foreach (var sessionId in _sessions.Keys.ToList())
|
||||
{
|
||||
_ = StopSessionAsync(sessionId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
228
Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs
Normal file
228
Jellyfin.Plugin.JellyLMS/Services/LmsStatusPoller.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
234
Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs
Normal file
234
Jellyfin.Plugin.JellyLMS/Services/PlaybackStateMachine.cs
Normal 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
122
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user