From fe741f9fda64f1b5304b15c26bfc25cf68b40758 Mon Sep 17 00:00:00 2001 From: Luke Pulverenti Date: Fri, 12 Feb 2016 02:01:38 -0500 Subject: [PATCH] add recording encoding setting --- MediaBrowser.Model/LiveTv/LiveTvOptions.cs | 1 + .../LiveTv/EmbyTV/DirectRecorder.cs | 50 ++++ .../LiveTv/EmbyTV/EmbyTV.cs | 44 +-- .../LiveTv/EmbyTV/EncodedRecorder.cs | 255 ++++++++++++++++++ .../LiveTv/EmbyTV/IRecorder.cs | 12 + ...MediaBrowser.Server.Implementations.csproj | 3 + 6 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 MediaBrowser.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs create mode 100644 MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs create mode 100644 MediaBrowser.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs diff --git a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs index 2b45422ec..838325d68 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs @@ -8,6 +8,7 @@ namespace MediaBrowser.Model.LiveTv public bool EnableMovieProviders { get; set; } public string RecordingPath { get; set; } public bool EnableAutoOrganize { get; set; } + public bool EnableRecordingEncoding { get; set; } public List TunerHosts { get; set; } public List ListingProviders { get; set; } diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs new file mode 100644 index 000000000..9ac96165f --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using CommonIO; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Logging; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class DirectRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IFileSystem _fileSystem; + + public DirectRecorder(ILogger logger, IHttpClient httpClient, IFileSystem fileSystem) + { + _logger = logger; + _httpClient = httpClient; + _fileSystem = fileSystem; + } + + public async Task Record(MediaSourceInfo mediaSource, string targetFile, Action onStarted, CancellationToken cancellationToken) + { + var httpRequestOptions = new HttpRequestOptions() + { + Url = mediaSource.Path + }; + + httpRequestOptions.BufferContent = false; + + using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false)) + { + _logger.Info("Opened recording stream from tuner provider"); + + using (var output = _fileSystem.GetFileStream(targetFile, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + onStarted(); + + _logger.Info("Copying recording stream to file stream"); + + await response.Content.CopyToAsync(output, StreamDefaults.DefaultCopyToBufferSize, cancellationToken).ConfigureAwait(false); + } + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 408d58244..5d7bb7c28 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -764,10 +764,12 @@ namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV var duration = recordingEndDate - DateTime.UtcNow; - HttpRequestOptions httpRequestOptions = new HttpRequestOptions() + var recorder = await GetRecorder().ConfigureAwait(false); + + if (recorder is EncodedRecorder) { - Url = mediaStreamInfo.Path - }; + recordPath = Path.ChangeExtension(recordPath, ".mp4"); + } recording.Path = recordPath; recording.Status = RecordingStatus.InProgress; @@ -776,26 +778,19 @@ namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV _logger.Info("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - httpRequestOptions.BufferContent = false; var durationToken = new CancellationTokenSource(duration); var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; - httpRequestOptions.CancellationToken = linkedToken; + _logger.Info("Writing file to path: " + recordPath); _logger.Info("Opening recording stream from tuner provider"); - using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false)) + + Action onStarted = () => { - _logger.Info("Opened recording stream from tuner provider"); + result.Item2.Release(); + isResourceOpen = false; + }; - using (var output = _fileSystem.GetFileStream(recordPath, FileMode.Create, FileAccess.Write, FileShare.Read)) - { - result.Item2.Release(); - isResourceOpen = false; - - _logger.Info("Copying recording stream to file stream"); - - await response.Content.CopyToAsync(output, StreamDefaults.DefaultCopyToBufferSize, linkedToken).ConfigureAwait(false); - } - } + await recorder.Record(mediaStreamInfo, recordPath, onStarted, linkedToken).ConfigureAwait(false); recording.Status = RecordingStatus.Completed; _logger.Info("Recording completed"); @@ -846,6 +841,21 @@ namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV } } + private async Task GetRecorder() + { + if (GetConfiguration().EnableRecordingEncoding) + { + var regInfo = await _security.GetRegistrationStatus("embytvseriesrecordings").ConfigureAwait(false); + + if (regInfo.IsValid) + { + return new EncodedRecorder(_logger, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer); + } + } + + return new DirectRecorder(_logger, _httpClient, _fileSystem); + } + private async void OnSuccessfulRecording(RecordingInfo recording) { if (GetConfiguration().EnableAutoOrganize) diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs new file mode 100644 index 000000000..2ed330431 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CommonIO; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class EncodedRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private readonly IMediaEncoder _mediaEncoder; + private readonly IApplicationPaths _appPaths; + private bool _hasExited; + private Stream _logFileStream; + private string _targetPath; + private Process _process; + private readonly IJsonSerializer _json; + + public EncodedRecorder(ILogger logger, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IApplicationPaths appPaths, IJsonSerializer json) + { + _logger = logger; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + _json = json; + } + + public async Task Record(MediaSourceInfo mediaSource, string targetFile, Action onStarted, CancellationToken cancellationToken) + { + _targetPath = targetFile; + _fileSystem.CreateDirectory(Path.GetDirectoryName(targetFile)); + + 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 = GetCommandLineArgs(mediaSource, targetFile), + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }, + + EnableRaisingEvents = true + }; + + _process = process; + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + _logger.Info(commandLineLogMessage); + + var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); + _fileSystem.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. + _logFileStream = _fileSystem.GetFileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(_json.SerializeToString(mediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + await _logFileStream.WriteAsync(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length, cancellationToken).ConfigureAwait(false); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process); + + process.Start(); + + cancellationToken.Register(Stop); + + // 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 + StartStreamingLog(process.StandardError.BaseStream, _logFileStream); + + // Wait for the file to exist before proceeeding + while (!_hasExited) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string targetFile) + { + string videoArgs; + if (EncodeVideo(mediaSource)) + { + var maxBitrate = 25000000; + videoArgs = string.Format( + "-codec:v:0 libx264 -force_key_frames expr:gte(t,n_forced*5) {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync vfr -profile:v high -level 41", + GetOutputSizeParam(), + maxBitrate.ToString(CultureInfo.InvariantCulture)); + } + else + { + videoArgs = "-codec:v:0 copy"; + } + + var commandLineArgs = "-fflags +genpts -i \"{0}\" -sn {2} -map_metadata -1 -threads 0 {3} -y \"{1}\""; + + if (mediaSource.ReadAtNativeFramerate) + { + commandLineArgs = "-re " + commandLineArgs; + } + + commandLineArgs = string.Format(commandLineArgs, mediaSource.Path, targetFile, videoArgs, GetAudioArgs(mediaSource)); + + return commandLineArgs; + } + + private string GetAudioArgs(MediaSourceInfo mediaSource) + { + var copyAudio = new[] {"aac", "mp3"}; + var mediaStreams = mediaSource.MediaStreams ?? new List(); + if (mediaStreams.Any(i => i.Type == MediaStreamType.Audio && copyAudio.Contains(i.Codec, StringComparer.OrdinalIgnoreCase))) + { + return "-codec:a:0 copy"; + } + + var audioChannels = 2; + var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + if (audioStream != null) + { + audioChannels = audioStream.Channels ?? audioChannels; + } + return "-codec:a:0 aac -strict experimental -ab 320000 -ac " + audioChannels.ToString(CultureInfo.InvariantCulture); + } + + private bool EncodeVideo(MediaSourceInfo mediaSource) + { + var mediaStreams = mediaSource.MediaStreams ?? new List(); + return !mediaStreams.Any(i => i.Type == MediaStreamType.Video && string.Equals(i.Codec, "h264", StringComparison.OrdinalIgnoreCase)); + } + + protected string GetOutputSizeParam() + { + var filters = new List(); + + filters.Add("yadif=0:-1:0"); + + var output = string.Empty; + + if (filters.Count > 0) + { + output += string.Format(" -vf \"{0}\"", string.Join(",", filters.ToArray())); + } + + return output; + } + + private void Stop() + { + if (!_hasExited) + { + try + { + _logger.Info("Killing ffmpeg recording process for {0}", _targetPath); + + //process.Kill(); + _process.StandardInput.WriteLine("q"); + + // Need to wait because killing is asynchronous + _process.WaitForExit(5000); + } + catch (Exception ex) + { + _logger.ErrorException("Error killing transcoding job for {0}", ex, _targetPath); + } + } + } + + /// + /// Processes the exited. + /// + /// The process. + private void OnFfMpegProcessExited(Process process) + { + _hasExited = true; + + _logger.Debug("Disposing stream resources"); + DisposeLogStream(); + + try + { + _logger.Info("FFMpeg exited with code {0}", process.ExitCode); + } + catch + { + _logger.Error("FFMpeg exited with an error."); + } + } + + private void DisposeLogStream() + { + if (_logFileStream != null) + { + try + { + _logFileStream.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing log stream", ex); + } + + _logFileStream = null; + } + } + + private async void StartStreamingLog(Stream source, Stream target) + { + try + { + using (var reader = new StreamReader(source)) + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await target.FlushAsync().ConfigureAwait(false); + } + } + } + catch (ObjectDisposedException) + { + // Don't spam the log. This doesn't seem to throw in windows, but sometimes under linux + } + catch (Exception ex) + { + _logger.ErrorException("Error reading ffmpeg log", ex); + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs new file mode 100644 index 000000000..12e73c1f3 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public interface IRecorder + { + Task Record(MediaSourceInfo mediaSource, string targetFile, Action onStarted, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index f3c28b61c..2dee8a5ed 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -215,8 +215,11 @@ + + +