diff options
Diffstat (limited to 'MediaBrowser.Api')
16 files changed, 5674 insertions, 1 deletions
diff --git a/MediaBrowser.Api/ApiEntryPoint.cs b/MediaBrowser.Api/ApiEntryPoint.cs index 9676a2c2af..5aa803b9b6 100644 --- a/MediaBrowser.Api/ApiEntryPoint.cs +++ b/MediaBrowser.Api/ApiEntryPoint.cs @@ -10,6 +10,15 @@ using MediaBrowser.Model.Logging; using MediaBrowser.Model.Threading; using System.Collections.Generic; using System.Threading; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Api.Playback; +using System.IO; +using MediaBrowser.Model.Session; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Common.Configuration; namespace MediaBrowser.Api { @@ -41,6 +50,11 @@ namespace MediaBrowser.Api public readonly ITimerFactory TimerFactory; public readonly IProcessFactory ProcessFactory; + /// <summary> + /// The active transcoding jobs + /// </summary> + private readonly List<TranscodingJob> _activeTranscodingJobs = new List<TranscodingJob>(); + private readonly Dictionary<string, SemaphoreSlim> _transcodingLocks = new Dictionary<string, SemaphoreSlim>(); @@ -64,6 +78,8 @@ namespace MediaBrowser.Api ResultFactory = resultFactory; Instance = this; + _sessionManager.PlaybackProgress += _sessionManager_PlaybackProgress; + _sessionManager.PlaybackStart += _sessionManager_PlaybackStart; } public static string[] Split(string value, char separator, bool removeEmpty) @@ -81,13 +97,739 @@ namespace MediaBrowser.Api return value.Split(separator); } + public SemaphoreSlim GetTranscodingLock(string outputPath) + { + lock (_transcodingLocks) + { + SemaphoreSlim result; + if (!_transcodingLocks.TryGetValue(outputPath, out result)) + { + result = new SemaphoreSlim(1, 1); + _transcodingLocks[outputPath] = result; + } + + return result; + } + } + + private void _sessionManager_PlaybackStart(object sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + { + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } + + void _sessionManager_PlaybackProgress(object sender, PlaybackProgressEventArgs e) + { + if (!string.IsNullOrWhiteSpace(e.PlaySessionId)) + { + PingTranscodingJob(e.PlaySessionId, e.IsPaused); + } + } + + /// <summary> + /// Runs this instance. + /// </summary> public void Run() { - + try + { + DeleteEncodedMediaCache(); + } + catch (FileNotFoundException) + { + // Don't clutter the log + } + catch (IOException) + { + // Don't clutter the log + } + catch (Exception ex) + { + Logger.ErrorException("Error deleting encoded media cache", ex); + } + } + + public EncodingOptions GetEncodingOptions() + { + return ConfigurationManagerExtensions.GetConfiguration<EncodingOptions>(_config, "encoding"); + } + + /// <summary> + /// Deletes the encoded media cache. + /// </summary> + private void DeleteEncodedMediaCache() + { + var path = _config.ApplicationPaths.TranscodingTempPath; + + foreach (var file in _fileSystem.GetFilePaths(path, true) + .ToList()) + { + _fileSystem.DeleteFile(file); + } } + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + var list = _activeTranscodingJobs.ToList(); + var jobCount = list.Count; + + Parallel.ForEach(list, j => KillTranscodingJob(j, false, path => true)); + + // Try to allow for some time to kill the ffmpeg processes and delete the partial stream files + if (jobCount > 0) + { + var task = Task.Delay(1000); + Task.WaitAll(task); + } + } + + + /// <summary> + /// Called when [transcode beginning]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="playSessionId">The play session identifier.</param> + /// <param name="liveStreamId">The live stream identifier.</param> + /// <param name="transcodingJobId">The transcoding job identifier.</param> + /// <param name="type">The type.</param> + /// <param name="process">The process.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="state">The state.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>TranscodingJob.</returns> + public TranscodingJob OnTranscodeBeginning(string path, + string playSessionId, + string liveStreamId, + string transcodingJobId, + TranscodingJobType type, + IProcess process, + string deviceId, + StreamState state, + CancellationTokenSource cancellationTokenSource) + { + lock (_activeTranscodingJobs) + { + var job = new TranscodingJob(Logger, TimerFactory) + { + Type = type, + Path = path, + Process = process, + ActiveRequestCount = 1, + DeviceId = deviceId, + CancellationTokenSource = cancellationTokenSource, + Id = transcodingJobId, + PlaySessionId = playSessionId, + LiveStreamId = liveStreamId, + MediaSource = state.MediaSource + }; + + _activeTranscodingJobs.Add(job); + + ReportTranscodingProgress(job, state, null, null, null, null, null); + + return job; + } + } + + public void ReportTranscodingProgress(TranscodingJob job, StreamState state, TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + var ticks = transcodingPosition.HasValue ? transcodingPosition.Value.Ticks : (long?)null; + + if (job != null) + { + job.Framerate = framerate; + job.CompletionPercentage = percentComplete; + job.TranscodingPositionTicks = ticks; + job.BytesTranscoded = bytesTranscoded; + job.BitRate = bitRate; + } + + var deviceId = state.Request.DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + _sessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + { + Bitrate = bitRate ?? state.TotalOutputBitrate, + AudioCodec = audioCodec, + VideoCodec = videoCodec, + Container = state.OutputContainer, + Framerate = framerate, + CompletionPercentage = percentComplete, + Width = state.OutputWidth, + Height = state.OutputHeight, + AudioChannels = state.OutputAudioChannels, + IsAudioDirect = string.Equals(state.OutputAudioCodec, "copy", StringComparison.OrdinalIgnoreCase), + IsVideoDirect = string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase), + TranscodeReasons = state.TranscodeReasons + }); + } + } + + /// <summary> + /// <summary> + /// The progressive + /// </summary> + /// Called when [transcode failed to start]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <param name="state">The state.</param> + public void OnTranscodeFailedToStart(string path, TranscodingJobType type, StreamState state) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job != null) + { + _activeTranscodingJobs.Remove(job); + } + } + + lock (_transcodingLocks) + { + _transcodingLocks.Remove(path); + } + + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) + { + _sessionManager.ClearTranscodingInfo(state.Request.DeviceId); + } + } + + /// <summary> + /// Determines whether [has active transcoding job] [the specified path]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + /// <returns><c>true</c> if [has active transcoding job] [the specified path]; otherwise, <c>false</c>.</returns> + public bool HasActiveTranscodingJob(string path, TranscodingJobType type) + { + return GetTranscodingJob(path, type) != null; + } + + public TranscodingJob GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } + + public TranscodingJob GetTranscodingJob(string playSessionId) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase)); + } + } + + /// <summary> + /// Called when [transcode begin request]. + /// </summary> + /// <param name="path">The path.</param> + /// <param name="type">The type.</param> + public TranscodingJob OnTranscodeBeginRequest(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + var job = _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + + if (job == null) + { + return null; + } + + OnTranscodeBeginRequest(job); + + return job; + } + } + + public void OnTranscodeBeginRequest(TranscodingJob job) + { + job.ActiveRequestCount++; + + if (string.IsNullOrWhiteSpace(job.PlaySessionId) || job.Type == TranscodingJobType.Progressive) + { + job.StopKillTimer(); + } + } + + public void OnTranscodeEndRequest(TranscodingJob job) + { + job.ActiveRequestCount--; + //Logger.Debug("OnTranscodeEndRequest job.ActiveRequestCount={0}", job.ActiveRequestCount); + if (job.ActiveRequestCount <= 0) + { + PingTimer(job, false); + } + } + internal void PingTranscodingJob(string playSessionId, bool? isUserPaused) + { + if (string.IsNullOrEmpty(playSessionId)) + { + throw new ArgumentNullException("playSessionId"); + } + + //Logger.Debug("PingTranscodingJob PlaySessionId={0} isUsedPaused: {1}", playSessionId, isUserPaused); + + List<TranscodingJob> 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.Debug("Setting job.IsUserPaused to {0}. jobId: {1}", isUserPaused, job.Id); + job.IsUserPaused = isUserPaused.Value; + } + PingTimer(job, true); + } + } + + private void PingTimer(TranscodingJob 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 void OnTranscodeKillTimerStopped(object state) + { + var job = (TranscodingJob)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.Info("Transcoding kill timer stopped for JobId {0} PlaySessionId {1}. Killing transcoding", job.Id, job.PlaySessionId); + + KillTranscodingJob(job, true, path => true); + } + + /// <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> + internal void KillTranscodingJobs(string deviceId, string playSessionId, Func<string, bool> deleteFiles) + { + KillTranscodingJobs(j => + { + if (!string.IsNullOrWhiteSpace(playSessionId)) + { + return string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals(deviceId, j.DeviceId, 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 void KillTranscodingJobs(Func<TranscodingJob, bool> killJob, Func<string, bool> deleteFiles) + { + var jobs = new List<TranscodingJob>(); + + 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; + } + + foreach (var job in jobs) + { + KillTranscodingJob(job, false, deleteFiles); + } + } + + /// <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 void KillTranscodingJob(TranscodingJob job, bool closeLiveStream, Func<string, bool> delete) + { + job.DisposeKillTimer(); + + Logger.Debug("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) + { + if (job.TranscodingThrottler != null) + { + job.TranscodingThrottler.Stop(); + } + + var process = job.Process; + + var hasExited = job.HasExited; + + if (!hasExited) + { + try + { + Logger.Info("Stopping ffmpeg process with q command for {0}", job.Path); + + //process.Kill(); + process.StandardInput.WriteLine("q"); + + // Need to wait because killing is asynchronous + if (!process.WaitForExit(5000)) + { + Logger.Info("Killing ffmpeg process for {0}", job.Path); + process.Kill(); + } + } + catch (Exception ex) + { + Logger.ErrorException("Error killing transcoding job for {0}", ex, job.Path); + } + } + } + + if (delete(job.Path)) + { + DeletePartialStreamFiles(job.Path, job.Type, 0, 1500); + } + + if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(job.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.ErrorException("Error closing live stream for {0}", ex, job.Path); + } + } + } + + private async void DeletePartialStreamFiles(string path, TranscodingJobType jobType, int retryCount, int delayMs) + { + if (retryCount >= 10) + { + return; + } + + Logger.Info("Deleting partial stream file(s) {0}", path); + + await Task.Delay(delayMs).ConfigureAwait(false); + + try + { + if (jobType == TranscodingJobType.Progressive) + { + DeleteProgressivePartialStreamFiles(path); + } + else + { + DeleteHlsPartialStreamFiles(path); + } + } + catch (FileNotFoundException) + { + + } + catch (IOException) + { + //Logger.ErrorException("Error deleting partial stream file(s) {0}", ex, path); + + DeletePartialStreamFiles(path, jobType, retryCount + 1, 500); + } + catch + { + //Logger.ErrorException("Error deleting partial stream file(s) {0}", ex, path); + } + } + + /// <summary> + /// Deletes the progressive partial stream files. + /// </summary> + /// <param name="outputFilePath">The output file path.</param> + private void DeleteProgressivePartialStreamFiles(string 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 = _fileSystem.GetDirectoryName(outputFilePath); + var name = Path.GetFileNameWithoutExtension(outputFilePath); + + var filesToDelete = _fileSystem.GetFilePaths(directory) + .Where(f => f.IndexOf(name, StringComparison.OrdinalIgnoreCase) != -1) + .ToList(); + + Exception e = null; + + foreach (var file in filesToDelete) + { + try + { + //Logger.Debug("Deleting HLS file {0}", file); + _fileSystem.DeleteFile(file); + } + catch (FileNotFoundException) + { + + } + catch (IOException ex) + { + e = ex; + //Logger.ErrorException("Error deleting HLS file {0}", ex, file); + } + } + + if (e != null) + { + throw e; + } + } + } + + /// <summary> + /// Class TranscodingJob + /// </summary> + public class TranscodingJob + { + /// <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; } + + public bool IsLiveOutput { get; set; } + + /// <summary> + /// Gets or sets the path. + /// </summary> + /// <value>The path.</value> + public MediaSourceInfo MediaSource { get; set; } + 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 IProcess Process { get; set; } + public ILogger 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 ITimer KillTimer { get; set; } + + private readonly ITimerFactory _timerFactory; + + public string DeviceId { get; set; } + + public CancellationTokenSource CancellationTokenSource { get; set; } + + public object ProcessLock = new object(); + + public bool HasExited { get; set; } + public bool IsUserPaused { get; set; } + + public string Id { get; set; } + + public float? Framerate { get; set; } + public double? CompletionPercentage { get; set; } + + public long? BytesDownloaded { get; set; } + public long? BytesTranscoded { get; set; } + public int? BitRate { get; set; } + + public long? TranscodingPositionTicks { get; set; } + public long? DownloadPositionTicks { get; set; } + + public TranscodingThrottler TranscodingThrottler { get; set; } + + private readonly object _timerLock = new object(); + + public DateTime LastPingDate { get; set; } + public int PingTimeout { get; set; } + + public TranscodingJob(ILogger logger, ITimerFactory timerFactory) + { + Logger = logger; + _timerFactory = timerFactory; + } + + public void StopKillTimer() + { + lock (_timerLock) + { + if (KillTimer != null) + { + KillTimer.Change(Timeout.Infinite, Timeout.Infinite); + } + } + } + + public void DisposeKillTimer() + { + lock (_timerLock) + { + if (KillTimer != null) + { + KillTimer.Dispose(); + KillTimer = null; + } + } + } + + public void StartKillTimer(Action<object> callback) + { + StartKillTimer(callback, PingTimeout); + } + + public void StartKillTimer(Action<object> callback, int intervalMs) + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (KillTimer == null) + { + //Logger.Debug("Starting kill timer at {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer = _timerFactory.Create(callback, this, intervalMs, Timeout.Infinite); + } + else + { + //Logger.Debug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); + } + } + } + + public void ChangeKillTimerIfStarted() + { + if (HasExited) + { + return; + } + + lock (_timerLock) + { + if (KillTimer != null) + { + var intervalMs = PingTimeout; + + //Logger.Debug("Changing kill timer to {0}ms. JobId {1} PlaySessionId {2}", intervalMs, Id, PlaySessionId); + KillTimer.Change(intervalMs, Timeout.Infinite); + } + } } } } diff --git a/MediaBrowser.Api/Playback/BaseStreamingService.cs b/MediaBrowser.Api/Playback/BaseStreamingService.cs new file mode 100644 index 0000000000..9c2e0e9d8f --- /dev/null +++ b/MediaBrowser.Api/Playback/BaseStreamingService.cs @@ -0,0 +1,1025 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Diagnostics; + +namespace MediaBrowser.Api.Playback +{ + /// <summary> + /// Class BaseStreamingService + /// </summary> + public abstract class BaseStreamingService : BaseApiService + { + /// <summary> + /// Gets or sets the application paths. + /// </summary> + /// <value>The application paths.</value> + protected IServerConfigurationManager ServerConfigurationManager { get; private set; } + + /// <summary> + /// Gets or sets the user manager. + /// </summary> + /// <value>The user manager.</value> + protected IUserManager UserManager { get; private set; } + + /// <summary> + /// Gets or sets the library manager. + /// </summary> + /// <value>The library manager.</value> + protected ILibraryManager LibraryManager { get; private set; } + + /// <summary> + /// Gets or sets the iso manager. + /// </summary> + /// <value>The iso manager.</value> + protected IIsoManager IsoManager { get; private set; } + + /// <summary> + /// Gets or sets the media encoder. + /// </summary> + /// <value>The media encoder.</value> + protected IMediaEncoder MediaEncoder { get; private set; } + + protected IFileSystem FileSystem { get; private set; } + + protected IDlnaManager DlnaManager { get; private set; } + protected IDeviceManager DeviceManager { get; private set; } + protected ISubtitleEncoder SubtitleEncoder { get; private set; } + protected IMediaSourceManager MediaSourceManager { get; private set; } + protected IZipClient ZipClient { get; private set; } + protected IJsonSerializer JsonSerializer { get; private set; } + + public static IServerApplicationHost AppHost; + public static IHttpClient HttpClient; + protected IAuthorizationContext AuthorizationContext { get; private set; } + + protected EncodingHelper EncodingHelper { get; set; } + + /// <summary> + /// Initializes a new instance of the <see cref="BaseStreamingService" /> class. + /// </summary> + protected BaseStreamingService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext) + { + JsonSerializer = jsonSerializer; + AuthorizationContext = authorizationContext; + ZipClient = zipClient; + MediaSourceManager = mediaSourceManager; + DeviceManager = deviceManager; + SubtitleEncoder = subtitleEncoder; + DlnaManager = dlnaManager; + FileSystem = fileSystem; + ServerConfigurationManager = serverConfig; + UserManager = userManager; + LibraryManager = libraryManager; + IsoManager = isoManager; + MediaEncoder = mediaEncoder; + EncodingHelper = new EncodingHelper(MediaEncoder, FileSystem, SubtitleEncoder); + } + + /// <summary> + /// Gets the command line arguments. + /// </summary> + protected abstract string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding); + + /// <summary> + /// Gets the type of the transcoding job. + /// </summary> + /// <value>The type of the transcoding job.</value> + protected abstract TranscodingJobType TranscodingJobType { get; } + + /// <summary> + /// Gets the output file extension. + /// </summary> + /// <param name="state">The state.</param> + /// <returns>System.String.</returns> + protected virtual string GetOutputFileExtension(StreamState state) + { + return Path.GetExtension(state.RequestedUrl); + } + + /// <summary> + /// Gets the output file path. + /// </summary> + private string GetOutputFilePath(StreamState state, EncodingOptions encodingOptions, string outputFileExtension) + { + var folder = ServerConfigurationManager.ApplicationPaths.TranscodingTempPath; + + var data = GetCommandLineArguments("dummy\\dummy", encodingOptions, state, false); + + data += "-" + (state.Request.DeviceId ?? string.Empty); + data += "-" + (state.Request.PlaySessionId ?? string.Empty); + + var dataHash = data.GetMD5().ToString("N"); + + if (EnableOutputInSubFolder) + { + return Path.Combine(folder, dataHash, dataHash + (outputFileExtension ?? string.Empty).ToLower()); + } + + return Path.Combine(folder, dataHash + (outputFileExtension ?? string.Empty).ToLower()); + } + + protected virtual bool EnableOutputInSubFolder + { + get { return false; } + } + + protected readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + protected virtual string GetDefaultH264Preset() + { + return "superfast"; + } + + private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource) + { + if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && IsoManager.CanMount(state.MediaPath)) + { + state.IsoMount = await IsoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false); + } + + if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId)) + { + var liveStreamResponse = await MediaSourceManager.OpenLiveStream(new LiveStreamRequest + { + OpenToken = state.MediaSource.OpenToken + + }, cancellationTokenSource.Token).ConfigureAwait(false); + + EncodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl); + + if (state.VideoRequest != null) + { + EncodingHelper.TryStreamCopy(state); + } + } + + if (state.MediaSource.BufferMs.HasValue) + { + await Task.Delay(state.MediaSource.BufferMs.Value, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + /// <summary> + /// Starts the FFMPEG. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="outputPath">The output path.</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <param name="workingDirectory">The working directory.</param> + /// <returns>Task.</returns> + protected async Task<TranscodingJob> StartFfMpeg(StreamState state, + string outputPath, + CancellationTokenSource cancellationTokenSource, + string workingDirectory = null) + { + FileSystem.CreateDirectory(FileSystem.GetDirectoryName(outputPath)); + + await AcquireResources(state, cancellationTokenSource).ConfigureAwait(false); + + if (state.VideoRequest != null && !string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var auth = AuthorizationContext.GetAuthorizationInfo(Request); + if (auth.User != null) + { + if (!auth.User.Policy.EnableVideoPlaybackTranscoding) + { + ApiEntryPoint.Instance.OnTranscodeFailedToStart(outputPath, TranscodingJobType, state); + + throw new ArgumentException("User does not have access to video transcoding"); + } + } + } + + var encodingOptions = ApiEntryPoint.Instance.GetEncodingOptions(); + + var transcodingId = Guid.NewGuid().ToString("N"); + var commandLineArgs = GetCommandLineArguments(outputPath, encodingOptions, state, true); + + var process = ApiEntryPoint.Instance.ProcessFactory.Create(new ProcessOptions + { + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + //RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + + FileName = MediaEncoder.EncoderPath, + Arguments = commandLineArgs, + + IsHidden = true, + ErrorDialog = false, + EnableRaisingEvents = true, + WorkingDirectory = !string.IsNullOrWhiteSpace(workingDirectory) ? workingDirectory : null + }); + + var transcodingJob = ApiEntryPoint.Instance.OnTranscodeBeginning(outputPath, + state.Request.PlaySessionId, + state.MediaSource.LiveStreamId, + transcodingId, + TranscodingJobType, + process, + state.Request.DeviceId, + state, + cancellationTokenSource); + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + Logger.Info(commandLineLogMessage); + + var logFilePrefix = "ffmpeg-transcode"; + if (state.VideoRequest != null && string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase) && string.Equals(state.OutputAudioCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + logFilePrefix = "ffmpeg-directstream"; + } + else if (state.VideoRequest != null && string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + logFilePrefix = "ffmpeg-remux"; + } + + var logFilePath = Path.Combine(ServerConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt"); + FileSystem.CreateDirectory(FileSystem.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + state.LogFileStream = FileSystem.GetFileStream(logFilePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(Request.AbsoluteUri + Environment.NewLine + Environment.NewLine + JsonSerializer.SerializeToString(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await state.LogFileStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationTokenSource.Token).ConfigureAwait(false); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process, transcodingJob, state); + + try + { + process.Start(); + } + catch (Exception ex) + { + Logger.ErrorException("Error starting ffmpeg", ex); + + ApiEntryPoint.Instance.OnTranscodeFailedToStart(outputPath, TranscodingJobType, state); + + throw; + } + + // MUST read both stdout and stderr asynchronously or a deadlock may occurr + //process.BeginOutputReadLine(); + + state.TranscodingJob = transcodingJob; + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + new JobLogger(Logger).StartStreamingLog(state, process.StandardError.BaseStream, state.LogFileStream); + + // Wait for the file to exist before proceeeding + while (!FileSystem.FileExists(state.WaitForPath ?? outputPath) && !transcodingJob.HasExited) + { + await Task.Delay(100, cancellationTokenSource.Token).ConfigureAwait(false); + } + + if (state.IsInputVideo && transcodingJob.Type == TranscodingJobType.Progressive && !transcodingJob.HasExited) + { + await Task.Delay(1000, cancellationTokenSource.Token).ConfigureAwait(false); + + if (state.ReadInputAtNativeFramerate && !transcodingJob.HasExited) + { + await Task.Delay(1500, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + + if (!transcodingJob.HasExited) + { + StartThrottler(state, transcodingJob); + } + + return transcodingJob; + } + + private void StartThrottler(StreamState state, TranscodingJob transcodingJob) + { + if (EnableThrottling(state)) + { + transcodingJob.TranscodingThrottler = state.TranscodingThrottler = new TranscodingThrottler(transcodingJob, Logger, ServerConfigurationManager, ApiEntryPoint.Instance.TimerFactory, FileSystem); + state.TranscodingThrottler.Start(); + } + } + + private bool EnableThrottling(StreamState state) + { + return false; + //// do not use throttling with hardware encoders + //return state.InputProtocol == MediaProtocol.File && + // state.RunTimeTicks.HasValue && + // state.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && + // state.IsInputVideo && + // state.VideoType == VideoType.VideoFile && + // !string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase) && + // string.Equals(GetVideoEncoder(state), "libx264", StringComparison.OrdinalIgnoreCase); + } + + /// <summary> + /// Processes the exited. + /// </summary> + /// <param name="process">The process.</param> + /// <param name="job">The job.</param> + /// <param name="state">The state.</param> + private void OnFfMpegProcessExited(IProcess process, TranscodingJob job, StreamState state) + { + if (job != null) + { + job.HasExited = true; + } + + Logger.Debug("Disposing stream resources"); + state.Dispose(); + + try + { + Logger.Info("FFMpeg exited with code {0}", process.ExitCode); + } + catch + { + Logger.Error("FFMpeg exited with an error."); + } + + // This causes on exited to be called twice: + //try + //{ + // // Dispose the process + // process.Dispose(); + //} + //catch (Exception ex) + //{ + // Logger.ErrorException("Error disposing ffmpeg.", ex); + //} + } + + /// <summary> + /// Parses the parameters. + /// </summary> + /// <param name="request">The request.</param> + private void ParseParams(StreamRequest request) + { + var vals = request.Params.Split(';'); + + var videoRequest = request as VideoStreamRequest; + + for (var i = 0; i < vals.Length; i++) + { + var val = vals[i]; + + if (string.IsNullOrWhiteSpace(val)) + { + continue; + } + + if (i == 0) + { + request.DeviceProfileId = val; + } + else if (i == 1) + { + request.DeviceId = val; + } + else if (i == 2) + { + request.MediaSourceId = val; + } + else if (i == 3) + { + request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + else if (i == 4) + { + if (videoRequest != null) + { + videoRequest.VideoCodec = val; + } + } + else if (i == 5) + { + request.AudioCodec = val; + } + else if (i == 6) + { + if (videoRequest != null) + { + videoRequest.AudioStreamIndex = int.Parse(val, UsCulture); + } + } + else if (i == 7) + { + if (videoRequest != null) + { + videoRequest.SubtitleStreamIndex = int.Parse(val, UsCulture); + } + } + else if (i == 8) + { + if (videoRequest != null) + { + videoRequest.VideoBitRate = int.Parse(val, UsCulture); + } + } + else if (i == 9) + { + request.AudioBitRate = int.Parse(val, UsCulture); + } + else if (i == 10) + { + request.MaxAudioChannels = int.Parse(val, UsCulture); + } + else if (i == 11) + { + if (videoRequest != null) + { + videoRequest.MaxFramerate = float.Parse(val, UsCulture); + } + } + else if (i == 12) + { + if (videoRequest != null) + { + videoRequest.MaxWidth = int.Parse(val, UsCulture); + } + } + else if (i == 13) + { + if (videoRequest != null) + { + videoRequest.MaxHeight = int.Parse(val, UsCulture); + } + } + else if (i == 14) + { + request.StartTimeTicks = long.Parse(val, UsCulture); + } + else if (i == 15) + { + if (videoRequest != null) + { + videoRequest.Level = val; + } + } + else if (i == 16) + { + if (videoRequest != null) + { + videoRequest.MaxRefFrames = int.Parse(val, UsCulture); + } + } + else if (i == 17) + { + if (videoRequest != null) + { + videoRequest.MaxVideoBitDepth = int.Parse(val, UsCulture); + } + } + else if (i == 18) + { + if (videoRequest != null) + { + videoRequest.Profile = val; + } + } + else if (i == 19) + { + // cabac no longer used + } + else if (i == 20) + { + request.PlaySessionId = val; + } + else if (i == 21) + { + // api_key + } + else if (i == 22) + { + request.LiveStreamId = val; + } + else if (i == 23) + { + // Duplicating ItemId because of MediaMonkey + } + else if (i == 24) + { + if (videoRequest != null) + { + videoRequest.CopyTimestamps = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + } + else if (i == 25) + { + if (!string.IsNullOrWhiteSpace(val) && videoRequest != null) + { + SubtitleDeliveryMethod method; + if (Enum.TryParse(val, out method)) + { + videoRequest.SubtitleMethod = method; + } + } + } + else if (i == 26) + { + request.TranscodingMaxAudioChannels = int.Parse(val, UsCulture); + } + else if (i == 27) + { + if (videoRequest != null) + { + videoRequest.EnableSubtitlesInManifest = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + } + else if (i == 28) + { + request.Tag = val; + } + else if (i == 29) + { + if (videoRequest != null) + { + videoRequest.RequireAvc = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + } + else if (i == 30) + { + request.SubtitleCodec = val; + } + else if (i == 31) + { + if (videoRequest != null) + { + videoRequest.RequireNonAnamorphic = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + } + else if (i == 32) + { + if (videoRequest != null) + { + videoRequest.DeInterlace = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); + } + } + else if (i == 33) + { + request.TranscodeReasons = val; + } + } + } + + /// <summary> + /// Parses the dlna headers. + /// </summary> + /// <param name="request">The request.</param> + private void ParseDlnaHeaders(StreamRequest request) + { + if (!request.StartTimeTicks.HasValue) + { + var timeSeek = GetHeader("TimeSeekRange.dlna.org"); + + request.StartTimeTicks = ParseTimeSeekHeader(timeSeek); + } + } + + /// <summary> + /// Parses the time seek header. + /// </summary> + private long? ParseTimeSeekHeader(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (value.IndexOf("npt=", StringComparison.OrdinalIgnoreCase) != 0) + { + throw new ArgumentException("Invalid timeseek header"); + } + value = value.Substring(4).Split(new[] { '-' }, 2)[0]; + + if (value.IndexOf(':') == -1) + { + // Parses npt times in the format of '417.33' + double seconds; + if (double.TryParse(value, NumberStyles.Any, UsCulture, out seconds)) + { + return TimeSpan.FromSeconds(seconds).Ticks; + } + + throw new ArgumentException("Invalid timeseek header"); + } + + // Parses npt times in the format of '10:19:25.7' + var tokens = value.Split(new[] { ':' }, 3); + double secondsSum = 0; + var timeFactor = 3600; + + foreach (var time in tokens) + { + double digit; + if (double.TryParse(time, NumberStyles.Any, UsCulture, out digit)) + { + secondsSum += digit * timeFactor; + } + else + { + throw new ArgumentException("Invalid timeseek header"); + } + timeFactor /= 60; + } + return TimeSpan.FromSeconds(secondsSum).Ticks; + } + + /// <summary> + /// Gets the state. + /// </summary> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>StreamState.</returns> + protected async Task<StreamState> GetState(StreamRequest request, CancellationToken cancellationToken) + { + ParseDlnaHeaders(request); + + if (!string.IsNullOrWhiteSpace(request.Params)) + { + ParseParams(request); + } + + var url = Request.PathInfo; + + if (string.IsNullOrEmpty(request.AudioCodec)) + { + request.AudioCodec = EncodingHelper.InferAudioCodec(url); + } + + var enableDlnaHeaders = !string.IsNullOrWhiteSpace(request.Params) /*|| + string.Equals(Request.Headers.Get("GetContentFeatures.DLNA.ORG"), "1", StringComparison.OrdinalIgnoreCase)*/; + + var state = new StreamState(MediaSourceManager, Logger, TranscodingJobType) + { + Request = request, + RequestedUrl = url, + UserAgent = Request.UserAgent, + EnableDlnaHeaders = enableDlnaHeaders + }; + + var auth = AuthorizationContext.GetAuthorizationInfo(Request); + if (auth.UserId != null) + { + state.User = UserManager.GetUserById(auth.UserId); + } + + //if ((Request.UserAgent ?? string.Empty).IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || + // (Request.UserAgent ?? string.Empty).IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || + // (Request.UserAgent ?? string.Empty).IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) + //{ + // state.SegmentLength = 6; + //} + + if (state.VideoRequest != null) + { + if (!string.IsNullOrWhiteSpace(state.VideoRequest.VideoCodec)) + { + state.SupportedVideoCodecs = state.VideoRequest.VideoCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.VideoRequest.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault(); + } + } + + if (!string.IsNullOrWhiteSpace(request.AudioCodec)) + { + state.SupportedAudioCodecs = request.AudioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.Request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => MediaEncoder.CanEncodeToAudioCodec(i)) + ?? state.SupportedAudioCodecs.FirstOrDefault(); + } + + if (!string.IsNullOrWhiteSpace(request.SubtitleCodec)) + { + state.SupportedSubtitleCodecs = request.SubtitleCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); + state.Request.SubtitleCodec = state.SupportedSubtitleCodecs.FirstOrDefault(i => MediaEncoder.CanEncodeToSubtitleCodec(i)) + ?? state.SupportedSubtitleCodecs.FirstOrDefault(); + } + + var item = LibraryManager.GetItemById(request.Id); + + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + + //var primaryImage = item.GetImageInfo(ImageType.Primary, 0) ?? + // item.Parents.Select(i => i.GetImageInfo(ImageType.Primary, 0)).FirstOrDefault(i => i != null); + //if (primaryImage != null) + //{ + // state.AlbumCoverPath = primaryImage.Path; + //} + + MediaSourceInfo mediaSource = null; + if (string.IsNullOrWhiteSpace(request.LiveStreamId)) + { + TranscodingJob currentJob = !string.IsNullOrWhiteSpace(request.PlaySessionId) ? + ApiEntryPoint.Instance.GetTranscodingJob(request.PlaySessionId) + : null; + + if (currentJob != null) + { + mediaSource = currentJob.MediaSource; + } + + if (mediaSource == null) + { + var mediaSources = (await MediaSourceManager.GetPlayackMediaSources(LibraryManager.GetItemById(request.Id), null, false, false, cancellationToken).ConfigureAwait(false)).ToList(); + + mediaSource = string.IsNullOrEmpty(request.MediaSourceId) + ? mediaSources.First() + : mediaSources.FirstOrDefault(i => string.Equals(i.Id, request.MediaSourceId)); + + if (mediaSource == null && request.MediaSourceId.Equals(request.Id)) + { + mediaSource = mediaSources.First(); + } + } + } + else + { + var liveStreamInfo = await MediaSourceManager.GetLiveStreamWithDirectStreamProvider(request.LiveStreamId, cancellationToken).ConfigureAwait(false); + mediaSource = liveStreamInfo.Item1; + state.DirectStreamProvider = liveStreamInfo.Item2; + } + + var videoRequest = request as VideoStreamRequest; + + EncodingHelper.AttachMediaSourceInfo(state, mediaSource, url); + + var container = Path.GetExtension(state.RequestedUrl); + + if (string.IsNullOrEmpty(container)) + { + container = request.Container; + } + + if (string.IsNullOrEmpty(container)) + { + container = request.Static ? + StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(state.InputContainer, state.MediaPath, null, DlnaProfileType.Audio) : + GetOutputFileExtension(state); + } + + state.OutputContainer = (container ?? string.Empty).TrimStart('.'); + + state.OutputAudioBitrate = EncodingHelper.GetAudioBitrateParam(state.Request, state.AudioStream); + + state.OutputAudioCodec = state.Request.AudioCodec; + + state.OutputAudioChannels = EncodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); + + if (videoRequest != null) + { + state.OutputVideoCodec = state.VideoRequest.VideoCodec; + state.OutputVideoBitrate = EncodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec); + + if (videoRequest != null) + { + EncodingHelper.TryStreamCopy(state); + } + + if (state.OutputVideoBitrate.HasValue && !string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var resolution = ResolutionNormalizer.Normalize( + state.VideoStream == null ? (int?)null : state.VideoStream.BitRate, + state.VideoStream == null ? (int?)null : state.VideoStream.Width, + state.VideoStream == null ? (int?)null : state.VideoStream.Height, + state.OutputVideoBitrate.Value, + state.VideoStream == null ? null : state.VideoStream.Codec, + state.OutputVideoCodec, + videoRequest.MaxWidth, + videoRequest.MaxHeight); + + videoRequest.MaxWidth = resolution.MaxWidth; + videoRequest.MaxHeight = resolution.MaxHeight; + } + + ApplyDeviceProfileSettings(state); + } + else + { + ApplyDeviceProfileSettings(state); + } + + var ext = string.IsNullOrWhiteSpace(state.OutputContainer) + ? GetOutputFileExtension(state) + : ("." + state.OutputContainer); + + var encodingOptions = ApiEntryPoint.Instance.GetEncodingOptions(); + + state.OutputFilePath = GetOutputFilePath(state, encodingOptions, ext); + + return state; + } + + private void ApplyDeviceProfileSettings(StreamState state) + { + var headers = Request.Headers.ToDictionary(); + + if (!string.IsNullOrWhiteSpace(state.Request.DeviceProfileId)) + { + state.DeviceProfile = DlnaManager.GetProfile(state.Request.DeviceProfileId); + } + else + { + if (!string.IsNullOrWhiteSpace(state.Request.DeviceId)) + { + var caps = DeviceManager.GetCapabilities(state.Request.DeviceId); + + if (caps != null) + { + state.DeviceProfile = caps.DeviceProfile; + } + else + { + state.DeviceProfile = DlnaManager.GetProfile(headers); + } + } + } + + var profile = state.DeviceProfile; + + if (profile == null) + { + // Don't use settings from the default profile. + // Only use a specific profile if it was requested. + return; + } + + var audioCodec = state.ActualOutputAudioCodec; + var videoCodec = state.ActualOutputVideoCodec; + + var mediaProfile = state.VideoRequest == null ? + profile.GetAudioMediaProfile(state.OutputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate, state.OutputAudioSampleRate, state.OutputAudioBitDepth) : + profile.GetVideoMediaProfile(state.OutputContainer, + audioCodec, + videoCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC); + + if (mediaProfile != null) + { + state.MimeType = mediaProfile.MimeType; + } + + if (!state.Request.Static) + { + var transcodingProfile = state.VideoRequest == null ? + profile.GetAudioTranscodingProfile(state.OutputContainer, audioCodec) : + profile.GetVideoTranscodingProfile(state.OutputContainer, audioCodec, videoCodec); + + if (transcodingProfile != null) + { + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + //state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; + + if (state.VideoRequest != null) + { + state.VideoRequest.CopyTimestamps = transcodingProfile.CopyTimestamps; + state.VideoRequest.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest; + } + } + } + } + + /// <summary> + /// Adds the dlna headers. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isStaticallyStreamed">if set to <c>true</c> [is statically streamed].</param> + /// <returns><c>true</c> if XXXX, <c>false</c> otherwise</returns> + protected void AddDlnaHeaders(StreamState state, IDictionary<string, string> responseHeaders, bool isStaticallyStreamed) + { + if (!state.EnableDlnaHeaders) + { + return; + } + + var profile = state.DeviceProfile; + + var transferMode = GetHeader("transferMode.dlna.org"); + responseHeaders["transferMode.dlna.org"] = string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode; + responseHeaders["realTimeInfo.dlna.org"] = "DLNA.ORG_TLAG=*"; + + if (string.Equals(GetHeader("getMediaInfo.sec"), "1", StringComparison.OrdinalIgnoreCase)) + { + if (state.RunTimeTicks.HasValue) + { + var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; + responseHeaders["MediaInfo.sec"] = string.Format("SEC_Duration={0};", Convert.ToInt32(ms).ToString(CultureInfo.InvariantCulture)); + } + } + + if (state.RunTimeTicks.HasValue && !isStaticallyStreamed && profile != null) + { + AddTimeSeekResponseHeaders(state, responseHeaders); + } + + if (profile == null) + { + profile = DlnaManager.GetDefaultProfile(); + } + + var audioCodec = state.ActualOutputAudioCodec; + + if (state.VideoRequest == null) + { + responseHeaders["contentFeatures.dlna.org"] = new ContentFeatureBuilder(profile) + .BuildAudioHeader( + state.OutputContainer, + audioCodec, + state.OutputAudioBitrate, + state.OutputAudioSampleRate, + state.OutputAudioChannels, + state.OutputAudioBitDepth, + isStaticallyStreamed, + state.RunTimeTicks, + state.TranscodeSeekInfo + ); + } + else + { + var videoCodec = state.ActualOutputVideoCodec; + + responseHeaders["contentFeatures.dlna.org"] = new ContentFeatureBuilder(profile) + .BuildVideoHeader( + state.OutputContainer, + videoCodec, + audioCodec, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetTimestamp, + isStaticallyStreamed, + state.RunTimeTicks, + state.TargetVideoProfile, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TranscodeSeekInfo, + state.IsTargetAnamorphic, + state.IsTargetInterlaced, + state.TargetRefFrames, + state.TargetVideoStreamCount, + state.TargetAudioStreamCount, + state.TargetVideoCodecTag, + state.IsTargetAVC + + ).FirstOrDefault() ?? string.Empty; + } + + foreach (var item in responseHeaders) + { + Request.Response.AddHeader(item.Key, item.Value); + } + } + + private void AddTimeSeekResponseHeaders(StreamState state, IDictionary<string, string> responseHeaders) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds.ToString(UsCulture); + var startSeconds = TimeSpan.FromTicks(state.Request.StartTimeTicks ?? 0).TotalSeconds.ToString(UsCulture); + + responseHeaders["TimeSeekRange.dlna.org"] = string.Format("npt={0}-{1}/{1}", startSeconds, runtimeSeconds); + responseHeaders["X-AvailableSeekRange"] = string.Format("1 npt={0}-{1}", startSeconds, runtimeSeconds); + } + } +} diff --git a/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs b/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs new file mode 100644 index 0000000000..a0f4a2e715 --- /dev/null +++ b/MediaBrowser.Api/Playback/Hls/BaseHlsService.cs @@ -0,0 +1,333 @@ +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Api.Playback.Hls +{ + /// <summary> + /// Class BaseHlsService + /// </summary> + public abstract class BaseHlsService : BaseStreamingService + { + /// <summary> + /// Gets the audio arguments. + /// </summary> + protected abstract string GetAudioArguments(StreamState state, EncodingOptions encodingOptions); + + /// <summary> + /// Gets the video arguments. + /// </summary> + protected abstract string GetVideoArguments(StreamState state, EncodingOptions encodingOptions); + + /// <summary> + /// Gets the segment file extension. + /// </summary> + protected string GetSegmentFileExtension(StreamRequest request) + { + var segmentContainer = request.SegmentContainer; + if (!string.IsNullOrWhiteSpace(segmentContainer)) + { + return "." + segmentContainer; + } + + return ".ts"; + } + + /// <summary> + /// Gets the type of the transcoding job. + /// </summary> + /// <value>The type of the transcoding job.</value> + protected override TranscodingJobType TranscodingJobType + { + get { return TranscodingJobType.Hls; } + } + + /// <summary> + /// Processes the request. + /// </summary> + /// <param name="request">The request.</param> + /// <param name="isLive">if set to <c>true</c> [is live].</param> + /// <returns>System.Object.</returns> + protected async Task<object> ProcessRequest(StreamRequest request, bool isLive) + { + return await ProcessRequestAsync(request, isLive).ConfigureAwait(false); + } + + /// <summary> + /// Processes the request async. + /// </summary> + /// <param name="request">The request.</param> + /// <param name="isLive">if set to <c>true</c> [is live].</param> + /// <returns>Task{System.Object}.</returns> + /// <exception cref="ArgumentException">A video bitrate is required + /// or + /// An audio bitrate is required</exception> + private async Task<object> ProcessRequestAsync(StreamRequest request, bool isLive) + { + var cancellationTokenSource = new CancellationTokenSource(); + + var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); + + TranscodingJob job = null; + var playlist = state.OutputFilePath; + + if (!FileSystem.FileExists(playlist)) + { + var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(playlist); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + if (!FileSystem.FileExists(playlist)) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + job = await StartFfMpeg(state, playlist, cancellationTokenSource).ConfigureAwait(false); + job.IsLiveOutput = isLive; + } + catch + { + state.Dispose(); + throw; + } + + var minSegments = state.MinSegments; + if (minSegments > 0) + { + await WaitForMinimumSegmentCount(playlist, minSegments, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + } + finally + { + transcodingLock.Release(); + } + } + + if (isLive) + { + job = job ?? ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlist, TranscodingJobType); + + if (job != null) + { + ApiEntryPoint.Instance.OnTranscodeEndRequest(job); + } + return ResultFactory.GetResult(GetLivePlaylistText(playlist, state.SegmentLength), MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); + } + + var audioBitrate = state.OutputAudioBitrate ?? 0; + var videoBitrate = state.OutputVideoBitrate ?? 0; + + var baselineStreamBitrate = 64000; + + var playlistText = GetMasterPlaylistFileText(playlist, videoBitrate + audioBitrate, baselineStreamBitrate); + + job = job ?? ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlist, TranscodingJobType); + + if (job != null) + { + ApiEntryPoint.Instance.OnTranscodeEndRequest(job); + } + + return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); + } + + private string GetLivePlaylistText(string path, int segmentLength) + { + using (var stream = FileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite)) + { + using (var reader = new StreamReader(stream)) + { + var text = reader.ReadToEnd(); + + text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT"); + + var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(UsCulture); + + text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(UsCulture), newDuration, StringComparison.OrdinalIgnoreCase); + //text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(UsCulture), newDuration, StringComparison.OrdinalIgnoreCase); + + return text; + } + } + } + + private string GetMasterPlaylistFileText(string firstPlaylist, int bitrate, int baselineStreamBitrate) + { + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + + // Pad a little to satisfy the apple hls validator + var paddedBitrate = Convert.ToInt32(bitrate * 1.15); + + // Main stream + builder.AppendLine("#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=" + paddedBitrate.ToString(UsCulture)); + var playlistUrl = "hls/" + Path.GetFileName(firstPlaylist).Replace(".m3u8", "/stream.m3u8"); + builder.AppendLine(playlistUrl); + + return builder.ToString(); + } + + protected virtual async Task WaitForMinimumSegmentCount(string playlist, int segmentCount, CancellationToken cancellationToken) + { + Logger.Debug("Waiting for {0} segments in {1}", segmentCount, playlist); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + // Need to use FileShareMode.ReadWrite because we're reading the file at the same time it's being written + using (var fileStream = GetPlaylistFileStream(playlist)) + { + using (var reader = new StreamReader(fileStream)) + { + var count = 0; + + while (!reader.EndOfStream) + { + var line = reader.ReadLine(); + + if (line.IndexOf("#EXTINF:", StringComparison.OrdinalIgnoreCase) != -1) + { + count++; + if (count >= segmentCount) + { + Logger.Debug("Finished waiting for {0} segments in {1}", segmentCount, playlist); + return; + } + } + } + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + } + catch (IOException) + { + // May get an error if the file is locked + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + protected Stream GetPlaylistFileStream(string path) + { + var tmpPath = path + ".tmp"; + tmpPath = path; + + try + { + return FileSystem.GetFileStream(tmpPath, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite, FileOpenOptions.SequentialScan); + } + catch (IOException) + { + return FileSystem.GetFileStream(path, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite, FileOpenOptions.SequentialScan); + } + } + + protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) + { + var itsOffsetMs = 0; + + var itsOffset = itsOffsetMs == 0 ? string.Empty : string.Format("-itsoffset {0} ", TimeSpan.FromMilliseconds(itsOffsetMs).TotalSeconds.ToString(UsCulture)); + + var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); + + var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + + var inputModifier = EncodingHelper.GetInputModifier(state, encodingOptions); + + // If isEncoding is true we're actually starting ffmpeg + var startNumberParam = isEncoding ? GetStartNumber(state).ToString(UsCulture) : "0"; + + var baseUrlParam = string.Empty; + + if (state.Request is GetLiveHlsStream) + { + baseUrlParam = string.Format(" -hls_base_url \"{0}/\"", + "hls/" + Path.GetFileNameWithoutExtension(outputPath)); + } + + var useGenericSegmenter = true; + if (useGenericSegmenter) + { + var outputTsArg = Path.Combine(FileSystem.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request); + + var timeDeltaParam = String.Empty; + + var segmentFormat = GetSegmentFileExtension(state.Request).TrimStart('.'); + if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + + baseUrlParam = string.Format("\"{0}/\"", "hls/" + Path.GetFileNameWithoutExtension(outputPath)); + + return string.Format("{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {10} -individual_header_trailer 0 -segment_format {11} -segment_list_entry_prefix {12} -segment_list_type m3u8 -segment_start_number {7} -segment_list \"{8}\" -y \"{9}\"", + inputModifier, + EncodingHelper.GetInputArgument(state, encodingOptions), + threads, + EncodingHelper.GetMapArgs(state), + GetVideoArguments(state, encodingOptions), + GetAudioArguments(state, encodingOptions), + state.SegmentLength.ToString(UsCulture), + startNumberParam, + outputPath, + outputTsArg, + timeDeltaParam, + segmentFormat, + baseUrlParam + ).Trim(); + } + + // add when stream copying? + // -avoid_negative_ts make_zero -fflags +genpts + + var args = string.Format("{0} {1} {2} -map_metadata -1 -map_chapters -1 -threads {3} {4} {5} -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero {6} -hls_time {7} -individual_header_trailer 0 -start_number {8} -hls_list_size {9}{10} -y \"{11}\"", + itsOffset, + inputModifier, + EncodingHelper.GetInputArgument(state, encodingOptions), + threads, + EncodingHelper.GetMapArgs(state), + GetVideoArguments(state, encodingOptions), + GetAudioArguments(state, encodingOptions), + state.SegmentLength.ToString(UsCulture), + startNumberParam, + state.HlsListSize.ToString(UsCulture), + baseUrlParam, + outputPath + ).Trim(); + + return args; + } + + protected override string GetDefaultH264Preset() + { + return "veryfast"; + } + + protected virtual int GetStartNumber(StreamState state) + { + return 0; + } + + public BaseHlsService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext) + { + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs b/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs new file mode 100644 index 0000000000..0525c8cc48 --- /dev/null +++ b/MediaBrowser.Api/Playback/Hls/DynamicHlsService.cs @@ -0,0 +1,971 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Services; +using MimeTypes = MediaBrowser.Model.Net.MimeTypes; + +namespace MediaBrowser.Api.Playback.Hls +{ + /// <summary> + /// Options is needed for chromecast. Threw Head in there since it's related + /// </summary> + [Route("/Videos/{Id}/master.m3u8", "GET", Summary = "Gets a video stream using HTTP live streaming.")] + [Route("/Videos/{Id}/master.m3u8", "HEAD", Summary = "Gets a video stream using HTTP live streaming.")] + public class GetMasterHlsVideoPlaylist : VideoStreamRequest, IMasterHlsRequest + { + public bool EnableAdaptiveBitrateStreaming { get; set; } + + public GetMasterHlsVideoPlaylist() + { + EnableAdaptiveBitrateStreaming = true; + } + } + + [Route("/Audio/{Id}/master.m3u8", "GET", Summary = "Gets an audio stream using HTTP live streaming.")] + [Route("/Audio/{Id}/master.m3u8", "HEAD", Summary = "Gets an audio stream using HTTP live streaming.")] + public class GetMasterHlsAudioPlaylist : StreamRequest, IMasterHlsRequest + { + public bool EnableAdaptiveBitrateStreaming { get; set; } + + public GetMasterHlsAudioPlaylist() + { + EnableAdaptiveBitrateStreaming = true; + } + } + + public interface IMasterHlsRequest + { + bool EnableAdaptiveBitrateStreaming { get; set; } + } + + [Route("/Videos/{Id}/main.m3u8", "GET", Summary = "Gets a video stream using HTTP live streaming.")] + public class GetVariantHlsVideoPlaylist : VideoStreamRequest + { + } + + [Route("/Audio/{Id}/main.m3u8", "GET", Summary = "Gets an audio stream using HTTP live streaming.")] + public class GetVariantHlsAudioPlaylist : StreamRequest + { + } + + [Route("/Videos/{Id}/hls1/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] + public class GetHlsVideoSegment : VideoStreamRequest + { + public string PlaylistId { get; set; } + + /// <summary> + /// Gets or sets the segment id. + /// </summary> + /// <value>The segment id.</value> + public string SegmentId { get; set; } + } + + [Route("/Audio/{Id}/hls1/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] + public class GetHlsAudioSegment : StreamRequest + { + public string PlaylistId { get; set; } + + /// <summary> + /// Gets or sets the segment id. + /// </summary> + /// <value>The segment id.</value> + public string SegmentId { get; set; } + } + + [Authenticated] + public class DynamicHlsService : BaseHlsService + { + + public DynamicHlsService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext, INetworkManager networkManager) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext) + { + NetworkManager = networkManager; + } + + protected INetworkManager NetworkManager { get; private set; } + + public Task<object> Get(GetMasterHlsVideoPlaylist request) + { + return GetMasterPlaylistInternal(request, "GET"); + } + + public Task<object> Head(GetMasterHlsVideoPlaylist request) + { + return GetMasterPlaylistInternal(request, "HEAD"); + } + + public Task<object> Get(GetMasterHlsAudioPlaylist request) + { + return GetMasterPlaylistInternal(request, "GET"); + } + + public Task<object> Head(GetMasterHlsAudioPlaylist request) + { + return GetMasterPlaylistInternal(request, "HEAD"); + } + + public Task<object> Get(GetVariantHlsVideoPlaylist request) + { + return GetVariantPlaylistInternal(request, true, "main"); + } + + public Task<object> Get(GetVariantHlsAudioPlaylist request) + { + return GetVariantPlaylistInternal(request, false, "main"); + } + + public Task<object> Get(GetHlsVideoSegment request) + { + return GetDynamicSegment(request, request.SegmentId); + } + + public Task<object> Get(GetHlsAudioSegment request) + { + return GetDynamicSegment(request, request.SegmentId); + } + + private async Task<object> GetDynamicSegment(StreamRequest request, string segmentId) + { + if ((request.StartTimeTicks ?? 0) > 0) + { + throw new ArgumentException("StartTimeTicks is not allowed."); + } + + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var requestedIndex = int.Parse(segmentId, NumberStyles.Integer, UsCulture); + + var state = await GetState(request, cancellationToken).ConfigureAwait(false); + + var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8"); + + var segmentPath = GetSegmentPath(state, playlistPath, requestedIndex); + + var segmentExtension = GetSegmentFileExtension(state.Request); + + TranscodingJob job = null; + + if (FileSystem.FileExists(segmentPath)) + { + job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); + } + + var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(playlistPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + var released = false; + var startTranscoding = false; + + try + { + if (FileSystem.FileExists(segmentPath)) + { + job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + transcodingLock.Release(); + released = true; + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); + } + else + { + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength; + + if (currentTranscodingIndex == null) + { + Logger.Debug("Starting transcoding because currentTranscodingIndex=null"); + startTranscoding = true; + } + else if (requestedIndex < currentTranscodingIndex.Value) + { + Logger.Debug("Starting transcoding because requestedIndex={0} and currentTranscodingIndex={1}", requestedIndex, currentTranscodingIndex); + startTranscoding = true; + } + else if (requestedIndex - currentTranscodingIndex.Value > segmentGapRequiringTranscodingChange) + { + Logger.Debug("Starting transcoding because segmentGap is {0} and max allowed gap is {1}. requestedIndex={2}", requestedIndex - currentTranscodingIndex.Value, segmentGapRequiringTranscodingChange, requestedIndex); + startTranscoding = true; + } + if (startTranscoding) + { + // If the playlist doesn't already exist, startup ffmpeg + try + { + ApiEntryPoint.Instance.KillTranscodingJobs(request.DeviceId, request.PlaySessionId, p => false); + + if (currentTranscodingIndex.HasValue) + { + DeleteLastFile(playlistPath, segmentExtension, 0); + } + + request.StartTimeTicks = GetStartPositionTicks(state, requestedIndex); + + job = await StartFfMpeg(state, playlistPath, cancellationTokenSource).ConfigureAwait(false); + } + catch + { + state.Dispose(); + throw; + } + + //await WaitForMinimumSegmentCount(playlistPath, 1, cancellationTokenSource.Token).ConfigureAwait(false); + } + else + { + job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + if (job.TranscodingThrottler != null) + { + job.TranscodingThrottler.UnpauseTranscoding(); + } + } + } + } + finally + { + if (!released) + { + transcodingLock.Release(); + } + } + + //Logger.Info("waiting for {0}", segmentPath); + //while (!File.Exists(segmentPath)) + //{ + // await Task.Delay(50, cancellationToken).ConfigureAwait(false); + //} + + Logger.Info("returning {0}", segmentPath); + job = job ?? ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType); + return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, requestedIndex, job, cancellationToken).ConfigureAwait(false); + } + + private const int BufferSize = 81920; + + private long GetStartPositionTicks(StreamState state, int requestedIndex) + { + double startSeconds = 0; + var lengths = GetSegmentLengths(state); + + if (requestedIndex >= lengths.Length) + { + var msg = string.Format("Invalid segment index requested: {0} - Segment count: {1}", requestedIndex, lengths.Length); + throw new ArgumentException(msg); + } + + for (var i = 0; i < requestedIndex; i++) + { + startSeconds += lengths[i]; + } + + var position = TimeSpan.FromSeconds(startSeconds).Ticks; + return position; + } + + private long GetEndPositionTicks(StreamState state, int requestedIndex) + { + double startSeconds = 0; + var lengths = GetSegmentLengths(state); + + if (requestedIndex >= lengths.Length) + { + var msg = string.Format("Invalid segment index requested: {0} - Segment count: {1}", requestedIndex, lengths.Length); + throw new ArgumentException(msg); + } + + for (var i = 0; i <= requestedIndex; i++) + { + startSeconds += lengths[i]; + } + + var position = TimeSpan.FromSeconds(startSeconds).Ticks; + return position; + } + + private double[] GetSegmentLengths(StreamState state) + { + var result = new List<double>(); + + var ticks = state.RunTimeTicks ?? 0; + + var segmentLengthTicks = TimeSpan.FromSeconds(state.SegmentLength).Ticks; + + while (ticks > 0) + { + var length = ticks >= segmentLengthTicks ? segmentLengthTicks : ticks; + + result.Add(TimeSpan.FromTicks(length).TotalSeconds); + + ticks -= length; + } + + return result.ToArray(); + } + + public int? GetCurrentTranscodingIndex(string playlist, string segmentExtension) + { + var job = ApiEntryPoint.Instance.GetTranscodingJob(playlist, TranscodingJobType); + + if (job == null || job.HasExited) + { + return null; + } + + var file = GetLastTranscodingFile(playlist, segmentExtension, FileSystem); + + if (file == null) + { + return null; + } + + var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + + var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + + return int.Parse(indexString, NumberStyles.Integer, UsCulture); + } + + private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount) + { + var file = GetLastTranscodingFile(playlistPath, segmentExtension, FileSystem); + + if (file != null) + { + DeleteFile(file.FullName, retryCount); + } + } + + private void DeleteFile(string path, int retryCount) + { + if (retryCount >= 5) + { + return; + } + + Logger.Debug("Deleting partial HLS file {0}", path); + + try + { + FileSystem.DeleteFile(path); + } + catch (IOException ex) + { + Logger.ErrorException("Error deleting partial stream file(s) {0}", ex, path); + + var task = Task.Delay(100); + Task.WaitAll(task); + DeleteFile(path, retryCount + 1); + } + catch (Exception ex) + { + Logger.ErrorException("Error deleting partial stream file(s) {0}", ex, path); + } + } + + private static FileSystemMetadata GetLastTranscodingFile(string playlist, string segmentExtension, IFileSystem fileSystem) + { + var folder = fileSystem.GetDirectoryName(playlist); + + var filePrefix = Path.GetFileNameWithoutExtension(playlist) ?? string.Empty; + + try + { + return fileSystem.GetFiles(folder, new[] { segmentExtension }, true, false) + .Where(i => Path.GetFileNameWithoutExtension(i.Name).StartsWith(filePrefix, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(fileSystem.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + catch (IOException) + { + return null; + } + } + + protected override int GetStartNumber(StreamState state) + { + return GetStartNumber(state.VideoRequest); + } + + private int GetStartNumber(VideoStreamRequest request) + { + var segmentId = "0"; + + var segmentRequest = request as GetHlsVideoSegment; + if (segmentRequest != null) + { + segmentId = segmentRequest.SegmentId; + } + + return int.Parse(segmentId, NumberStyles.Integer, UsCulture); + } + + private string GetSegmentPath(StreamState state, string playlist, int index) + { + var folder = FileSystem.GetDirectoryName(playlist); + + var filename = Path.GetFileNameWithoutExtension(playlist); + + return Path.Combine(folder, filename + index.ToString(UsCulture) + GetSegmentFileExtension(state.Request)); + } + + private async Task<object> GetSegmentResult(StreamState state, + string playlistPath, + string segmentPath, + string segmentExtension, + int segmentIndex, + TranscodingJob transcodingJob, + CancellationToken cancellationToken) + { + var segmentFileExists = FileSystem.FileExists(segmentPath); + + // If all transcoding has completed, just return immediately + if (transcodingJob != null && transcodingJob.HasExited && segmentFileExists) + { + return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); + } + + if (segmentFileExists) + { + var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension); + + // If requested segment is less than transcoding position, we can't transcode backwards, so assume it's ready + if (segmentIndex < currentTranscodingIndex) + { + return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); + } + } + + var segmentFilename = Path.GetFileName(segmentPath); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + var text = FileSystem.ReadAllText(playlistPath, Encoding.UTF8); + + // If it appears in the playlist, it's done + if (text.IndexOf(segmentFilename, StringComparison.OrdinalIgnoreCase) != -1) + { + if (!segmentFileExists) + { + segmentFileExists = FileSystem.FileExists(segmentPath); + } + if (segmentFileExists) + { + return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); + } + //break; + } + } + catch (IOException) + { + // May get an error if the file is locked + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + cancellationToken.ThrowIfCancellationRequested(); + return await GetSegmentResult(state, segmentPath, segmentIndex, transcodingJob).ConfigureAwait(false); + } + + private Task<object> GetSegmentResult(StreamState state, string segmentPath, int index, TranscodingJob transcodingJob) + { + var segmentEndingPositionTicks = GetEndPositionTicks(state, index); + + return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions + { + Path = segmentPath, + FileShare = FileShareMode.ReadWrite, + OnComplete = () => + { + if (transcodingJob != null) + { + transcodingJob.DownloadPositionTicks = Math.Max(transcodingJob.DownloadPositionTicks ?? segmentEndingPositionTicks, segmentEndingPositionTicks); + ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); + } + } + }); + } + + private async Task<object> GetMasterPlaylistInternal(StreamRequest request, string method) + { + var state = await GetState(request, CancellationToken.None).ConfigureAwait(false); + + if (string.IsNullOrEmpty(request.MediaSourceId)) + { + throw new ArgumentException("MediaSourceId is required"); + } + + var playlistText = string.Empty; + + if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase)) + { + var audioBitrate = state.OutputAudioBitrate ?? 0; + var videoBitrate = state.OutputVideoBitrate ?? 0; + + playlistText = GetMasterPlaylistFileText(state, videoBitrate + audioBitrate); + } + + return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); + } + + private string GetMasterPlaylistFileText(StreamState state, int totalBitrate) + { + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + + var isLiveStream = state.IsSegmentedLiveStream; + + var queryStringIndex = Request.RawUrl.IndexOf('?'); + var queryString = queryStringIndex == -1 ? string.Empty : Request.RawUrl.Substring(queryStringIndex); + + // from universal audio service + if (queryString.IndexOf("SegmentContainer", StringComparison.OrdinalIgnoreCase) == -1 && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)) + { + queryString += "&SegmentContainer=" + state.Request.SegmentContainer; + } + // from universal audio service + if (!string.IsNullOrWhiteSpace(state.Request.TranscodeReasons) && queryString.IndexOf("TranscodeReasons=", StringComparison.OrdinalIgnoreCase) == -1) + { + queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; + } + + // Main stream + var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; + + playlistUrl += queryString; + + var request = state.Request; + + var subtitleStreams = state.MediaSource + .MediaStreams + .Where(i => i.IsTextSubtitleStream) + .ToList(); + + var subtitleGroup = subtitleStreams.Count > 0 && + request is GetMasterHlsVideoPlaylist && + (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Hls || state.VideoRequest.EnableSubtitlesInManifest) ? + "subs" : + null; + + // If we're burning in subtitles then don't add additional subs to the manifest + if (state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) + { + subtitleGroup = null; + } + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + AddSubtitles(state, subtitleStreams, builder); + } + + AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); + + if (EnableAdaptiveBitrateStreaming(state, isLiveStream)) + { + var requestedVideoBitrate = state.VideoRequest == null ? 0 : state.VideoRequest.VideoBitRate ?? 0; + + // By default, vary by just 200k + var variation = GetBitrateVariation(totalBitrate); + + var newBitrate = totalBitrate - variation; + var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + + variation *= 2; + newBitrate = totalBitrate - variation; + variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); + AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); + } + + return builder.ToString(); + } + + private string ReplaceBitrate(string url, int oldValue, int newValue) + { + return url.Replace( + "videobitrate=" + oldValue.ToString(UsCulture), + "videobitrate=" + newValue.ToString(UsCulture), + StringComparison.OrdinalIgnoreCase); + } + + private void AddSubtitles(StreamState state, IEnumerable<MediaStream> subtitles, StringBuilder builder) + { + var selectedIndex = state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Hls ? (int?)null : state.SubtitleStream.Index; + + foreach (var stream in subtitles) + { + const string format = "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"{0}\",DEFAULT={1},FORCED={2},AUTOSELECT=YES,URI=\"{3}\",LANGUAGE=\"{4}\""; + + var name = stream.DisplayTitle; + + var isDefault = selectedIndex.HasValue && selectedIndex.Value == stream.Index; + var isForced = stream.IsForced; + + var url = string.Format("{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&api_key={3}", + state.Request.MediaSourceId, + stream.Index.ToString(UsCulture), + 30.ToString(UsCulture), + AuthorizationContext.GetAuthorizationInfo(Request).Token); + + var line = string.Format(format, + name, + isDefault ? "YES" : "NO", + isForced ? "YES" : "NO", + url, + stream.Language ?? "Unknown"); + + builder.AppendLine(line); + } + } + + private bool EnableAdaptiveBitrateStreaming(StreamState state, bool isLiveStream) + { + // Within the local network this will likely do more harm than good. + if (Request.IsLocal || NetworkManager.IsInLocalNetwork(Request.RemoteIp)) + { + return false; + } + + var request = state.Request as IMasterHlsRequest; + if (request != null && !request.EnableAdaptiveBitrateStreaming) + { + return false; + } + + if (isLiveStream || string.IsNullOrWhiteSpace(state.MediaPath)) + { + // Opening live streams is so slow it's not even worth it + return false; + } + + if (string.Equals(state.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (string.Equals(state.OutputAudioCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!state.IsOutputVideo) + { + return false; + } + + // Having problems in android + return false; + //return state.VideoRequest.VideoBitRate.HasValue; + } + + private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string subtitleGroup) + { + var header = "#EXT-X-STREAM-INF:BANDWIDTH=" + bitrate.ToString(UsCulture) + ",AVERAGE-BANDWIDTH=" + bitrate.ToString(UsCulture); + + // tvos wants resolution, codecs, framerate + //if (state.TargetFramerate.HasValue) + //{ + // header += string.Format(",FRAME-RATE=\"{0}\"", state.TargetFramerate.Value.ToString(CultureInfo.InvariantCulture)); + //} + + if (!string.IsNullOrWhiteSpace(subtitleGroup)) + { + header += string.Format(",SUBTITLES=\"{0}\"", subtitleGroup); + } + + builder.AppendLine(header); + builder.AppendLine(url); + } + + private int GetBitrateVariation(int bitrate) + { + // By default, vary by just 50k + var variation = 50000; + + if (bitrate >= 10000000) + { + variation = 2000000; + } + else if (bitrate >= 5000000) + { + variation = 1500000; + } + else if (bitrate >= 3000000) + { + variation = 1000000; + } + else if (bitrate >= 2000000) + { + variation = 500000; + } + else if (bitrate >= 1000000) + { + variation = 300000; + } + else if (bitrate >= 600000) + { + variation = 200000; + } + else if (bitrate >= 400000) + { + variation = 100000; + } + + return variation; + } + + private async Task<object> GetVariantPlaylistInternal(StreamRequest request, bool isOutputVideo, string name) + { + var state = await GetState(request, CancellationToken.None).ConfigureAwait(false); + + var segmentLengths = GetSegmentLengths(state); + + var builder = new StringBuilder(); + + builder.AppendLine("#EXTM3U"); + builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); + builder.AppendLine("#EXT-X-VERSION:3"); + builder.AppendLine("#EXT-X-TARGETDURATION:" + Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength).ToString(UsCulture)); + builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); + + var queryStringIndex = Request.RawUrl.IndexOf('?'); + var queryString = queryStringIndex == -1 ? string.Empty : Request.RawUrl.Substring(queryStringIndex); + + //if ((Request.UserAgent ?? string.Empty).IndexOf("roku", StringComparison.OrdinalIgnoreCase) != -1) + //{ + // queryString = string.Empty; + //} + + var index = 0; + + foreach (var length in segmentLengths) + { + builder.AppendLine("#EXTINF:" + length.ToString("0.0000", UsCulture) + ", nodesc"); + + builder.AppendLine(string.Format("hls1/{0}/{1}{2}{3}", + + name, + index.ToString(UsCulture), + GetSegmentFileExtension(request), + queryString)); + + index++; + } + + builder.AppendLine("#EXT-X-ENDLIST"); + + var playlistText = builder.ToString(); + + return ResultFactory.GetResult(playlistText, MimeTypes.GetMimeType("playlist.m3u8"), new Dictionary<string, string>()); + } + + protected override string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + { + var audioCodec = EncodingHelper.GetAudioEncoder(state); + + if (!state.IsOutputVideo) + { + if (string.Equals(audioCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return "-acodec copy"; + } + + var audioTranscodeParams = new List<string>(); + + audioTranscodeParams.Add("-acodec " + audioCodec); + + if (state.OutputAudioBitrate.HasValue) + { + audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(UsCulture)); + } + + if (state.OutputAudioChannels.HasValue) + { + audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(UsCulture)); + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(UsCulture)); + } + + audioTranscodeParams.Add("-vn"); + return string.Join(" ", audioTranscodeParams.ToArray()); + } + + if (string.Equals(audioCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); + + if (string.Equals(videoCodec, "copy", StringComparison.OrdinalIgnoreCase) && state.EnableBreakOnNonKeyFrames(videoCodec)) + { + return "-codec:a:0 copy -copypriorss:a:0 0"; + } + + return "-codec:a:0 copy"; + } + + var args = "-codec:a:0 " + audioCodec; + + var channels = state.OutputAudioChannels; + + if (channels.HasValue) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(UsCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(UsCulture); + } + + args += " " + EncodingHelper.GetAudioFilterParam(state, encodingOptions, true); + + return args; + } + + protected override string GetVideoArguments(StreamState state, EncodingOptions encodingOptions) + { + if (!state.IsOutputVideo) + { + return string.Empty; + } + + var codec = EncodingHelper.GetVideoEncoder(state, encodingOptions); + + var args = "-codec:v:0 " + codec; + + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } + + // See if we can save come cpu cycles by avoiding encoding + if (string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase)) + { + if (state.VideoStream != null && EncodingHelper.IsH264(state.VideoStream) && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + { + args += " -bsf:v h264_mp4toannexb"; + } + + //args += " -flags -global_header"; + } + else + { + var keyFrameArg = string.Format(" -force_key_frames \"expr:gte(t,n_forced*{0})\"", + state.SegmentLength.ToString(UsCulture)); + + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + + args += " " + EncodingHelper.GetVideoQualityParam(state, codec, encodingOptions, GetDefaultH264Preset()) + keyFrameArg; + + //args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0"; + + // Add resolution params, if specified + if (!hasGraphicalSubs) + { + args += EncodingHelper.GetOutputSizeParam(state, encodingOptions, codec, true); + } + + // This is for internal graphical subs + if (hasGraphicalSubs) + { + args += EncodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + } + + //args += " -flags -global_header"; + } + + if (args.IndexOf("-copyts", StringComparison.OrdinalIgnoreCase) == -1) + { + args += " -copyts"; + } + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } + + args += EncodingHelper.GetOutputFFlags(state); + + return args; + } + + protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) + { + var videoCodec = EncodingHelper.GetVideoEncoder(state, encodingOptions); + + var threads = EncodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec); + + var inputModifier = EncodingHelper.GetInputModifier(state, encodingOptions); + + // If isEncoding is true we're actually starting ffmpeg + var startNumber = GetStartNumber(state); + var startNumberParam = isEncoding ? startNumber.ToString(UsCulture) : "0"; + + var mapArgs = state.IsOutputVideo ? EncodingHelper.GetMapArgs(state) : string.Empty; + + var outputTsArg = Path.Combine(FileSystem.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request); + + var timeDeltaParam = String.Empty; + + if (isEncoding && startNumber > 0) + { + var startTime = state.SegmentLength * startNumber; + timeDeltaParam = string.Format("-segment_time_delta -{0}", startTime); + } + + var segmentFormat = GetSegmentFileExtension(state.Request).TrimStart('.'); + if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)) + { + segmentFormat = "mpegts"; + } + + var breakOnNonKeyFrames = state.EnableBreakOnNonKeyFrames(videoCodec); + + var breakOnNonKeyFramesArg = breakOnNonKeyFrames ? " -break_non_keyframes 1" : ""; + + return string.Format("{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {10} -individual_header_trailer 0{12} -segment_format {11} -segment_list_type m3u8 -segment_start_number {7} -segment_list \"{8}\" -y \"{9}\"", + inputModifier, + EncodingHelper.GetInputArgument(state, encodingOptions), + threads, + mapArgs, + GetVideoArguments(state, encodingOptions), + GetAudioArguments(state, encodingOptions), + state.SegmentLength.ToString(UsCulture), + startNumberParam, + outputPath, + outputTsArg, + timeDeltaParam, + segmentFormat, + breakOnNonKeyFramesArg + ).Trim(); + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs b/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs new file mode 100644 index 0000000000..52cc025283 --- /dev/null +++ b/MediaBrowser.Api/Playback/Hls/HlsSegmentService.cs @@ -0,0 +1,163 @@ +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Net; +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Services; + +namespace MediaBrowser.Api.Playback.Hls +{ + /// <summary> + /// Class GetHlsAudioSegment + /// </summary> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + //[Authenticated] + [Route("/Audio/{Id}/hls/{SegmentId}/stream.mp3", "GET")] + [Route("/Audio/{Id}/hls/{SegmentId}/stream.aac", "GET")] + public class GetHlsAudioSegmentLegacy + { + // TODO: Deprecate with new iOS app + + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public string Id { get; set; } + + /// <summary> + /// Gets or sets the segment id. + /// </summary> + /// <value>The segment id.</value> + public string SegmentId { get; set; } + } + + /// <summary> + /// Class GetHlsVideoSegment + /// </summary> + [Route("/Videos/{Id}/hls/{PlaylistId}/stream.m3u8", "GET")] + [Authenticated] + public class GetHlsPlaylistLegacy + { + // TODO: Deprecate with new iOS app + + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + public string Id { get; set; } + + public string PlaylistId { get; set; } + } + + [Route("/Videos/ActiveEncodings", "DELETE")] + [Authenticated] + public class StopEncodingProcess + { + [ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string DeviceId { get; set; } + + [ApiMember(Name = "PlaySessionId", Description = "The play session id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] + public string PlaySessionId { get; set; } + } + + /// <summary> + /// Class GetHlsVideoSegment + /// </summary> + // Can't require authentication just yet due to seeing some requests come from Chrome without full query string + //[Authenticated] + [Route("/Videos/{Id}/hls/{PlaylistId}/{SegmentId}.{SegmentContainer}", "GET")] + public class GetHlsVideoSegmentLegacy : VideoStreamRequest + { + public string PlaylistId { get; set; } + + /// <summary> + /// Gets or sets the segment id. + /// </summary> + /// <value>The segment id.</value> + public string SegmentId { get; set; } + } + + public class HlsSegmentService : BaseApiService + { + private readonly IServerApplicationPaths _appPaths; + private readonly IServerConfigurationManager _config; + private readonly IFileSystem _fileSystem; + + public HlsSegmentService(IServerApplicationPaths appPaths, IServerConfigurationManager config, IFileSystem fileSystem) + { + _appPaths = appPaths; + _config = config; + _fileSystem = fileSystem; + } + + public Task<object> Get(GetHlsPlaylistLegacy request) + { + var file = request.PlaylistId + Path.GetExtension(Request.PathInfo); + file = Path.Combine(_appPaths.TranscodingTempPath, file); + + return GetFileResult(file, file); + } + + public void Delete(StopEncodingProcess request) + { + ApiEntryPoint.Instance.KillTranscodingJobs(request.DeviceId, request.PlaySessionId, path => true); + } + + /// <summary> + /// Gets the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Get(GetHlsVideoSegmentLegacy request) + { + var file = request.SegmentId + Path.GetExtension(Request.PathInfo); + + var transcodeFolderPath = _config.ApplicationPaths.TranscodingTempPath; + file = Path.Combine(transcodeFolderPath, file); + + var normalizedPlaylistId = request.PlaylistId; + + var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath) + .FirstOrDefault(i => string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase) && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1); + + return GetFileResult(file, playlistPath); + } + + /// <summary> + /// Gets the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Get(GetHlsAudioSegmentLegacy request) + { + // TODO: Deprecate with new iOS app + var file = request.SegmentId + Path.GetExtension(Request.PathInfo); + file = Path.Combine(_appPaths.TranscodingTempPath, file); + + return ResultFactory.GetStaticFileResult(Request, file, FileShareMode.ReadWrite); + } + + private Task<object> GetFileResult(string path, string playlistPath) + { + var transcodingJob = ApiEntryPoint.Instance.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); + + return ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions + { + Path = path, + FileShare = FileShareMode.ReadWrite, + OnComplete = () => + { + if (transcodingJob != null) + { + ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); + } + } + }); + } + } +} diff --git a/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs b/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs new file mode 100644 index 0000000000..1ae7ea3a84 --- /dev/null +++ b/MediaBrowser.Api/Playback/Hls/VideoHlsService.cs @@ -0,0 +1,137 @@ +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; +using System; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Services; + +namespace MediaBrowser.Api.Playback.Hls +{ + [Route("/Videos/{Id}/live.m3u8", "GET")] + public class GetLiveHlsStream : VideoStreamRequest + { + } + + /// <summary> + /// Class VideoHlsService + /// </summary> + [Authenticated] + public class VideoHlsService : BaseHlsService + { + public object Get(GetLiveHlsStream request) + { + return ProcessRequest(request, true); + } + + /// <summary> + /// Gets the audio arguments. + /// </summary> + protected override string GetAudioArguments(StreamState state, EncodingOptions encodingOptions) + { + var codec = EncodingHelper.GetAudioEncoder(state); + + if (string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return "-codec:a:0 copy"; + } + + var args = "-codec:a:0 " + codec; + + var channels = state.OutputAudioChannels; + + if (channels.HasValue) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(UsCulture); + } + + if (state.OutputAudioSampleRate.HasValue) + { + args += " -ar " + state.OutputAudioSampleRate.Value.ToString(UsCulture); + } + + args += " " + EncodingHelper.GetAudioFilterParam(state, encodingOptions, true); + + return args; + } + + /// <summary> + /// Gets the video arguments. + /// </summary> + protected override string GetVideoArguments(StreamState state, EncodingOptions encodingOptions) + { + if (!state.IsOutputVideo) + { + return string.Empty; + } + + var codec = EncodingHelper.GetVideoEncoder(state, encodingOptions); + + var args = "-codec:v:0 " + codec; + + // if (state.EnableMpegtsM2TsMode) + // { + // args += " -mpegts_m2ts_mode 1"; + // } + + // See if we can save come cpu cycles by avoiding encoding + if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) + { + // if h264_mp4toannexb is ever added, do not use it for live tv + if (state.VideoStream != null && EncodingHelper.IsH264(state.VideoStream) && + !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase)) + { + args += " -bsf:v h264_mp4toannexb"; + } + } + else + { + var keyFrameArg = string.Format(" -force_key_frames \"expr:gte(t,n_forced*{0})\"", + state.SegmentLength.ToString(UsCulture)); + + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + + args += " " + EncodingHelper.GetVideoQualityParam(state, codec, encodingOptions, GetDefaultH264Preset()) + keyFrameArg; + + // Add resolution params, if specified + if (!hasGraphicalSubs) + { + args += EncodingHelper.GetOutputSizeParam(state, encodingOptions, codec); + } + + // This is for internal graphical subs + if (hasGraphicalSubs) + { + args += EncodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec); + } + } + + args += " -flags -global_header"; + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + args += " -vsync " + state.OutputVideoSync; + } + + args += EncodingHelper.GetOutputFFlags(state); + + return args; + } + + public VideoHlsService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext) + { + } + } +} diff --git a/MediaBrowser.Api/Playback/MediaInfoService.cs b/MediaBrowser.Api/Playback/MediaInfoService.cs new file mode 100644 index 0000000000..2db0f8f419 --- /dev/null +++ b/MediaBrowser.Api/Playback/MediaInfoService.cs @@ -0,0 +1,618 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Session; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Services; + +namespace MediaBrowser.Api.Playback +{ + [Route("/Items/{Id}/PlaybackInfo", "GET", Summary = "Gets live playback media info for an item")] + public class GetPlaybackInfo : IReturn<PlaybackInfoResponse> + { + [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid Id { get; set; } + + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] + public Guid UserId { get; set; } + } + + [Route("/Items/{Id}/PlaybackInfo", "POST", Summary = "Gets live playback media info for an item")] + public class GetPostedPlaybackInfo : PlaybackInfoRequest, IReturn<PlaybackInfoResponse> + { + } + + [Route("/LiveStreams/Open", "POST", Summary = "Opens a media source")] + public class OpenMediaSource : LiveStreamRequest, IReturn<LiveStreamResponse> + { + } + + [Route("/LiveStreams/Close", "POST", Summary = "Closes a media source")] + public class CloseMediaSource : IReturnVoid + { + [ApiMember(Name = "LiveStreamId", Description = "LiveStreamId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] + public string LiveStreamId { get; set; } + } + + [Route("/Playback/BitrateTest", "GET")] + public class GetBitrateTestBytes + { + [ApiMember(Name = "Size", Description = "Size", IsRequired = true, DataType = "int", ParameterType = "query", Verb = "GET")] + public long Size { get; set; } + + public GetBitrateTestBytes() + { + // 100k + Size = 102400; + } + } + + [Authenticated] + public class MediaInfoService : BaseApiService + { + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDeviceManager _deviceManager; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _config; + private readonly INetworkManager _networkManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IUserManager _userManager; + private readonly IJsonSerializer _json; + private readonly IAuthorizationContext _authContext; + + public MediaInfoService(IMediaSourceManager mediaSourceManager, IDeviceManager deviceManager, ILibraryManager libraryManager, IServerConfigurationManager config, INetworkManager networkManager, IMediaEncoder mediaEncoder, IUserManager userManager, IJsonSerializer json, IAuthorizationContext authContext) + { + _mediaSourceManager = mediaSourceManager; + _deviceManager = deviceManager; + _libraryManager = libraryManager; + _config = config; + _networkManager = networkManager; + _mediaEncoder = mediaEncoder; + _userManager = userManager; + _json = json; + _authContext = authContext; + } + + public object Get(GetBitrateTestBytes request) + { + var bytes = new byte[request.Size]; + + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = 0; + } + + return ResultFactory.GetResult(null, bytes, "application/octet-stream"); + } + + public async Task<object> Get(GetPlaybackInfo request) + { + var result = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }).ConfigureAwait(false); + return ToOptimizedResult(result); + } + + public async Task<object> Post(OpenMediaSource request) + { + var result = await OpenMediaSource(request).ConfigureAwait(false); + + return ToOptimizedResult(result); + } + + private async Task<LiveStreamResponse> OpenMediaSource(OpenMediaSource request) + { + var authInfo = _authContext.GetAuthorizationInfo(Request); + + var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false); + + var profile = request.DeviceProfile; + if (profile == null) + { + var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); + if (caps != null) + { + profile = caps.DeviceProfile; + } + } + + if (profile != null) + { + var item = _libraryManager.GetItemById(request.ItemId); + + SetDeviceSpecificData(item, result.MediaSource, profile, authInfo, request.MaxStreamingBitrate, + request.StartTimeTicks ?? 0, result.MediaSource.Id, request.AudioStreamIndex, + request.SubtitleStreamIndex, request.MaxAudioChannels, request.PlaySessionId, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, true, true, true); + } + else + { + if (!string.IsNullOrWhiteSpace(result.MediaSource.TranscodingUrl)) + { + result.MediaSource.TranscodingUrl += "&LiveStreamId=" + result.MediaSource.LiveStreamId; + } + } + + if (result.MediaSource != null) + { + NormalizeMediaSourceContainer(result.MediaSource, profile, DlnaProfileType.Video); + } + + return result; + } + + public void Post(CloseMediaSource request) + { + var task = _mediaSourceManager.CloseLiveStream(request.LiveStreamId); + Task.WaitAll(task); + } + + public async Task<PlaybackInfoResponse> GetPlaybackInfo(GetPostedPlaybackInfo request) + { + var authInfo = _authContext.GetAuthorizationInfo(Request); + + var profile = request.DeviceProfile; + + //Logger.Info("GetPostedPlaybackInfo profile: {0}", _json.SerializeToString(profile)); + + if (profile == null) + { + var caps = _deviceManager.GetCapabilities(authInfo.DeviceId); + if (caps != null) + { + profile = caps.DeviceProfile; + } + } + + var info = await GetPlaybackInfo(request.Id, request.UserId, new[] { MediaType.Audio, MediaType.Video }, request.MediaSourceId, request.LiveStreamId).ConfigureAwait(false); + + if (profile != null) + { + var mediaSourceId = request.MediaSourceId; + + SetDeviceSpecificData(request.Id, info, profile, authInfo, request.MaxStreamingBitrate ?? profile.MaxStreamingBitrate, request.StartTimeTicks ?? 0, mediaSourceId, request.AudioStreamIndex, request.SubtitleStreamIndex, request.MaxAudioChannels, request.UserId, request.EnableDirectPlay, true, request.EnableDirectStream, request.EnableTranscoding, request.AllowVideoStreamCopy, request.AllowAudioStreamCopy); + } + + if (request.AutoOpenLiveStream) + { + var mediaSource = string.IsNullOrWhiteSpace(request.MediaSourceId) ? info.MediaSources.FirstOrDefault() : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, request.MediaSourceId, StringComparison.Ordinal)); + + if (mediaSource != null && mediaSource.RequiresOpening && string.IsNullOrWhiteSpace(mediaSource.LiveStreamId)) + { + var openStreamResult = await OpenMediaSource(new OpenMediaSource + { + AudioStreamIndex = request.AudioStreamIndex, + DeviceProfile = request.DeviceProfile, + EnableDirectPlay = request.EnableDirectPlay, + EnableDirectStream = request.EnableDirectStream, + ItemId = request.Id, + MaxAudioChannels = request.MaxAudioChannels, + MaxStreamingBitrate = request.MaxStreamingBitrate, + PlaySessionId = info.PlaySessionId, + StartTimeTicks = request.StartTimeTicks, + SubtitleStreamIndex = request.SubtitleStreamIndex, + UserId = request.UserId, + OpenToken = mediaSource.OpenToken, + //EnableMediaProbe = request.EnableMediaProbe + + }).ConfigureAwait(false); + + info.MediaSources = new MediaSourceInfo[] { openStreamResult.MediaSource }; + } + } + + if (info.MediaSources != null) + { + foreach (var mediaSource in info.MediaSources) + { + NormalizeMediaSourceContainer(mediaSource, profile, DlnaProfileType.Video); + } + } + + return info; + } + + private void NormalizeMediaSourceContainer(MediaSourceInfo mediaSource, DeviceProfile profile, DlnaProfileType type) + { + mediaSource.Container = StreamBuilder.NormalizeMediaSourceFormatIntoSingleContainer(mediaSource.Container, mediaSource.Path, profile, type); + } + + public async Task<object> Post(GetPostedPlaybackInfo request) + { + var result = await GetPlaybackInfo(request).ConfigureAwait(false); + + return ToOptimizedResult(result); + } + + private T Clone<T>(T obj) + { + // Since we're going to be setting properties on MediaSourceInfos that come out of _mediaSourceManager, we should clone it + // Should we move this directly into MediaSourceManager? + + var json = _json.SerializeToString(obj); + return _json.DeserializeFromString<T>(json); + } + + private async Task<PlaybackInfoResponse> GetPlaybackInfo(Guid id, Guid userId, string[] supportedLiveMediaTypes, string mediaSourceId = null, string liveStreamId = null) + { + var user = _userManager.GetUserById(userId); + var item = _libraryManager.GetItemById(id); + var result = new PlaybackInfoResponse(); + + if (string.IsNullOrWhiteSpace(liveStreamId)) + { + IEnumerable<MediaSourceInfo> mediaSources; + try + { + // TODO handle supportedLiveMediaTypes ? + mediaSources = await _mediaSourceManager.GetPlayackMediaSources(item, user, true, false, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + mediaSources = new List<MediaSourceInfo>(); + // TODO PlaybackException ?? + //result.ErrorCode = ex.ErrorCode; + } + + result.MediaSources = mediaSources.ToArray(); + + if (!string.IsNullOrWhiteSpace(mediaSourceId)) + { + result.MediaSources = result.MediaSources + .Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + } + else + { + var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + + result.MediaSources = new MediaSourceInfo[] { mediaSource }; + } + + if (result.MediaSources.Length == 0) + { + if (!result.ErrorCode.HasValue) + { + result.ErrorCode = PlaybackErrorCode.NoCompatibleStream; + } + } + else + { + result.MediaSources = Clone(result.MediaSources); + + result.PlaySessionId = Guid.NewGuid().ToString("N"); + } + + return result; + } + + private void SetDeviceSpecificData(Guid itemId, + PlaybackInfoResponse result, + DeviceProfile profile, + AuthorizationInfo auth, + long? maxBitrate, + long startTimeTicks, + string mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + int? maxAudioChannels, + Guid userId, + bool enableDirectPlay, + bool forceDirectPlayRemoteMediaSource, + bool enableDirectStream, + bool enableTranscoding, + bool allowVideoStreamCopy, + bool allowAudioStreamCopy) + { + var item = _libraryManager.GetItemById(itemId); + + foreach (var mediaSource in result.MediaSources) + { + SetDeviceSpecificData(item, mediaSource, profile, auth, maxBitrate, startTimeTicks, mediaSourceId, audioStreamIndex, subtitleStreamIndex, maxAudioChannels, result.PlaySessionId, userId, enableDirectPlay, forceDirectPlayRemoteMediaSource, enableDirectStream, enableTranscoding, allowVideoStreamCopy, allowAudioStreamCopy); + } + + SortMediaSources(result, maxBitrate); + } + + private void SetDeviceSpecificData(BaseItem item, + MediaSourceInfo mediaSource, + DeviceProfile profile, + AuthorizationInfo auth, + long? maxBitrate, + long startTimeTicks, + string mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + int? maxAudioChannels, + string playSessionId, + Guid userId, + bool enableDirectPlay, + bool forceDirectPlayRemoteMediaSource, + bool enableDirectStream, + bool enableTranscoding, + bool allowVideoStreamCopy, + bool allowAudioStreamCopy) + { + var streamBuilder = new StreamBuilder(_mediaEncoder, Logger); + + var options = new VideoOptions + { + MediaSources = new MediaSourceInfo[] { mediaSource }, + Context = EncodingContext.Streaming, + DeviceId = auth.DeviceId, + ItemId = item.Id, + Profile = profile, + MaxAudioChannels = maxAudioChannels + }; + + if (string.Equals(mediaSourceId, mediaSource.Id, StringComparison.OrdinalIgnoreCase)) + { + options.MediaSourceId = mediaSourceId; + options.AudioStreamIndex = audioStreamIndex; + options.SubtitleStreamIndex = subtitleStreamIndex; + } + + var user = _userManager.GetUserById(userId); + + if (!enableDirectPlay) + { + mediaSource.SupportsDirectPlay = false; + } + if (!enableDirectStream) + { + mediaSource.SupportsDirectStream = false; + } + if (!enableTranscoding) + { + mediaSource.SupportsTranscoding = false; + } + + if (item is Audio) + { + Logger.Info("User policy for {0}. EnableAudioPlaybackTranscoding: {1}", user.Name, user.Policy.EnableAudioPlaybackTranscoding); + } + else + { + Logger.Info("User policy for {0}. EnablePlaybackRemuxing: {1} EnableVideoPlaybackTranscoding: {2} EnableAudioPlaybackTranscoding: {3}", + user.Name, + user.Policy.EnablePlaybackRemuxing, + user.Policy.EnableVideoPlaybackTranscoding, + user.Policy.EnableAudioPlaybackTranscoding); + } + + if (mediaSource.SupportsDirectPlay) + { + if (mediaSource.IsRemote && forceDirectPlayRemoteMediaSource) + { + } + else + { + var supportsDirectStream = mediaSource.SupportsDirectStream; + + // Dummy this up to fool StreamBuilder + mediaSource.SupportsDirectStream = true; + options.MaxBitrate = maxBitrate; + + if (item is Audio) + { + if (!user.Policy.EnableAudioPlaybackTranscoding) + { + options.ForceDirectPlay = true; + } + } + else if (item is Video) + { + if (!user.Policy.EnableAudioPlaybackTranscoding && !user.Policy.EnableVideoPlaybackTranscoding && !user.Policy.EnablePlaybackRemuxing) + { + options.ForceDirectPlay = true; + } + } + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) ? + streamBuilder.BuildAudioItem(options) : + streamBuilder.BuildVideoItem(options); + + if (streamInfo == null || !streamInfo.IsDirectStream) + { + mediaSource.SupportsDirectPlay = false; + } + + // Set this back to what it was + mediaSource.SupportsDirectStream = supportsDirectStream; + + if (streamInfo != null) + { + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + } + + if (mediaSource.SupportsDirectStream) + { + options.MaxBitrate = GetMaxBitrate(maxBitrate, user); + + if (item is Audio) + { + if (!user.Policy.EnableAudioPlaybackTranscoding) + { + options.ForceDirectStream = true; + } + } + else if (item is Video) + { + if (!user.Policy.EnableAudioPlaybackTranscoding && !user.Policy.EnableVideoPlaybackTranscoding && !user.Policy.EnablePlaybackRemuxing) + { + options.ForceDirectStream = true; + } + } + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) ? + streamBuilder.BuildAudioItem(options) : + streamBuilder.BuildVideoItem(options); + + if (streamInfo == null || !streamInfo.IsDirectStream) + { + mediaSource.SupportsDirectStream = false; + } + + if (streamInfo != null) + { + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + + if (mediaSource.SupportsTranscoding) + { + options.MaxBitrate = GetMaxBitrate(maxBitrate, user); + + // The MediaSource supports direct stream, now test to see if the client supports it + var streamInfo = string.Equals(item.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase) ? + streamBuilder.BuildAudioItem(options) : + streamBuilder.BuildVideoItem(options); + + if (streamInfo != null) + { + streamInfo.PlaySessionId = playSessionId; + + if (streamInfo.PlayMethod == PlayMethod.Transcode) + { + streamInfo.StartPositionTicks = startTimeTicks; + mediaSource.TranscodingUrl = streamInfo.ToUrl("-", auth.Token).TrimStart('-'); + + if (!allowVideoStreamCopy) + { + mediaSource.TranscodingUrl += "&allowVideoStreamCopy=false"; + } + if (!allowAudioStreamCopy) + { + mediaSource.TranscodingUrl += "&allowAudioStreamCopy=false"; + } + mediaSource.TranscodingContainer = streamInfo.Container; + mediaSource.TranscodingSubProtocol = streamInfo.SubProtocol; + } + + // Do this after the above so that StartPositionTicks is set + SetDeviceSpecificSubtitleInfo(streamInfo, mediaSource, auth.Token); + } + } + } + + private long? GetMaxBitrate(long? clientMaxBitrate, User user) + { + var maxBitrate = clientMaxBitrate; + var remoteClientMaxBitrate = user == null ? 0 : user.Policy.RemoteClientBitrateLimit; + + if (remoteClientMaxBitrate <= 0) + { + remoteClientMaxBitrate = _config.Configuration.RemoteClientBitrateLimit; + } + + if (remoteClientMaxBitrate > 0) + { + var isInLocalNetwork = _networkManager.IsInLocalNetwork(Request.RemoteIp); + + Logger.Info("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, Request.RemoteIp, isInLocalNetwork); + if (!isInLocalNetwork) + { + maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); + } + } + + return maxBitrate; + } + + private void SetDeviceSpecificSubtitleInfo(StreamInfo info, MediaSourceInfo mediaSource, string accessToken) + { + var profiles = info.GetSubtitleProfiles(_mediaEncoder, false, "-", accessToken); + mediaSource.DefaultSubtitleStreamIndex = info.SubtitleStreamIndex; + + mediaSource.TranscodeReasons = info.TranscodeReasons; + + foreach (var profile in profiles) + { + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Subtitle && stream.Index == profile.Index) + { + stream.DeliveryMethod = profile.DeliveryMethod; + + if (profile.DeliveryMethod == SubtitleDeliveryMethod.External) + { + stream.DeliveryUrl = profile.Url.TrimStart('-'); + stream.IsExternalUrl = profile.IsExternalUrl; + } + } + } + } + } + + private void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate) + { + var originalList = result.MediaSources.ToList(); + + result.MediaSources = result.MediaSources.OrderBy(i => + { + // Nothing beats direct playing a file + if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) + { + return 0; + } + + return 1; + + }).ThenBy(i => + { + // Let's assume direct streaming a file is just as desirable as direct playing a remote url + if (i.SupportsDirectPlay || i.SupportsDirectStream) + { + return 0; + } + + return 1; + + }).ThenBy(i => + { + switch (i.Protocol) + { + case MediaProtocol.File: + return 0; + default: + return 1; + } + + }).ThenBy(i => + { + if (maxBitrate.HasValue) + { + if (i.Bitrate.HasValue) + { + if (i.Bitrate.Value <= maxBitrate.Value) + { + return 0; + } + + return 2; + } + } + + return 1; + + }).ThenBy(originalList.IndexOf) + .ToArray(); + } + } +} diff --git a/MediaBrowser.Api/Playback/Progressive/AudioService.cs b/MediaBrowser.Api/Playback/Progressive/AudioService.cs new file mode 100644 index 0000000000..44e096dd7f --- /dev/null +++ b/MediaBrowser.Api/Playback/Progressive/AudioService.cs @@ -0,0 +1,67 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; +using System.Collections.Generic; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; + +namespace MediaBrowser.Api.Playback.Progressive +{ + /// <summary> + /// Class GetAudioStream + /// </summary> + [Route("/Audio/{Id}/stream.{Container}", "GET", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/stream", "GET", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/stream.{Container}", "HEAD", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/stream", "HEAD", Summary = "Gets an audio stream")] + public class GetAudioStream : StreamRequest + { + } + + /// <summary> + /// Class AudioService + /// </summary> + // TODO: In order to autheneticate this in the future, Dlna playback will require updating + //[Authenticated] + public class AudioService : BaseProgressiveStreamingService + { + public AudioService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext, IImageProcessor imageProcessor, IEnvironmentInfo environmentInfo) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext, imageProcessor, environmentInfo) + { + } + + /// <summary> + /// Gets the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Get(GetAudioStream request) + { + return ProcessRequest(request, false); + } + + /// <summary> + /// Gets the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Head(GetAudioStream request) + { + return ProcessRequest(request, true); + } + + protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) + { + return EncodingHelper.GetProgressiveAudioFullCommandLine(state, encodingOptions, outputPath); + } + } +} diff --git a/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs b/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs new file mode 100644 index 0000000000..44261f2d55 --- /dev/null +++ b/MediaBrowser.Api/Playback/Progressive/BaseProgressiveStreamingService.cs @@ -0,0 +1,429 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; + +namespace MediaBrowser.Api.Playback.Progressive +{ + /// <summary> + /// Class BaseProgressiveStreamingService + /// </summary> + public abstract class BaseProgressiveStreamingService : BaseStreamingService + { + protected readonly IImageProcessor ImageProcessor; + protected readonly IEnvironmentInfo EnvironmentInfo; + + public BaseProgressiveStreamingService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext, IImageProcessor imageProcessor, IEnvironmentInfo environmentInfo) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext) + { + ImageProcessor = imageProcessor; + EnvironmentInfo = environmentInfo; + } + + /// <summary> + /// Gets the output file extension. + /// </summary> + /// <param name="state">The state.</param> + /// <returns>System.String.</returns> + protected override string GetOutputFileExtension(StreamState state) + { + var ext = base.GetOutputFileExtension(state); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; + } + + var isVideoRequest = state.VideoRequest != null; + + // Try to infer based on the desired video codec + if (isVideoRequest) + { + var videoCodec = state.VideoRequest.VideoCodec; + + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + return ".ts"; + } + if (string.Equals(videoCodec, "theora", StringComparison.OrdinalIgnoreCase)) + { + return ".ogv"; + } + if (string.Equals(videoCodec, "vpx", StringComparison.OrdinalIgnoreCase)) + { + return ".webm"; + } + if (string.Equals(videoCodec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return ".asf"; + } + } + + // Try to infer based on the desired audio codec + if (!isVideoRequest) + { + var audioCodec = state.Request.AudioCodec; + + if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".aac"; + } + if (string.Equals("mp3", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".mp3"; + } + if (string.Equals("vorbis", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".ogg"; + } + if (string.Equals("wma", audioCodec, StringComparison.OrdinalIgnoreCase)) + { + return ".wma"; + } + } + + return null; + } + + /// <summary> + /// Gets the type of the transcoding job. + /// </summary> + /// <value>The type of the transcoding job.</value> + protected override TranscodingJobType TranscodingJobType + { + get { return TranscodingJobType.Progressive; } + } + + /// <summary> + /// Processes the request. + /// </summary> + /// <param name="request">The request.</param> + /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> + /// <returns>Task.</returns> + protected async Task<object> ProcessRequest(StreamRequest request, bool isHeadRequest) + { + var cancellationTokenSource = new CancellationTokenSource(); + + var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); + + var responseHeaders = new Dictionary<string, string>(); + + if (request.Static && state.DirectStreamProvider != null) + { + AddDlnaHeaders(state, responseHeaders, true); + + using (state) + { + var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // TODO: Don't hardcode this + outputHeaders["Content-Type"] = MediaBrowser.Model.Net.MimeTypes.GetMimeType("file.ts"); + + return new ProgressiveFileCopier(state.DirectStreamProvider, outputHeaders, null, Logger, EnvironmentInfo, CancellationToken.None) + { + AllowEndOfFile = false + }; + } + } + + // Static remote stream + if (request.Static && state.InputProtocol == MediaProtocol.Http) + { + AddDlnaHeaders(state, responseHeaders, true); + + using (state) + { + return await GetStaticRemoteStreamResult(state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); + } + } + + if (request.Static && state.InputProtocol != MediaProtocol.File) + { + throw new ArgumentException(string.Format("Input protocol {0} cannot be streamed statically.", state.InputProtocol)); + } + + var outputPath = state.OutputFilePath; + var outputPathExists = FileSystem.FileExists(outputPath); + + var transcodingJob = ApiEntryPoint.Instance.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob != null; + + AddDlnaHeaders(state, responseHeaders, request.Static || isTranscodeCached); + + // Static stream + if (request.Static) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + using (state) + { + if (state.MediaSource.IsInfiniteStream) + { + var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + outputHeaders["Content-Type"] = contentType; + + return new ProgressiveFileCopier(FileSystem, state.MediaPath, outputHeaders, null, Logger, EnvironmentInfo, CancellationToken.None) + { + AllowEndOfFile = false + }; + } + + TimeSpan? cacheDuration = null; + + if (!string.IsNullOrEmpty(request.Tag)) + { + cacheDuration = TimeSpan.FromDays(365); + } + + return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions + { + ResponseHeaders = responseHeaders, + ContentType = contentType, + IsHeadRequest = isHeadRequest, + Path = state.MediaPath, + CacheDuration = cacheDuration + + }).ConfigureAwait(false); + } + } + + //// Not static but transcode cache file exists + //if (isTranscodeCached && state.VideoRequest == null) + //{ + // var contentType = state.GetMimeType(outputPath); + + // try + // { + // if (transcodingJob != null) + // { + // ApiEntryPoint.Instance.OnTranscodeBeginRequest(transcodingJob); + // } + + // return await ResultFactory.GetStaticFileResult(Request, new StaticFileResultOptions + // { + // ResponseHeaders = responseHeaders, + // ContentType = contentType, + // IsHeadRequest = isHeadRequest, + // Path = outputPath, + // FileShare = FileShareMode.ReadWrite, + // OnComplete = () => + // { + // if (transcodingJob != null) + // { + // ApiEntryPoint.Instance.OnTranscodeEndRequest(transcodingJob); + // } + // } + + // }).ConfigureAwait(false); + // } + // finally + // { + // state.Dispose(); + // } + //} + + // Need to start ffmpeg + try + { + return await GetStreamResult(request, state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); + } + catch + { + state.Dispose(); + + throw; + } + } + + /// <summary> + /// Gets the static remote stream result. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>Task{System.Object}.</returns> + private async Task<object> GetStaticRemoteStreamResult(StreamState state, Dictionary<string, string> responseHeaders, bool isHeadRequest, CancellationTokenSource cancellationTokenSource) + { + string useragent = null; + state.RemoteHttpHeaders.TryGetValue("User-Agent", out useragent); + + var trySupportSeek = false; + + var options = new HttpRequestOptions + { + Url = state.MediaPath, + UserAgent = useragent, + BufferContent = false, + CancellationToken = cancellationTokenSource.Token + }; + + if (trySupportSeek) + { + if (!string.IsNullOrWhiteSpace(Request.QueryString["Range"])) + { + options.RequestHeaders["Range"] = Request.QueryString["Range"]; + } + } + var response = await HttpClient.GetResponse(options).ConfigureAwait(false); + + if (trySupportSeek) + { + foreach (var name in new[] { "Content-Range", "Accept-Ranges" }) + { + var val = response.Headers[name]; + if (!string.IsNullOrWhiteSpace(val)) + { + responseHeaders[name] = val; + } + } + } + else + { + responseHeaders["Accept-Ranges"] = "none"; + } + + // Seeing cases of -1 here + if (response.ContentLength.HasValue && response.ContentLength.Value >= 0) + { + responseHeaders["Content-Length"] = response.ContentLength.Value.ToString(UsCulture); + } + + if (isHeadRequest) + { + using (response) + { + return ResultFactory.GetResult(null, new byte[] { }, response.ContentType, responseHeaders); + } + } + + var result = new StaticRemoteStreamWriter(response); + + result.Headers["Content-Type"] = response.ContentType; + + // Add the response headers to the result object + foreach (var header in responseHeaders) + { + result.Headers[header.Key] = header.Value; + } + + return result; + } + + /// <summary> + /// Gets the stream result. + /// </summary> + /// <param name="state">The state.</param> + /// <param name="responseHeaders">The response headers.</param> + /// <param name="isHeadRequest">if set to <c>true</c> [is head request].</param> + /// <param name="cancellationTokenSource">The cancellation token source.</param> + /// <returns>Task{System.Object}.</returns> + private async Task<object> GetStreamResult(StreamRequest request, StreamState state, IDictionary<string, string> responseHeaders, bool isHeadRequest, CancellationTokenSource cancellationTokenSource) + { + // Use the command line args with a dummy playlist path + var outputPath = state.OutputFilePath; + + responseHeaders["Accept-Ranges"] = "none"; + + var contentType = state.GetMimeType(outputPath); + + // TODO: The isHeadRequest is only here because ServiceStack will add Content-Length=0 to the response + // What we really want to do is hunt that down and remove that + var contentLength = state.EstimateContentLength || isHeadRequest ? GetEstimatedContentLength(state) : null; + + if (contentLength.HasValue) + { + responseHeaders["Content-Length"] = contentLength.Value.ToString(UsCulture); + } + + // Headers only + if (isHeadRequest) + { + var streamResult = ResultFactory.GetResult(null, new byte[] { }, contentType, responseHeaders); + + var hasHeaders = streamResult as IHasHeaders; + if (hasHeaders != null) + { + if (contentLength.HasValue) + { + hasHeaders.Headers["Content-Length"] = contentLength.Value.ToString(CultureInfo.InvariantCulture); + } + else + { + if (hasHeaders.Headers.ContainsKey("Content-Length")) + { + hasHeaders.Headers.Remove("Content-Length"); + } + } + } + + return streamResult; + } + + var transcodingLock = ApiEntryPoint.Instance.GetTranscodingLock(outputPath); + await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false); + try + { + TranscodingJob job; + + if (!FileSystem.FileExists(outputPath)) + { + job = await StartFfMpeg(state, outputPath, cancellationTokenSource).ConfigureAwait(false); + } + else + { + job = ApiEntryPoint.Instance.OnTranscodeBeginRequest(outputPath, TranscodingJobType.Progressive); + state.Dispose(); + } + + var outputHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + outputHeaders["Content-Type"] = contentType; + + // Add the response headers to the result object + foreach (var item in responseHeaders) + { + outputHeaders[item.Key] = item.Value; + } + + return new ProgressiveFileCopier(FileSystem, outputPath, outputHeaders, job, Logger, EnvironmentInfo, CancellationToken.None); + } + finally + { + transcodingLock.Release(); + } + } + + /// <summary> + /// Gets the length of the estimated content. + /// </summary> + /// <param name="state">The state.</param> + /// <returns>System.Nullable{System.Int64}.</returns> + private long? GetEstimatedContentLength(StreamState state) + { + var totalBitrate = state.TotalOutputBitrate ?? 0; + + if (totalBitrate > 0 && state.RunTimeTicks.HasValue) + { + return Convert.ToInt64(totalBitrate * TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds / 8); + } + + return null; + } + } +} diff --git a/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs b/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs new file mode 100644 index 0000000000..9839a7a9aa --- /dev/null +++ b/MediaBrowser.Api/Playback/Progressive/ProgressiveStreamWriter.cs @@ -0,0 +1,193 @@ +using MediaBrowser.Model.Logging; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Controller.Net; +using System.Collections.Generic; + +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; + +namespace MediaBrowser.Api.Playback.Progressive +{ + public class ProgressiveFileCopier : IAsyncStreamWriter, IHasHeaders + { + private readonly IFileSystem _fileSystem; + private readonly TranscodingJob _job; + private readonly ILogger _logger; + private readonly string _path; + private readonly CancellationToken _cancellationToken; + private readonly Dictionary<string, string> _outputHeaders; + + const int StreamCopyToBufferSize = 81920; + + private long _bytesWritten = 0; + public long StartPosition { get; set; } + public bool AllowEndOfFile = true; + + private readonly IDirectStreamProvider _directStreamProvider; + private readonly IEnvironmentInfo _environment; + + public ProgressiveFileCopier(IFileSystem fileSystem, string path, Dictionary<string, string> outputHeaders, TranscodingJob job, ILogger logger, IEnvironmentInfo environment, CancellationToken cancellationToken) + { + _fileSystem = fileSystem; + _path = path; + _outputHeaders = outputHeaders; + _job = job; + _logger = logger; + _cancellationToken = cancellationToken; + _environment = environment; + } + + public ProgressiveFileCopier(IDirectStreamProvider directStreamProvider, Dictionary<string, string> outputHeaders, TranscodingJob job, ILogger logger, IEnvironmentInfo environment, CancellationToken cancellationToken) + { + _directStreamProvider = directStreamProvider; + _outputHeaders = outputHeaders; + _job = job; + _logger = logger; + _cancellationToken = cancellationToken; + _environment = environment; + } + + public IDictionary<string, string> Headers + { + get + { + return _outputHeaders; + } + } + + private Stream GetInputStream(bool allowAsyncFileRead) + { + var fileOpenOptions = FileOpenOptions.SequentialScan; + + if (allowAsyncFileRead) + { + fileOpenOptions |= FileOpenOptions.Asynchronous; + } + + return _fileSystem.GetFileStream(_path, FileOpenMode.Open, FileAccessMode.Read, FileShareMode.ReadWrite, fileOpenOptions); + } + + public async Task WriteToAsync(Stream outputStream, CancellationToken cancellationToken) + { + cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken).Token; + + try + { + if (_directStreamProvider != null) + { + await _directStreamProvider.CopyToAsync(outputStream, cancellationToken).ConfigureAwait(false); + return; + } + + var eofCount = 0; + + // use non-async filestream along with read due to https://github.com/dotnet/corefx/issues/6039 + var allowAsyncFileRead = _environment.OperatingSystem != Model.System.OperatingSystem.Windows; + + using (var inputStream = GetInputStream(allowAsyncFileRead)) + { + if (StartPosition > 0) + { + inputStream.Position = StartPosition; + } + + while (eofCount < 20 || !AllowEndOfFile) + { + int bytesRead; + if (allowAsyncFileRead) + { + bytesRead = await CopyToInternalAsync(inputStream, outputStream, cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = await CopyToInternalAsyncWithSyncRead(inputStream, outputStream, cancellationToken).ConfigureAwait(false); + } + + //var position = fs.Position; + //_logger.Debug("Streamed {0} bytes to position {1} from file {2}", bytesRead, position, path); + + if (bytesRead == 0) + { + if (_job == null || _job.HasExited) + { + eofCount++; + } + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + else + { + eofCount = 0; + } + } + } + } + finally + { + if (_job != null) + { + ApiEntryPoint.Instance.OnTranscodeEndRequest(_job); + } + } + } + + private async Task<int> CopyToInternalAsyncWithSyncRead(Stream source, Stream destination, CancellationToken cancellationToken) + { + var array = new byte[StreamCopyToBufferSize]; + int bytesRead; + int totalBytesRead = 0; + + while ((bytesRead = source.Read(array, 0, array.Length)) != 0) + { + var bytesToWrite = bytesRead; + + if (bytesToWrite > 0) + { + await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + } + + return totalBytesRead; + } + + private async Task<int> CopyToInternalAsync(Stream source, Stream destination, CancellationToken cancellationToken) + { + var array = new byte[StreamCopyToBufferSize]; + int bytesRead; + int totalBytesRead = 0; + + while ((bytesRead = await source.ReadAsync(array, 0, array.Length, cancellationToken).ConfigureAwait(false)) != 0) + { + var bytesToWrite = bytesRead; + + if (bytesToWrite > 0) + { + await destination.WriteAsync(array, 0, Convert.ToInt32(bytesToWrite), cancellationToken).ConfigureAwait(false); + + _bytesWritten += bytesRead; + totalBytesRead += bytesRead; + + if (_job != null) + { + _job.BytesDownloaded = Math.Max(_job.BytesDownloaded ?? _bytesWritten, _bytesWritten); + } + } + } + + return totalBytesRead; + } + } +} diff --git a/MediaBrowser.Api/Playback/Progressive/VideoService.cs b/MediaBrowser.Api/Playback/Progressive/VideoService.cs new file mode 100644 index 0000000000..a41b4cbf54 --- /dev/null +++ b/MediaBrowser.Api/Playback/Progressive/VideoService.cs @@ -0,0 +1,100 @@ +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Serialization; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; + +namespace MediaBrowser.Api.Playback.Progressive +{ + /// <summary> + /// Class GetVideoStream + /// </summary> + [Route("/Videos/{Id}/stream.mpegts", "GET")] + [Route("/Videos/{Id}/stream.ts", "GET")] + [Route("/Videos/{Id}/stream.webm", "GET")] + [Route("/Videos/{Id}/stream.asf", "GET")] + [Route("/Videos/{Id}/stream.wmv", "GET")] + [Route("/Videos/{Id}/stream.ogv", "GET")] + [Route("/Videos/{Id}/stream.mp4", "GET")] + [Route("/Videos/{Id}/stream.m4v", "GET")] + [Route("/Videos/{Id}/stream.mkv", "GET")] + [Route("/Videos/{Id}/stream.mpeg", "GET")] + [Route("/Videos/{Id}/stream.mpg", "GET")] + [Route("/Videos/{Id}/stream.avi", "GET")] + [Route("/Videos/{Id}/stream.m2ts", "GET")] + [Route("/Videos/{Id}/stream.3gp", "GET")] + [Route("/Videos/{Id}/stream.wmv", "GET")] + [Route("/Videos/{Id}/stream.wtv", "GET")] + [Route("/Videos/{Id}/stream.mov", "GET")] + [Route("/Videos/{Id}/stream.iso", "GET")] + [Route("/Videos/{Id}/stream.flv", "GET")] + [Route("/Videos/{Id}/stream", "GET")] + [Route("/Videos/{Id}/stream.ts", "HEAD")] + [Route("/Videos/{Id}/stream.webm", "HEAD")] + [Route("/Videos/{Id}/stream.asf", "HEAD")] + [Route("/Videos/{Id}/stream.wmv", "HEAD")] + [Route("/Videos/{Id}/stream.ogv", "HEAD")] + [Route("/Videos/{Id}/stream.mp4", "HEAD")] + [Route("/Videos/{Id}/stream.m4v", "HEAD")] + [Route("/Videos/{Id}/stream.mkv", "HEAD")] + [Route("/Videos/{Id}/stream.mpeg", "HEAD")] + [Route("/Videos/{Id}/stream.mpg", "HEAD")] + [Route("/Videos/{Id}/stream.avi", "HEAD")] + [Route("/Videos/{Id}/stream.3gp", "HEAD")] + [Route("/Videos/{Id}/stream.wmv", "HEAD")] + [Route("/Videos/{Id}/stream.wtv", "HEAD")] + [Route("/Videos/{Id}/stream.m2ts", "HEAD")] + [Route("/Videos/{Id}/stream.mov", "HEAD")] + [Route("/Videos/{Id}/stream.iso", "HEAD")] + [Route("/Videos/{Id}/stream.flv", "HEAD")] + [Route("/Videos/{Id}/stream", "HEAD")] + public class GetVideoStream : VideoStreamRequest + { + + } + + /// <summary> + /// Class VideoService + /// </summary> + // TODO: In order to autheneticate this in the future, Dlna playback will require updating + //[Authenticated] + public class VideoService : BaseProgressiveStreamingService + { + public VideoService(IServerConfigurationManager serverConfig, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, ISubtitleEncoder subtitleEncoder, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext, IImageProcessor imageProcessor, IEnvironmentInfo environmentInfo) : base(serverConfig, userManager, libraryManager, isoManager, mediaEncoder, fileSystem, dlnaManager, subtitleEncoder, deviceManager, mediaSourceManager, zipClient, jsonSerializer, authorizationContext, imageProcessor, environmentInfo) + { + } + + /// <summary> + /// Gets the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Get(GetVideoStream request) + { + return ProcessRequest(request, false); + } + + /// <summary> + /// Heads the specified request. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>System.Object.</returns> + public Task<object> Head(GetVideoStream request) + { + return ProcessRequest(request, true); + } + + protected override string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding) + { + return EncodingHelper.GetProgressiveVideoFullCommandLine(state, encodingOptions, outputPath, GetDefaultH264Preset()); + } + } +}
\ No newline at end of file diff --git a/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs b/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs new file mode 100644 index 0000000000..6bb3b6b804 --- /dev/null +++ b/MediaBrowser.Api/Playback/StaticRemoteStreamWriter.cs @@ -0,0 +1,47 @@ +using MediaBrowser.Common.Net; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Services; + +namespace MediaBrowser.Api.Playback +{ + /// <summary> + /// Class StaticRemoteStreamWriter + /// </summary> + public class StaticRemoteStreamWriter : IAsyncStreamWriter, IHasHeaders + { + /// <summary> + /// The _input stream + /// </summary> + private readonly HttpResponseInfo _response; + + /// <summary> + /// The _options + /// </summary> + private readonly IDictionary<string, string> _options = new Dictionary<string, string>(); + + public StaticRemoteStreamWriter(HttpResponseInfo response) + { + _response = response; + } + + /// <summary> + /// Gets the options. + /// </summary> + /// <value>The options.</value> + public IDictionary<string, string> Headers + { + get { return _options; } + } + + public async Task WriteToAsync(Stream responseStream, CancellationToken cancellationToken) + { + using (_response) + { + await _response.Content.CopyToAsync(responseStream, 81920, cancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/MediaBrowser.Api/Playback/StreamRequest.cs b/MediaBrowser.Api/Playback/StreamRequest.cs new file mode 100644 index 0000000000..d95c30d657 --- /dev/null +++ b/MediaBrowser.Api/Playback/StreamRequest.cs @@ -0,0 +1,64 @@ +using System; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Services; + +namespace MediaBrowser.Api.Playback +{ + /// <summary> + /// Class StreamRequest + /// </summary> + public class StreamRequest : BaseEncodingJobOptions + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid Id { get; set; } + + [ApiMember(Name = "MediaSourceId", Description = "The media version id, if playing an alternate version", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public string MediaSourceId { get; set; } + + [ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public string DeviceId { get; set; } + + [ApiMember(Name = "Container", Description = "Container", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public string Container { get; set; } + + /// <summary> + /// Gets or sets the audio codec. + /// </summary> + /// <value>The audio codec.</value> + [ApiMember(Name = "AudioCodec", Description = "Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public string AudioCodec { get; set; } + + [ApiMember(Name = "DeviceProfileId", Description = "Optional. The dlna device profile id to utilize.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public string DeviceProfileId { get; set; } + + public string Params { get; set; } + public string PlaySessionId { get; set; } + public string Tag { get; set; } + public string SegmentContainer { get; set; } + + public int? SegmentLength { get; set; } + public int? MinSegments { get; set; } + } + + public class VideoStreamRequest : StreamRequest + { + /// <summary> + /// Gets a value indicating whether this instance has fixed resolution. + /// </summary> + /// <value><c>true</c> if this instance has fixed resolution; otherwise, <c>false</c>.</value> + public bool HasFixedResolution + { + get + { + return Width.HasValue || Height.HasValue; + } + } + + public bool EnableSubtitlesInManifest { get; set; } + } +} diff --git a/MediaBrowser.Api/Playback/StreamState.cs b/MediaBrowser.Api/Playback/StreamState.cs new file mode 100644 index 0000000000..ac20b5e337 --- /dev/null +++ b/MediaBrowser.Api/Playback/StreamState.cs @@ -0,0 +1,259 @@ +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using MediaBrowser.Controller.MediaEncoding; + +namespace MediaBrowser.Api.Playback +{ + public class StreamState : EncodingJobInfo, IDisposable + { + private readonly ILogger _logger; + private readonly IMediaSourceManager _mediaSourceManager; + + public string RequestedUrl { get; set; } + + public StreamRequest Request + { + get { return (StreamRequest)BaseRequest; } + set + { + BaseRequest = value; + + IsVideoRequest = VideoRequest != null; + } + } + + public TranscodingThrottler TranscodingThrottler { get; set; } + + public VideoStreamRequest VideoRequest + { + get { return Request as VideoStreamRequest; } + } + + /// <summary> + /// Gets or sets the log file stream. + /// </summary> + /// <value>The log file stream.</value> + public Stream LogFileStream { get; set; } + public IDirectStreamProvider DirectStreamProvider { get; set; } + + public string WaitForPath { get; set; } + + public bool IsOutputVideo + { + get { return Request is VideoStreamRequest; } + } + + public int SegmentLength + { + get + { + if (Request.SegmentLength.HasValue) + { + return Request.SegmentLength.Value; + } + + if (string.Equals(OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var userAgent = UserAgent ?? string.Empty; + + if (userAgent.IndexOf("AppleTV", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("cfnetwork", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("ipad", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("iphone", StringComparison.OrdinalIgnoreCase) != -1 || + userAgent.IndexOf("ipod", StringComparison.OrdinalIgnoreCase) != -1) + { + if (IsSegmentedLiveStream) + { + return 6; + } + + return 6; + } + + if (IsSegmentedLiveStream) + { + return 3; + } + return 6; + } + + return 3; + } + } + + public int MinSegments + { + get + { + if (Request.MinSegments.HasValue) + { + return Request.MinSegments.Value; + } + + return SegmentLength >= 10 ? 2 : 3; + } + } + + public int HlsListSize + { + get + { + return 0; + } + } + + public string UserAgent { get; set; } + + public StreamState(IMediaSourceManager mediaSourceManager, ILogger logger, TranscodingJobType transcodingType) + : base(logger, mediaSourceManager, transcodingType) + { + _mediaSourceManager = mediaSourceManager; + _logger = logger; + } + + public string MimeType { get; set; } + + public bool EstimateContentLength { get; set; } + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + public long? EncodingDurationTicks { get; set; } + + public string GetMimeType(string outputPath, bool enableStreamDefault = true) + { + if (!string.IsNullOrEmpty(MimeType)) + { + return MimeType; + } + + return MimeTypes.GetMimeType(outputPath, enableStreamDefault); + } + + public bool EnableDlnaHeaders { get; set; } + + public void Dispose() + { + DisposeTranscodingThrottler(); + DisposeLiveStream(); + DisposeLogStream(); + DisposeIsoMount(); + + TranscodingJob = null; + } + + private void DisposeLogStream() + { + if (LogFileStream != null) + { + try + { + LogFileStream.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing log stream", ex); + } + + LogFileStream = null; + } + } + + private void DisposeTranscodingThrottler() + { + if (TranscodingThrottler != null) + { + try + { + TranscodingThrottler.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing TranscodingThrottler", ex); + } + + TranscodingThrottler = null; + } + } + + private async void DisposeLiveStream() + { + if (MediaSource.RequiresClosing && string.IsNullOrWhiteSpace(Request.LiveStreamId) && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error closing media source", ex); + } + } + } + + public string OutputFilePath { get; set; } + + public string ActualOutputVideoCodec + { + get + { + var codec = OutputVideoCodec; + + if (string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var stream = VideoStream; + + if (stream != null) + { + return stream.Codec; + } + + return null; + } + + return codec; + } + } + + public string ActualOutputAudioCodec + { + get + { + var codec = OutputAudioCodec; + + if (string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase)) + { + var stream = AudioStream; + + if (stream != null) + { + return stream.Codec; + } + + return null; + } + + return codec; + } + } + + public DeviceProfile DeviceProfile { get; set; } + + public TranscodingJob TranscodingJob; + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + ApiEntryPoint.Instance.ReportTranscodingProgress(TranscodingJob, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + } +} diff --git a/MediaBrowser.Api/Playback/TranscodingThrottler.cs b/MediaBrowser.Api/Playback/TranscodingThrottler.cs new file mode 100644 index 0000000000..c42d0c3e4c --- /dev/null +++ b/MediaBrowser.Api/Playback/TranscodingThrottler.cs @@ -0,0 +1,176 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Logging; +using System; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Threading; + +namespace MediaBrowser.Api.Playback +{ + public class TranscodingThrottler : IDisposable + { + private readonly TranscodingJob _job; + private readonly ILogger _logger; + private ITimer _timer; + private bool _isPaused; + private readonly IConfigurationManager _config; + private readonly ITimerFactory _timerFactory; + private readonly IFileSystem _fileSystem; + + public TranscodingThrottler(TranscodingJob job, ILogger logger, IConfigurationManager config, ITimerFactory timerFactory, IFileSystem fileSystem) + { + _job = job; + _logger = logger; + _config = config; + _timerFactory = timerFactory; + _fileSystem = fileSystem; + } + + private EncodingOptions GetOptions() + { + return _config.GetConfiguration<EncodingOptions>("encoding"); + } + + public void Start() + { + _timer = _timerFactory.Create(TimerCallback, null, 5000, 5000); + } + + private void TimerCallback(object state) + { + if (_job.HasExited) + { + DisposeTimer(); + return; + } + + var options = GetOptions(); + + if (options.EnableThrottling && IsThrottleAllowed(_job, options.ThrottleDelaySeconds)) + { + PauseTranscoding(); + } + else + { + UnpauseTranscoding(); + } + } + + private void PauseTranscoding() + { + if (!_isPaused) + { + _logger.Debug("Sending pause command to ffmpeg"); + + try + { + _job.Process.StandardInput.Write("c"); + _isPaused = true; + } + catch (Exception ex) + { + _logger.ErrorException("Error pausing transcoding", ex); + } + } + } + + public void UnpauseTranscoding() + { + if (_isPaused) + { + _logger.Debug("Sending unpause command to ffmpeg"); + + try + { + _job.Process.StandardInput.WriteLine(); + _isPaused = false; + } + catch (Exception ex) + { + _logger.ErrorException("Error unpausing transcoding", ex); + } + } + } + + private bool IsThrottleAllowed(TranscodingJob 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.Debug("Not throttling transcoder gap {0} target gap {1}", gap, targetGap); + return false; + } + + //_logger.Debug("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.Debug("Not throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); + return false; + } + + //_logger.Debug("Throttling transcoder gap {0} target gap {1} bytes downloaded {2}", gap, targetGap, bytesDownloaded); + return true; + } + catch + { + //_logger.Error("Error getting output size"); + return false; + } + } + + //_logger.Debug("No throttle data for " + path); + return false; + } + + public void Stop() + { + DisposeTimer(); + UnpauseTranscoding(); + } + + public void Dispose() + { + DisposeTimer(); + } + + private void DisposeTimer() + { + if (_timer != null) + { + _timer.Dispose(); + _timer = null; + } + } + } +} diff --git a/MediaBrowser.Api/Playback/UniversalAudioService.cs b/MediaBrowser.Api/Playback/UniversalAudioService.cs new file mode 100644 index 0000000000..6f928000a6 --- /dev/null +++ b/MediaBrowser.Api/Playback/UniversalAudioService.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Api.Playback.Hls; +using MediaBrowser.Api.Playback.Progressive; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Services; +using MediaBrowser.Model.System; + +namespace MediaBrowser.Api.Playback +{ + public class BaseUniversalRequest + { + /// <summary> + /// Gets or sets the id. + /// </summary> + /// <value>The id.</value> + [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid Id { get; set; } + + [ApiMember(Name = "MediaSourceId", Description = "The media version id, if playing an alternate version", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public string MediaSourceId { get; set; } + + [ApiMember(Name = "DeviceId", Description = "The device id of the client requesting. Used to stop encoding processes when needed.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] + public string DeviceId { get; set; } + + public Guid UserId { get; set; } + public string AudioCodec { get; set; } + public string Container { get; set; } + + public int? MaxAudioChannels { get; set; } + public int? TranscodingAudioChannels { get; set; } + + public long? MaxStreamingBitrate { get; set; } + + [ApiMember(Name = "StartTimeTicks", Description = "Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] + public long? StartTimeTicks { get; set; } + + public string TranscodingContainer { get; set; } + public string TranscodingProtocol { get; set; } + public int? MaxAudioSampleRate { get; set; } + public int? MaxAudioBitDepth { get; set; } + + public bool EnableRedirection { get; set; } + public bool EnableRemoteMedia { get; set; } + public bool BreakOnNonKeyFrames { get; set; } + + public BaseUniversalRequest() + { + EnableRedirection = true; + } + } + + [Route("/Audio/{Id}/universal.{Container}", "GET", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/universal", "GET", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/universal.{Container}", "HEAD", Summary = "Gets an audio stream")] + [Route("/Audio/{Id}/universal", "HEAD", Summary = "Gets an audio stream")] + public class GetUniversalAudioStream : BaseUniversalRequest + { + } + + [Authenticated] + public class UniversalAudioService : BaseApiService + { + public UniversalAudioService(IServerConfigurationManager serverConfigurationManager, IUserManager userManager, ILibraryManager libraryManager, IIsoManager isoManager, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IDlnaManager dlnaManager, IDeviceManager deviceManager, ISubtitleEncoder subtitleEncoder, IMediaSourceManager mediaSourceManager, IZipClient zipClient, IJsonSerializer jsonSerializer, IAuthorizationContext authorizationContext, IImageProcessor imageProcessor, INetworkManager networkManager, IEnvironmentInfo environmentInfo) + { + ServerConfigurationManager = serverConfigurationManager; + UserManager = userManager; + LibraryManager = libraryManager; + IsoManager = isoManager; + MediaEncoder = mediaEncoder; + FileSystem = fileSystem; + DlnaManager = dlnaManager; + DeviceManager = deviceManager; + SubtitleEncoder = subtitleEncoder; + MediaSourceManager = mediaSourceManager; + ZipClient = zipClient; + JsonSerializer = jsonSerializer; + AuthorizationContext = authorizationContext; + ImageProcessor = imageProcessor; + NetworkManager = networkManager; + EnvironmentInfo = environmentInfo; + } + + protected IServerConfigurationManager ServerConfigurationManager { get; private set; } + protected IUserManager UserManager { get; private set; } + protected ILibraryManager LibraryManager { get; private set; } + protected IIsoManager IsoManager { get; private set; } + protected IMediaEncoder MediaEncoder { get; private set; } + protected IFileSystem FileSystem { get; private set; } + protected IDlnaManager DlnaManager { get; private set; } + protected IDeviceManager DeviceManager { get; private set; } + protected ISubtitleEncoder SubtitleEncoder { get; private set; } + protected IMediaSourceManager MediaSourceManager { get; private set; } + protected IZipClient ZipClient { get; private set; } + protected IJsonSerializer JsonSerializer { get; private set; } + protected IAuthorizationContext AuthorizationContext { get; private set; } + protected IImageProcessor ImageProcessor { get; private set; } + protected INetworkManager NetworkManager { get; private set; } + protected IEnvironmentInfo EnvironmentInfo { get; private set; } + + public Task<object> Get(GetUniversalAudioStream request) + { + return GetUniversalStream(request, false); + } + + public Task<object> Head(GetUniversalAudioStream request) + { + return GetUniversalStream(request, true); + } + + private DeviceProfile GetDeviceProfile(GetUniversalAudioStream request) + { + var deviceProfile = new DeviceProfile(); + + var directPlayProfiles = new List<DirectPlayProfile>(); + + var containers = (request.Container ?? string.Empty).Split(new [] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var container in containers) + { + var parts = container.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + + var audioCodecs = parts.Length == 1 ? null : string.Join(",", parts.Skip(1).ToArray()); + + directPlayProfiles.Add(new DirectPlayProfile + { + Type = DlnaProfileType.Audio, + Container = parts[0], + AudioCodec = audioCodecs + }); + } + + deviceProfile.DirectPlayProfiles = directPlayProfiles.ToArray(); + + deviceProfile.TranscodingProfiles = new[] + { + new TranscodingProfile + { + Type = DlnaProfileType.Audio, + Context = EncodingContext.Streaming, + Container = request.TranscodingContainer, + AudioCodec = request.AudioCodec, + Protocol = request.TranscodingProtocol, + BreakOnNonKeyFrames = request.BreakOnNonKeyFrames, + MaxAudioChannels = request.TranscodingAudioChannels.HasValue ? request.TranscodingAudioChannels.Value.ToString(CultureInfo.InvariantCulture) : null + } + }; + + var codecProfiles = new List<CodecProfile>(); + var conditions = new List<ProfileCondition>(); + + if (request.MaxAudioSampleRate.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioSampleRate, + Value = request.MaxAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture) + }); + } + + if (request.MaxAudioBitDepth.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioBitDepth, + Value = request.MaxAudioBitDepth.Value.ToString(CultureInfo.InvariantCulture) + }); + } + + if (request.MaxAudioChannels.HasValue) + { + // codec profile + conditions.Add(new ProfileCondition + { + Condition = ProfileConditionType.LessThanEqual, + IsRequired = false, + Property = ProfileConditionValue.AudioChannels, + Value = request.MaxAudioChannels.Value.ToString(CultureInfo.InvariantCulture) + }); + } + + if (conditions.Count > 0) + { + // codec profile + codecProfiles.Add(new CodecProfile + { + Type = CodecType.Audio, + Container = request.Container, + Conditions = conditions.ToArray() + }); + } + + deviceProfile.CodecProfiles = codecProfiles.ToArray(); + + return deviceProfile; + } + + private async Task<object> GetUniversalStream(GetUniversalAudioStream request, bool isHeadRequest) + { + var deviceProfile = GetDeviceProfile(request); + + AuthorizationContext.GetAuthorizationInfo(Request).DeviceId = request.DeviceId; + + var mediaInfoService = new MediaInfoService(MediaSourceManager, DeviceManager, LibraryManager, ServerConfigurationManager, NetworkManager, MediaEncoder, UserManager, JsonSerializer, AuthorizationContext) + { + Request = Request + }; + + var playbackInfoResult = await mediaInfoService.GetPlaybackInfo(new GetPostedPlaybackInfo + { + Id = request.Id, + MaxAudioChannels = request.MaxAudioChannels, + MaxStreamingBitrate = request.MaxStreamingBitrate, + StartTimeTicks = request.StartTimeTicks, + UserId = request.UserId, + DeviceProfile = deviceProfile, + MediaSourceId = request.MediaSourceId + + }).ConfigureAwait(false); + + var mediaSource = playbackInfoResult.MediaSources[0]; + + if (mediaSource.SupportsDirectPlay && mediaSource.Protocol == MediaProtocol.Http) + { + if (request.EnableRedirection) + { + if (mediaSource.IsRemote && request.EnableRemoteMedia) + { + return ResultFactory.GetRedirectResult(mediaSource.Path); + } + } + } + + var isStatic = mediaSource.SupportsDirectStream; + + if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase)) + { + var service = new DynamicHlsService(ServerConfigurationManager, + UserManager, + LibraryManager, + IsoManager, + MediaEncoder, + FileSystem, + DlnaManager, + SubtitleEncoder, + DeviceManager, + MediaSourceManager, + ZipClient, + JsonSerializer, + AuthorizationContext, + NetworkManager) + { + Request = Request + }; + + var transcodingProfile = deviceProfile.TranscodingProfiles[0]; + + var newRequest = new GetMasterHlsAudioPlaylist + { + AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(request.MaxStreamingBitrate ?? 192000, int.MaxValue)), + AudioCodec = transcodingProfile.AudioCodec, + Container = ".m3u8", + DeviceId = request.DeviceId, + Id = request.Id, + MaxAudioChannels = request.MaxAudioChannels, + MediaSourceId = mediaSource.Id, + PlaySessionId = playbackInfoResult.PlaySessionId, + StartTimeTicks = request.StartTimeTicks, + Static = isStatic, + SegmentContainer = request.TranscodingContainer, + AudioSampleRate = request.MaxAudioSampleRate, + MaxAudioBitDepth = request.MaxAudioBitDepth, + BreakOnNonKeyFrames = transcodingProfile.BreakOnNonKeyFrames, + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()) + }; + + if (isHeadRequest) + { + return await service.Head(newRequest).ConfigureAwait(false); + } + return await service.Get(newRequest).ConfigureAwait(false); + } + else + { + var service = new AudioService(ServerConfigurationManager, + UserManager, + LibraryManager, + IsoManager, + MediaEncoder, + FileSystem, + DlnaManager, + SubtitleEncoder, + DeviceManager, + MediaSourceManager, + ZipClient, + JsonSerializer, + AuthorizationContext, + ImageProcessor, + EnvironmentInfo) + { + Request = Request + }; + + var newRequest = new GetAudioStream + { + AudioBitRate = isStatic ? (int?)null : Convert.ToInt32(Math.Min(request.MaxStreamingBitrate ?? 192000, int.MaxValue)), + AudioCodec = request.AudioCodec, + Container = isStatic ? null : ("." + mediaSource.TranscodingContainer), + DeviceId = request.DeviceId, + Id = request.Id, + MaxAudioChannels = request.MaxAudioChannels, + MediaSourceId = mediaSource.Id, + PlaySessionId = playbackInfoResult.PlaySessionId, + StartTimeTicks = request.StartTimeTicks, + Static = isStatic, + AudioSampleRate = request.MaxAudioSampleRate, + MaxAudioBitDepth = request.MaxAudioBitDepth, + TranscodeReasons = mediaSource.TranscodeReasons == null ? null : string.Join(",", mediaSource.TranscodeReasons.Select(i => i.ToString()).ToArray()) + }; + + if (isHeadRequest) + { + return await service.Head(newRequest).ConfigureAwait(false); + } + return await service.Get(newRequest).ConfigureAwait(false); + } + } + } +} |
