diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs new file mode 100644 index 000000000..39df1e1b1 --- /dev/null +++ b/Jellyfin.Api/Controllers/AudioController.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Net; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; + +namespace Jellyfin.Api.Controllers +{ + + /// + /// The audio controller. + /// + public class AudioController : BaseJellyfinApiController + { + private readonly IDlnaManager _dlnaManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public AudioController(IDlnaManager dlnaManager, ILogger logger) + { + _dlnaManager = dlnaManager; + _logger = logger; + } + + [HttpGet("{id}/stream.{container}")] + [HttpGet("{id}/stream")] + [HttpHead("{id}/stream.{container}")] + [HttpGet("{id}/stream")] + public async Task GetAudioStream( + [FromRoute] string id, + [FromRoute] string container, + [FromQuery] bool Static, + [FromQuery] string tag) + { + bool isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head; + + var cancellationTokenSource = new CancellationTokenSource(); + + var state = await GetState(request, cancellationTokenSource.Token).ConfigureAwait(false); + + if (Static && state.DirectStreamProvider != null) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager); + + using (state) + { + var outputHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // TODO: Don't hardcode this + outputHeaders[HeaderNames.ContentType] = MimeTypes.GetMimeType("file.ts"); + + return new ProgressiveFileCopier(state.DirectStreamProvider, outputHeaders, null, _logger, CancellationToken.None) + { + AllowEndOfFile = false + }; + } + } + + // Static remote stream + if (Static && state.InputProtocol == MediaProtocol.Http) + { + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, true, Request, _dlnaManager); + + using (state) + { + return await GetStaticRemoteStreamResult(state, responseHeaders, isHeadRequest, cancellationTokenSource).ConfigureAwait(false); + } + } + + if (Static && state.InputProtocol != MediaProtocol.File) + { + throw new ArgumentException(string.Format($"Input protocol {state.InputProtocol} cannot be streamed statically.")); + } + + var outputPath = state.OutputFilePath; + var outputPathExists = File.Exists(outputPath); + + var transcodingJob = TranscodingJobHelper.GetTranscodingJob(outputPath, TranscodingJobType.Progressive); + var isTranscodeCached = outputPathExists && transcodingJob != null; + + StreamingHelpers.AddDlnaHeaders(state, Response.Headers, Static || isTranscodeCached, Request, _dlnaManager); + + // Static stream + if (Static) + { + var contentType = state.GetMimeType("." + state.OutputContainer, false) ?? state.GetMimeType(state.MediaPath); + + using (state) + { + if (state.MediaSource.IsInfiniteStream) + { + var outputHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [HeaderNames.ContentType] = contentType + }; + + + return new ProgressiveFileCopier(FileSystem, state.MediaPath, outputHeaders, null, _logger, CancellationToken.None) + { + AllowEndOfFile = false + }; + } + + TimeSpan? cacheDuration = null; + + if (!string.IsNullOrEmpty(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 = FileShare.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; + } + } + } +} diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs new file mode 100644 index 000000000..4cebf40f6 --- /dev/null +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Models; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Model.Dlna; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Api.Helpers +{ + /// + /// The streaming helpers + /// + public class StreamingHelpers + { + /// + /// Adds the dlna headers. + /// + /// The state. + /// The response headers. + /// if set to true [is statically streamed]. + /// The . + /// Instance of the interface. + public static void AddDlnaHeaders( + StreamState state, + IHeaderDictionary responseHeaders, + bool isStaticallyStreamed, + HttpRequest request, + IDlnaManager dlnaManager) + { + if (!state.EnableDlnaHeaders) + { + return; + } + + var profile = state.DeviceProfile; + + StringValues transferMode = request.Headers["transferMode.dlna.org"]; + responseHeaders.Add("transferMode.dlna.org", string.IsNullOrEmpty(transferMode) ? "Streaming" : transferMode.ToString()); + responseHeaders.Add("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*"); + + if (state.RunTimeTicks.HasValue) + { + if (string.Equals(request.Headers["getMediaInfo.sec"], "1", StringComparison.OrdinalIgnoreCase)) + { + var ms = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalMilliseconds; + responseHeaders.Add("MediaInfo.sec", string.Format( + CultureInfo.InvariantCulture, + "SEC_Duration={0};", + Convert.ToInt32(ms))); + } + + if (!isStaticallyStreamed && profile != null) + { + AddTimeSeekResponseHeaders(state, responseHeaders); + } + } + + if (profile == null) + { + profile = dlnaManager.GetDefaultProfile(); + } + + var audioCodec = state.ActualOutputAudioCodec; + + if (state.VideoRequest == null) + { + responseHeaders.Add("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.Add("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); + } + } + + /// + /// Parses the dlna headers. + /// + /// The start time ticks. + /// The . + public void ParseDlnaHeaders(long? startTimeTicks, HttpRequest request) + { + if (!startTimeTicks.HasValue) + { + var timeSeek = request.Headers["TimeSeekRange.dlna.org"]; + + startTimeTicks = ParseTimeSeekHeader(timeSeek); + } + } + + /// + /// Parses the time seek header. + /// + public long? ParseTimeSeekHeader(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + const string Npt = "npt="; + if (!value.StartsWith(Npt, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Invalid timeseek header"); + } + int index = value.IndexOf('-'); + value = index == -1 + ? value.Substring(Npt.Length) + : value.Substring(Npt.Length, index - Npt.Length); + + if (value.IndexOf(':') == -1) + { + // Parses npt times in the format of '417.33' + if (double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var 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) + { + if (double.TryParse(time, NumberStyles.Any, CultureInfo.InvariantCulture, out var digit)) + { + secondsSum += digit * timeFactor; + } + else + { + throw new ArgumentException("Invalid timeseek header"); + } + timeFactor /= 60; + } + return TimeSpan.FromSeconds(secondsSum).Ticks; + } + + public void AddTimeSeekResponseHeaders(StreamState state, IHeaderDictionary responseHeaders) + { + var runtimeSeconds = TimeSpan.FromTicks(state.RunTimeTicks.Value).TotalSeconds.ToString(CultureInfo.InvariantCulture); + var startSeconds = TimeSpan.FromTicks(state.Request.StartTimeTicks ?? 0).TotalSeconds.ToString(CultureInfo.InvariantCulture); + + responseHeaders.Add("TimeSeekRange.dlna.org", string.Format( + CultureInfo.InvariantCulture, + "npt={0}-{1}/{1}", + startSeconds, + runtimeSeconds)); + responseHeaders.Add("X-AvailableSeekRange", string.Format( + CultureInfo.InvariantCulture, + "1 npt={0}-{1}", + startSeconds, + runtimeSeconds)); + } + } +} diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index 44f662e6e..7db75387a 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -5,10 +5,12 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Models; using Jellyfin.Api.Models.PlaybackDtos; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Helpers @@ -61,6 +63,14 @@ namespace Jellyfin.Api.Helpers } } + public static TranscodingJobDto GetTranscodingJob(string path, TranscodingJobType type) + { + lock (_activeTranscodingJobs) + { + return _activeTranscodingJobs.FirstOrDefault(j => j.Type == type && string.Equals(j.Path, path, StringComparison.OrdinalIgnoreCase)); + } + } + /// /// Ping transcoding job. /// @@ -350,5 +360,50 @@ namespace Jellyfin.Api.Helpers throw new AggregateException("Error deleting HLS files", exs); } } + + public void ReportTranscodingProgress( + TranscodingJob job, + StreamState state, + TimeSpan? transcodingPosition, + float? framerate, + double? percentComplete, + long? bytesTranscoded, + int? bitRate) + { + var ticks = transcodingPosition?.Ticks; + + 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 = EncodingHelper.IsCopyCodec(state.OutputAudioCodec), + IsVideoDirect = EncodingHelper.IsCopyCodec(state.OutputVideoCodec), + TranscodeReasons = state.TranscodeReasons + }); + } + } } } diff --git a/Jellyfin.Api/Models/StreamState.cs b/Jellyfin.Api/Models/StreamState.cs new file mode 100644 index 000000000..9fe5f52c3 --- /dev/null +++ b/Jellyfin.Api/Models/StreamState.cs @@ -0,0 +1,145 @@ +using System; +using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.PlaybackDtos; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; + +namespace Jellyfin.Api.Models +{ + public class StreamState : EncodingJobInfo, IDisposable + { + private readonly IMediaSourceManager _mediaSourceManager; + private bool _disposed = false; + + public string RequestedUrl { get; set; } + + public StreamRequest Request + { + get => (StreamRequest)BaseRequest; + set + { + BaseRequest = value; + + IsVideoRequest = VideoRequest != null; + } + } + + public TranscodingThrottler TranscodingThrottler { get; set; } + + public VideoStreamRequest VideoRequest => Request as VideoStreamRequest; + + public IDirectStreamProvider DirectStreamProvider { get; set; } + + public string WaitForPath { get; set; } + + public bool IsOutputVideo => Request is VideoStreamRequest; + + public int SegmentLength + { + get + { + if (Request.SegmentLength.HasValue) + { + return Request.SegmentLength.Value; + } + + if (EncodingHelper.IsCopyCodec(OutputVideoCodec)) + { + 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 string UserAgent { get; set; } + + public bool EstimateContentLength { get; set; } + + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + + public bool EnableDlnaHeaders { get; set; } + + public DeviceProfile DeviceProfile { get; set; } + + public TranscodingJobDto TranscodingJob { get; set; } + + public StreamState(IMediaSourceManager mediaSourceManager, TranscodingJobType transcodingType) + : base(transcodingType) + { + _mediaSourceManager = mediaSourceManager; + } + + public override void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded, int? bitRate) + { + TranscodingJobHelper.ReportTranscodingProgress(TranscodingJob, this, transcodingPosition, framerate, percentComplete, bytesTranscoded, bitRate); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + // REVIEW: Is this the right place for this? + if (MediaSource.RequiresClosing + && string.IsNullOrWhiteSpace(Request.LiveStreamId) + && !string.IsNullOrWhiteSpace(MediaSource.LiveStreamId)) + { + _mediaSourceManager.CloseLiveStream(MediaSource.LiveStreamId).GetAwaiter().GetResult(); + } + + TranscodingThrottler?.Dispose(); + } + + TranscodingThrottler = null; + TranscodingJob = null; + + _disposed = true; + } + } +}