diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs new file mode 100644 index 0000000000..a988c2f974 --- /dev/null +++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobOptions.cs @@ -0,0 +1,91 @@ +using MediaBrowser.Model.Dlna; + +namespace MediaBrowser.Controller.MediaEncoding +{ + public class EncodingJobOptions + { + public string OutputContainer { get; set; } + + public long? StartTimeTicks { get; set; } + public int? Width { get; set; } + public int? Height { get; set; } + public int? MaxWidth { get; set; } + public int? MaxHeight { get; set; } + public bool Static = false; + public float? Framerate { get; set; } + public float? MaxFramerate { get; set; } + public string Profile { get; set; } + public int? Level { get; set; } + + public string DeviceId { get; set; } + public string ItemId { get; set; } + public string MediaSourceId { get; set; } + public string AudioCodec { get; set; } + + public bool EnableAutoStreamCopy { get; set; } + + public int? MaxAudioChannels { get; set; } + public int? AudioChannels { get; set; } + public int? AudioBitRate { get; set; } + public int? AudioSampleRate { get; set; } + + public DeviceProfile DeviceProfile { get; set; } + public EncodingContext Context { get; set; } + + public string VideoCodec { get; set; } + + public int? VideoBitRate { get; set; } + public int? AudioStreamIndex { get; set; } + public int? VideoStreamIndex { get; set; } + public int? SubtitleStreamIndex { get; set; } + public int? MaxRefFrames { get; set; } + public int? MaxVideoBitDepth { get; set; } + public SubtitleDeliveryMethod SubtitleMethod { get; set; } + + /// + /// Gets a value indicating whether this instance has fixed resolution. + /// + /// true if this instance has fixed resolution; otherwise, false. + public bool HasFixedResolution + { + get + { + return Width.HasValue || Height.HasValue; + } + } + + public bool? Cabac { get; set; } + + public EncodingJobOptions() + { + + } + + public EncodingJobOptions(StreamInfo info, DeviceProfile deviceProfile) + { + OutputContainer = info.Container; + StartTimeTicks = info.StartPositionTicks; + MaxWidth = info.MaxWidth; + MaxHeight = info.MaxHeight; + MaxFramerate = info.MaxFramerate; + Profile = info.VideoProfile; + Level = info.VideoLevel; + ItemId = info.ItemId; + MediaSourceId = info.MediaSourceId; + AudioCodec = info.AudioCodec; + MaxAudioChannels = info.MaxAudioChannels; + AudioBitRate = info.AudioBitrate; + AudioSampleRate = info.TargetAudioSampleRate; + DeviceProfile = deviceProfile; + VideoCodec = info.VideoCodec; + VideoBitRate = info.VideoBitrate; + AudioStreamIndex = info.AudioStreamIndex; + SubtitleStreamIndex = info.SubtitleStreamIndex; + MaxRefFrames = info.MaxRefFrames; + MaxVideoBitDepth = info.MaxVideoBitDepth; + SubtitleMethod = info.SubtitleDeliveryMethod; + Cabac = info.Cabac; + Context = info.Context; + } + } +} diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 8f56bfda58..47544f972b 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -107,5 +107,16 @@ namespace MediaBrowser.Controller.MediaEncoding Task EncodeAudio(EncodingJobOptions options, IProgress progress, CancellationToken cancellationToken); + + /// + /// Encodes the video. + /// + /// The options. + /// The progress. + /// The cancellation token. + /// Task<System.String>. + Task EncodeVideo(EncodingJobOptions options, + IProgress progress, + CancellationToken cancellationToken); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/AudioEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/AudioEncoder.cs new file mode 100644 index 0000000000..7054accfad --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/AudioEncoder.cs @@ -0,0 +1,86 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public class AudioEncoder : BaseEncoder + { + public AudioEncoder(MediaEncoder mediaEncoder, ILogger logger, IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILiveTvManager liveTvManager, IIsoManager isoManager, ILibraryManager libraryManager, IChannelManager channelManager, ISessionManager sessionManager, ISubtitleEncoder subtitleEncoder) : base(mediaEncoder, logger, configurationManager, fileSystem, liveTvManager, isoManager, libraryManager, channelManager, sessionManager, subtitleEncoder) + { + } + + protected override string GetCommandLineArguments(EncodingJob job) + { + var audioTranscodeParams = new List(); + + var bitrate = job.OutputAudioBitrate; + + if (bitrate.HasValue) + { + audioTranscodeParams.Add("-ab " + bitrate.Value.ToString(UsCulture)); + } + + if (job.OutputAudioChannels.HasValue) + { + audioTranscodeParams.Add("-ac " + job.OutputAudioChannels.Value.ToString(UsCulture)); + } + + if (job.OutputAudioSampleRate.HasValue) + { + audioTranscodeParams.Add("-ar " + job.OutputAudioSampleRate.Value.ToString(UsCulture)); + } + + var threads = GetNumberOfThreads(job, false); + + var inputModifier = GetInputModifier(job); + + return string.Format("{0} {1} -threads {2}{3} {4} -id3v2_version 3 -write_id3v1 1 -y \"{5}\"", + inputModifier, + GetInputArgument(job), + threads, + " -vn", + string.Join(" ", audioTranscodeParams.ToArray()), + job.OutputFilePath).Trim(); + } + + protected override string GetOutputFileExtension(EncodingJob state) + { + var ext = base.GetOutputFileExtension(state); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; + } + + var audioCodec = state.Options.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; + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/BaseEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/BaseEncoder.cs new file mode 100644 index 0000000000..a350d05774 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/BaseEncoder.cs @@ -0,0 +1,1049 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Session; +using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public abstract class BaseEncoder + { + protected readonly MediaEncoder MediaEncoder; + protected readonly ILogger Logger; + protected readonly IServerConfigurationManager ConfigurationManager; + protected readonly IFileSystem FileSystem; + protected readonly ILiveTvManager LiveTvManager; + protected readonly IIsoManager IsoManager; + protected readonly ILibraryManager LibraryManager; + protected readonly IChannelManager ChannelManager; + protected readonly ISessionManager SessionManager; + protected readonly ISubtitleEncoder SubtitleEncoder; + + protected readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + public BaseEncoder(MediaEncoder mediaEncoder, + ILogger logger, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem, + ILiveTvManager liveTvManager, + IIsoManager isoManager, + ILibraryManager libraryManager, + IChannelManager channelManager, + ISessionManager sessionManager, ISubtitleEncoder subtitleEncoder) + { + MediaEncoder = mediaEncoder; + Logger = logger; + ConfigurationManager = configurationManager; + FileSystem = fileSystem; + LiveTvManager = liveTvManager; + IsoManager = isoManager; + LibraryManager = libraryManager; + ChannelManager = channelManager; + SessionManager = sessionManager; + SubtitleEncoder = subtitleEncoder; + } + + public async Task Start(EncodingJobOptions options, + IProgress progress, + CancellationToken cancellationToken) + { + var encodingJob = await new EncodingJobFactory(Logger, LiveTvManager, LibraryManager, ChannelManager) + .CreateJob(options, IsVideoEncoder, progress, cancellationToken).ConfigureAwait(false); + + encodingJob.OutputFilePath = GetOutputFilePath(encodingJob); + Directory.CreateDirectory(Path.GetDirectoryName(encodingJob.OutputFilePath)); + + if (options.Context == EncodingContext.Static && encodingJob.IsInputVideo) + { + encodingJob.ReadInputAtNativeFramerate = true; + } + + await AcquireResources(encodingJob, cancellationToken).ConfigureAwait(false); + + var commandLineArgs = GetCommandLineArguments(encodingJob); + + if (GetEncodingOptions().EnableDebugLogging) + { + commandLineArgs = "-loglevel debug " + commandLineArgs; + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + 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, + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + + EnableRaisingEvents = true + }; + + var workingDirectory = GetWorkingDirectory(options); + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + process.StartInfo.WorkingDirectory = workingDirectory; + } + + OnTranscodeBeginning(encodingJob); + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + Logger.Info(commandLineLogMessage); + + var logFilePath = Path.Combine(ConfigurationManager.CommonApplicationPaths.LogDirectoryPath, "transcode-" + Guid.NewGuid() + ".txt"); + Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + encodingJob.LogFileStream = FileSystem.GetFileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await encodingJob.LogFileStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationToken).ConfigureAwait(false); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process, encodingJob); + + try + { + process.Start(); + } + catch (Exception ex) + { + Logger.ErrorException("Error starting ffmpeg", ex); + + OnTranscodeFailedToStart(encodingJob.OutputFilePath, encodingJob); + + throw; + } + + // MUST read both stdout and stderr asynchronously or a deadlock may occurr + process.BeginOutputReadLine(); + + // 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(encodingJob, process.StandardError.BaseStream, encodingJob.LogFileStream); + + // Wait for the file to exist before proceeeding + while (!File.Exists(encodingJob.OutputFilePath) && !encodingJob.HasExited) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return encodingJob; + } + + /// + /// Processes the exited. + /// + /// The process. + /// The job. + private void OnFfMpegProcessExited(Process process, EncodingJob job) + { + job.HasExited = true; + + Logger.Debug("Disposing stream resources"); + job.Dispose(); + + try + { + Logger.Info("FFMpeg exited with code {0}", process.ExitCode); + + try + { + job.TaskCompletionSource.TrySetResult(true); + } + catch + { + } + } + catch + { + Logger.Error("FFMpeg exited with an error."); + + try + { + job.TaskCompletionSource.TrySetException(new ApplicationException()); + } + catch + { + } + } + + // This causes on exited to be called twice: + //try + //{ + // // Dispose the process + // process.Dispose(); + //} + //catch (Exception ex) + //{ + // Logger.ErrorException("Error disposing ffmpeg.", ex); + //} + } + + private void OnTranscodeBeginning(EncodingJob job) + { + job.ReportTranscodingProgress(null, null, null, null); + } + + private void OnTranscodeFailedToStart(string path, EncodingJob job) + { + if (!string.IsNullOrWhiteSpace(job.Options.DeviceId)) + { + SessionManager.ClearTranscodingInfo(job.Options.DeviceId); + } + } + + protected virtual bool IsVideoEncoder + { + get { return false; } + } + + protected virtual string GetWorkingDirectory(EncodingJobOptions options) + { + return null; + } + + protected EncodingOptions GetEncodingOptions() + { + return ConfigurationManager.GetConfiguration("encoding"); + } + + protected abstract string GetCommandLineArguments(EncodingJob job); + + private string GetOutputFilePath(EncodingJob state) + { + var folder = ConfigurationManager.ApplicationPaths.TranscodingTempPath; + + var outputFileExtension = GetOutputFileExtension(state); + var context = state.Options.Context; + + var filename = state.Id + (outputFileExtension ?? string.Empty).ToLower(); + return Path.Combine(folder, context.ToString().ToLower(), filename); + } + + protected virtual string GetOutputFileExtension(EncodingJob state) + { + if (!string.IsNullOrWhiteSpace(state.Options.OutputContainer)) + { + return "." + state.Options.OutputContainer; + } + + return null; + } + + /// + /// Gets the number of threads. + /// + /// System.Int32. + protected int GetNumberOfThreads(EncodingJob job, bool isWebm) + { + if (isWebm) + { + // Recommended per docs + return Math.Max(Environment.ProcessorCount - 1, 2); + } + + // Use more when this is true. -re will keep cpu usage under control + if (job.ReadInputAtNativeFramerate) + { + if (isWebm) + { + return Math.Max(Environment.ProcessorCount - 1, 2); + } + + return 0; + } + + // Webm: http://www.webmproject.org/docs/encoder-parameters/ + // The decoder will usually automatically use an appropriate number of threads according to how many cores are available but it can only use multiple threads + // for the coefficient data if the encoder selected --token-parts > 0 at encode time. + + switch (GetQualitySetting()) + { + case EncodingQuality.HighSpeed: + return 2; + case EncodingQuality.HighQuality: + return 2; + case EncodingQuality.MaxQuality: + return isWebm ? Math.Max(Environment.ProcessorCount - 1, 2) : 0; + default: + throw new Exception("Unrecognized MediaEncodingQuality value."); + } + } + + protected EncodingQuality GetQualitySetting() + { + var quality = GetEncodingOptions().EncodingQuality; + + if (quality == EncodingQuality.Auto) + { + var cpuCount = Environment.ProcessorCount; + + if (cpuCount >= 4) + { + //return EncodingQuality.HighQuality; + } + + return EncodingQuality.HighSpeed; + } + + return quality; + } + + protected string GetInputModifier(EncodingJob job, bool genPts = true) + { + var inputModifier = string.Empty; + + var probeSize = GetProbeSizeArgument(job); + inputModifier += " " + probeSize; + inputModifier = inputModifier.Trim(); + + var userAgentParam = GetUserAgentParam(job); + + if (!string.IsNullOrWhiteSpace(userAgentParam)) + { + inputModifier += " " + userAgentParam; + } + + inputModifier = inputModifier.Trim(); + + inputModifier += " " + GetFastSeekCommandLineParameter(job.Options); + inputModifier = inputModifier.Trim(); + + if (job.IsVideoRequest && genPts) + { + inputModifier += " -fflags +genpts"; + } + + if (!string.IsNullOrEmpty(job.InputAudioSync)) + { + inputModifier += " -async " + job.InputAudioSync; + } + + if (!string.IsNullOrEmpty(job.InputVideoSync)) + { + inputModifier += " -vsync " + job.InputVideoSync; + } + + if (job.ReadInputAtNativeFramerate) + { + inputModifier += " -re"; + } + + return inputModifier; + } + + private string GetUserAgentParam(EncodingJob job) + { + string useragent = null; + + job.RemoteHttpHeaders.TryGetValue("User-Agent", out useragent); + + if (!string.IsNullOrWhiteSpace(useragent)) + { + return "-user-agent \"" + useragent + "\""; + } + + return string.Empty; + } + + /// + /// Gets the probe size argument. + /// + /// The job. + /// System.String. + private string GetProbeSizeArgument(EncodingJob job) + { + if (job.PlayableStreamFileNames.Count > 0) + { + return MediaEncoder.GetProbeSizeArgument(job.PlayableStreamFileNames.ToArray(), job.InputProtocol); + } + + return MediaEncoder.GetProbeSizeArgument(new[] { job.MediaPath }, job.InputProtocol); + } + + /// + /// Gets the fast seek command line parameter. + /// + /// The options. + /// System.String. + /// The fast seek command line parameter. + protected string GetFastSeekCommandLineParameter(EncodingJobOptions options) + { + var time = options.StartTimeTicks; + + if (time.HasValue && time.Value > 0) + { + return string.Format("-ss {0}", MediaEncoder.GetTimeParameter(time.Value)); + } + + return string.Empty; + } + + /// + /// Gets the input argument. + /// + /// The job. + /// System.String. + protected string GetInputArgument(EncodingJob job) + { + var arg = "-i " + GetInputPathArgument(job); + + if (job.SubtitleStream != null) + { + if (job.SubtitleStream.IsExternal && !job.SubtitleStream.IsTextSubtitleStream) + { + arg += " -i " + job.SubtitleStream.Path; + } + } + + return arg; + } + + private string GetInputPathArgument(EncodingJob job) + { + //if (job.InputProtocol == MediaProtocol.File && + // job.RunTimeTicks.HasValue && + // job.VideoType == VideoType.VideoFile && + // !string.Equals(job.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + //{ + // if (job.RunTimeTicks.Value >= TimeSpan.FromMinutes(5).Ticks && job.IsInputVideo) + // { + // if (SupportsThrottleWithStream) + // { + // var url = "http://localhost:" + ServerConfigurationManager.Configuration.HttpServerPortNumber.ToString(UsCulture) + "/mediabrowser/videos/" + job.Request.Id + "/stream?static=true&Throttle=true&mediaSourceId=" + job.Request.MediaSourceId; + + // url += "&transcodingJobId=" + transcodingJobId; + + // return string.Format("\"{0}\"", url); + // } + // } + //} + + var protocol = job.InputProtocol; + + var inputPath = new[] { job.MediaPath }; + + if (job.IsInputVideo) + { + if (!(job.VideoType == VideoType.Iso && job.IsoMount == null)) + { + inputPath = MediaEncoderHelpers.GetInputArgument(job.MediaPath, job.InputProtocol, job.IsoMount, job.PlayableStreamFileNames); + } + } + + return MediaEncoder.GetInputArgument(inputPath, protocol); + } + + private async Task AcquireResources(EncodingJob state, CancellationToken cancellationToken) + { + if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && IsoManager.CanMount(state.MediaPath)) + { + state.IsoMount = await IsoManager.Mount(state.MediaPath, cancellationToken).ConfigureAwait(false); + } + + if (string.IsNullOrEmpty(state.MediaPath)) + { + var checkCodecs = false; + + if (string.Equals(state.ItemType, typeof(LiveTvChannel).Name)) + { + var streamInfo = await LiveTvManager.GetChannelStream(state.Options.ItemId, cancellationToken).ConfigureAwait(false); + + state.LiveTvStreamId = streamInfo.Id; + + state.MediaPath = streamInfo.Path; + state.InputProtocol = streamInfo.Protocol; + + await Task.Delay(1500, cancellationToken).ConfigureAwait(false); + + AttachMediaStreamInfo(state, streamInfo, state.Options); + checkCodecs = true; + } + + else if (string.Equals(state.ItemType, typeof(LiveTvVideoRecording).Name) || + string.Equals(state.ItemType, typeof(LiveTvAudioRecording).Name)) + { + var streamInfo = await LiveTvManager.GetRecordingStream(state.Options.ItemId, cancellationToken).ConfigureAwait(false); + + state.LiveTvStreamId = streamInfo.Id; + + state.MediaPath = streamInfo.Path; + state.InputProtocol = streamInfo.Protocol; + + await Task.Delay(1500, cancellationToken).ConfigureAwait(false); + + AttachMediaStreamInfo(state, streamInfo, state.Options); + checkCodecs = true; + } + + if (state.IsVideoRequest && checkCodecs) + { + if (state.VideoStream != null && EncodingJobFactory.CanStreamCopyVideo(state.Options, state.VideoStream)) + { + state.OutputVideoCodec = "copy"; + } + + if (state.AudioStream != null && EncodingJobFactory.CanStreamCopyAudio(state.Options, state.AudioStream, state.SupportedAudioCodecs)) + { + state.OutputAudioCodec = "copy"; + } + } + } + } + + private void AttachMediaStreamInfo(EncodingJob state, + ChannelMediaInfo mediaInfo, + EncodingJobOptions videoRequest) + { + var mediaSource = mediaInfo.ToMediaSource(); + + state.InputProtocol = mediaSource.Protocol; + state.MediaPath = mediaSource.Path; + state.RunTimeTicks = mediaSource.RunTimeTicks; + state.RemoteHttpHeaders = mediaSource.RequiredHttpHeaders; + state.InputBitrate = mediaSource.Bitrate; + state.InputFileSize = mediaSource.Size; + state.ReadInputAtNativeFramerate = mediaSource.ReadAtNativeFramerate; + + if (state.ReadInputAtNativeFramerate) + { + state.OutputAudioSync = "1000"; + state.InputVideoSync = "-1"; + state.InputAudioSync = "1"; + } + + EncodingJobFactory.AttachMediaStreamInfo(state, mediaSource.MediaStreams, videoRequest); + } + + /// + /// Gets the internal graphical subtitle param. + /// + /// The state. + /// The output video codec. + /// System.String. + protected string GetGraphicalSubtitleParam(EncodingJob state, string outputVideoCodec) + { + var outputSizeParam = string.Empty; + + var request = state.Options; + + // Add resolution params, if specified + if (request.Width.HasValue || request.Height.HasValue || request.MaxHeight.HasValue || request.MaxWidth.HasValue) + { + outputSizeParam = GetOutputSizeParam(state, outputVideoCodec).TrimEnd('"'); + outputSizeParam = "," + outputSizeParam.Substring(outputSizeParam.IndexOf("scale", StringComparison.OrdinalIgnoreCase)); + } + + var videoSizeParam = string.Empty; + + if (state.VideoStream != null && state.VideoStream.Width.HasValue && state.VideoStream.Height.HasValue) + { + videoSizeParam = string.Format(",scale={0}:{1}", state.VideoStream.Width.Value.ToString(UsCulture), state.VideoStream.Height.Value.ToString(UsCulture)); + } + + var mapPrefix = state.SubtitleStream.IsExternal ? + 1 : + 0; + + var subtitleStreamIndex = state.SubtitleStream.IsExternal + ? 0 + : state.SubtitleStream.Index; + + return string.Format(" -filter_complex \"[{0}:{1}]format=yuva444p{4},lut=u=128:v=128:y=gammaval(.3)[sub] ; [0:{2}] [sub] overlay{3}\"", + mapPrefix.ToString(UsCulture), + subtitleStreamIndex.ToString(UsCulture), + state.VideoStream.Index.ToString(UsCulture), + outputSizeParam, + videoSizeParam); + } + + /// + /// Gets the video bitrate to specify on the command line + /// + /// The state. + /// The video codec. + /// if set to true [is HLS]. + /// System.String. + protected string GetVideoQualityParam(EncodingJob state, string videoCodec, bool isHls) + { + var param = string.Empty; + + var isVc1 = state.VideoStream != null && + string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase); + + var qualitySetting = GetQualitySetting(); + + if (string.Equals(videoCodec, "libx264", StringComparison.OrdinalIgnoreCase)) + { + switch (qualitySetting) + { + case EncodingQuality.HighSpeed: + param = "-preset superfast"; + break; + case EncodingQuality.HighQuality: + param = "-preset superfast"; + break; + case EncodingQuality.MaxQuality: + param = "-preset superfast"; + break; + } + + switch (qualitySetting) + { + case EncodingQuality.HighSpeed: + param += " -crf 23"; + break; + case EncodingQuality.HighQuality: + param += " -crf 20"; + break; + case EncodingQuality.MaxQuality: + param += " -crf 18"; + break; + } + } + + // webm + else if (string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) + { + // Values 0-3, 0 being highest quality but slower + var profileScore = 0; + + string crf; + var qmin = "0"; + var qmax = "50"; + + switch (qualitySetting) + { + case EncodingQuality.HighSpeed: + crf = "10"; + break; + case EncodingQuality.HighQuality: + crf = "6"; + break; + case EncodingQuality.MaxQuality: + crf = "4"; + break; + default: + throw new ArgumentException("Unrecognized quality setting"); + } + + if (isVc1) + { + profileScore++; + } + + // Max of 2 + profileScore = Math.Min(profileScore, 2); + + // http://www.webmproject.org/docs/encoder-parameters/ + param = string.Format("-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}", + profileScore.ToString(UsCulture), + crf, + qmin, + qmax); + } + + else if (string.Equals(videoCodec, "mpeg4", StringComparison.OrdinalIgnoreCase)) + { + param = "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2"; + } + + // asf/wmv + else if (string.Equals(videoCodec, "wmv2", StringComparison.OrdinalIgnoreCase)) + { + param = "-qmin 2"; + } + + else if (string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase)) + { + param = "-mbd 2"; + } + + param += GetVideoBitrateParam(state, videoCodec, isHls); + + var framerate = GetFramerateParam(state); + if (framerate.HasValue) + { + param += string.Format(" -r {0}", framerate.Value.ToString(UsCulture)); + } + + if (!string.IsNullOrEmpty(state.OutputVideoSync)) + { + param += " -vsync " + state.OutputVideoSync; + } + + if (!string.IsNullOrEmpty(state.Options.Profile)) + { + param += " -profile:v " + state.Options.Profile; + } + + if (state.Options.Level.HasValue) + { + param += " -level " + state.Options.Level.Value.ToString(UsCulture); + } + + return param; + } + + protected string GetVideoBitrateParam(EncodingJob state, string videoCodec, bool isHls) + { + var bitrate = state.OutputVideoBitrate; + + if (bitrate.HasValue) + { + var hasFixedResolution = state.Options.HasFixedResolution; + + if (string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) + { + if (hasFixedResolution) + { + return string.Format(" -minrate:v ({0}*.90) -maxrate:v ({0}*1.10) -bufsize:v {0} -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + // With vpx when crf is used, b:v becomes a max rate + // https://trac.ffmpeg.org/wiki/vpxEncodingGuide. But higher bitrate source files -b:v causes judder so limite the bitrate but dont allow it to "saturate" the bitrate. So dont contrain it down just up. + return string.Format(" -maxrate:v {0} -bufsize:v ({0}*2) -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + if (string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase)) + { + return string.Format(" -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + // H264 + if (hasFixedResolution) + { + if (isHls) + { + return string.Format(" -b:v {0} -maxrate ({0}*.80) -bufsize {0}", bitrate.Value.ToString(UsCulture)); + } + + return string.Format(" -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + return string.Format(" -maxrate {0} -bufsize {1}", + bitrate.Value.ToString(UsCulture), + (bitrate.Value * 2).ToString(UsCulture)); + } + + return string.Empty; + } + + protected double? GetFramerateParam(EncodingJob state) + { + if (state.Options.Framerate.HasValue) + { + return state.Options.Framerate.Value; + } + + var maxrate = state.Options.MaxFramerate; + + if (maxrate.HasValue && state.VideoStream != null) + { + var contentRate = state.VideoStream.AverageFrameRate ?? state.VideoStream.RealFrameRate; + + if (contentRate.HasValue && contentRate.Value > maxrate.Value) + { + return maxrate; + } + } + + return null; + } + + /// + /// Gets the map args. + /// + /// The state. + /// System.String. + protected virtual string GetMapArgs(EncodingJob state) + { + // If we don't have known media info + // If input is video, use -sn to drop subtitles + // Otherwise just return empty + if (state.VideoStream == null && state.AudioStream == null) + { + return state.IsInputVideo ? "-sn" : string.Empty; + } + + // We have media info, but we don't know the stream indexes + if (state.VideoStream != null && state.VideoStream.Index == -1) + { + return "-sn"; + } + + // We have media info, but we don't know the stream indexes + if (state.AudioStream != null && state.AudioStream.Index == -1) + { + return state.IsInputVideo ? "-sn" : string.Empty; + } + + var args = string.Empty; + + if (state.VideoStream != null) + { + args += string.Format("-map 0:{0}", state.VideoStream.Index); + } + else + { + args += "-map -0:v"; + } + + if (state.AudioStream != null) + { + args += string.Format(" -map 0:{0}", state.AudioStream.Index); + } + + else + { + args += " -map -0:a"; + } + + if (state.SubtitleStream == null) + { + args += " -map -0:s"; + } + else if (state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream) + { + args += " -map 1:0 -sn"; + } + + return args; + } + + /// + /// Determines whether the specified stream is H264. + /// + /// The stream. + /// true if the specified stream is H264; otherwise, false. + protected bool IsH264(MediaStream stream) + { + var codec = stream.Codec ?? string.Empty; + + return codec.IndexOf("264", StringComparison.OrdinalIgnoreCase) != -1 || + codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; + } + + /// + /// If we're going to put a fixed size on the command line, this will calculate it + /// + /// The state. + /// The output video codec. + /// if set to true [allow time stamp copy]. + /// System.String. + protected string GetOutputSizeParam(EncodingJob state, + string outputVideoCodec, + bool allowTimeStampCopy = true) + { + // http://sonnati.wordpress.com/2012/10/19/ffmpeg-the-swiss-army-knife-of-internet-streaming-part-vi/ + + var request = state.Options; + + var filters = new List(); + + if (state.DeInterlace) + { + filters.Add("yadif=0:-1:0"); + } + + // If fixed dimensions were supplied + if (request.Width.HasValue && request.Height.HasValue) + { + var widthParam = request.Width.Value.ToString(UsCulture); + var heightParam = request.Height.Value.ToString(UsCulture); + + filters.Add(string.Format("scale=trunc({0}/2)*2:trunc({1}/2)*2", widthParam, heightParam)); + } + + // If Max dimensions were supplied, for width selects lowest even number between input width and width req size and selects lowest even number from in width*display aspect and requested size + else if (request.MaxWidth.HasValue && request.MaxHeight.HasValue) + { + var maxWidthParam = request.MaxWidth.Value.ToString(UsCulture); + var maxHeightParam = request.MaxHeight.Value.ToString(UsCulture); + + filters.Add(string.Format("scale=trunc(min(iw\\,{0})/2)*2:trunc(min((iw/dar)\\,{1})/2)*2", maxWidthParam, maxHeightParam)); + } + + // If a fixed width was requested + else if (request.Width.HasValue) + { + var widthParam = request.Width.Value.ToString(UsCulture); + + filters.Add(string.Format("scale={0}:trunc(ow/a/2)*2", widthParam)); + } + + // If a fixed height was requested + else if (request.Height.HasValue) + { + var heightParam = request.Height.Value.ToString(UsCulture); + + filters.Add(string.Format("scale=trunc(oh*a*2)/2:{0}", heightParam)); + } + + // If a max width was requested + else if (request.MaxWidth.HasValue && (!request.MaxHeight.HasValue || state.VideoStream == null)) + { + var maxWidthParam = request.MaxWidth.Value.ToString(UsCulture); + + filters.Add(string.Format("scale=min(iw\\,{0}):trunc(ow/dar/2)*2", maxWidthParam)); + } + + // If a max height was requested + else if (request.MaxHeight.HasValue && (!request.MaxWidth.HasValue || state.VideoStream == null)) + { + var maxHeightParam = request.MaxHeight.Value.ToString(UsCulture); + + filters.Add(string.Format("scale=trunc(oh*a*2)/2:min(ih\\,{0})", maxHeightParam)); + } + + else if (request.MaxWidth.HasValue || + request.MaxHeight.HasValue || + request.Width.HasValue || + request.Height.HasValue) + { + if (state.VideoStream != null) + { + // Need to perform calculations manually + + // Try to account for bad media info + var currentHeight = state.VideoStream.Height ?? request.MaxHeight ?? request.Height ?? 0; + var currentWidth = state.VideoStream.Width ?? request.MaxWidth ?? request.Width ?? 0; + + var outputSize = DrawingUtils.Resize(currentWidth, currentHeight, request.Width, request.Height, request.MaxWidth, request.MaxHeight); + + var manualWidthParam = outputSize.Width.ToString(UsCulture); + var manualHeightParam = outputSize.Height.ToString(UsCulture); + + filters.Add(string.Format("scale=trunc({0}/2)*2:trunc({1}/2)*2", manualWidthParam, manualHeightParam)); + } + } + + var output = string.Empty; + + if (state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream) + { + var subParam = GetTextSubtitleParam(state); + + filters.Add(subParam); + + if (allowTimeStampCopy) + { + output += " -copyts"; + } + } + + if (filters.Count > 0) + { + output += string.Format(" -vf \"{0}\"", string.Join(",", filters.ToArray())); + } + + return output; + } + + /// + /// Gets the text subtitle param. + /// + /// The state. + /// System.String. + protected string GetTextSubtitleParam(EncodingJob state) + { + var seconds = Math.Round(TimeSpan.FromTicks(state.Options.StartTimeTicks ?? 0).TotalSeconds); + + if (state.SubtitleStream.IsExternal) + { + var subtitlePath = state.SubtitleStream.Path; + + var charsetParam = string.Empty; + + if (!string.IsNullOrEmpty(state.SubtitleStream.Language)) + { + var charenc = SubtitleEncoder.GetSubtitleFileCharacterSet(subtitlePath, state.SubtitleStream.Language); + + if (!string.IsNullOrEmpty(charenc)) + { + charsetParam = ":charenc=" + charenc; + } + } + + // TODO: Perhaps also use original_size=1920x800 ?? + return string.Format("subtitles=filename='{0}'{1},setpts=PTS -{2}/TB", + subtitlePath.Replace('\\', '/').Replace(":/", "\\:/"), + charsetParam, + seconds.ToString(UsCulture)); + } + + return string.Format("subtitles='{0}:si={1}',setpts=PTS -{2}/TB", + state.MediaPath.Replace('\\', '/').Replace(":/", "\\:/"), + state.InternalSubtitleStreamOffset.ToString(UsCulture), + seconds.ToString(UsCulture)); + } + + protected string GetAudioFilterParam(EncodingJob state, bool isHls) + { + var volParam = string.Empty; + var audioSampleRate = string.Empty; + + var channels = state.OutputAudioChannels; + + // Boost volume to 200% when downsampling from 6ch to 2ch + if (channels.HasValue && channels.Value <= 2) + { + if (state.AudioStream != null && state.AudioStream.Channels.HasValue && state.AudioStream.Channels.Value > 5) + { + volParam = ",volume=" + GetEncodingOptions().DownMixAudioBoost.ToString(UsCulture); + } + } + + if (state.OutputAudioSampleRate.HasValue) + { + audioSampleRate = state.OutputAudioSampleRate.Value + ":"; + } + + var adelay = isHls ? "adelay=1," : string.Empty; + + var pts = string.Empty; + + if (state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream) + { + var seconds = TimeSpan.FromTicks(state.Options.StartTimeTicks ?? 0).TotalSeconds; + + pts = string.Format(",asetpts=PTS-{0}/TB", Math.Round(seconds).ToString(UsCulture)); + } + + return string.Format("-af \"{0}aresample={1}async={4}{2}{3}\"", + + adelay, + audioSampleRate, + volParam, + pts, + state.OutputAudioSync); + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingJob.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingJob.cs new file mode 100644 index 0000000000..40ca08c405 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingJob.cs @@ -0,0 +1,434 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Drawing; +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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public class EncodingJob : IDisposable + { + public bool HasExited { get; internal set; } + + public Stream LogFileStream { get; set; } + public IProgress Progress { get; set; } + public TaskCompletionSource TaskCompletionSource; + + public EncodingJobOptions Options { get; set; } + public string InputContainer { get; set; } + public List AllMediaStreams { get; set; } + public MediaStream AudioStream { get; set; } + public MediaStream VideoStream { get; set; } + public MediaStream SubtitleStream { get; set; } + public IIsoMount IsoMount { get; set; } + + public bool ReadInputAtNativeFramerate { get; set; } + public bool IsVideoRequest { get; set; } + public string InputAudioSync { get; set; } + public string InputVideoSync { get; set; } + public string Id { get; set; } + + public string MediaPath { get; set; } + public MediaProtocol InputProtocol { get; set; } + public bool IsInputVideo { get; set; } + public VideoType VideoType { get; set; } + public IsoType? IsoType { get; set; } + public List PlayableStreamFileNames { get; set; } + + public List SupportedAudioCodecs { get; set; } + public Dictionary RemoteHttpHeaders { get; set; } + public TransportStreamTimestamp InputTimestamp { get; set; } + + public bool DeInterlace { get; set; } + public string MimeType { get; set; } + public bool EstimateContentLength { get; set; } + public bool EnableMpegtsM2TsMode { get; set; } + public TranscodeSeekInfo TranscodeSeekInfo { get; set; } + public long? EncodingDurationTicks { get; set; } + public string LiveTvStreamId { get; set; } + public long? RunTimeTicks; + + public string ItemType { get; set; } + + public long? InputBitrate { get; set; } + public long? InputFileSize { get; set; } + public string OutputAudioSync = "1"; + public string OutputVideoSync = "vfr"; + + public string GetMimeType(string outputPath) + { + if (!string.IsNullOrEmpty(MimeType)) + { + return MimeType; + } + + return MimeTypes.GetMimeType(outputPath); + } + + private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + + public EncodingJob(ILogger logger, ILiveTvManager liveTvManager) + { + _logger = logger; + _liveTvManager = liveTvManager; + Id = Guid.NewGuid().ToString("N"); + + RemoteHttpHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + _logger = logger; + SupportedAudioCodecs = new List(); + PlayableStreamFileNames = new List(); + RemoteHttpHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + AllMediaStreams = new List(); + TaskCompletionSource = new TaskCompletionSource(); + } + + public void Dispose() + { + DisposeLiveStream(); + DisposeLogStream(); + DisposeIsoMount(); + } + + private void DisposeLogStream() + { + if (LogFileStream != null) + { + try + { + LogFileStream.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing log stream", ex); + } + + LogFileStream = null; + } + } + + private void DisposeIsoMount() + { + if (IsoMount != null) + { + try + { + IsoMount.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing iso mount", ex); + } + + IsoMount = null; + } + } + + private async void DisposeLiveStream() + { + if (!string.IsNullOrEmpty(LiveTvStreamId)) + { + try + { + await _liveTvManager.CloseLiveStream(LiveTvStreamId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error closing live tv stream", ex); + } + } + } + + public int InternalSubtitleStreamOffset { get; set; } + + public string OutputFilePath { get; set; } + public string OutputVideoCodec { get; set; } + public string OutputAudioCodec { get; set; } + public int? OutputAudioChannels; + public int? OutputAudioSampleRate; + public int? OutputAudioBitrate; + public int? OutputVideoBitrate; + + 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 int? TotalOutputBitrate + { + get + { + return (OutputAudioBitrate ?? 0) + (OutputVideoBitrate ?? 0); + } + } + + public int? OutputWidth + { + get + { + if (VideoStream != null && VideoStream.Width.HasValue && VideoStream.Height.HasValue) + { + var size = new ImageSize + { + Width = VideoStream.Width.Value, + Height = VideoStream.Height.Value + }; + + var newSize = DrawingUtils.Resize(size, + Options.Width, + Options.Height, + Options.MaxWidth, + Options.MaxHeight); + + return Convert.ToInt32(newSize.Width); + } + + if (!IsVideoRequest) + { + return null; + } + + return Options.MaxWidth ?? Options.Width; + } + } + + public int? OutputHeight + { + get + { + if (VideoStream != null && VideoStream.Width.HasValue && VideoStream.Height.HasValue) + { + var size = new ImageSize + { + Width = VideoStream.Width.Value, + Height = VideoStream.Height.Value + }; + + var newSize = DrawingUtils.Resize(size, + Options.Width, + Options.Height, + Options.MaxWidth, + Options.MaxHeight); + + return Convert.ToInt32(newSize.Height); + } + + if (!IsVideoRequest) + { + return null; + } + + return Options.MaxHeight ?? Options.Height; + } + } + + /// + /// Predicts the audio sample rate that will be in the output stream + /// + public int? TargetVideoBitDepth + { + get + { + var stream = VideoStream; + return stream == null || !Options.Static ? null : stream.BitDepth; + } + } + + /// + /// Gets the target reference frames. + /// + /// The target reference frames. + public int? TargetRefFrames + { + get + { + var stream = VideoStream; + return stream == null || !Options.Static ? null : stream.RefFrames; + } + } + + /// + /// Predicts the audio sample rate that will be in the output stream + /// + public float? TargetFramerate + { + get + { + var stream = VideoStream; + var requestedFramerate = Options.MaxFramerate ?? Options.Framerate; + + return requestedFramerate.HasValue && !Options.Static + ? requestedFramerate + : stream == null ? null : stream.AverageFrameRate ?? stream.RealFrameRate; + } + } + + /// + /// Predicts the audio sample rate that will be in the output stream + /// + public double? TargetVideoLevel + { + get + { + var stream = VideoStream; + return Options.Level.HasValue && !Options.Static + ? Options.Level.Value + : stream == null ? null : stream.Level; + } + } + + public TransportStreamTimestamp TargetTimestamp + { + get + { + var defaultValue = string.Equals(Options.OutputContainer, "m2ts", StringComparison.OrdinalIgnoreCase) ? + TransportStreamTimestamp.Valid : + TransportStreamTimestamp.None; + + return !Options.Static + ? defaultValue + : InputTimestamp; + } + } + + /// + /// Predicts the audio sample rate that will be in the output stream + /// + public int? TargetPacketLength + { + get + { + var stream = VideoStream; + return !Options.Static + ? null + : stream == null ? null : stream.PacketLength; + } + } + + /// + /// Predicts the audio sample rate that will be in the output stream + /// + public string TargetVideoProfile + { + get + { + var stream = VideoStream; + return !string.IsNullOrEmpty(Options.Profile) && !Options.Static + ? Options.Profile + : stream == null ? null : stream.Profile; + } + } + + public bool? IsTargetAnamorphic + { + get + { + if (Options.Static) + { + return VideoStream == null ? null : VideoStream.IsAnamorphic; + } + + return false; + } + } + + public bool? IsTargetCabac + { + get + { + if (Options.Static) + { + return VideoStream == null ? null : VideoStream.IsCabac; + } + + return true; + } + } + + public void ReportTranscodingProgress(TimeSpan? transcodingPosition, float? framerate, double? percentComplete, long? bytesTranscoded) + { + var ticks = transcodingPosition.HasValue ? transcodingPosition.Value.Ticks : (long?)null; + + // job.Framerate = framerate; + + if (percentComplete.HasValue) + { + Progress.Report(percentComplete.Value); + } + + // job.TranscodingPositionTicks = ticks; + // job.BytesTranscoded = bytesTranscoded; + + var deviceId = Options.DeviceId; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + var audioCodec = ActualOutputVideoCodec; + var videoCodec = ActualOutputVideoCodec; + + // SessionManager.ReportTranscodingInfo(deviceId, new TranscodingInfo + // { + // Bitrate = job.TotalOutputBitrate, + // AudioCodec = audioCodec, + // VideoCodec = videoCodec, + // Container = job.Options.OutputContainer, + // Framerate = framerate, + // CompletionPercentage = percentComplete, + // Width = job.OutputWidth, + // Height = job.OutputHeight, + // AudioChannels = job.OutputAudioChannels, + // IsAudioDirect = string.Equals(job.OutputAudioCodec, "copy", StringComparison.OrdinalIgnoreCase), + // IsVideoDirect = string.Equals(job.OutputVideoCodec, "copy", StringComparison.OrdinalIgnoreCase) + // }); + } + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingJobFactory.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingJobFactory.cs new file mode 100644 index 0000000000..00c7b61e3d --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingJobFactory.cs @@ -0,0 +1,830 @@ +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public class EncodingJobFactory + { + private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + private readonly ILibraryManager _libraryManager; + private readonly IChannelManager _channelManager; + + protected static readonly CultureInfo UsCulture = new CultureInfo("en-US"); + + public EncodingJobFactory(ILogger logger, ILiveTvManager liveTvManager, ILibraryManager libraryManager, IChannelManager channelManager) + { + _logger = logger; + _liveTvManager = liveTvManager; + _libraryManager = libraryManager; + _channelManager = channelManager; + } + + public async Task CreateJob(EncodingJobOptions options, bool isVideoRequest, IProgress progress, CancellationToken cancellationToken) + { + var request = options; + + if (string.IsNullOrEmpty(request.AudioCodec)) + { + request.AudioCodec = InferAudioCodec(request.OutputContainer); + } + + var state = new EncodingJob(_logger, _liveTvManager) + { + Options = options, + IsVideoRequest = isVideoRequest, + Progress = progress + }; + + if (!string.IsNullOrWhiteSpace(request.AudioCodec)) + { + state.SupportedAudioCodecs = request.AudioCodec.Split(',').Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); + request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(); + } + + var item = _libraryManager.GetItemById(request.ItemId); + + List mediaStreams = null; + + state.ItemType = item.GetType().Name; + + if (item is ILiveTvRecording) + { + var recording = await _liveTvManager.GetInternalRecording(request.ItemId, cancellationToken).ConfigureAwait(false); + + state.VideoType = VideoType.VideoFile; + state.IsInputVideo = string.Equals(recording.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + + var path = recording.RecordingInfo.Path; + var mediaUrl = recording.RecordingInfo.Url; + + var source = string.IsNullOrEmpty(request.MediaSourceId) + ? recording.GetMediaSources(false).First() + : recording.GetMediaSources(false).First(i => string.Equals(i.Id, request.MediaSourceId)); + + mediaStreams = source.MediaStreams; + + // Just to prevent this from being null and causing other methods to fail + state.MediaPath = string.Empty; + + if (!string.IsNullOrEmpty(path)) + { + state.MediaPath = path; + state.InputProtocol = MediaProtocol.File; + } + else if (!string.IsNullOrEmpty(mediaUrl)) + { + state.MediaPath = mediaUrl; + state.InputProtocol = MediaProtocol.Http; + } + + state.RunTimeTicks = recording.RunTimeTicks; + state.DeInterlace = true; + state.OutputAudioSync = "1000"; + state.InputVideoSync = "-1"; + state.InputAudioSync = "1"; + state.InputContainer = recording.Container; + state.ReadInputAtNativeFramerate = source.ReadAtNativeFramerate; + } + else if (item is LiveTvChannel) + { + var channel = _liveTvManager.GetInternalChannel(request.ItemId); + + state.VideoType = VideoType.VideoFile; + state.IsInputVideo = string.Equals(channel.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + mediaStreams = new List(); + + state.DeInterlace = true; + + // Just to prevent this from being null and causing other methods to fail + state.MediaPath = string.Empty; + } + else if (item is IChannelMediaItem) + { + var mediaSource = await GetChannelMediaInfo(request.ItemId, request.MediaSourceId, cancellationToken).ConfigureAwait(false); + state.IsInputVideo = string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase); + state.InputProtocol = mediaSource.Protocol; + state.MediaPath = mediaSource.Path; + state.RunTimeTicks = item.RunTimeTicks; + state.RemoteHttpHeaders = mediaSource.RequiredHttpHeaders; + state.InputBitrate = mediaSource.Bitrate; + state.InputFileSize = mediaSource.Size; + state.ReadInputAtNativeFramerate = mediaSource.ReadAtNativeFramerate; + mediaStreams = mediaSource.MediaStreams; + } + else + { + var hasMediaSources = (IHasMediaSources)item; + var mediaSource = string.IsNullOrEmpty(request.MediaSourceId) + ? hasMediaSources.GetMediaSources(false).First() + : hasMediaSources.GetMediaSources(false).First(i => string.Equals(i.Id, request.MediaSourceId)); + + mediaStreams = mediaSource.MediaStreams; + + state.MediaPath = mediaSource.Path; + state.InputProtocol = mediaSource.Protocol; + state.InputContainer = mediaSource.Container; + state.InputFileSize = mediaSource.Size; + state.InputBitrate = mediaSource.Bitrate; + state.ReadInputAtNativeFramerate = mediaSource.ReadAtNativeFramerate; + + var video = item as Video; + + if (video != null) + { + state.IsInputVideo = true; + + if (mediaSource.VideoType.HasValue) + { + state.VideoType = mediaSource.VideoType.Value; + } + + state.IsoType = mediaSource.IsoType; + + state.PlayableStreamFileNames = mediaSource.PlayableStreamFileNames.ToList(); + + if (mediaSource.Timestamp.HasValue) + { + state.InputTimestamp = mediaSource.Timestamp.Value; + } + } + + state.RunTimeTicks = mediaSource.RunTimeTicks; + } + + AttachMediaStreamInfo(state, mediaStreams, request); + + state.OutputAudioBitrate = GetAudioBitrateParam(request, state.AudioStream); + state.OutputAudioSampleRate = request.AudioSampleRate; + + state.OutputAudioCodec = GetAudioCodec(request); + + state.OutputAudioChannels = GetNumAudioChannelsParam(request, state.AudioStream, state.OutputAudioCodec); + + if (isVideoRequest) + { + state.OutputVideoCodec = GetVideoCodec(request); + state.OutputVideoBitrate = GetVideoBitrateParamValue(request, state.VideoStream); + + if (state.OutputVideoBitrate.HasValue) + { + var resolution = ResolutionNormalizer.Normalize(state.OutputVideoBitrate.Value, + state.OutputVideoCodec, + request.MaxWidth, + request.MaxHeight); + + request.MaxWidth = resolution.MaxWidth; + request.MaxHeight = resolution.MaxHeight; + } + } + + ApplyDeviceProfileSettings(state); + + if (isVideoRequest) + { + if (state.VideoStream != null && CanStreamCopyVideo(request, state.VideoStream)) + { + state.OutputVideoCodec = "copy"; + } + + if (state.AudioStream != null && CanStreamCopyAudio(request, state.AudioStream, state.SupportedAudioCodecs)) + { + state.OutputAudioCodec = "copy"; + } + } + + return state; + } + + internal static void AttachMediaStreamInfo(EncodingJob state, + List mediaStreams, + EncodingJobOptions videoRequest) + { + if (videoRequest != null) + { + if (string.IsNullOrEmpty(videoRequest.VideoCodec)) + { + videoRequest.VideoCodec = InferVideoCodec(videoRequest.OutputContainer); + } + + state.VideoStream = GetMediaStream(mediaStreams, videoRequest.VideoStreamIndex, MediaStreamType.Video); + state.SubtitleStream = GetMediaStream(mediaStreams, videoRequest.SubtitleStreamIndex, MediaStreamType.Subtitle, false); + state.AudioStream = GetMediaStream(mediaStreams, videoRequest.AudioStreamIndex, MediaStreamType.Audio); + + if (state.SubtitleStream != null && !state.SubtitleStream.IsExternal) + { + state.InternalSubtitleStreamOffset = mediaStreams.Where(i => i.Type == MediaStreamType.Subtitle && !i.IsExternal).ToList().IndexOf(state.SubtitleStream); + } + + if (state.VideoStream != null && state.VideoStream.IsInterlaced) + { + state.DeInterlace = true; + } + + EnforceResolutionLimit(state, videoRequest); + } + else + { + state.AudioStream = GetMediaStream(mediaStreams, null, MediaStreamType.Audio, true); + } + + state.AllMediaStreams = mediaStreams; + } + + /// + /// Infers the video codec. + /// + /// The container. + /// System.Nullable{VideoCodecs}. + private static string InferVideoCodec(string container) + { + if (string.Equals(container, "asf", StringComparison.OrdinalIgnoreCase)) + { + return "wmv"; + } + if (string.Equals(container, "webm", StringComparison.OrdinalIgnoreCase)) + { + return "vpx"; + } + if (string.Equals(container, "ogg", StringComparison.OrdinalIgnoreCase) || string.Equals(container, "ogv", StringComparison.OrdinalIgnoreCase)) + { + return "theora"; + } + if (string.Equals(container, "m3u8", StringComparison.OrdinalIgnoreCase) || string.Equals(container, "ts", StringComparison.OrdinalIgnoreCase)) + { + return "h264"; + } + + return "copy"; + } + + private string InferAudioCodec(string container) + { + if (string.Equals(container, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return "mp3"; + } + if (string.Equals(container, "aac", StringComparison.OrdinalIgnoreCase)) + { + return "aac"; + } + if (string.Equals(container, "wma", StringComparison.OrdinalIgnoreCase)) + { + return "wma"; + } + if (string.Equals(container, "ogg", StringComparison.OrdinalIgnoreCase)) + { + return "vorbis"; + } + if (string.Equals(container, "oga", StringComparison.OrdinalIgnoreCase)) + { + return "vorbis"; + } + if (string.Equals(container, "ogv", StringComparison.OrdinalIgnoreCase)) + { + return "vorbis"; + } + if (string.Equals(container, "webm", StringComparison.OrdinalIgnoreCase)) + { + return "vorbis"; + } + if (string.Equals(container, "webma", StringComparison.OrdinalIgnoreCase)) + { + return "vorbis"; + } + + return "copy"; + } + + /// + /// Determines which stream will be used for playback + /// + /// All stream. + /// Index of the desired. + /// The type. + /// if set to true [return first if no index]. + /// MediaStream. + private static MediaStream GetMediaStream(IEnumerable allStream, int? desiredIndex, MediaStreamType type, bool returnFirstIfNoIndex = true) + { + var streams = allStream.Where(s => s.Type == type).OrderBy(i => i.Index).ToList(); + + if (desiredIndex.HasValue) + { + var stream = streams.FirstOrDefault(s => s.Index == desiredIndex.Value); + + if (stream != null) + { + return stream; + } + } + + if (type == MediaStreamType.Video) + { + streams = streams.Where(i => !string.Equals(i.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList(); + } + + if (returnFirstIfNoIndex && type == MediaStreamType.Audio) + { + return streams.FirstOrDefault(i => i.Channels.HasValue && i.Channels.Value > 0) ?? + streams.FirstOrDefault(); + } + + // Just return the first one + return returnFirstIfNoIndex ? streams.FirstOrDefault() : null; + } + + /// + /// Enforces the resolution limit. + /// + /// The state. + /// The video request. + private static void EnforceResolutionLimit(EncodingJob state, EncodingJobOptions videoRequest) + { + // Switch the incoming params to be ceilings rather than fixed values + videoRequest.MaxWidth = videoRequest.MaxWidth ?? videoRequest.Width; + videoRequest.MaxHeight = videoRequest.MaxHeight ?? videoRequest.Height; + + videoRequest.Width = null; + videoRequest.Height = null; + } + + /// + /// Gets the number of audio channels to specify on the command line + /// + /// The request. + /// The audio stream. + /// The output audio codec. + /// System.Nullable{System.Int32}. + private int? GetNumAudioChannelsParam(EncodingJobOptions request, MediaStream audioStream, string outputAudioCodec) + { + if (audioStream != null) + { + var codec = outputAudioCodec ?? string.Empty; + + if (audioStream.Channels > 2 && codec.IndexOf("wma", StringComparison.OrdinalIgnoreCase) != -1) + { + // wmav2 currently only supports two channel output + return 2; + } + } + + if (request.MaxAudioChannels.HasValue) + { + if (audioStream != null && audioStream.Channels.HasValue) + { + return Math.Min(request.MaxAudioChannels.Value, audioStream.Channels.Value); + } + + // If we don't have any media info then limit it to 5 to prevent encoding errors due to asking for too many channels + return Math.Min(request.MaxAudioChannels.Value, 5); + } + + return request.AudioChannels; + } + + private int? GetVideoBitrateParamValue(EncodingJobOptions request, MediaStream videoStream) + { + var bitrate = request.VideoBitRate; + + if (videoStream != null) + { + var isUpscaling = request.Height.HasValue && videoStream.Height.HasValue && + request.Height.Value > videoStream.Height.Value; + + if (request.Width.HasValue && videoStream.Width.HasValue && + request.Width.Value > videoStream.Width.Value) + { + isUpscaling = true; + } + + // Don't allow bitrate increases unless upscaling + if (!isUpscaling) + { + if (bitrate.HasValue && videoStream.BitRate.HasValue) + { + bitrate = Math.Min(bitrate.Value, videoStream.BitRate.Value); + } + } + } + + return bitrate; + } + + private async Task GetChannelMediaInfo(string id, + string mediaSourceId, + CancellationToken cancellationToken) + { + var channelMediaSources = await _channelManager.GetChannelItemMediaSources(id, true, cancellationToken) + .ConfigureAwait(false); + + var list = channelMediaSources.ToList(); + + if (!string.IsNullOrWhiteSpace(mediaSourceId)) + { + var source = list + .FirstOrDefault(i => string.Equals(mediaSourceId, i.Id)); + + if (source != null) + { + return source; + } + } + + return list.First(); + } + + protected string GetVideoBitrateParam(EncodingJob state, string videoCodec, bool isHls) + { + var bitrate = state.OutputVideoBitrate; + + if (bitrate.HasValue) + { + var hasFixedResolution = state.Options.HasFixedResolution; + + if (string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)) + { + if (hasFixedResolution) + { + return string.Format(" -minrate:v ({0}*.90) -maxrate:v ({0}*1.10) -bufsize:v {0} -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + // With vpx when crf is used, b:v becomes a max rate + // https://trac.ffmpeg.org/wiki/vpxEncodingGuide. But higher bitrate source files -b:v causes judder so limite the bitrate but dont allow it to "saturate" the bitrate. So dont contrain it down just up. + return string.Format(" -maxrate:v {0} -bufsize:v ({0}*2) -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + if (string.Equals(videoCodec, "msmpeg4", StringComparison.OrdinalIgnoreCase)) + { + return string.Format(" -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + // H264 + if (hasFixedResolution) + { + if (isHls) + { + return string.Format(" -b:v {0} -maxrate ({0}*.80) -bufsize {0}", bitrate.Value.ToString(UsCulture)); + } + + return string.Format(" -b:v {0}", bitrate.Value.ToString(UsCulture)); + } + + return string.Format(" -maxrate {0} -bufsize {1}", + bitrate.Value.ToString(UsCulture), + (bitrate.Value * 2).ToString(UsCulture)); + } + + return string.Empty; + } + + private int? GetAudioBitrateParam(EncodingJobOptions request, MediaStream audioStream) + { + if (request.AudioBitRate.HasValue) + { + // Make sure we don't request a bitrate higher than the source + var currentBitrate = audioStream == null ? request.AudioBitRate.Value : audioStream.BitRate ?? request.AudioBitRate.Value; + + return request.AudioBitRate.Value; + //return Math.Min(currentBitrate, request.AudioBitRate.Value); + } + + return null; + } + + /// + /// Determines whether the specified stream is H264. + /// + /// The stream. + /// true if the specified stream is H264; otherwise, false. + protected bool IsH264(MediaStream stream) + { + var codec = stream.Codec ?? string.Empty; + + return codec.IndexOf("264", StringComparison.OrdinalIgnoreCase) != -1 || + codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1; + } + + /// + /// Gets the name of the output audio codec + /// + /// The request. + /// System.String. + private string GetAudioCodec(EncodingJobOptions request) + { + var codec = request.AudioCodec; + + if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)) + { + return "aac -strict experimental"; + } + if (string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase)) + { + return "libmp3lame"; + } + if (string.Equals(codec, "vorbis", StringComparison.OrdinalIgnoreCase)) + { + return "libvorbis"; + } + if (string.Equals(codec, "wma", StringComparison.OrdinalIgnoreCase)) + { + return "wmav2"; + } + + return (codec ?? string.Empty).ToLower(); + } + + /// + /// Gets the name of the output video codec + /// + /// The request. + /// System.String. + private string GetVideoCodec(EncodingJobOptions request) + { + var codec = request.VideoCodec; + + if (!string.IsNullOrEmpty(codec)) + { + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)) + { + return "libx264"; + } + if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)) + { + return "libx265"; + } + if (string.Equals(codec, "vpx", StringComparison.OrdinalIgnoreCase)) + { + return "libvpx"; + } + if (string.Equals(codec, "wmv", StringComparison.OrdinalIgnoreCase)) + { + return "wmv2"; + } + if (string.Equals(codec, "theora", StringComparison.OrdinalIgnoreCase)) + { + return "libtheora"; + } + + return codec.ToLower(); + } + + return "copy"; + } + + internal static bool CanStreamCopyVideo(EncodingJobOptions request, MediaStream videoStream) + { + if (videoStream.IsInterlaced) + { + return false; + } + + // Can't stream copy if we're burning in subtitles + if (request.SubtitleStreamIndex.HasValue) + { + if (request.SubtitleMethod == SubtitleDeliveryMethod.Encode) + { + return false; + } + } + + // Source and target codecs must match + if (!string.Equals(request.VideoCodec, videoStream.Codec, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // If client is requesting a specific video profile, it must match the source + if (!string.IsNullOrEmpty(request.Profile)) + { + if (string.IsNullOrEmpty(videoStream.Profile)) + { + return false; + } + + if (!string.Equals(request.Profile, videoStream.Profile, StringComparison.OrdinalIgnoreCase)) + { + var currentScore = GetVideoProfileScore(videoStream.Profile); + var requestedScore = GetVideoProfileScore(request.Profile); + + if (currentScore == -1 || currentScore > requestedScore) + { + return false; + } + } + } + + // Video width must fall within requested value + if (request.MaxWidth.HasValue) + { + if (!videoStream.Width.HasValue || videoStream.Width.Value > request.MaxWidth.Value) + { + return false; + } + } + + // Video height must fall within requested value + if (request.MaxHeight.HasValue) + { + if (!videoStream.Height.HasValue || videoStream.Height.Value > request.MaxHeight.Value) + { + return false; + } + } + + // Video framerate must fall within requested value + var requestedFramerate = request.MaxFramerate ?? request.Framerate; + if (requestedFramerate.HasValue) + { + var videoFrameRate = videoStream.AverageFrameRate ?? videoStream.RealFrameRate; + + if (!videoFrameRate.HasValue || videoFrameRate.Value > requestedFramerate.Value) + { + return false; + } + } + + // Video bitrate must fall within requested value + if (request.VideoBitRate.HasValue) + { + if (!videoStream.BitRate.HasValue || videoStream.BitRate.Value > request.VideoBitRate.Value) + { + return false; + } + } + + if (request.MaxVideoBitDepth.HasValue) + { + if (videoStream.BitDepth.HasValue && videoStream.BitDepth.Value > request.MaxVideoBitDepth.Value) + { + return false; + } + } + + if (request.MaxRefFrames.HasValue) + { + if (videoStream.RefFrames.HasValue && videoStream.RefFrames.Value > request.MaxRefFrames.Value) + { + return false; + } + } + + // If a specific level was requested, the source must match or be less than + if (request.Level.HasValue) + { + if (!videoStream.Level.HasValue) + { + return false; + } + + if (videoStream.Level.Value > request.Level.Value) + { + return false; + } + } + + if (request.Cabac.HasValue && request.Cabac.Value) + { + if (videoStream.IsCabac.HasValue && !videoStream.IsCabac.Value) + { + return false; + } + } + + return request.EnableAutoStreamCopy; + } + + private static int GetVideoProfileScore(string profile) + { + var list = new List + { + "Constrained Baseline", + "Baseline", + "Extended", + "Main", + "High", + "Progressive High", + "Constrained High" + }; + + return Array.FindIndex(list.ToArray(), t => string.Equals(t, profile, StringComparison.OrdinalIgnoreCase)); + } + + internal static bool CanStreamCopyAudio(EncodingJobOptions request, MediaStream audioStream, List supportedAudioCodecs) + { + // Source and target codecs must match + if (string.IsNullOrEmpty(audioStream.Codec) || !supportedAudioCodecs.Contains(audioStream.Codec, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + // Video bitrate must fall within requested value + if (request.AudioBitRate.HasValue) + { + if (!audioStream.BitRate.HasValue || audioStream.BitRate.Value <= 0) + { + return false; + } + if (audioStream.BitRate.Value > request.AudioBitRate.Value) + { + return false; + } + } + + // Channels must fall within requested value + var channels = request.AudioChannels ?? request.MaxAudioChannels; + if (channels.HasValue) + { + if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0) + { + return false; + } + if (audioStream.Channels.Value > channels.Value) + { + return false; + } + } + + // Sample rate must fall within requested value + if (request.AudioSampleRate.HasValue) + { + if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0) + { + return false; + } + if (audioStream.SampleRate.Value > request.AudioSampleRate.Value) + { + return false; + } + } + + return request.EnableAutoStreamCopy; + } + + private void ApplyDeviceProfileSettings(EncodingJob state) + { + var profile = state.Options.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 outputContainer = state.Options.OutputContainer; + + var mediaProfile = state.IsVideoRequest ? + profile.GetAudioMediaProfile(outputContainer, audioCodec, state.OutputAudioChannels, state.OutputAudioBitrate) : + profile.GetVideoMediaProfile(outputContainer, + audioCodec, + videoCodec, + state.OutputAudioBitrate, + state.OutputAudioChannels, + state.OutputWidth, + state.OutputHeight, + state.TargetVideoBitDepth, + state.OutputVideoBitrate, + state.TargetVideoProfile, + state.TargetVideoLevel, + state.TargetFramerate, + state.TargetPacketLength, + state.TargetTimestamp, + state.IsTargetAnamorphic, + state.IsTargetCabac, + state.TargetRefFrames); + + if (mediaProfile != null) + { + state.MimeType = mediaProfile.MimeType; + } + + var transcodingProfile = state.IsVideoRequest ? + profile.GetAudioTranscodingProfile(outputContainer, audioCodec) : + profile.GetVideoTranscodingProfile(outputContainer, audioCodec, videoCodec); + + if (transcodingProfile != null) + { + state.EstimateContentLength = transcodingProfile.EstimateContentLength; + state.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode; + state.TranscodeSeekInfo = transcodingProfile.TranscodeSeekInfo; + } + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/JobLogger.cs b/MediaBrowser.MediaEncoding/Encoder/JobLogger.cs new file mode 100644 index 0000000000..6be8705192 --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/JobLogger.cs @@ -0,0 +1,122 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Model.Logging; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public class JobLogger + { + private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + private readonly ILogger _logger; + + public JobLogger(ILogger logger) + { + _logger = logger; + } + + public async void StartStreamingLog(EncodingJob transcodingJob, Stream source, Stream target) + { + try + { + using (var reader = new StreamReader(source)) + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + + ParseLogLine(line, transcodingJob); + + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger.ErrorException("Error reading ffmpeg log", ex); + } + } + + private void ParseLogLine(string line, EncodingJob transcodingJob) + { + float? framerate = null; + double? percent = null; + TimeSpan? transcodingPosition = null; + long? bytesTranscoded = null; + + var parts = line.Split(' '); + + var totalMs = transcodingJob.RunTimeTicks.HasValue + ? TimeSpan.FromTicks(transcodingJob.RunTimeTicks.Value).TotalMilliseconds + : 0; + + var startMs = transcodingJob.Options.StartTimeTicks.HasValue + ? TimeSpan.FromTicks(transcodingJob.Options.StartTimeTicks.Value).TotalMilliseconds + : 0; + + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + if (string.Equals(part, "fps=", StringComparison.OrdinalIgnoreCase) && + (i + 1 < parts.Length)) + { + var rate = parts[i + 1]; + float val; + + if (float.TryParse(rate, NumberStyles.Any, _usCulture, out val)) + { + framerate = val; + } + } + else if (transcodingJob.RunTimeTicks.HasValue && + part.StartsWith("time=", StringComparison.OrdinalIgnoreCase)) + { + var time = part.Split(new[] { '=' }, 2).Last(); + TimeSpan val; + + if (TimeSpan.TryParse(time, _usCulture, out val)) + { + var currentMs = startMs + val.TotalMilliseconds; + + var percentVal = currentMs / totalMs; + percent = 100 * percentVal; + + transcodingPosition = val; + } + } + else if (part.StartsWith("size=", StringComparison.OrdinalIgnoreCase)) + { + var size = part.Split(new[] { '=' }, 2).Last(); + + int? scale = null; + if (size.IndexOf("kb", StringComparison.OrdinalIgnoreCase) != -1) + { + scale = 1024; + size = size.Replace("kb", string.Empty, StringComparison.OrdinalIgnoreCase); + } + + if (scale.HasValue) + { + long val; + + if (long.TryParse(size, NumberStyles.Any, _usCulture, out val)) + { + bytesTranscoded = val * scale.Value; + } + } + } + } + + if (framerate.HasValue || percent.HasValue) + { + transcodingJob.ReportTranscodingProgress(transcodingPosition, framerate, percent, bytesTranscoded); + } + } + } +} diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index a7d8b6f1d0..e800b4254a 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -64,8 +64,9 @@ namespace MediaBrowser.MediaEncoding.Encoder protected readonly ILibraryManager LibraryManager; protected readonly IChannelManager ChannelManager; protected readonly ISessionManager SessionManager; - - public MediaEncoder(ILogger logger, IJsonSerializer jsonSerializer, string ffMpegPath, string ffProbePath, string version, IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILiveTvManager liveTvManager, IIsoManager isoManager, ILibraryManager libraryManager, IChannelManager channelManager, ISessionManager sessionManager) + protected readonly Func SubtitleEncoder; + + public MediaEncoder(ILogger logger, IJsonSerializer jsonSerializer, string ffMpegPath, string ffProbePath, string version, IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILiveTvManager liveTvManager, IIsoManager isoManager, ILibraryManager libraryManager, IChannelManager channelManager, ISessionManager sessionManager, Func subtitleEncoder) { _logger = logger; _jsonSerializer = jsonSerializer; @@ -77,6 +78,7 @@ namespace MediaBrowser.MediaEncoding.Encoder LibraryManager = libraryManager; ChannelManager = channelManager; SessionManager = sessionManager; + SubtitleEncoder = subtitleEncoder; FFProbePath = ffProbePath; FFMpegPath = ffMpegPath; } @@ -494,7 +496,7 @@ namespace MediaBrowser.MediaEncoding.Encoder }; _logger.Info(process.StartInfo.FileName + " " + process.StartInfo.Arguments); - + await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); process.Start(); @@ -537,15 +539,37 @@ namespace MediaBrowser.MediaEncoding.Encoder IProgress progress, CancellationToken cancellationToken) { - var job = await new AudioEncoder(this, - _logger, - ConfigurationManager, - FileSystem, + var job = await new AudioEncoder(this, + _logger, + ConfigurationManager, + FileSystem, LiveTvManager, - IsoManager, - LibraryManager, - ChannelManager, - SessionManager) + IsoManager, + LibraryManager, + ChannelManager, + SessionManager, + SubtitleEncoder()) + .Start(options, progress, cancellationToken).ConfigureAwait(false); + + await job.TaskCompletionSource.Task.ConfigureAwait(false); + + return job.OutputFilePath; + } + + public async Task EncodeVideo(EncodingJobOptions options, + IProgress progress, + CancellationToken cancellationToken) + { + var job = await new VideoEncoder(this, + _logger, + ConfigurationManager, + FileSystem, + LiveTvManager, + IsoManager, + LibraryManager, + ChannelManager, + SessionManager, + SubtitleEncoder()) .Start(options, progress, cancellationToken).ConfigureAwait(false); await job.TaskCompletionSource.Task.ConfigureAwait(false); diff --git a/MediaBrowser.MediaEncoding/Encoder/VideoEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/VideoEncoder.cs new file mode 100644 index 0000000000..36406e3a3f --- /dev/null +++ b/MediaBrowser.MediaEncoding/Encoder/VideoEncoder.cs @@ -0,0 +1,177 @@ +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Logging; +using System; +using System.IO; + +namespace MediaBrowser.MediaEncoding.Encoder +{ + public class VideoEncoder : BaseEncoder + { + public VideoEncoder(MediaEncoder mediaEncoder, ILogger logger, IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILiveTvManager liveTvManager, IIsoManager isoManager, ILibraryManager libraryManager, IChannelManager channelManager, ISessionManager sessionManager, ISubtitleEncoder subtitleEncoder) : base(mediaEncoder, logger, configurationManager, fileSystem, liveTvManager, isoManager, libraryManager, channelManager, sessionManager, subtitleEncoder) + { + } + + protected override string GetCommandLineArguments(EncodingJob state) + { + // Get the output codec name + var videoCodec = state.OutputVideoCodec; + + var format = string.Empty; + var keyFrame = string.Empty; + + if (string.Equals(Path.GetExtension(state.OutputFilePath), ".mp4", StringComparison.OrdinalIgnoreCase)) + { + format = " -f mp4 -movflags frag_keyframe+empty_moov"; + } + + var threads = GetNumberOfThreads(state, string.Equals(videoCodec, "libvpx", StringComparison.OrdinalIgnoreCase)); + + var inputModifier = GetInputModifier(state); + + return string.Format("{0} {1}{2} {3} {4} -map_metadata -1 -threads {5} {6}{7} -y \"{8}\"", + inputModifier, + GetInputArgument(state), + keyFrame, + GetMapArgs(state), + GetVideoArguments(state, videoCodec), + threads, + GetAudioArguments(state), + format, + state.OutputFilePath + ).Trim(); + } + + /// + /// Gets video arguments to pass to ffmpeg + /// + /// The state. + /// The video codec. + /// System.String. + private string GetVideoArguments(EncodingJob state, string codec) + { + 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)) + { + return state.VideoStream != null && IsH264(state.VideoStream) && string.Equals(state.Options.OutputContainer, "ts", StringComparison.OrdinalIgnoreCase) ? + args + " -bsf:v h264_mp4toannexb" : + args; + } + + var keyFrameArg = string.Format(" -force_key_frames expr:gte(t,n_forced*{0})", + 5.ToString(UsCulture)); + + args += keyFrameArg; + + var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream; + + // Add resolution params, if specified + if (!hasGraphicalSubs) + { + args += GetOutputSizeParam(state, codec); + } + + var qualityParam = GetVideoQualityParam(state, codec, false); + + if (!string.IsNullOrEmpty(qualityParam)) + { + args += " " + qualityParam.Trim(); + } + + // This is for internal graphical subs + if (hasGraphicalSubs) + { + args += GetGraphicalSubtitleParam(state, codec); + } + + return args; + } + + /// + /// Gets audio arguments to pass to ffmpeg + /// + /// The state. + /// System.String. + private string GetAudioArguments(EncodingJob state) + { + // If the video doesn't have an audio stream, return a default. + if (state.AudioStream == null && state.VideoStream != null) + { + return string.Empty; + } + + // Get the output codec name + var codec = state.OutputAudioCodec; + + var args = "-codec:a:0 " + codec; + + if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase)) + { + return args; + } + + // Add the number of audio channels + var channels = state.OutputAudioChannels; + + if (channels.HasValue) + { + args += " -ac " + channels.Value; + } + + var bitrate = state.OutputAudioBitrate; + + if (bitrate.HasValue) + { + args += " -ab " + bitrate.Value.ToString(UsCulture); + } + + args += " " + GetAudioFilterParam(state, false); + + return args; + } + + protected override string GetOutputFileExtension(EncodingJob state) + { + var ext = base.GetOutputFileExtension(state); + + if (!string.IsNullOrEmpty(ext)) + { + return ext; + } + + var videoCodec = state.Options.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"; + } + + return null; + } + } +} diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj index 9daa3319f9..38d8fa1383 100644 --- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj +++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj @@ -64,6 +64,7 @@ + diff --git a/MediaBrowser.Server.Implementations/Sync/SyncJobProcessor.cs b/MediaBrowser.Server.Implementations/Sync/SyncJobProcessor.cs index 8e5b765a6a..896e49cb21 100644 --- a/MediaBrowser.Server.Implementations/Sync/SyncJobProcessor.cs +++ b/MediaBrowser.Server.Implementations/Sync/SyncJobProcessor.cs @@ -412,7 +412,7 @@ namespace MediaBrowser.Server.Implementations.Sync jobItem.Status = SyncJobItemStatus.Converting; await _syncRepo.Update(jobItem).ConfigureAwait(false); - //jobItem.OutputPath = await MediaEncoder.EncodeAudio(new EncodingJobOptions(streamInfo, profile), new Progress(), cancellationToken); + jobItem.OutputPath = await MediaEncoder.EncodeVideo(new EncodingJobOptions(streamInfo, profile), new Progress(), cancellationToken); } else { @@ -420,7 +420,7 @@ namespace MediaBrowser.Server.Implementations.Sync { jobItem.OutputPath = mediaSource.Path; } - if (mediaSource.Protocol == MediaProtocol.Http) + else if (mediaSource.Protocol == MediaProtocol.Http) { jobItem.OutputPath = await DownloadFile(jobItem, mediaSource, cancellationToken).ConfigureAwait(false); } @@ -464,7 +464,7 @@ namespace MediaBrowser.Server.Implementations.Sync { jobItem.OutputPath = mediaSource.Path; } - if (mediaSource.Protocol == MediaProtocol.Http) + else if (mediaSource.Protocol == MediaProtocol.Http) { jobItem.OutputPath = await DownloadFile(jobItem, mediaSource, cancellationToken).ConfigureAwait(false); } diff --git a/MediaBrowser.Server.Startup.Common/ApplicationHost.cs b/MediaBrowser.Server.Startup.Common/ApplicationHost.cs index 8a1721b7e0..41f0a28060 100644 --- a/MediaBrowser.Server.Startup.Common/ApplicationHost.cs +++ b/MediaBrowser.Server.Startup.Common/ApplicationHost.cs @@ -185,6 +185,7 @@ namespace MediaBrowser.Server.Startup.Common /// /// The media encoder. private IMediaEncoder MediaEncoder { get; set; } + private ISubtitleEncoder SubtitleEncoder { get; set; } private IConnectManager ConnectManager { get; set; } private ISessionManager SessionManager { get; set; } @@ -560,7 +561,8 @@ namespace MediaBrowser.Server.Startup.Common RegisterSingleInstance(new SessionContext(UserManager, authContext, SessionManager)); RegisterSingleInstance(new AuthService(UserManager, authContext, ServerConfigurationManager, ConnectManager, SessionManager, DeviceManager)); - RegisterSingleInstance(new SubtitleEncoder(LibraryManager, LogManager.GetLogger("SubtitleEncoder"), ApplicationPaths, FileSystemManager, MediaEncoder, JsonSerializer)); + SubtitleEncoder = new SubtitleEncoder(LibraryManager, LogManager.GetLogger("SubtitleEncoder"), ApplicationPaths, FileSystemManager, MediaEncoder, JsonSerializer); + RegisterSingleInstance(SubtitleEncoder); await ConfigureDisplayPreferencesRepositories().ConfigureAwait(false); await ConfigureItemRepositories().ConfigureAwait(false); @@ -602,7 +604,8 @@ namespace MediaBrowser.Server.Startup.Common IsoManager, LibraryManager, ChannelManager, - SessionManager); + SessionManager, + () => SubtitleEncoder); RegisterSingleInstance(MediaEncoder); }