using System.Globalization; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.IO; using MediaBrowser.Common.MediaInfo; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Server.Implementations.MediaEncoder { /// /// Class MediaEncoder /// public class MediaEncoder : IMediaEncoder, IDisposable { /// /// Gets or sets the zip client. /// /// The zip client. private readonly IZipClient _zipClient; /// /// The _logger /// private readonly ILogger _logger; /// /// The _app paths /// private readonly IApplicationPaths _appPaths; /// /// Gets the json serializer. /// /// The json serializer. private readonly IJsonSerializer _jsonSerializer; /// /// The video image resource pool /// private readonly SemaphoreSlim _videoImageResourcePool = new SemaphoreSlim(1, 1); /// /// The audio image resource pool /// private readonly SemaphoreSlim _audioImageResourcePool = new SemaphoreSlim(1, 1); /// /// The _subtitle extraction resource pool /// private readonly SemaphoreSlim _subtitleExtractionResourcePool = new SemaphoreSlim(2, 2); /// /// The FF probe resource pool /// private readonly SemaphoreSlim _ffProbeResourcePool = new SemaphoreSlim(2, 2); /// /// Gets or sets the versioned directory path. /// /// The versioned directory path. private string VersionedDirectoryPath { get; set; } /// /// Initializes a new instance of the class. /// /// The logger. /// The zip client. /// The app paths. /// The json serializer. public MediaEncoder(ILogger logger, IZipClient zipClient, IApplicationPaths appPaths, IJsonSerializer jsonSerializer) { _logger = logger; _zipClient = zipClient; _appPaths = appPaths; _jsonSerializer = jsonSerializer; // Not crazy about this but it's the only way to suppress ffmpeg crash dialog boxes SetErrorMode(ErrorModes.SEM_FAILCRITICALERRORS | ErrorModes.SEM_NOALIGNMENTFAULTEXCEPT | ErrorModes.SEM_NOGPFAULTERRORBOX | ErrorModes.SEM_NOOPENFILEERRORBOX); Task.Run(() => VersionedDirectoryPath = GetVersionedDirectoryPath()); } /// /// The _media tools path /// private string _mediaToolsPath; /// /// Gets the folder path to tools /// /// The media tools path. private string MediaToolsPath { get { if (_mediaToolsPath == null) { _mediaToolsPath = Path.Combine(_appPaths.ProgramDataPath, "ffmpeg"); if (!Directory.Exists(_mediaToolsPath)) { Directory.CreateDirectory(_mediaToolsPath); } } return _mediaToolsPath; } } /// /// Gets the encoder path. /// /// The encoder path. public string EncoderPath { get { return FFMpegPath; } } /// /// The _ FF MPEG path /// private string _FFMpegPath; /// /// Gets the path to ffmpeg.exe /// /// The FF MPEG path. public string FFMpegPath { get { return _FFMpegPath ?? (_FFMpegPath = Path.Combine(VersionedDirectoryPath, "ffmpeg.exe")); } } /// /// The _ FF probe path /// private string _FFProbePath; /// /// Gets the path to ffprobe.exe /// /// The FF probe path. private string FFProbePath { get { return _FFProbePath ?? (_FFProbePath = Path.Combine(VersionedDirectoryPath, "ffprobe.exe")); } } /// /// Gets the version. /// /// The version. public string Version { get { return Path.GetFileNameWithoutExtension(VersionedDirectoryPath); } } /// /// Gets the versioned directory path. /// /// System.String. private string GetVersionedDirectoryPath() { var assembly = GetType().Assembly; var prefix = GetType().Namespace + "."; var srch = prefix + "ffmpeg"; var resource = assembly.GetManifestResourceNames().First(r => r.StartsWith(srch)); var filename = resource.Substring(resource.IndexOf(prefix, StringComparison.OrdinalIgnoreCase) + prefix.Length); var versionedDirectoryPath = Path.Combine(MediaToolsPath, Path.GetFileNameWithoutExtension(filename)); if (!Directory.Exists(versionedDirectoryPath)) { Directory.CreateDirectory(versionedDirectoryPath); } ExtractTools(assembly, resource, versionedDirectoryPath); return versionedDirectoryPath; } /// /// Extracts the tools. /// /// The assembly. /// The zip file resource path. /// The target path. private void ExtractTools(Assembly assembly, string zipFileResourcePath, string targetPath) { using (var resourceStream = assembly.GetManifestResourceStream(zipFileResourcePath)) { _zipClient.ExtractAll(resourceStream, targetPath, false); } ExtractFonts(assembly, targetPath); } /// /// Extracts the fonts. /// /// The assembly. /// The target path. private async void ExtractFonts(Assembly assembly, string targetPath) { var fontsDirectory = Path.Combine(targetPath, "fonts"); if (!Directory.Exists(fontsDirectory)) { Directory.CreateDirectory(fontsDirectory); } const string fontFilename = "ARIALUNI.TTF"; var fontFile = Path.Combine(fontsDirectory, fontFilename); if (!File.Exists(fontFile)) { using (var stream = assembly.GetManifestResourceStream(GetType().Namespace + ".fonts." + fontFilename)) { using (var fileStream = new FileStream(fontFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await stream.CopyToAsync(fileStream).ConfigureAwait(false); } } } await ExtractFontConfigFile(assembly, fontsDirectory).ConfigureAwait(false); } /// /// Extracts the font config file. /// /// The assembly. /// The fonts directory. /// Task. private async Task ExtractFontConfigFile(Assembly assembly, string fontsDirectory) { const string fontConfigFilename = "fonts.conf"; var fontConfigFile = Path.Combine(fontsDirectory, fontConfigFilename); if (!File.Exists(fontConfigFile)) { using (var stream = assembly.GetManifestResourceStream(GetType().Namespace + ".fonts." + fontConfigFilename)) { using (var streamReader = new StreamReader(stream)) { var contents = await streamReader.ReadToEndAsync().ConfigureAwait(false); contents = contents.Replace("", "" + fontsDirectory + ""); var bytes = Encoding.UTF8.GetBytes(contents); using (var fileStream = new FileStream(fontConfigFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await fileStream.WriteAsync(bytes, 0, bytes.Length); } } } } } /// /// Gets the media info. /// /// The input files. /// The type. /// The cancellation token. /// Task. public Task GetMediaInfo(string[] inputFiles, InputType type, CancellationToken cancellationToken) { return GetMediaInfoInternal(GetInputArgument(inputFiles, type), type != InputType.AudioFile, GetProbeSizeArgument(type), cancellationToken); } /// /// Gets the input argument. /// /// The input files. /// The type. /// System.String. /// Unrecognized InputType public string GetInputArgument(string[] inputFiles, InputType type) { string inputPath; switch (type) { case InputType.Dvd: case InputType.VideoFile: case InputType.AudioFile: inputPath = GetConcatInputArgument(inputFiles); break; case InputType.Bluray: inputPath = GetBlurayInputArgument(inputFiles[0]); break; case InputType.Url: inputPath = GetHttpInputArgument(inputFiles); break; default: throw new ArgumentException("Unrecognized InputType"); } return inputPath; } /// /// Gets the HTTP input argument. /// /// The input files. /// System.String. private string GetHttpInputArgument(string[] inputFiles) { var url = inputFiles[0]; return string.Format("\"{0}\"", url); } /// /// Gets the probe size argument. /// /// The type. /// System.String. public string GetProbeSizeArgument(InputType type) { return type == InputType.Dvd ? "-probesize 1G -analyzeduration 200M" : string.Empty; } /// /// Gets the media info internal. /// /// The input path. /// if set to true [extract chapters]. /// The probe size argument. /// The cancellation token. /// Task{MediaInfoResult}. /// private async Task GetMediaInfoInternal(string inputPath, bool extractChapters, string probeSizeArgument, CancellationToken cancellationToken) { var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, // Must consume both or ffmpeg may hang due to deadlocks. See comments below. RedirectStandardOutput = true, RedirectStandardError = true, FileName = FFProbePath, Arguments = string.Format("{0} -i {1} -threads 0 -v info -print_format json -show_streams -show_format", probeSizeArgument, inputPath).Trim(), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false }, EnableRaisingEvents = true }; _logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); process.Exited += ProcessExited; await _ffProbeResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); MediaInfoResult result; string standardError = null; try { process.Start(); } catch (Exception ex) { _ffProbeResourcePool.Release(); _logger.ErrorException("Error starting ffprobe", ex); throw; } try { Task standardErrorReadTask = null; // MUST read both stdout and stderr asynchronously or a deadlock may occurr if (extractChapters) { standardErrorReadTask = process.StandardError.ReadToEndAsync(); } else { process.BeginErrorReadLine(); } result = _jsonSerializer.DeserializeFromStream(process.StandardOutput.BaseStream); if (extractChapters) { standardError = await standardErrorReadTask.ConfigureAwait(false); } } catch { // Hate having to do this try { process.Kill(); } catch (InvalidOperationException ex1) { _logger.ErrorException("Error killing ffprobe", ex1); } catch (Win32Exception ex1) { _logger.ErrorException("Error killing ffprobe", ex1); } throw; } finally { _ffProbeResourcePool.Release(); } if (result == null) { throw new ApplicationException(string.Format("FFProbe failed for {0}", inputPath)); } cancellationToken.ThrowIfCancellationRequested(); if (result.streams != null) { // Normalize aspect ratio if invalid foreach (var stream in result.streams) { if (string.Equals(stream.display_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase)) { stream.display_aspect_ratio = string.Empty; } if (string.Equals(stream.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase)) { stream.sample_aspect_ratio = string.Empty; } } } if (extractChapters && !string.IsNullOrEmpty(standardError)) { AddChapters(result, standardError); } return result; } /// /// The us culture /// protected readonly CultureInfo UsCulture = new CultureInfo("en-US"); /// /// Adds the chapters. /// /// The result. /// The standard error. private void AddChapters(MediaInfoResult result, string standardError) { var lines = standardError.Split('\n').Select(l => l.TrimStart()); var chapters = new List(); ChapterInfo lastChapter = null; foreach (var line in lines) { if (line.StartsWith("Chapter", StringComparison.OrdinalIgnoreCase)) { // Example: // Chapter #0.2: start 400.534, end 4565.435 const string srch = "start "; var start = line.IndexOf(srch, StringComparison.OrdinalIgnoreCase); if (start == -1) { continue; } var subString = line.Substring(start + srch.Length); subString = subString.Substring(0, subString.IndexOf(',')); double seconds; if (double.TryParse(subString, NumberStyles.Any, UsCulture, out seconds)) { lastChapter = new ChapterInfo { StartPositionTicks = TimeSpan.FromSeconds(seconds).Ticks }; chapters.Add(lastChapter); } } else if (line.StartsWith("title", StringComparison.OrdinalIgnoreCase)) { if (lastChapter != null && string.IsNullOrEmpty(lastChapter.Name)) { var index = line.IndexOf(':'); if (index != -1) { lastChapter.Name = line.Substring(index + 1).Trim().TrimEnd('\r'); } } } } result.Chapters = chapters; } /// /// Processes the exited. /// /// The sender. /// The instance containing the event data. void ProcessExited(object sender, EventArgs e) { ((Process)sender).Dispose(); } /// /// Converts the text subtitle to ass. /// /// The input path. /// The output path. /// The offset. /// The cancellation token. /// Task. /// inputPath /// or /// outputPath /// public async Task ConvertTextSubtitleToAss(string inputPath, string outputPath, TimeSpan offset, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { throw new ArgumentNullException("inputPath"); } if (string.IsNullOrEmpty(outputPath)) { throw new ArgumentNullException("outputPath"); } var offsetParam = offset.Ticks > 0 ? "-ss " + offset.TotalSeconds + " " : string.Empty; var process = new Process { StartInfo = new ProcessStartInfo { RedirectStandardOutput = false, RedirectStandardError = true, CreateNoWindow = true, UseShellExecute = false, FileName = FFMpegPath, Arguments = string.Format("{0}-i \"{1}\" \"{2}\"", offsetParam, inputPath, outputPath), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false } }; _logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); await _subtitleExtractionResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "ffmpeg-sub-convert-" + Guid.NewGuid() + ".txt"); var logFileStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous); try { process.Start(); } catch (Exception ex) { _subtitleExtractionResourcePool.Release(); logFileStream.Dispose(); _logger.ErrorException("Error starting ffmpeg", ex); throw; } var logTask = process.StandardError.BaseStream.CopyToAsync(logFileStream); var ranToCompletion = process.WaitForExit(60000); if (!ranToCompletion) { try { _logger.Info("Killing ffmpeg process"); process.Kill(); process.WaitForExit(1000); await logTask.ConfigureAwait(false); } catch (Win32Exception ex) { _logger.ErrorException("Error killing process", ex); } catch (InvalidOperationException ex) { _logger.ErrorException("Error killing process", ex); } catch (NotSupportedException ex) { _logger.ErrorException("Error killing process", ex); } finally { logFileStream.Dispose(); _subtitleExtractionResourcePool.Release(); } } var exitCode = ranToCompletion ? process.ExitCode : -1; process.Dispose(); var failed = false; if (exitCode == -1) { failed = true; if (File.Exists(outputPath)) { try { _logger.Info("Deleting converted subtitle due to failure: ", outputPath); File.Delete(outputPath); } catch (IOException ex) { _logger.ErrorException("Error deleting converted subtitle {0}", ex, outputPath); } } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { var msg = string.Format("ffmpeg subtitle converted failed for {0}", inputPath); _logger.Error(msg); throw new ApplicationException(msg); } } /// /// Extracts the text subtitle. /// /// The input files. /// The type. /// Index of the subtitle stream. /// The offset. /// The output path. /// The cancellation token. /// Task. /// Must use inputPath list overload public Task ExtractTextSubtitle(string[] inputFiles, InputType type, int subtitleStreamIndex, TimeSpan offset, string outputPath, CancellationToken cancellationToken) { return ExtractTextSubtitleInternal(GetInputArgument(inputFiles, type), subtitleStreamIndex, offset, outputPath, cancellationToken); } /// /// Extracts the text subtitle. /// /// The input path. /// Index of the subtitle stream. /// The offset. /// The output path. /// The cancellation token. /// Task. /// inputPath /// or /// outputPath /// or /// cancellationToken /// private async Task ExtractTextSubtitleInternal(string inputPath, int subtitleStreamIndex, TimeSpan offset, string outputPath, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { throw new ArgumentNullException("inputPath"); } if (string.IsNullOrEmpty(outputPath)) { throw new ArgumentNullException("outputPath"); } if (cancellationToken == null) { throw new ArgumentNullException("cancellationToken"); } var offsetParam = offset.Ticks > 0 ? "-ss " + offset.TotalSeconds + " " : string.Empty; var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = false, RedirectStandardError = true, FileName = FFMpegPath, Arguments = string.Format("{0}-i {1} -map 0:{2} -an -vn -c:s ass \"{3}\"", offsetParam, inputPath, subtitleStreamIndex, outputPath), WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false } }; _logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); await _subtitleExtractionResourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "ffmpeg-sub-extract-" + Guid.NewGuid() + ".txt"); var logFileStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous); try { process.Start(); } catch (Exception ex) { _subtitleExtractionResourcePool.Release(); logFileStream.Dispose(); _logger.ErrorException("Error starting ffmpeg", ex); throw; } process.StandardError.BaseStream.CopyToAsync(logFileStream); var ranToCompletion = process.WaitForExit(60000); if (!ranToCompletion) { try { _logger.Info("Killing ffmpeg process"); process.Kill(); process.WaitForExit(1000); } catch (Win32Exception ex) { _logger.ErrorException("Error killing process", ex); } catch (InvalidOperationException ex) { _logger.ErrorException("Error killing process", ex); } catch (NotSupportedException ex) { _logger.ErrorException("Error killing process", ex); } finally { logFileStream.Dispose(); _subtitleExtractionResourcePool.Release(); } } var exitCode = ranToCompletion ? process.ExitCode : -1; process.Dispose(); var failed = false; if (exitCode == -1) { failed = true; if (File.Exists(outputPath)) { try { _logger.Info("Deleting extracted subtitle due to failure: ", outputPath); File.Delete(outputPath); } catch (IOException ex) { _logger.ErrorException("Error deleting extracted subtitle {0}", ex, outputPath); } } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { var msg = string.Format("ffmpeg subtitle extraction failed for {0}", inputPath); _logger.Error(msg); throw new ApplicationException(msg); } } /// /// Extracts the image. /// /// The input files. /// The type. /// The offset. /// The output path. /// The cancellation token. /// Task. /// Must use inputPath list overload public async Task ExtractImage(string[] inputFiles, InputType type, TimeSpan? offset, string outputPath, CancellationToken cancellationToken) { var resourcePool = type == InputType.AudioFile ? _audioImageResourcePool : _videoImageResourcePool; var inputArgument = GetInputArgument(inputFiles, type); if (type != InputType.AudioFile) { try { await ExtractImageInternal(inputArgument, type, offset, outputPath, true, resourcePool, cancellationToken).ConfigureAwait(false); return; } catch { _logger.Error("I-frame image extraction failed, will attempt standard way. Input: {0}", inputArgument); } } await ExtractImageInternal(inputArgument, type, offset, outputPath, false, resourcePool, cancellationToken).ConfigureAwait(false); } /// /// Extracts the image. /// /// The input path. /// The type. /// The offset. /// The output path. /// if set to true [use I frame]. /// The resource pool. /// The cancellation token. /// Task. /// inputPath /// or /// outputPath /// private async Task ExtractImageInternal(string inputPath, InputType type, TimeSpan? offset, string outputPath, bool useIFrame, SemaphoreSlim resourcePool, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) { throw new ArgumentNullException("inputPath"); } if (string.IsNullOrEmpty(outputPath)) { throw new ArgumentNullException("outputPath"); } var args = useIFrame ? string.Format("-i {0} -threads 0 -v quiet -vframes 1 -filter:v select=\"eq(pict_type\\,I)\" -vf \"scale=iw*sar:ih, scale=600:-1\" -f image2 \"{1}\"", inputPath, outputPath) : string.Format("-i {0} -threads 0 -v quiet -vframes 1 -vf \"scale=iw*sar:ih, scale=600:-1\" -f image2 \"{1}\"", inputPath, outputPath); var probeSize = GetProbeSizeArgument(type); if (!string.IsNullOrEmpty(probeSize)) { args = probeSize + " " + args; } if (offset.HasValue) { args = string.Format("-ss {0} ", Convert.ToInt32(offset.Value.TotalSeconds)) + args; } var process = new Process { StartInfo = new ProcessStartInfo { CreateNoWindow = true, UseShellExecute = false, FileName = FFMpegPath, Arguments = args, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false } }; await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); var ranToCompletion = StartAndWaitForProcess(process); resourcePool.Release(); var exitCode = ranToCompletion ? process.ExitCode : -1; process.Dispose(); var failed = false; if (exitCode == -1) { failed = true; if (File.Exists(outputPath)) { try { _logger.Info("Deleting extracted image due to failure: ", outputPath); File.Delete(outputPath); } catch (IOException ex) { _logger.ErrorException("Error deleting extracted image {0}", ex, outputPath); } } } else if (!File.Exists(outputPath)) { failed = true; } if (failed) { var msg = string.Format("ffmpeg image extraction failed for {0}", inputPath); _logger.Error(msg); throw new ApplicationException(msg); } } /// /// Starts the and wait for process. /// /// The process. /// The timeout. /// true if XXXX, false otherwise private bool StartAndWaitForProcess(Process process, int timeout = 10000) { process.Start(); var ranToCompletion = process.WaitForExit(timeout); if (!ranToCompletion) { try { _logger.Info("Killing ffmpeg process"); process.Kill(); process.WaitForExit(1000); } catch (Win32Exception ex) { _logger.ErrorException("Error killing process", ex); } catch (InvalidOperationException ex) { _logger.ErrorException("Error killing process", ex); } catch (NotSupportedException ex) { _logger.ErrorException("Error killing process", ex); } } return ranToCompletion; } /// /// Gets the file input argument. /// /// The path. /// System.String. private string GetFileInputArgument(string path) { return string.Format("file:\"{0}\"", path); } /// /// Gets the concat input argument. /// /// The playable stream files. /// System.String. private string GetConcatInputArgument(string[] playableStreamFiles) { // Get all streams // If there's more than one we'll need to use the concat command if (playableStreamFiles.Length > 1) { var files = string.Join("|", playableStreamFiles); return string.Format("concat:\"{0}\"", files); } // Determine the input path for video files return GetFileInputArgument(playableStreamFiles[0]); } /// /// Gets the bluray input argument. /// /// The bluray root. /// System.String. private string GetBlurayInputArgument(string blurayRoot) { return string.Format("bluray:\"{0}\"", blurayRoot); } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() { Dispose(true); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool dispose) { if (dispose) { _videoImageResourcePool.Dispose(); } SetErrorMode(ErrorModes.SYSTEM_DEFAULT); } /// /// Sets the error mode. /// /// The u mode. /// ErrorModes. [DllImport("kernel32.dll")] static extern ErrorModes SetErrorMode(ErrorModes uMode); /// /// Enum ErrorModes /// [Flags] public enum ErrorModes : uint { /// /// The SYSTE m_ DEFAULT /// SYSTEM_DEFAULT = 0x0, /// /// The SE m_ FAILCRITICALERRORS /// SEM_FAILCRITICALERRORS = 0x0001, /// /// The SE m_ NOALIGNMENTFAULTEXCEPT /// SEM_NOALIGNMENTFAULTEXCEPT = 0x0004, /// /// The SE m_ NOGPFAULTERRORBOX /// SEM_NOGPFAULTERRORBOX = 0x0002, /// /// The SE m_ NOOPENFILEERRORBOX /// SEM_NOOPENFILEERRORBOX = 0x8000 } } }