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;
+ }
+ }
+}