Forráskód Böngészése

Move PlaystateService.cs to Jellyfin.Api

crobibero 5 éve
szülő
commit
f45d44f321

+ 372 - 0
Jellyfin.Api/Controllers/PlaystateController.cs

@@ -0,0 +1,372 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Jellyfin.Api.Constants;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Controllers
+{
+    /// <summary>
+    /// Playstate controller.
+    /// </summary>
+    [Authorize(Policy = Policies.DefaultAuthorization)]
+    public class PlaystateController : BaseJellyfinApiController
+    {
+        private readonly IUserManager _userManager;
+        private readonly IUserDataManager _userDataRepository;
+        private readonly ILibraryManager _libraryManager;
+        private readonly ISessionManager _sessionManager;
+        private readonly IAuthorizationContext _authContext;
+        private readonly ILogger<PlaystateController> _logger;
+        private readonly TranscodingJobHelper _transcodingJobHelper;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PlaystateController"/> class.
+        /// </summary>
+        /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+        /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
+        /// <param name="authContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
+        /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public PlaystateController(
+            IUserManager userManager,
+            IUserDataManager userDataRepository,
+            ILibraryManager libraryManager,
+            ISessionManager sessionManager,
+            IAuthorizationContext authContext,
+            ILoggerFactory loggerFactory,
+            IMediaSourceManager mediaSourceManager,
+            IFileSystem fileSystem)
+        {
+            _userManager = userManager;
+            _userDataRepository = userDataRepository;
+            _libraryManager = libraryManager;
+            _sessionManager = sessionManager;
+            _authContext = authContext;
+            _logger = loggerFactory.CreateLogger<PlaystateController>();
+
+            _transcodingJobHelper = new TranscodingJobHelper(
+                loggerFactory.CreateLogger<TranscodingJobHelper>(),
+                mediaSourceManager,
+                fileSystem);
+        }
+
+        /// <summary>
+        /// Marks an item as played for user.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="datePlayed">Optional. The date the item was played.</param>
+        /// <response code="200">Item marked as played.</response>
+        /// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayedItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkPlayedItem(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] DateTime? datePlayed)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, true, datePlayed);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, true, datePlayed);
+            }
+
+            return dto;
+        }
+
+        /// <summary>
+        /// Marks an item as unplayed for user.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <response code="200">Item marked as unplayed.</response>
+        /// <returns>A <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
+        [HttpDelete("/Users/{userId}/PlayedItem/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<UserItemDataDto> MarkUnplayedItem([FromRoute] Guid userId, [FromRoute] Guid itemId)
+        {
+            var user = _userManager.GetUserById(userId);
+            var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
+            var dto = UpdatePlayedStatus(user, itemId, false, null);
+            foreach (var additionalUserInfo in session.AdditionalUsers)
+            {
+                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
+                UpdatePlayedStatus(additionalUser, itemId, false, null);
+            }
+
+            return dto;
+        }
+
+        /// <summary>
+        /// Reports playback has started within a session.
+        /// </summary>
+        /// <param name="playbackStartInfo">The playback start info.</param>
+        /// <response code="204">Playback start recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackStart([FromBody] PlaybackStartInfo playbackStartInfo)
+        {
+            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+            playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports playback progress within a session.
+        /// </summary>
+        /// <param name="playbackProgressInfo">The playback progress info.</param>
+        /// <response code="204">Playback progress recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Progress")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackProgress([FromBody] PlaybackProgressInfo playbackProgressInfo)
+        {
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Pings a playback session.
+        /// </summary>
+        /// <param name="playSessionId">Playback session id.</param>
+        /// <response code="204">Playback session pinged.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Ping")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult PingPlaybackSession([FromQuery] string playSessionId)
+        {
+            _transcodingJobHelper.PingTranscodingJob(playSessionId, null);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports playback has stopped within a session.
+        /// </summary>
+        /// <param name="playbackStopInfo">The playback stop info.</param>
+        /// <response code="204">Playback stop recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Sessions/Playing/Stopped")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public async Task<ActionResult> ReportPlaybackStopped([FromBody] PlaybackStopInfo playbackStopInfo)
+        {
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a user has begun playing an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="canSeek">Indicates if the client can seek.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="playMethod">The play method.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <response code="204">Play start recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayingItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackStart(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] bool canSeek,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] PlayMethod playMethod,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId)
+        {
+            var playbackStartInfo = new PlaybackStartInfo
+            {
+                CanSeek = canSeek,
+                ItemId = itemId,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId
+            };
+
+            playbackStartInfo.PlayMethod = ValidatePlayMethod(playbackStartInfo.PlayMethod, playbackStartInfo.PlaySessionId);
+            playbackStartInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStart(playbackStartInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports a user's playback progress.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="positionTicks">Optional. The current position, in ticks. 1 tick = 10000 ms.</param>
+        /// <param name="isPaused">Indicates if the player is paused.</param>
+        /// <param name="isMuted">Indicates if the player is muted.</param>
+        /// <param name="audioStreamIndex">The audio stream index.</param>
+        /// <param name="subtitleStreamIndex">The subtitle stream index.</param>
+        /// <param name="volumeLevel">Scale of 0-100.</param>
+        /// <param name="playMethod">The play method.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <param name="repeatMode">The repeat mode.</param>
+        /// <response code="204">Play progress recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpPost("/Users/{userId}/PlayingItems/{itemId}/Progress")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackProgress(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] long? positionTicks,
+            [FromQuery] bool isPaused,
+            [FromQuery] bool isMuted,
+            [FromQuery] int? audioStreamIndex,
+            [FromQuery] int? subtitleStreamIndex,
+            [FromQuery] int? volumeLevel,
+            [FromQuery] PlayMethod playMethod,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId,
+            [FromQuery] RepeatMode repeatMode)
+        {
+            var playbackProgressInfo = new PlaybackProgressInfo
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                IsMuted = isMuted,
+                IsPaused = isPaused,
+                MediaSourceId = mediaSourceId,
+                AudioStreamIndex = audioStreamIndex,
+                SubtitleStreamIndex = subtitleStreamIndex,
+                VolumeLevel = volumeLevel,
+                PlayMethod = playMethod,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                RepeatMode = repeatMode
+            };
+
+            playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId);
+            playbackProgressInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Reports that a user has stopped playing an item.
+        /// </summary>
+        /// <param name="userId">User id.</param>
+        /// <param name="itemId">Item id.</param>
+        /// <param name="mediaSourceId">The id of the MediaSource.</param>
+        /// <param name="nextMediaType">The next media type that will play.</param>
+        /// <param name="positionTicks">Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms.</param>
+        /// <param name="liveStreamId">The live stream id.</param>
+        /// <param name="playSessionId">The play session id.</param>
+        /// <response code="204">Playback stop recorded.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("/Users/{userId}/PlayingItems/{itemId}")]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Required for ServiceStack")]
+        public async Task<ActionResult> OnPlaybackStopped(
+            [FromRoute] Guid userId,
+            [FromRoute] Guid itemId,
+            [FromQuery] string mediaSourceId,
+            [FromQuery] string nextMediaType,
+            [FromQuery] long? positionTicks,
+            [FromQuery] string liveStreamId,
+            [FromQuery] string playSessionId)
+        {
+            var playbackStopInfo = new PlaybackStopInfo
+            {
+                ItemId = itemId,
+                PositionTicks = positionTicks,
+                MediaSourceId = mediaSourceId,
+                PlaySessionId = playSessionId,
+                LiveStreamId = liveStreamId,
+                NextMediaType = nextMediaType
+            };
+
+            _logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", playbackStopInfo.PlaySessionId ?? string.Empty);
+            if (!string.IsNullOrWhiteSpace(playbackStopInfo.PlaySessionId))
+            {
+                await _transcodingJobHelper.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false);
+            }
+
+            playbackStopInfo.SessionId = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
+            await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false);
+            return NoContent();
+        }
+
+        /// <summary>
+        /// Updates the played status.
+        /// </summary>
+        /// <param name="user">The user.</param>
+        /// <param name="itemId">The item id.</param>
+        /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
+        /// <param name="datePlayed">The date played.</param>
+        /// <returns>Task.</returns>
+        private UserItemDataDto UpdatePlayedStatus(User user, Guid itemId, bool wasPlayed, DateTime? datePlayed)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            if (wasPlayed)
+            {
+                item.MarkPlayed(user, datePlayed, true);
+            }
+            else
+            {
+                item.MarkUnplayed(user);
+            }
+
+            return _userDataRepository.GetUserDataDto(item, user);
+        }
+
+        private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
+        {
+            if (method == PlayMethod.Transcode)
+            {
+                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : _transcodingJobHelper.GetTranscodingJob(playSessionId);
+                if (job == null)
+                {
+                    return PlayMethod.DirectPlay;
+                }
+            }
+
+            return method;
+        }
+    }
+}

+ 354 - 0
Jellyfin.Api/Helpers/TranscodingJobHelper.cs

@@ -0,0 +1,354 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.PlaybackDtos;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Transcoding job helpers.
+    /// </summary>
+    public class TranscodingJobHelper
+    {
+        /// <summary>
+        /// The active transcoding jobs.
+        /// </summary>
+        private static readonly List<TranscodingJobDto> _activeTranscodingJobs = new List<TranscodingJobDto>();
+
+        /// <summary>
+        /// The transcoding locks.
+        /// </summary>
+        private static readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>();
+
+        private readonly ILogger<TranscodingJobHelper> _logger;
+        private readonly IMediaSourceManager _mediaSourceManager;
+        private readonly IFileSystem _fileSystem;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingJobHelper"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobHelpers}"/> interface.</param>
+        /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public TranscodingJobHelper(
+            ILogger<TranscodingJobHelper> logger,
+            IMediaSourceManager mediaSourceManager,
+            IFileSystem fileSystem)
+        {
+            _logger = logger;
+            _mediaSourceManager = mediaSourceManager;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Get transcoding job.
+        /// </summary>
+        /// <param name="playSessionId">Playback session id.</param>
+        /// <returns>The transcoding job.</returns>
+        public TranscodingJobDto GetTranscodingJob(string playSessionId)
+        {
+            lock (_activeTranscodingJobs)
+            {
+                return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
+            }
+        }
+
+        /// <summary>
+        /// Ping transcoding job.
+        /// </summary>
+        /// <param name="playSessionId">Play session id.</param>
+        /// <param name="isUserPaused">Is user paused.</param>
+        /// <exception cref="ArgumentNullException">Play session id is null.</exception>
+        public void PingTranscodingJob(string playSessionId, bool? isUserPaused)
+        {
+            if (string.IsNullOrEmpty(playSessionId))
+            {
+                throw new ArgumentNullException(nameof(playSessionId));
+            }
+
+            _logger.LogDebug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused);
+
+            List<TranscodingJobDto> jobs;
+
+            lock (_activeTranscodingJobs)
+            {
+                // This is really only needed for HLS.
+                // Progressive streams can stop on their own reliably
+                jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
+            }
+
+            foreach (var job in jobs)
+            {
+                if (isUserPaused.HasValue)
+                {
+                    _logger.LogDebug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id);
+                    job.IsUserPaused = isUserPaused.Value;
+                }
+
+                PingTimer(job, true);
+            }
+        }
+
+        private void PingTimer(TranscodingJobDto job, bool isProgressCheckIn)
+        {
+            if (job.HasExited)
+            {
+                job.StopKillTimer();
+                return;
+            }
+
+            var timerDuration = 10000;
+
+            if (job.Type != TranscodingJobType.Progressive)
+            {
+                timerDuration = 60000;
+            }
+
+            job.PingTimeout = timerDuration;
+            job.LastPingDate = DateTime.UtcNow;
+
+            // Don't start the timer for playback checkins with progressive streaming
+            if (job.Type != TranscodingJobType.Progressive || !isProgressCheckIn)
+            {
+                job.StartKillTimer(OnTranscodeKillTimerStopped);
+            }
+            else
+            {
+                job.ChangeKillTimerIfStarted();
+            }
+        }
+
+        /// <summary>
+        /// Called when [transcode kill timer stopped].
+        /// </summary>
+        /// <param name="state">The state.</param>
+        private async void OnTranscodeKillTimerStopped(object state)
+        {
+            var job = (TranscodingJobDto)state;
+
+            if (!job.HasExited && job.Type != TranscodingJobType.Progressive)
+            {
+                var timeSinceLastPing = (DateTime.UtcNow - job.LastPingDate).TotalMilliseconds;
+
+                if (timeSinceLastPing < job.PingTimeout)
+                {
+                    job.StartKillTimer(OnTranscodeKillTimerStopped, job.PingTimeout);
+                    return;
+                }
+            }
+
+            _logger.LogInformation("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+            await KillTranscodingJob(job, true, path => true).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Kills the single transcoding job.
+        /// </summary>
+        /// <param name="deviceId">The device id.</param>
+        /// <param name="playSessionId">The play session identifier.</param>
+        /// <param name="deleteFiles">The delete files.</param>
+        /// <returns>Task.</returns>
+        public Task KillTranscodingJobs(string deviceId, string playSessionId, Func<string, bool> deleteFiles)
+        {
+            return KillTranscodingJobs(
+                j => string.IsNullOrWhiteSpace(playSessionId)
+                    ? string.Equals(deviceId, j.DeviceId, StringComparison.OrdinalIgnoreCase)
+                    : string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase), deleteFiles);
+        }
+
+        /// <summary>
+        /// Kills the transcoding jobs.
+        /// </summary>
+        /// <param name="killJob">The kill job.</param>
+        /// <param name="deleteFiles">The delete files.</param>
+        /// <returns>Task.</returns>
+        private Task KillTranscodingJobs(Func<TranscodingJobDto, bool> killJob, Func<string, bool> deleteFiles)
+        {
+            var jobs = new List<TranscodingJobDto>();
+
+            lock (_activeTranscodingJobs)
+            {
+                // This is really only needed for HLS.
+                // Progressive streams can stop on their own reliably
+                jobs.AddRange(_activeTranscodingJobs.Where(killJob));
+            }
+
+            if (jobs.Count == 0)
+            {
+                return Task.CompletedTask;
+            }
+
+            IEnumerable<Task> GetKillJobs()
+            {
+                foreach (var job in jobs)
+                {
+                    yield return KillTranscodingJob(job, false, deleteFiles);
+                }
+            }
+
+            return Task.WhenAll(GetKillJobs());
+        }
+
+        /// <summary>
+        /// Kills the transcoding job.
+        /// </summary>
+        /// <param name="job">The job.</param>
+        /// <param name="closeLiveStream">if set to <c>true</c> [close live stream].</param>
+        /// <param name="delete">The delete.</param>
+        private async Task KillTranscodingJob(TranscodingJobDto job, bool closeLiveStream, Func<string, bool> delete)
+        {
+            job.DisposeKillTimer();
+
+            _logger.LogDebug("KillTranscodingJob - JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId);
+
+            lock (_activeTranscodingJobs)
+            {
+                _activeTranscodingJobs.Remove(job);
+
+                if (!job.CancellationTokenSource!.IsCancellationRequested)
+                {
+                    job.CancellationTokenSource.Cancel();
+                }
+            }
+
+            lock (_transcodingLocks)
+            {
+                _transcodingLocks.Remove(job.Path!);
+            }
+
+            lock (job.ProcessLock!)
+            {
+                job.TranscodingThrottler?.Stop().GetAwaiter().GetResult();
+
+                var process = job.Process;
+
+                var hasExited = job.HasExited;
+
+                if (!hasExited)
+                {
+                    try
+                    {
+                        _logger.LogInformation("Stopping ffmpeg process with q command for {Path}", job.Path);
+
+                        process!.StandardInput.WriteLine("q");
+
+                        // Need to wait because killing is asynchronous
+                        if (!process.WaitForExit(5000))
+                        {
+                            _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+                            process.Kill();
+                        }
+                    }
+                    catch (InvalidOperationException)
+                    {
+                    }
+                }
+            }
+
+            if (delete(job.Path!))
+            {
+                await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
+            }
+
+            if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
+            {
+                try
+                {
+                    await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error closing live stream for {Path}", job.Path);
+                }
+            }
+        }
+
+        private async Task DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs)
+        {
+            if (retryCount >= 10)
+            {
+                return;
+            }
+
+            _logger.LogInformation("Deleting partial stream file(s) {Path}", path);
+
+            await Task.Delay(delayMs).ConfigureAwait(false);
+
+            try
+            {
+                if (jobType == TranscodingJobType.Progressive)
+                {
+                    DeleteProgressivePartialStreamFiles(path);
+                }
+                else
+                {
+                    DeleteHlsPartialStreamFiles(path);
+                }
+            }
+            catch (IOException ex)
+            {
+                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+
+                await DeletePartialStreamFiles(path, jobType, retryCount + 1, 500).ConfigureAwait(false);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
+            }
+        }
+
+        /// <summary>
+        /// Deletes the progressive partial stream files.
+        /// </summary>
+        /// <param name="outputFilePath">The output file path.</param>
+        private void DeleteProgressivePartialStreamFiles(string outputFilePath)
+        {
+            if (File.Exists(outputFilePath))
+            {
+                _fileSystem.DeleteFile(outputFilePath);
+            }
+        }
+
+        /// <summary>
+        /// Deletes the HLS partial stream files.
+        /// </summary>
+        /// <param name="outputFilePath">The output file path.</param>
+        private void DeleteHlsPartialStreamFiles(string outputFilePath)
+        {
+            var directory = Path.GetDirectoryName(outputFilePath);
+            var name = Path.GetFileNameWithoutExtension(outputFilePath);
+
+            var filesToDelete = _fileSystem.GetFilePaths(directory)
+                .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1);
+
+            List<Exception>? exs = null;
+            foreach (var file in filesToDelete)
+            {
+                try
+                {
+                    _logger.LogDebug("Deleting HLS file {0}", file);
+                    _fileSystem.DeleteFile(file);
+                }
+                catch (IOException ex)
+                {
+                    (exs ??= new List<Exception>(4)).Add(ex);
+                    _logger.LogError(ex, "Error deleting HLS file {Path}", file);
+                }
+            }
+
+            if (exs != null)
+            {
+                throw new AggregateException("Error deleting HLS files", exs);
+            }
+        }
+    }
+}

+ 256 - 0
Jellyfin.Api/Models/PlaybackDtos/TranscodingJobDto.cs

@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Models.PlaybackDtos
+{
+    /// <summary>
+    /// Class TranscodingJob.
+    /// </summary>
+    public class TranscodingJobDto
+    {
+        /// <summary>
+        /// The process lock.
+        /// </summary>
+        [SuppressMessage("Microsoft.Performance", "CA1051:NoVisibleInstanceFields", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
+        [SuppressMessage("Microsoft.Performance", "SA1401:PrivateField", MessageId = "ProcessLock", Justification = "Imported from ServiceStack")]
+        public readonly object ProcessLock = new object();
+
+        /// <summary>
+        /// Timer lock.
+        /// </summary>
+        private readonly object _timerLock = new object();
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingJobDto"/> class.
+        /// </summary>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingJobDto}"/> interface.</param>
+        public TranscodingJobDto(ILogger<TranscodingJobDto> logger)
+        {
+            Logger = logger;
+        }
+
+        /// <summary>
+        /// Gets or sets the play session identifier.
+        /// </summary>
+        /// <value>The play session identifier.</value>
+        public string? PlaySessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the live stream identifier.
+        /// </summary>
+        /// <value>The live stream identifier.</value>
+        public string? LiveStreamId { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is live output.
+        /// </summary>
+        public bool IsLiveOutput { get; set; }
+
+        /// <summary>
+        /// Gets or sets the path.
+        /// </summary>
+        /// <value>The path.</value>
+        public MediaSourceInfo? MediaSource { get; set; }
+
+        /// <summary>
+        /// Gets or sets path.
+        /// </summary>
+        public string? Path { get; set; }
+
+        /// <summary>
+        /// Gets or sets the type.
+        /// </summary>
+        /// <value>The type.</value>
+        public TranscodingJobType Type { get; set; }
+
+        /// <summary>
+        /// Gets or sets the process.
+        /// </summary>
+        /// <value>The process.</value>
+        public Process? Process { get; set; }
+
+        /// <summary>
+        /// Gets logger.
+        /// </summary>
+        public ILogger<TranscodingJobDto> Logger { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the active request count.
+        /// </summary>
+        /// <value>The active request count.</value>
+        public int ActiveRequestCount { get; set; }
+
+        /// <summary>
+        /// Gets or sets the kill timer.
+        /// </summary>
+        /// <value>The kill timer.</value>
+        private Timer? KillTimer { get; set; }
+
+        /// <summary>
+        /// Gets or sets device id.
+        /// </summary>
+        public string? DeviceId { get; set; }
+
+        /// <summary>
+        /// Gets or sets cancellation token source.
+        /// </summary>
+        public CancellationTokenSource? CancellationTokenSource { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether has exited.
+        /// </summary>
+        public bool HasExited { get; set; }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether is user paused.
+        /// </summary>
+        public bool IsUserPaused { get; set; }
+
+        /// <summary>
+        /// Gets or sets id.
+        /// </summary>
+        public string? Id { get; set; }
+
+        /// <summary>
+        /// Gets or sets framerate.
+        /// </summary>
+        public float? Framerate { get; set; }
+
+        /// <summary>
+        /// Gets or sets completion percentage.
+        /// </summary>
+        public double? CompletionPercentage { get; set; }
+
+        /// <summary>
+        /// Gets or sets bytes downloaded.
+        /// </summary>
+        public long? BytesDownloaded { get; set; }
+
+        /// <summary>
+        /// Gets or sets bytes transcoded.
+        /// </summary>
+        public long? BytesTranscoded { get; set; }
+
+        /// <summary>
+        /// Gets or sets bit rate.
+        /// </summary>
+        public int? BitRate { get; set; }
+
+        /// <summary>
+        /// Gets or sets transcoding position ticks.
+        /// </summary>
+        public long? TranscodingPositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets download position ticks.
+        /// </summary>
+        public long? DownloadPositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets transcoding throttler.
+        /// </summary>
+        public TranscodingThrottler? TranscodingThrottler { get; set; }
+
+        /// <summary>
+        /// Gets or sets last ping date.
+        /// </summary>
+        public DateTime LastPingDate { get; set; }
+
+        /// <summary>
+        /// Gets or sets ping timeout.
+        /// </summary>
+        public int PingTimeout { get; set; }
+
+        /// <summary>
+        /// Stop kill timer.
+        /// </summary>
+        public void StopKillTimer()
+        {
+            lock (_timerLock)
+            {
+                KillTimer?.Change(Timeout.Infinite, Timeout.Infinite);
+            }
+        }
+
+        /// <summary>
+        /// Dispose kill timer.
+        /// </summary>
+        public void DisposeKillTimer()
+        {
+            lock (_timerLock)
+            {
+                if (KillTimer != null)
+                {
+                    KillTimer.Dispose();
+                    KillTimer = null;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Start kill timer.
+        /// </summary>
+        /// <param name="callback">Callback action.</param>
+        public void StartKillTimer(Action<object> callback)
+        {
+            StartKillTimer(callback, PingTimeout);
+        }
+
+        /// <summary>
+        /// Start kill timer.
+        /// </summary>
+        /// <param name="callback">Callback action.</param>
+        /// <param name="intervalMs">Callback interval.</param>
+        public void StartKillTimer(Action<object> callback, int intervalMs)
+        {
+            if (HasExited)
+            {
+                return;
+            }
+
+            lock (_timerLock)
+            {
+                if (KillTimer == null)
+                {
+                    Logger.LogDebug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer = new Timer(new TimerCallback(callback), this, intervalMs, Timeout.Infinite);
+                }
+                else
+                {
+                    Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer.Change(intervalMs, Timeout.Infinite);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Change kill timer if started.
+        /// </summary>
+        public void ChangeKillTimerIfStarted()
+        {
+            if (HasExited)
+            {
+                return;
+            }
+
+            lock (_timerLock)
+            {
+                if (KillTimer != null)
+                {
+                    var intervalMs = PingTimeout;
+
+                    Logger.LogDebug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId);
+                    KillTimer.Change(intervalMs, Timeout.Infinite);
+                }
+            }
+        }
+    }
+}

+ 212 - 0
Jellyfin.Api/Models/PlaybackDtos/TranscodingThrottler.cs

@@ -0,0 +1,212 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.Models.PlaybackDtos
+{
+    /// <summary>
+    /// Transcoding throttler.
+    /// </summary>
+    public class TranscodingThrottler : IDisposable
+    {
+        private readonly TranscodingJobDto _job;
+        private readonly ILogger<TranscodingThrottler> _logger;
+        private readonly IConfigurationManager _config;
+        private readonly IFileSystem _fileSystem;
+        private Timer? _timer;
+        private bool _isPaused;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="TranscodingThrottler"/> class.
+        /// </summary>
+        /// <param name="job">Transcoding job dto.</param>
+        /// <param name="logger">Instance of the <see cref="ILogger{TranscodingThrottler}"/> interface.</param>
+        /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+        /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+        public TranscodingThrottler(TranscodingJobDto job, ILogger<TranscodingThrottler> logger, IConfigurationManager config, IFileSystem fileSystem)
+        {
+            _job = job;
+            _logger = logger;
+            _config = config;
+            _fileSystem = fileSystem;
+        }
+
+        /// <summary>
+        /// Start timer.
+        /// </summary>
+        public void Start()
+        {
+            _timer = new Timer(TimerCallback, null, 5000, 5000);
+        }
+
+        /// <summary>
+        /// Unpause transcoding.
+        /// </summary>
+        /// <returns>A <see cref="Task"/>.</returns>
+        public async Task UnpauseTranscoding()
+        {
+            if (_isPaused)
+            {
+                _logger.LogDebug("Sending resume command to ffmpeg");
+
+                try
+                {
+                    await _job.Process!.StandardInput.WriteLineAsync().ConfigureAwait(false);
+                    _isPaused = false;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error resuming transcoding");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Stop throttler.
+        /// </summary>
+        /// <returns>A <see cref="Task"/>.</returns>
+        public async Task Stop()
+        {
+            DisposeTimer();
+            await UnpauseTranscoding().ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Dispose throttler.
+        /// </summary>
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Dispose throttler.
+        /// </summary>
+        /// <param name="disposing">Disposing.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (disposing)
+            {
+                DisposeTimer();
+            }
+        }
+
+        private EncodingOptions GetOptions()
+        {
+            return _config.GetConfiguration<EncodingOptions>("encoding");
+        }
+
+        private async void TimerCallback(object state)
+        {
+            if (_job.HasExited)
+            {
+                DisposeTimer();
+                return;
+            }
+
+            var options = GetOptions();
+
+            if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds))
+            {
+                await PauseTranscoding().ConfigureAwait(false);
+            }
+            else
+            {
+                await UnpauseTranscoding().ConfigureAwait(false);
+            }
+        }
+
+        private async Task PauseTranscoding()
+        {
+            if (!_isPaused)
+            {
+                _logger.LogDebug("Sending pause command to ffmpeg");
+
+                try
+                {
+                    await _job.Process!.StandardInput.WriteAsync("c").ConfigureAwait(false);
+                    _isPaused = true;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error pausing transcoding");
+                }
+            }
+        }
+
+        private bool IsThrottleAllowed(TranscodingJobDto job, int thresholdSeconds)
+        {
+            var bytesDownloaded = job.BytesDownloaded ?? 0;
+            var transcodingPositionTicks = job.TranscodingPositionTicks ?? 0;
+            var downloadPositionTicks = job.DownloadPositionTicks ?? 0;
+
+            var path = job.Path;
+            var gapLengthInTicks = TimeSpan.FromSeconds(thresholdSeconds).Ticks;
+
+            if (downloadPositionTicks > 0 && transcodingPositionTicks > 0)
+            {
+                // HLS - time-based consideration
+
+                var targetGap = gapLengthInTicks;
+                var gap = transcodingPositionTicks - downloadPositionTicks;
+
+                if (gap < targetGap)
+                {
+                    _logger.LogDebug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap);
+                    return false;
+                }
+
+                _logger.LogDebug("Throttling transcoder gap {0} target gap {1}", gap, targetGap);
+                return true;
+            }
+
+            if (bytesDownloaded > 0 && transcodingPositionTicks > 0)
+            {
+                // Progressive Streaming - byte-based consideration
+
+                try
+                {
+                    var bytesTranscoded = job.BytesTranscoded ?? _fileSystem.GetFileInfo(path).Length;
+
+                    // Estimate the bytes the transcoder should be ahead
+                    double gapFactor = gapLengthInTicks;
+                    gapFactor /= transcodingPositionTicks;
+                    var targetGap = bytesTranscoded * gapFactor;
+
+                    var gap = bytesTranscoded - bytesDownloaded;
+
+                    if (gap < targetGap)
+                    {
+                        _logger.LogDebug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
+                        return false;
+                    }
+
+                    _logger.LogDebug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded);
+                    return true;
+                }
+                catch (Exception ex)
+                {
+                    _logger.LogError(ex, "Error getting output size");
+                    return false;
+                }
+            }
+
+            _logger.LogDebug("No throttle data for " + path);
+            return false;
+        }
+
+        private void DisposeTimer()
+        {
+            if (_timer != null)
+            {
+                _timer.Dispose();
+                _timer = null;
+            }
+        }
+    }
+}

+ 0 - 456
MediaBrowser.Api/UserLibrary/PlaystateService.cs

@@ -1,456 +0,0 @@
-using System;
-using System.Globalization;
-using System.Threading.Tasks;
-using Jellyfin.Data.Entities;
-using MediaBrowser.Controller.Configuration;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Services;
-using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Logging;
-
-namespace MediaBrowser.Api.UserLibrary
-{
-    /// <summary>
-    /// Class MarkPlayedItem
-    /// </summary>
-    [Route("/Users/{UserId}/PlayedItems/{Id}", "POST", Summary = "Marks an item as played")]
-    public class MarkPlayedItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        [ApiMember(Name = "DatePlayed", Description = "The date the item was played (if any). Format = yyyyMMddHHmmss", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string DatePlayed { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class MarkUnplayedItem
-    /// </summary>
-    [Route("/Users/{UserId}/PlayedItems/{Id}", "DELETE", Summary = "Marks an item as unplayed")]
-    public class MarkUnplayedItem : IReturn<UserItemDataDto>
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    [Route("/Sessions/Playing", "POST", Summary = "Reports playback has started within a session")]
-    public class ReportPlaybackStart : PlaybackStartInfo, IReturnVoid
-    {
-    }
-
-    [Route("/Sessions/Playing/Progress", "POST", Summary = "Reports playback progress within a session")]
-    public class ReportPlaybackProgress : PlaybackProgressInfo, IReturnVoid
-    {
-    }
-
-    [Route("/Sessions/Playing/Ping", "POST", Summary = "Pings a playback session")]
-    public class PingPlaybackSession : IReturnVoid
-    {
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    [Route("/Sessions/Playing/Stopped", "POST", Summary = "Reports playback has stopped within a session")]
-    public class ReportPlaybackStopped : PlaybackStopInfo, IReturnVoid
-    {
-    }
-
-    /// <summary>
-    /// Class OnPlaybackStart
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}", "POST", Summary = "Reports that a user has begun playing an item")]
-    public class OnPlaybackStart : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string MediaSourceId { get; set; }
-
-        [ApiMember(Name = "CanSeek", Description = "Indicates if the client can seek", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool CanSeek { get; set; }
-
-        [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? AudioStreamIndex { get; set; }
-
-        [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? SubtitleStreamIndex { get; set; }
-
-        [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public PlayMethod PlayMethod { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    /// <summary>
-    /// Class OnPlaybackProgress
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}/Progress", "POST", Summary = "Reports a user's playback progress")]
-    public class OnPlaybackProgress : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string MediaSourceId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        [ApiMember(Name = "PositionTicks", Description = "Optional. The current position, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public long? PositionTicks { get; set; }
-
-        [ApiMember(Name = "IsPaused", Description = "Indicates if the player is paused.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool IsPaused { get; set; }
-
-        [ApiMember(Name = "IsMuted", Description = "Indicates if the player is muted.", IsRequired = false, DataType = "boolean", ParameterType = "query", Verb = "POST")]
-        public bool IsMuted { get; set; }
-
-        [ApiMember(Name = "AudioStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? AudioStreamIndex { get; set; }
-
-        [ApiMember(Name = "SubtitleStreamIndex", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? SubtitleStreamIndex { get; set; }
-
-        [ApiMember(Name = "VolumeLevel", Description = "Scale of 0-100", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "POST")]
-        public int? VolumeLevel { get; set; }
-
-        [ApiMember(Name = "PlayMethod", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public PlayMethod PlayMethod { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-
-        [ApiMember(Name = "RepeatMode", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public RepeatMode RepeatMode { get; set; }
-    }
-
-    /// <summary>
-    /// Class OnPlaybackStopped
-    /// </summary>
-    [Route("/Users/{UserId}/PlayingItems/{Id}", "DELETE", Summary = "Reports that a user has stopped playing an item")]
-    public class OnPlaybackStopped : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string UserId { get; set; }
-
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-
-        [ApiMember(Name = "MediaSourceId", Description = "The id of the MediaSource", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string MediaSourceId { get; set; }
-
-        [ApiMember(Name = "NextMediaType", Description = "The next media type that will play", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")]
-        public string NextMediaType { get; set; }
-
-        /// <summary>
-        /// Gets or sets the position ticks.
-        /// </summary>
-        /// <value>The position ticks.</value>
-        [ApiMember(Name = "PositionTicks", Description = "Optional. The position, in ticks, where playback stopped. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "DELETE")]
-        public long? PositionTicks { get; set; }
-
-        [ApiMember(Name = "LiveStreamId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string LiveStreamId { get; set; }
-
-        [ApiMember(Name = "PlaySessionId", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string PlaySessionId { get; set; }
-    }
-
-    [Authenticated]
-    public class PlaystateService : BaseApiService
-    {
-        private readonly IUserManager _userManager;
-        private readonly IUserDataManager _userDataRepository;
-        private readonly ILibraryManager _libraryManager;
-        private readonly ISessionManager _sessionManager;
-        private readonly ISessionContext _sessionContext;
-        private readonly IAuthorizationContext _authContext;
-
-        public PlaystateService(
-            ILogger<PlaystateService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IUserManager userManager,
-            IUserDataManager userDataRepository,
-            ILibraryManager libraryManager,
-            ISessionManager sessionManager,
-            ISessionContext sessionContext,
-            IAuthorizationContext authContext)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _userManager = userManager;
-            _userDataRepository = userDataRepository;
-            _libraryManager = libraryManager;
-            _sessionManager = sessionManager;
-            _sessionContext = sessionContext;
-            _authContext = authContext;
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Post(MarkPlayedItem request)
-        {
-            var result = MarkPlayed(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        private UserItemDataDto MarkPlayed(MarkPlayedItem request)
-        {
-            var user = _userManager.GetUserById(Guid.Parse(request.UserId));
-
-            DateTime? datePlayed = null;
-
-            if (!string.IsNullOrEmpty(request.DatePlayed))
-            {
-                datePlayed = DateTime.ParseExact(request.DatePlayed, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
-            }
-
-            var session = GetSession(_sessionContext);
-
-            var dto = UpdatePlayedStatus(user, request.Id, true, datePlayed);
-
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-
-                UpdatePlayedStatus(additionalUser, request.Id, true, datePlayed);
-            }
-
-            return dto;
-        }
-
-        private PlayMethod ValidatePlayMethod(PlayMethod method, string playSessionId)
-        {
-            if (method == PlayMethod.Transcode)
-            {
-                var job = string.IsNullOrWhiteSpace(playSessionId) ? null : ApiEntryPoint.Instance.GetTranscodingJob(playSessionId);
-                if (job == null)
-                {
-                    return PlayMethod.DirectPlay;
-                }
-            }
-
-            return method;
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(OnPlaybackStart request)
-        {
-            Post(new ReportPlaybackStart
-            {
-                CanSeek = request.CanSeek,
-                ItemId = new Guid(request.Id),
-                MediaSourceId = request.MediaSourceId,
-                AudioStreamIndex = request.AudioStreamIndex,
-                SubtitleStreamIndex = request.SubtitleStreamIndex,
-                PlayMethod = request.PlayMethod,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId
-            });
-        }
-
-        public void Post(ReportPlaybackStart request)
-        {
-            request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId);
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            var task = _sessionManager.OnPlaybackStart(request);
-
-            Task.WaitAll(task);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Post(OnPlaybackProgress request)
-        {
-            Post(new ReportPlaybackProgress
-            {
-                ItemId = new Guid(request.Id),
-                PositionTicks = request.PositionTicks,
-                IsMuted = request.IsMuted,
-                IsPaused = request.IsPaused,
-                MediaSourceId = request.MediaSourceId,
-                AudioStreamIndex = request.AudioStreamIndex,
-                SubtitleStreamIndex = request.SubtitleStreamIndex,
-                VolumeLevel = request.VolumeLevel,
-                PlayMethod = request.PlayMethod,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId,
-                RepeatMode = request.RepeatMode
-            });
-        }
-
-        public void Post(ReportPlaybackProgress request)
-        {
-            request.PlayMethod = ValidatePlayMethod(request.PlayMethod, request.PlaySessionId);
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            var task = _sessionManager.OnPlaybackProgress(request);
-
-            Task.WaitAll(task);
-        }
-
-        public void Post(PingPlaybackSession request)
-        {
-            ApiEntryPoint.Instance.PingTranscodingJob(request.PlaySessionId, null);
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public Task Delete(OnPlaybackStopped request)
-        {
-            return Post(new ReportPlaybackStopped
-            {
-                ItemId = new Guid(request.Id),
-                PositionTicks = request.PositionTicks,
-                MediaSourceId = request.MediaSourceId,
-                PlaySessionId = request.PlaySessionId,
-                LiveStreamId = request.LiveStreamId,
-                NextMediaType = request.NextMediaType
-            });
-        }
-
-        public async Task Post(ReportPlaybackStopped request)
-        {
-            Logger.LogDebug("ReportPlaybackStopped PlaySessionId: {0}", request.PlaySessionId ?? string.Empty);
-
-            if (!string.IsNullOrWhiteSpace(request.PlaySessionId))
-            {
-                await ApiEntryPoint.Instance.KillTranscodingJobs(_authContext.GetAuthorizationInfo(Request).DeviceId, request.PlaySessionId, s => true);
-            }
-
-            request.SessionId = GetSession(_sessionContext).Id;
-
-            await _sessionManager.OnPlaybackStopped(request);
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public object Delete(MarkUnplayedItem request)
-        {
-            var task = MarkUnplayed(request);
-
-            return ToOptimizedResult(task);
-        }
-
-        private UserItemDataDto MarkUnplayed(MarkUnplayedItem request)
-        {
-            var user = _userManager.GetUserById(Guid.Parse(request.UserId));
-
-            var session = GetSession(_sessionContext);
-
-            var dto = UpdatePlayedStatus(user, request.Id, false, null);
-
-            foreach (var additionalUserInfo in session.AdditionalUsers)
-            {
-                var additionalUser = _userManager.GetUserById(additionalUserInfo.UserId);
-
-                UpdatePlayedStatus(additionalUser, request.Id, false, null);
-            }
-
-            return dto;
-        }
-
-        /// <summary>
-        /// Updates the played status.
-        /// </summary>
-        /// <param name="user">The user.</param>
-        /// <param name="itemId">The item id.</param>
-        /// <param name="wasPlayed">if set to <c>true</c> [was played].</param>
-        /// <param name="datePlayed">The date played.</param>
-        /// <returns>Task.</returns>
-        private UserItemDataDto UpdatePlayedStatus(User user, string itemId, bool wasPlayed, DateTime? datePlayed)
-        {
-            var item = _libraryManager.GetItemById(itemId);
-
-            if (wasPlayed)
-            {
-                item.MarkPlayed(user, datePlayed, true);
-            }
-            else
-            {
-                item.MarkUnplayed(user);
-            }
-
-            return _userDataRepository.GetUserDataDto(item, user);
-        }
-    }
-}