Browse Source

fixes #674 - Support converting subtitles to webvtt

Luke Pulverenti 11 years ago
parent
commit
77ad0fc336

+ 32 - 16
MediaBrowser.Api/Library/SubtitleService.cs

@@ -1,7 +1,6 @@
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities;
 using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.MediaEncoding;
 using MediaBrowser.Controller.Providers;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Model.Entities;
@@ -9,6 +8,7 @@ using MediaBrowser.Model.Providers;
 using ServiceStack;
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
@@ -16,6 +16,7 @@ using System.Threading.Tasks;
 namespace MediaBrowser.Api.Library
 {
     [Route("/Videos/{Id}/Subtitles/{Index}", "GET", Summary = "Gets an external subtitle file")]
+    [Route("/Videos/{Id}/Subtitles/{Index}/Stream.{Format}", "GET", Summary = "Gets subtitles in a specified format (vtt).")]
     public class GetSubtitle
     {
         /// <summary>
@@ -25,8 +26,14 @@ namespace MediaBrowser.Api.Library
         [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
         public string Id { get; set; }
 
+        [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")]
+        public string MediaSourceId { get; set; }
+
         [ApiMember(Name = "Index", Description = "The subtitle stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")]
         public int Index { get; set; }
+
+        [ApiMember(Name = "Format", Description = "Format", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
+        public string Format { get; set; }
     }
 
     [Route("/Videos/{Id}/Subtitles/{Index}", "DELETE", Summary = "Deletes an external subtitle file")]
@@ -81,13 +88,13 @@ namespace MediaBrowser.Api.Library
     {
         private readonly ILibraryManager _libraryManager;
         private readonly ISubtitleManager _subtitleManager;
-        private readonly IItemRepository _itemRepo;
+        private readonly ISubtitleEncoder _subtitleEncoder;
 
-        public SubtitleService(ILibraryManager libraryManager, ISubtitleManager subtitleManager, IItemRepository itemRepo)
+        public SubtitleService(ILibraryManager libraryManager, ISubtitleManager subtitleManager, ISubtitleEncoder subtitleEncoder)
         {
             _libraryManager = libraryManager;
             _subtitleManager = subtitleManager;
-            _itemRepo = itemRepo;
+            _subtitleEncoder = subtitleEncoder;
         }
 
         public object Get(SearchRemoteSubtitles request)
@@ -100,21 +107,30 @@ namespace MediaBrowser.Api.Library
         }
         public object Get(GetSubtitle request)
         {
-            var subtitleStream = _itemRepo.GetMediaStreams(new MediaStreamQuery
+            if (string.IsNullOrEmpty(request.Format))
             {
+                var item = (Video)_libraryManager.GetItemById(new Guid(request.Id));
 
-                Index = request.Index,
-                ItemId = new Guid(request.Id),
-                Type = MediaStreamType.Subtitle
+                var mediaSource = item.GetMediaSources(false)
+                    .First(i => string.Equals(i.Id, request.MediaSourceId ?? request.Id));
 
-            }).FirstOrDefault();
+                var subtitleStream = mediaSource.MediaStreams
+                    .First(i => i.Type == MediaStreamType.Subtitle && i.Index == request.Index);
 
-            if (subtitleStream == null)
-            {
-                throw new ResourceNotFoundException();
+                return ToStaticFileResult(subtitleStream.Path);
             }
 
-            return ToStaticFileResult(subtitleStream.Path);
+            var stream = GetSubtitles(request).Result;
+
+            return ResultFactory.GetResult(stream, Common.Net.MimeTypes.GetMimeType("file." + request.Format));
+        }
+
+        private async Task<Stream> GetSubtitles(GetSubtitle request)
+        {
+            var stream = await _subtitleEncoder.GetSubtitles(request.Id, request.MediaSourceId, request.Index, request.Format,
+                        CancellationToken.None);
+
+            return stream;
         }
 
         public void Delete(DeleteSubtitle request)
@@ -135,7 +151,7 @@ namespace MediaBrowser.Api.Library
         {
             var result = _subtitleManager.GetRemoteSubtitles(request.Id, CancellationToken.None).Result;
 
-            return ResultFactory.GetResult(result.Stream, MimeTypes.GetMimeType("file." + result.Format));
+            return ResultFactory.GetResult(result.Stream, Common.Net.MimeTypes.GetMimeType("file." + result.Format));
         }
 
         public void Post(DownloadRemoteSubtitles request)

+ 5 - 0
MediaBrowser.Common/Net/MimeTypes.cs

@@ -223,6 +223,11 @@ namespace MediaBrowser.Common.Net
                 return "text/plain";
             }
 
+            if (ext.Equals(".vtt", StringComparison.OrdinalIgnoreCase))
+            {
+                return "text/vtt";
+            }
+
             throw new ArgumentException("Argument not supported: " + path);
         }
     }

+ 9 - 3
MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs

@@ -1,5 +1,4 @@
-using System;
-using System.IO;
+using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
 
@@ -7,9 +6,16 @@ namespace MediaBrowser.Controller.MediaEncoding
 {
     public interface ISubtitleEncoder
     {
-        Task<Stream> ConvertTextSubtitle(String stream, 
+        Task<Stream> ConvertSubtitles(
+            Stream stream, 
             string inputFormat, 
             string outputFormat,
             CancellationToken cancellationToken);
+
+        Task<Stream> GetSubtitles(string itemId, 
+            string mediaSourceId,
+            int subtitleStreamIndex,
+            string outputFormat,
+            CancellationToken cancellationToken);
     }
 }

+ 1 - 0
MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj

@@ -60,6 +60,7 @@
     <Compile Include="Subtitles\ISubtitleWriter.cs" />
     <Compile Include="Subtitles\SrtParser.cs" />
     <Compile Include="Subtitles\SsaParser.cs" />
+    <Compile Include="Subtitles\SubtitleEncoder.cs" />
     <Compile Include="Subtitles\SubtitleTrackInfo.cs" />
     <Compile Include="Subtitles\VttWriter.cs" />
   </ItemGroup>

+ 8 - 1
MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs

@@ -1,9 +1,16 @@
 using System.IO;
+using System.Threading;
 
 namespace MediaBrowser.MediaEncoding.Subtitles
 {
     public interface ISubtitleParser
     {
-        SubtitleTrackInfo Parse(Stream stream);
+        /// <summary>
+        /// Parses the specified stream.
+        /// </summary>
+        /// <param name="stream">The stream.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>SubtitleTrackInfo.</returns>
+        SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken);
     }
 }

+ 3 - 1
MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs

@@ -1,4 +1,5 @@
 using System.IO;
+using System.Threading;
 
 namespace MediaBrowser.MediaEncoding.Subtitles
 {
@@ -12,6 +13,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
         /// </summary>
         /// <param name="info">The information.</param>
         /// <param name="stream">The stream.</param>
-        void Write(SubtitleTrackInfo info, Stream stream);
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken);
     }
 }

+ 11 - 1
MediaBrowser.MediaEncoding/Subtitles/SrtParser.cs

@@ -3,25 +3,35 @@ using System.Collections.Generic;
 using System.Globalization;
 using System.IO;
 using System.Text.RegularExpressions;
+using System.Threading;
 
 namespace MediaBrowser.MediaEncoding.Subtitles
 {
     public class SrtParser : ISubtitleParser
     {
         private readonly CultureInfo _usCulture = new CultureInfo("en-US");
-        public SubtitleTrackInfo Parse(Stream stream) {
+        public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken)
+        {
             var trackInfo = new SubtitleTrackInfo();
             using ( var reader = new StreamReader(stream))
             {
                 string line;
                 while ((line = reader.ReadLine()) != null)
                 {
+                    cancellationToken.ThrowIfCancellationRequested();
+
                     if (string.IsNullOrWhiteSpace(line))
                     {
                         continue;
                     }
                     var subEvent = new SubtitleTrackEvent {Id = line};
                     line = reader.ReadLine();
+
+                    if (string.IsNullOrWhiteSpace(line))
+                    {
+                        continue;
+                    }
+                    
                     var time = Regex.Split(line, @"[\t ]*-->[\t ]*");
                     subEvent.StartPositionTicks = GetTicks(time[0]);
                     var endTime = time[1];

+ 4 - 1
MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs

@@ -4,6 +4,7 @@ using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Text.RegularExpressions;
+using System.Threading;
 
 namespace MediaBrowser.MediaEncoding.Subtitles
 {
@@ -11,7 +12,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
     {
         private readonly CultureInfo _usCulture = new CultureInfo("en-US");
 
-        public SubtitleTrackInfo Parse(Stream stream)
+        public SubtitleTrackInfo Parse(Stream stream, CancellationToken cancellationToken)
         {
             var trackInfo = new SubtitleTrackInfo();
             var eventIndex = 1;
@@ -24,6 +25,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
 
                 while ((line = reader.ReadLine()) != null)
                 {
+                    cancellationToken.ThrowIfCancellationRequested();
+                    
                     if (string.IsNullOrWhiteSpace(line))
                     {
                         continue;

+ 619 - 0
MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs

@@ -0,0 +1,619 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.IO;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Logging;
+using MediaBrowser.Model.MediaInfo;
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.MediaEncoding.Subtitles
+{
+    public class SubtitleEncoder : ISubtitleEncoder
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly ILogger _logger;
+        private readonly IApplicationPaths _appPaths;
+        private readonly IFileSystem _fileSystem;
+        private readonly IMediaEncoder _mediaEncoder;
+
+        public SubtitleEncoder(ILibraryManager libraryManager, ILogger logger, IApplicationPaths appPaths, IFileSystem fileSystem, IMediaEncoder mediaEncoder)
+        {
+            _libraryManager = libraryManager;
+            _logger = logger;
+            _appPaths = appPaths;
+            _fileSystem = fileSystem;
+            _mediaEncoder = mediaEncoder;
+        }
+
+        private string SubtitleCachePath
+        {
+            get
+            {
+                return Path.Combine(_appPaths.CachePath, "subtitles");
+            }
+        }
+
+        public async Task<Stream> ConvertSubtitles(Stream stream,
+            string inputFormat,
+            string outputFormat,
+            CancellationToken cancellationToken)
+        {
+            var ms = new MemoryStream();
+
+            try
+            {
+                if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase))
+                {
+                    await stream.CopyToAsync(ms, 81920, cancellationToken).ConfigureAwait(false);
+                }
+                else
+                {
+                    var trackInfo = await GetTrackInfo(stream, inputFormat, cancellationToken).ConfigureAwait(false);
+
+                    var writer = GetWriter(outputFormat);
+
+                    writer.Write(trackInfo, ms, cancellationToken);
+                }
+                ms.Position = 0;
+            }
+            catch
+            {
+                ms.Dispose();
+                throw;
+            }
+
+            return ms;
+        }
+
+        public async Task<Stream> GetSubtitles(string itemId,
+            string mediaSourceId,
+            int subtitleStreamIndex,
+            string outputFormat,
+            CancellationToken cancellationToken)
+        {
+            var subtitle = await GetSubtitleStream(itemId, mediaSourceId, subtitleStreamIndex, cancellationToken)
+                        .ConfigureAwait(false);
+
+            using (var stream = subtitle.Item1)
+            {
+                var inputFormat = subtitle.Item2;
+
+                return await ConvertSubtitles(stream, inputFormat, outputFormat, cancellationToken).ConfigureAwait(false);
+            }
+        }
+
+        private async Task<Tuple<Stream, string>> GetSubtitleStream(string itemId,
+            string mediaSourceId,
+            int subtitleStreamIndex,
+            CancellationToken cancellationToken)
+        {
+            var item = (Video)_libraryManager.GetItemById(new Guid(itemId));
+
+            var mediaSource = item.GetMediaSources(false)
+                .First(i => string.Equals(i.Id, mediaSourceId));
+
+            var subtitleStream = mediaSource.MediaStreams
+                .First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
+
+            var inputType = mediaSource.LocationType == LocationType.Remote ? InputType.Url : InputType.File;
+            var inputFiles = new[] { mediaSource.Path };
+
+            if (mediaSource.VideoType.HasValue)
+            {
+                if (mediaSource.VideoType.Value == VideoType.BluRay)
+                {
+                    inputType = InputType.Bluray;
+                    var mediaSourceItem = (Video)_libraryManager.GetItemById(new Guid(mediaSourceId));
+                    inputFiles = mediaSourceItem.GetPlayableStreamFiles().ToArray();
+                }
+                else if (mediaSource.VideoType.Value == VideoType.Dvd)
+                {
+                    inputType = InputType.Dvd;
+                    var mediaSourceItem = (Video)_libraryManager.GetItemById(new Guid(mediaSourceId));
+                    inputFiles = mediaSourceItem.GetPlayableStreamFiles().ToArray();
+                }
+            }
+
+            var fileInfo = await GetReadableFile(mediaSource.Path, inputFiles, inputType, subtitleStream, cancellationToken).ConfigureAwait(false);
+
+            var stream = File.OpenRead(fileInfo.Item1);
+
+            return new Tuple<Stream, string>(stream, fileInfo.Item2);
+        }
+
+        private async Task<Tuple<string, string>> GetReadableFile(string mediaPath,
+            string[] inputFiles,
+            InputType type,
+            MediaStream subtitleStream,
+            CancellationToken cancellationToken)
+        {
+            if (!subtitleStream.IsExternal)
+            {
+                // Extract    
+                var outputPath = GetSubtitleCachePath(mediaPath, subtitleStream.Index, ".ass");
+
+                await ExtractTextSubtitle(inputFiles, type, subtitleStream.Index, false, outputPath, cancellationToken)
+                        .ConfigureAwait(false);
+
+                return new Tuple<string, string>(outputPath, "ass");
+            }
+
+            var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
+                .TrimStart('.');
+
+            if (GetReader(currentFormat, false) == null)
+            {
+                // Convert    
+                var outputPath = GetSubtitleCachePath(mediaPath, subtitleStream.Index, ".ass");
+
+                await ConvertTextSubtitleToAss(subtitleStream.Path, outputPath, subtitleStream.Language, cancellationToken)
+                        .ConfigureAwait(false);
+
+                return new Tuple<string, string>(outputPath, "ass");
+            }
+
+            return new Tuple<string, string>(subtitleStream.Path, currentFormat);
+        }
+
+        private async Task<SubtitleTrackInfo> GetTrackInfo(Stream stream,
+            string inputFormat,
+            CancellationToken cancellationToken)
+        {
+            var reader = GetReader(inputFormat, true);
+
+            return reader.Parse(stream, cancellationToken);
+        }
+
+        private ISubtitleParser GetReader(string format, bool throwIfMissing)
+        {
+            if (string.IsNullOrEmpty(format))
+            {
+                throw new ArgumentNullException("format");
+            }
+
+            if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase))
+            {
+                return new SrtParser();
+            }
+            if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase) ||
+                string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
+            {
+                return new SsaParser();
+            }
+
+            if (throwIfMissing)
+            {
+                throw new ArgumentException("Unsupported format: " + format);
+            }
+
+            return null;
+        }
+
+        private ISubtitleWriter GetWriter(string format)
+        {
+            if (string.IsNullOrEmpty(format))
+            {
+                throw new ArgumentNullException("format");
+            }
+
+            if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase))
+            {
+                return new VttWriter();
+            }
+
+            throw new ArgumentException("Unsupported format: " + format);
+        }
+
+        /// <summary>
+        /// The _semaphoreLocks
+        /// </summary>
+        private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks =
+            new ConcurrentDictionary<string, SemaphoreSlim>();
+
+        /// <summary>
+        /// Gets the lock.
+        /// </summary>
+        /// <param name="filename">The filename.</param>
+        /// <returns>System.Object.</returns>
+        private SemaphoreSlim GetLock(string filename)
+        {
+            return _semaphoreLocks.GetOrAdd(filename, key => new SemaphoreSlim(1, 1));
+        }
+
+        /// <summary>
+        /// Converts the text subtitle to ass.
+        /// </summary>
+        /// <param name="inputPath">The input path.</param>
+        /// <param name="outputPath">The output path.</param>
+        /// <param name="language">The language.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        public async Task ConvertTextSubtitleToAss(string inputPath, string outputPath, string language,
+            CancellationToken cancellationToken)
+        {
+            var semaphore = GetLock(outputPath);
+
+            await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+            try
+            {
+                if (!File.Exists(outputPath))
+                {
+                    await ConvertTextSubtitleToAssInternal(inputPath, outputPath, language).ConfigureAwait(false);
+                }
+            }
+            finally
+            {
+                semaphore.Release();
+            }
+        }
+
+        /// <summary>
+        /// Converts the text subtitle to ass.
+        /// </summary>
+        /// <param name="inputPath">The input path.</param>
+        /// <param name="outputPath">The output path.</param>
+        /// <param name="language">The language.</param>
+        /// <returns>Task.</returns>
+        /// <exception cref="System.ArgumentNullException">inputPath
+        /// or
+        /// outputPath</exception>
+        /// <exception cref="System.ApplicationException"></exception>
+        private async Task ConvertTextSubtitleToAssInternal(string inputPath, string outputPath, string language)
+        {
+            if (string.IsNullOrEmpty(inputPath))
+            {
+                throw new ArgumentNullException("inputPath");
+            }
+
+            if (string.IsNullOrEmpty(outputPath))
+            {
+                throw new ArgumentNullException("outputPath");
+            }
+
+
+            var encodingParam = string.IsNullOrEmpty(language)
+                ? string.Empty
+                : _mediaEncoder.GetSubtitleLanguageEncodingParam(inputPath, language);
+
+            if (!string.IsNullOrEmpty(encodingParam))
+            {
+                encodingParam = " -sub_charenc " + encodingParam;
+            }
+
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    RedirectStandardOutput = false,
+                    RedirectStandardError = true,
+
+                    CreateNoWindow = true,
+                    UseShellExecute = false,
+                    FileName = _mediaEncoder.EncoderPath,
+                    Arguments =
+                        string.Format("{0} -i \"{1}\" -c:s ass \"{2}\"", encodingParam, inputPath, outputPath),
+
+                    WindowStyle = ProcessWindowStyle.Hidden,
+                    ErrorDialog = false
+                }
+            };
+
+            _logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+            var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "ffmpeg-sub-convert-" + Guid.NewGuid() + ".txt");
+            Directory.CreateDirectory(Path.GetDirectoryName(logFilePath));
+
+            var logFileStream = _fileSystem.GetFileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read,
+                true);
+
+            try
+            {
+                process.Start();
+            }
+            catch (Exception ex)
+            {
+                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 subtitle conversion process");
+
+                    process.Kill();
+
+                    process.WaitForExit(1000);
+
+                    await logTask.ConfigureAwait(false);
+                }
+                catch (Exception ex)
+                {
+                    _logger.ErrorException("Error killing subtitle conversion process", ex);
+                }
+                finally
+                {
+                    logFileStream.Dispose();
+                }
+            }
+
+            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);
+            }
+            await SetAssFont(outputPath).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Extracts the text subtitle.
+        /// </summary>
+        /// <param name="inputFiles">The input files.</param>
+        /// <param name="type">The type.</param>
+        /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
+        /// <param name="copySubtitleStream">if set to true, copy stream instead of converting.</param>
+        /// <param name="outputPath">The output path.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        /// <exception cref="System.ArgumentException">Must use inputPath list overload</exception>
+        private async Task ExtractTextSubtitle(string[] inputFiles, InputType type, int subtitleStreamIndex,
+            bool copySubtitleStream, string outputPath, CancellationToken cancellationToken)
+        {
+            var semaphore = GetLock(outputPath);
+
+            await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+            try
+            {
+                if (!File.Exists(outputPath))
+                {
+                    await ExtractTextSubtitleInternal(_mediaEncoder.GetInputArgument(inputFiles, type), subtitleStreamIndex,
+                            copySubtitleStream, outputPath, cancellationToken).ConfigureAwait(false);
+                }
+            }
+            finally
+            {
+                semaphore.Release();
+            }
+        }
+
+        /// <summary>
+        /// Extracts the text subtitle.
+        /// </summary>
+        /// <param name="inputPath">The input path.</param>
+        /// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
+        /// <param name="copySubtitleStream">if set to true, copy stream instead of converting.</param>
+        /// <param name="outputPath">The output path.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        /// <exception cref="System.ArgumentNullException">inputPath
+        /// or
+        /// outputPath
+        /// or
+        /// cancellationToken</exception>
+        /// <exception cref="System.ApplicationException"></exception>
+        private async Task ExtractTextSubtitleInternal(string inputPath, int subtitleStreamIndex,
+            bool copySubtitleStream, string outputPath, CancellationToken cancellationToken)
+        {
+            if (string.IsNullOrEmpty(inputPath))
+            {
+                throw new ArgumentNullException("inputPath");
+            }
+
+            if (string.IsNullOrEmpty(outputPath))
+            {
+                throw new ArgumentNullException("outputPath");
+            }
+
+            string processArgs = string.Format("-i {0} -map 0:{1} -an -vn -c:s ass \"{2}\"", inputPath,
+                subtitleStreamIndex, outputPath);
+
+            if (copySubtitleStream)
+            {
+                processArgs = string.Format("-i {0} -map 0:{1} -an -vn -c:s copy \"{2}\"", inputPath,
+                    subtitleStreamIndex, outputPath);
+            }
+
+            var process = new Process
+            {
+                StartInfo = new ProcessStartInfo
+                {
+                    CreateNoWindow = true,
+                    UseShellExecute = false,
+
+                    RedirectStandardOutput = false,
+                    RedirectStandardError = true,
+
+                    FileName = _mediaEncoder.EncoderPath,
+                    Arguments = processArgs,
+                    WindowStyle = ProcessWindowStyle.Hidden,
+                    ErrorDialog = false
+                }
+            };
+
+            _logger.Debug("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+            var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "ffmpeg-sub-extract-" + Guid.NewGuid() + ".txt");
+            Directory.CreateDirectory(Path.GetDirectoryName(logFilePath));
+
+            var logFileStream = _fileSystem.GetFileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read,
+                true);
+
+            try
+            {
+                process.Start();
+            }
+            catch (Exception ex)
+            {
+                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 subtitle extraction process");
+
+                    process.Kill();
+
+                    process.WaitForExit(1000);
+                }
+                catch (Exception ex)
+                {
+                    _logger.ErrorException("Error killing subtitle extraction process", ex);
+                }
+                finally
+                {
+                    logFileStream.Dispose();
+                }
+            }
+
+            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} to {1}", inputPath, outputPath);
+
+                _logger.Error(msg);
+
+                throw new ApplicationException(msg);
+            }
+            else
+            {
+                var msg = string.Format("ffmpeg subtitle extraction completed for {0} to {1}", inputPath, outputPath);
+
+                _logger.Info(msg);
+            }
+
+            await SetAssFont(outputPath).ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Sets the ass font.
+        /// </summary>
+        /// <param name="file">The file.</param>
+        /// <returns>Task.</returns>
+        private async Task SetAssFont(string file)
+        {
+            _logger.Info("Setting ass font within {0}", file);
+
+            string text;
+            Encoding encoding;
+
+            using (var reader = new StreamReader(file, true))
+            {
+                encoding = reader.CurrentEncoding;
+
+                text = await reader.ReadToEndAsync().ConfigureAwait(false);
+            }
+
+            var newText = text.Replace(",Arial,", ",Arial Unicode MS,");
+
+            if (!string.Equals(text, newText))
+            {
+                using (var writer = new StreamWriter(file, false, encoding))
+                {
+                    writer.Write(newText);
+                }
+            }
+        }
+
+        private string GetSubtitleCachePath(string mediaPath, int subtitleStreamIndex, string outputSubtitleExtension)
+        {
+            var ticksParam = string.Empty;
+
+            var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
+
+            var filename = (mediaPath + "_" + subtitleStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture) + ticksParam).GetMD5() + outputSubtitleExtension;
+
+            var prefix = filename.Substring(0, 1);
+
+            return Path.Combine(SubtitleCachePath, prefix, filename);
+        }
+    }
+}

+ 14 - 6
MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs

@@ -1,26 +1,34 @@
 using System;
-using System.Collections.Generic;
 using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
+using System.Threading;
 
 namespace MediaBrowser.MediaEncoding.Subtitles
 {
     public class VttWriter : ISubtitleWriter
     {
-        public void Write(SubtitleTrackInfo info, Stream stream) {
-            using (var writer = new StreamWriter(stream))
+        public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
+        {
+            var writer = new StreamWriter(stream);
+
+            try
             {
                 writer.WriteLine("WEBVTT");
                 writer.WriteLine(string.Empty);
                 foreach (var trackEvent in info.TrackEvents)
                 {
+                    cancellationToken.ThrowIfCancellationRequested();
+
                     writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff}", TimeSpan.FromTicks(trackEvent.StartPositionTicks), TimeSpan.FromTicks(trackEvent.EndPositionTicks));
                     writer.WriteLine(trackEvent.Text);
                     writer.WriteLine(string.Empty);
                 }
             }
+            catch
+            {
+                writer.Dispose();
+
+                throw;
+            }
         }
     }
 }

+ 5 - 1
MediaBrowser.Server.Implementations/MediaEncoder/EncodingManager.cs

@@ -26,7 +26,11 @@ namespace MediaBrowser.Server.Implementations.MediaEncoder
         private readonly IMediaEncoder _encoder;
         private readonly IChapterManager _chapterManager;
 
-        public EncodingManager(IServerConfigurationManager config, IFileSystem fileSystem, ILogger logger, IMediaEncoder encoder, IChapterManager chapterManager)
+        public EncodingManager(IServerConfigurationManager config, 
+            IFileSystem fileSystem, 
+            ILogger logger, 
+            IMediaEncoder encoder, 
+            IChapterManager chapterManager)
         {
             _config = config;
             _fileSystem = fileSystem;

+ 4 - 1
MediaBrowser.ServerApplication/ApplicationHost.cs

@@ -40,6 +40,7 @@ using MediaBrowser.Dlna.ContentDirectory;
 using MediaBrowser.Dlna.Main;
 using MediaBrowser.MediaEncoding.BdInfo;
 using MediaBrowser.MediaEncoding.Encoder;
+using MediaBrowser.MediaEncoding.Subtitles;
 using MediaBrowser.Model.Logging;
 using MediaBrowser.Model.MediaInfo;
 using MediaBrowser.Model.System;
@@ -550,6 +551,8 @@ namespace MediaBrowser.ServerApplication
                 MediaEncoder, ChapterManager);
             RegisterSingleInstance(EncodingManager);
 
+            RegisterSingleInstance<ISubtitleEncoder>(new SubtitleEncoder(LibraryManager, LogManager.GetLogger("SubtitleEncoder"), ApplicationPaths, FileSystemManager, MediaEncoder));
+
             var displayPreferencesTask = Task.Run(async () => await ConfigureDisplayPreferencesRepositories().ConfigureAwait(false));
             var itemsTask = Task.Run(async () => await ConfigureItemRepositories().ConfigureAwait(false));
             var userdataTask = Task.Run(async () => await ConfigureUserDataRepositories().ConfigureAwait(false));
@@ -732,7 +735,7 @@ namespace MediaBrowser.ServerApplication
 
             SubtitleManager.AddParts(GetExports<ISubtitleProvider>());
             ChapterManager.AddParts(GetExports<IChapterProvider>());
-       
+
             SessionManager.AddParts(GetExports<ISessionControllerFactory>());
 
             ChannelManager.AddParts(GetExports<IChannel>(), GetExports<IChannelFactory>());

+ 2 - 1
MediaBrowser.Tests/MediaEncoding/Subtitles/SrtParserTests.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
+using System.Threading;
 using MediaBrowser.MediaEncoding.Subtitles;
 using Microsoft.VisualStudio.TestTools.UnitTesting;
 
@@ -91,7 +92,7 @@ namespace MediaBrowser.Tests.MediaEncoding.Subtitles {
 
             var stream = File.OpenRead(@"MediaEncoding\Subtitles\TestSubtitles\unit.srt");
 
-            var result = sut.Parse(stream);
+            var result = sut.Parse(stream, CancellationToken.None);
 
             Assert.IsNotNull(result);
             Assert.AreEqual(expectedSubs.TrackEvents.Count,result.TrackEvents.Count);

+ 2 - 1
MediaBrowser.Tests/MediaEncoding/Subtitles/SsaParserTests.cs

@@ -1,6 +1,7 @@
 using System;
 using System.Collections.Generic;
 using System.IO;
+using System.Threading;
 using MediaBrowser.MediaEncoding.Subtitles;
 using Microsoft.VisualStudio.TestTools.UnitTesting;
 
@@ -42,7 +43,7 @@ namespace MediaBrowser.Tests.MediaEncoding.Subtitles {
 
             var stream = File.OpenRead(@"MediaEncoding\Subtitles\TestSubtitles\data.ssa");
 
-            var result = sut.Parse(stream);
+            var result = sut.Parse(stream, CancellationToken.None);
 
             Assert.IsNotNull(result);
             Assert.AreEqual(expectedSubs.TrackEvents.Count,result.TrackEvents.Count);

+ 2 - 1
MediaBrowser.Tests/MediaEncoding/Subtitles/VttWriterTest.cs

@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.IO;
+using System.Threading;
 using MediaBrowser.MediaEncoding.Subtitles;
 using Microsoft.VisualStudio.TestTools.UnitTesting;
 
@@ -91,7 +92,7 @@ namespace MediaBrowser.Tests.MediaEncoding.Subtitles {
                 File.Delete("testVTT.vtt");
             using (var file = File.OpenWrite("testVTT.vtt"))
             {
-                sut.Write(infoSubs,file);
+                sut.Write(infoSubs, file, CancellationToken.None);
             }
 
             var result = File.ReadAllText("testVTT.vtt");

+ 2 - 2
Nuget/MediaBrowser.Common.Internal.nuspec

@@ -2,7 +2,7 @@
 <package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
     <metadata>
         <id>MediaBrowser.Common.Internal</id>
-        <version>3.0.400</version>
+        <version>3.0.401</version>
         <title>MediaBrowser.Common.Internal</title>
         <authors>Luke</authors>
         <owners>ebr,Luke,scottisafool</owners>
@@ -12,7 +12,7 @@
         <description>Contains common components shared by Media Browser Theater and Media Browser Server. Not intended for plugin developer consumption.</description>
         <copyright>Copyright © Media Browser 2013</copyright>
         <dependencies>
-            <dependency id="MediaBrowser.Common" version="3.0.400" />
+            <dependency id="MediaBrowser.Common" version="3.0.401" />
             <dependency id="NLog" version="2.1.0" />
             <dependency id="SimpleInjector" version="2.5.0" />
             <dependency id="sharpcompress" version="0.10.2" />

+ 1 - 1
Nuget/MediaBrowser.Common.nuspec

@@ -2,7 +2,7 @@
 <package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
     <metadata>
         <id>MediaBrowser.Common</id>
-        <version>3.0.400</version>
+        <version>3.0.401</version>
         <title>MediaBrowser.Common</title>
         <authors>Media Browser Team</authors>
         <owners>ebr,Luke,scottisafool</owners>

+ 2 - 2
Nuget/MediaBrowser.Server.Core.nuspec

@@ -2,7 +2,7 @@
 <package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
     <metadata>
         <id>MediaBrowser.Server.Core</id>
-        <version>3.0.400</version>
+        <version>3.0.401</version>
         <title>Media Browser.Server.Core</title>
         <authors>Media Browser Team</authors>
         <owners>ebr,Luke,scottisafool</owners>
@@ -12,7 +12,7 @@
         <description>Contains core components required to build plugins for Media Browser Server.</description>
         <copyright>Copyright © Media Browser 2013</copyright>
         <dependencies>
-            <dependency id="MediaBrowser.Common" version="3.0.400" />
+            <dependency id="MediaBrowser.Common" version="3.0.401" />
         </dependencies>
     </metadata>
     <files>