aboutsummaryrefslogtreecommitdiff
path: root/MediaBrowser.MediaEncoding
diff options
context:
space:
mode:
Diffstat (limited to 'MediaBrowser.MediaEncoding')
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs206
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs47
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs3
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs20
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj1
-rw-r--r--MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs8
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs125
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs57
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs72
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs49
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs57
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs521
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs60
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs53
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs1
16 files changed, 600 insertions, 700 deletions
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index f7a1581a76..9dd3dcecba 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -1,8 +1,10 @@
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 AsyncKeyedLock;
@@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
- foreach (var attachment in mediaSource.MediaAttachments)
- {
- if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
- {
- await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
- }
- }
+ await ExtractAllAttachmentsIndividuallyInternal(
+ inputFile,
+ mediaSource,
+ cancellationToken).ConfigureAwait(false);
}
else
{
@@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments
}
}
+ private async Task ExtractAllAttachmentsIndividuallyInternal(
+ string inputFile,
+ MediaSourceInfo mediaSource,
+ CancellationToken cancellationToken)
+ {
+ var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
+
+ ArgumentException.ThrowIfNullOrEmpty(inputPath);
+
+ var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (outputFolder is null)
+ {
+ _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
+ return;
+ }
+
+ using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
+ {
+ Directory.CreateDirectory(outputFolder);
+
+ var dumpArgs = new StringBuilder();
+ var missingPaths = new List<string>();
+ foreach (var attachment in mediaSource.MediaAttachments)
+ {
+ if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName)
+ ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
+ if (File.Exists(attachmentPath))
+ {
+ continue;
+ }
+
+ dumpArgs.AppendFormat(
+ CultureInfo.InvariantCulture,
+ "-dump_attachment:{0} \"{1}\" ",
+ attachment.Index,
+ EncodingUtils.NormalizePath(attachmentPath));
+ missingPaths.Add(attachmentPath);
+ }
+
+ if (missingPaths.Count == 0)
+ {
+ // Skip extraction if all files already exist
+ return;
+ }
+
+ var hasVideoOrAudioStream = mediaSource.MediaStreams
+ .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
+ var processArgs = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}{1} -i {2} {3}",
+ dumpArgs,
+ inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
+ inputPath,
+ hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
+
+ int exitCode;
+
+ using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
+ {
+ _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+
+ process.Start();
+
+ try
+ {
+ await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
+ exitCode = process.ExitCode;
+ }
+ catch (OperationCanceledException)
+ {
+ process.Kill(true);
+ exitCode = -1;
+ }
+ }
+
+ var failed = false;
+
+ if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
+ {
+ failed = true;
+
+ foreach (var path in missingPaths)
+ {
+ if (!File.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ _fileSystem.DeleteFile(path);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
+ }
+ }
+ }
+
+ if (!failed && missingPaths.Exists(p => !File.Exists(p)))
+ {
+ failed = true;
+ }
+
+ if (failed)
+ {
+ _logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
+
+ throw new InvalidOperationException(
+ string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
+ }
+
+ _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
+ }
+ }
+
private async Task ExtractAllAttachmentsInternal(
string inputFile,
MediaSourceInfo mediaSource,
@@ -129,6 +262,12 @@ namespace MediaBrowser.MediaEncoding.Attachments
ArgumentException.ThrowIfNullOrEmpty(inputPath);
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (outputFolder is null)
+ {
+ _logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
+ return;
+ }
+
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
var directory = Directory.CreateDirectory(outputFolder);
@@ -157,19 +296,19 @@ namespace MediaBrowser.MediaEncoding.Attachments
int exitCode;
using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
{
- StartInfo = new ProcessStartInfo
- {
- Arguments = processArgs,
- FileName = _mediaEncoder.EncoderPath,
- UseShellExecute = false,
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden,
- WorkingDirectory = outputFolder,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ WorkingDirectory = outputFolder,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
@@ -241,9 +380,14 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
+ if (attachmentFolderPath is null)
+ {
+ throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id, e.g. Live TV stream).");
+ }
+
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
- var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
@@ -284,18 +428,18 @@ namespace MediaBrowser.MediaEncoding.Attachments
int exitCode;
using (var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
{
- StartInfo = new ProcessStartInfo
- {
- Arguments = processArgs,
- FileName = _mediaEncoder.EncoderPath,
- UseShellExecute = false,
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
+ Arguments = processArgs,
+ FileName = _mediaEncoder.EncoderPath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ ErrorDialog = false
+ },
+ EnableRaisingEvents = true
+ })
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
diff --git a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
index a8ff58b091..baea0df8cc 100644
--- a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
@@ -1,9 +1,11 @@
#pragma warning disable CA1031
using System;
+using System.Buffers;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
+using System.Text;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Encoder;
@@ -12,43 +14,43 @@ namespace MediaBrowser.MediaEncoding.Encoder;
/// Helper class for Apple platform specific operations.
/// </summary>
[SupportedOSPlatform("macos")]
-public static class ApplePlatformHelper
+public static partial class ApplePlatformHelper
{
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
- private static string GetSysctlValue(ReadOnlySpan<byte> name)
+ internal static string GetSysctlValue(string name)
{
- IntPtr length = IntPtr.Zero;
+ nuint length = 0;
// Get length of the value
- int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0);
-
- if (osStatus != 0)
+ int osStatus = sysctlbyname(name, Span<byte>.Empty, ref length, IntPtr.Zero, 0);
+ if (osStatus != 0 || length == 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32());
+ byte[] buffer = ArrayPool<byte>.Shared.Rent((int)length);
try
{
- osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0);
+ osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0);
if (osStatus != 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- return Marshal.PtrToStringAnsi(buffer) ?? string.Empty;
+ if (length < 1)
+ {
+ return string.Empty;
+ }
+
+ ReadOnlySpan<byte> data = buffer.AsSpan()[..(int)(length - 1)];
+ return Encoding.UTF8.GetString(data);
}
finally
{
- Marshal.FreeHGlobal(buffer);
+ ArrayPool<byte>.Shared.Return(buffer);
}
}
- private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen)
- {
- return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen);
- }
-
/// <summary>
/// Check if the current system has hardware acceleration for AV1 decoding.
/// </summary>
@@ -63,7 +65,7 @@ public static class ApplePlatformHelper
try
{
- string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8);
+ string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string");
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
}
catch (NotSupportedException e)
@@ -78,10 +80,7 @@ public static class ApplePlatformHelper
return false;
}
- private static class NativeMethods
- {
- [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
- [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
- internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen);
- }
+ [LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
+ internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen);
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 68d6d215b2..c8670c67cc 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -6,6 +6,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Versioning;
+using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -645,7 +646,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
RedirectStandardInput = redirectStandardIn,
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
}
})
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index f34e911a05..1199fd7d70 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -85,6 +86,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
private bool _isVaapiDeviceSupportVulkanDrmModifier = false;
private bool _isVaapiDeviceSupportVulkanDrmInterop = false;
+ private bool _canSetProcessPriority = true;
+
private bool _isVideoToolboxAv1DecodeAvailable = false;
private static string[] _vulkanImageDrmFmtModifierExts =
@@ -526,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
UseShellExecute = false,
// Must consume both or ffmpeg may hang due to deadlocks.
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
FileName = _ffprobePath,
@@ -1123,13 +1127,17 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.Start();
- try
- {
- process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
- }
- catch (Exception ex)
+ if (_canSetProcessPriority)
{
- _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName);
+ try
+ {
+ process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch (Exception ex)
+ {
+ _canSetProcessPriority = false;
+ _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}. Further attempts will be skipped.", process.Process.StartInfo.FileName);
+ }
}
lock (_runningProcessesLock)
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index fc11047a7f..288cd3e189 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -9,6 +9,7 @@
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
index 975c2b8161..fa2085ca6f 100644
--- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
+++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
@@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private static Dictionary<string, string?> ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary<string, string?> dict)
{
- return new Dictionary<string, string?>(dict, StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary<string, string?>(dict.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in dict)
+ {
+ result.TryAdd(key, value);
+ }
+
+ return result;
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index a4d17e4f9d..203e565ac7 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing
{
if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue)
{
- mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels);
+ mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels);
}
}
- var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum();
- // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong
- if (videoStreamsBitrate == (info.Bitrate ?? 0))
+ // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers).
+ // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates.
+ var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList();
+ if (info.Bitrate.HasValue
+ && videoStreams.Count == 1
+ && !videoStreams[0].BitRate.HasValue)
{
- info.InferTotalBitrate(true);
+ var otherStreams = info.MediaStreams
+ .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal)
+ .ToList();
+
+ // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known.
+ var audioBitratesKnown = otherStreams
+ .Where(i => i.Type == MediaStreamType.Audio)
+ .All(i => i.BitRate.HasValue);
+
+ if (audioBitratesKnown)
+ {
+ var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0);
+ if (estimatedVideoBitrate > 0)
+ {
+ videoStreams[0].BitRate = estimatedVideoBitrate;
+ }
+ }
}
+
+ // If the container bitrate is still unknown, infer it from the sum of the streams.
+ info.InferTotalBitrate();
}
return info;
@@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
- private static int? GetEstimatedAudioBitrate(string codec, int? channels)
+ internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels)
{
- if (!channels.HasValue)
+ if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec))
{
return null;
}
- var channelsValue = channels.Value;
-
- if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 320000;
- }
- }
-
- if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 640000;
- }
- }
+ // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate.
+ var channelCount = channels.Value;
+ var isMultichannel = channelCount > 2;
- if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
+ return codec.ToLowerInvariant() switch
{
- switch (channelsValue)
- {
- case <= 2:
- return 960000;
- case >= 5:
- return 2880000;
- }
- }
-
- return null;
+ "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000,
+ "ac3" or "eac3" => isMultichannel ? 640000 : 192000,
+ "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000),
+ "opus" => isMultichannel ? 256000 : 128000,
+ "vorbis" => isMultichannel ? 320000 : 160000,
+ "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000,
+ "flac" or "alac" => channelCount * 480000,
+ "truehd" or "mlp" => channelCount * 700000,
+ _ => null
+ };
}
+ private static bool IsDtsLossless(string profile)
+ => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase);
+
private void FetchFromItunesInfo(string xml, MediaInfo info)
{
// Make things simpler and strip out the dtd
@@ -729,6 +731,7 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.Type = MediaStreamType.Audio;
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
+ stream.LocalizedOriginal = _localization.GetLocalizedString("Original");
stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
? null
: _localization.FindLanguageInfo(stream.Language)?.DisplayName;
@@ -971,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing
bitrate = value;
}
- // The bitrate info of FLAC musics and some videos is included in formatInfo.
+ // The bitrate info of FLAC audio is included in formatInfo.
+ // Don't do this for video streams: formatInfo.BitRate is the overall container
+ // bitrate (video + audio + subtitles + overhead), not the video bitrate.
if (bitrate == 0
&& formatInfo is not null
- && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
+ && isAudio && stream.Type == MediaStreamType.Audio)
{
// If the stream info doesn't have a bitrate get the value from the media format info
if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value))
@@ -1031,6 +1036,11 @@ namespace MediaBrowser.MediaEncoding.Probing
{
stream.IsHearingImpaired = true;
}
+
+ if (disposition.GetValueOrDefault("original") == 1)
+ {
+ stream.IsOriginal = true;
+ }
}
NormalizeStreamTitle(stream);
@@ -1254,9 +1264,16 @@ namespace MediaBrowser.MediaEncoding.Probing
}
var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
- if (TimeSpan.TryParse(duration, out var parsedDuration))
+ if (!string.IsNullOrEmpty(duration))
{
- return parsedDuration.TotalSeconds;
+ // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but
+ // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so
+ // these durations parse instead of being silently dropped.
+ duration = DurationOverPrecisionRegex().Replace(duration, "$1");
+ if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration))
+ {
+ return parsedDuration.TotalSeconds;
+ }
}
return null;
@@ -1624,7 +1641,7 @@ namespace MediaBrowser.MediaEncoding.Probing
// Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/
// DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None)
- if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate))
+ if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate))
{
video.PremiereDate = parsedDate;
}
@@ -1702,6 +1719,13 @@ namespace MediaBrowser.MediaEncoding.Probing
return;
}
+ // Skip timestamp extration for remote resource (http, rtsp, etc.)
+ // as they cannot be opened with FileStream
+ if (video.Protocol != MediaProtocol.File)
+ {
+ return;
+ }
+
if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase))
@@ -1751,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing
[GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")]
private static partial Regex PerformerRegex();
+
+ [GeneratedRegex(@"(\.\d{7})\d+")]
+ private static partial Regex DurationOverPrecisionRegex();
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs
deleted file mode 100644
index 7d7b80e99d..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// ASS subtitle writer.
- /// </summary>
- public partial class AssWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
- var timeFormat = @"hh\:mm\:ss\.ff";
-
- // Write ASS header
- writer.WriteLine("[Script Info]");
- writer.WriteLine("Title: Jellyfin transcoded ASS subtitle");
- writer.WriteLine("ScriptType: v4.00+");
- writer.WriteLine();
- writer.WriteLine("[V4+ Styles]");
- writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding");
- writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1");
- writer.WriteLine();
- writer.WriteLine("[Events]");
- writer.WriteLine("Format: Layer, Start, End, Style, Text");
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
-
- writer.WriteLine(
- "Dialogue: 0,{0},{1},Default,{2}",
- startTime,
- endTime,
- text);
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs
deleted file mode 100644
index dec714121d..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleWriter.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.IO;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// Interface ISubtitleWriter.
- /// </summary>
- public interface ISubtitleWriter
- {
- /// <summary>
- /// Writes the specified information.
- /// </summary>
- /// <param name="info">The information.</param>
- /// <param name="stream">The stream.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken);
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
index 1b452b0cec..0e40181016 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/JsonWriter.cs
@@ -1,44 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
using System.IO;
+using System.Text;
using System.Text.Json;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Core.SubtitleFormats;
-namespace MediaBrowser.MediaEncoding.Subtitles
+namespace MediaBrowser.MediaEncoding.Subtitles;
+
+/// <summary>
+/// JSON subtitle writer.
+/// </summary>
+public class JsonWriter : SubtitleFormat
{
- /// <summary>
- /// JSON subtitle writer.
- /// </summary>
- public class JsonWriter : ISubtitleWriter
+ /// <inheritdoc />
+ public override string Extension => ".json";
+
+ /// <inheritdoc />
+ public override string Name => "JSON Jellyfin";
+
+ /// <inheritdoc />
+ public override string ToText(Subtitle subtitle, string title)
{
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
+ using var ms = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(ms))
{
- using (var writer = new Utf8JsonWriter(stream))
+ var trackevents = subtitle.Paragraphs;
+ writer.WriteStartObject();
+ writer.WriteStartArray("TrackEvents");
+
+ for (int i = 0; i < trackevents.Count; i++)
{
- var trackevents = info.TrackEvents;
+ var current = trackevents[i];
writer.WriteStartObject();
- writer.WriteStartArray("TrackEvents");
-
- for (int i = 0; i < trackevents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var current = trackevents[i];
- writer.WriteStartObject();
- writer.WriteString("Id", current.Id);
- writer.WriteString("Text", current.Text);
- writer.WriteNumber("StartPositionTicks", current.StartPositionTicks);
- writer.WriteNumber("EndPositionTicks", current.EndPositionTicks);
+ writer.WriteString("Id", current.Number.ToString(CultureInfo.InvariantCulture));
+ writer.WriteString("Text", current.Text);
+ writer.WriteNumber("StartPositionTicks", current.StartTime.TimeSpan.Ticks);
+ writer.WriteNumber("EndPositionTicks", current.EndTime.TimeSpan.Ticks);
- writer.WriteEndObject();
- }
-
- writer.WriteEndArray();
writer.WriteEndObject();
-
- writer.Flush();
}
+
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+
+ writer.Flush();
}
+
+ return Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length);
}
+
+ /// <inheritdoc />
+ public override void LoadSubtitle(Subtitle subtitle, List<string> lines, string fileName)
+ => throw new NotImplementedException();
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs
deleted file mode 100644
index 86f77aa067..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// SRT subtitle writer.
- /// </summary>
- public partial class SrtWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineEscapedRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
-
- writer.WriteLine((i + 1).ToString(CultureInfo.InvariantCulture));
- writer.WriteLine(
- @"{0:hh\:mm\:ss\,fff} --> {1:hh\:mm\:ss\,fff}",
- TimeSpan.FromTicks(trackEvent.StartPositionTicks),
- TimeSpan.FromTicks(trackEvent.EndPositionTicks));
-
- var text = trackEvent.Text;
-
- // TODO: Not sure how to handle these
- text = NewLineEscapedRegex().Replace(text, " ");
-
- writer.WriteLine(text);
- writer.WriteLine();
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs
deleted file mode 100644
index b5fd1ed935..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// SSA subtitle writer.
- /// </summary>
- public partial class SsaWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- var trackEvents = info.TrackEvents;
- var timeFormat = @"hh\:mm\:ss\.ff";
-
- // Write SSA header
- writer.WriteLine("[Script Info]");
- writer.WriteLine("Title: Jellyfin transcoded SSA subtitle");
- writer.WriteLine("ScriptType: v4.00");
- writer.WriteLine();
- writer.WriteLine("[V4 Styles]");
- writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding");
- writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H19333333,0,0,0,1,0,2,10,10,10,0,1");
- writer.WriteLine();
- writer.WriteLine("[Events]");
- writer.WriteLine("Format: Layer, Start, End, Style, Text");
-
- for (int i = 0; i < trackEvents.Count; i++)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var trackEvent = trackEvents[i];
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
- var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
-
- writer.WriteLine(
- "Dialogue: 0,{0},{1},Default,{2}",
- startTime,
- endTime,
- text);
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 894d0a3574..28f7b92268 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Core.SubtitleFormats;
using UtfUnknown;
+using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -72,55 +75,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private MemoryStream ConvertSubtitles(
Stream stream,
- string inputFormat,
+ SubtitleInfo inputInfo,
string outputFormat,
long startTimeTicks,
long endTimeTicks,
- bool preserveOriginalTimestamps,
- CancellationToken cancellationToken)
+ bool preserveOriginalTimestamps)
{
- var ms = new MemoryStream();
-
- try
- {
- var trackInfo = _subtitleParser.Parse(stream, inputFormat);
+ var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
- FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
+ FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
- var writer = GetWriter(outputFormat);
+ var formatter = GetWriter(outputFormat);
- writer.Write(trackInfo, ms, cancellationToken);
- ms.Position = 0;
- }
- catch
- {
- ms.Dispose();
- throw;
- }
+ var text = formatter.ToText(subtitle, "untitled");
+ var bytes = Encoding.UTF8.GetBytes(text);
- return ms;
+ return new MemoryStream(bytes, 0, bytes.Length, false, true);
}
- internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
+ internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
{
// Drop subs that have fully elapsed before the requested start position
- track.TrackEvents = track.TrackEvents
- .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0)
- .ToArray();
+ track.Paragraphs
+ .RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0);
if (endTimeTicks > 0)
{
- track.TrackEvents = track.TrackEvents
- .TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
- .ToArray();
+ track.Paragraphs
+ .RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks);
}
if (!preserveTimestamps)
{
- foreach (var trackEvent in track.TrackEvents)
+ foreach (var trackEvent in track.Paragraphs)
{
- trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks);
- trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks);
+ trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks)));
+ trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks)));
}
}
}
@@ -142,14 +132,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var subtitleStream = mediaSource.MediaStreams
.First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
- var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
+ var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
.ConfigureAwait(false);
// Return the original if the same format is being requested
// Character encoding was already handled in GetSubtitleStream
// ASS is a superset of SSA, skipping the conversion and preserving the styles
- if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)
- || (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
+ if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase)
+ || (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
&& string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)))
{
return stream;
@@ -157,11 +147,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
using (stream)
{
- return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);
+ return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
}
}
- private async Task<(Stream Stream, string Format)> GetSubtitleStream(
+ private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream(
MediaSourceInfo mediaSource,
MediaStream subtitleStream,
CancellationToken cancellationToken)
@@ -170,14 +160,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
- return (stream, fileInfo.Format);
+ return (stream, fileInfo);
}
private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
{
if (fileInfo.Protocol == MediaProtocol.Http)
{
- var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
if (detected is not null)
@@ -190,10 +180,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
await using (stream.ConfigureAwait(false))
{
- using var reader = new StreamReader(stream, detected.Encoding);
- var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ using var reader = new StreamReader(stream, detected.Encoding);
+ var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- return new MemoryStream(Encoding.UTF8.GetBytes(text));
+ return new MemoryStream(Encoding.UTF8.GetBytes(text));
}
}
}
@@ -212,19 +202,20 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
- var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension)
+ ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
return new SubtitleInfo()
{
Path = outputPath,
Protocol = MediaProtocol.File,
Format = outputFormat,
- IsExternal = false
+ IsExternal = MediaStream.IsVobSubFormat(outputFormat)
};
}
- var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path)
- .TrimStart('.');
+ // Normalize ffmpeg codec names to the file extensions the parser is keyed on
+ var currentFormat = NormalizeCodecToParserExtension((Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.'));
// Handle PGS subtitles as raw streams for the client to render
if (MediaStream.IsPgsFormat(currentFormat))
@@ -242,7 +233,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (!_subtitleParser.SupportsFileExtension(currentFormat))
{
// Convert
- var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt")
+ ?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
@@ -265,13 +257,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
};
}
- private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
+ private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value)
{
ArgumentException.ThrowIfNullOrEmpty(format);
if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
{
- value = new AssWriter();
+ value = new AdvancedSubStationAlpha();
return true;
}
@@ -281,27 +273,29 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return true;
}
- if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
{
- value = new SrtWriter();
+ value = new SubRip();
return true;
}
if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
{
- value = new SsaWriter();
+ value = new SubStationAlpha();
return true;
}
- if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
{
- value = new VttWriter();
+ value = new WebVTT();
return true;
}
if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
{
- value = new TtmlWriter();
+ value = new TimedText10();
return true;
}
@@ -309,7 +303,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return false;
}
- private ISubtitleWriter GetWriter(string format)
+ private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format)
{
if (TryGetWriter(format, out var writer))
{
@@ -331,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
+ if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path))
{
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
}
}
}
+ // ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on.
+ private static string NormalizeCodecToParserExtension(string codecOrExtension)
+ {
+ return codecOrExtension switch
+ {
+ "subrip" => "srt",
+ "webvtt" => "vtt",
+ _ => codecOrExtension
+ };
+ }
+
+ // Records "this cache was built from this exact source revision" in a sidecar file next to the cache: "<sizeBytes>:<mtimeTicks>"
+ private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta";
+
+ private static string FormatCacheMeta(long length, DateTime lastWriteUtc)
+ => string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}");
+
+ private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath)
+ {
+ if (!File.Exists(cachePath))
+ {
+ return false;
+ }
+
+ var cacheInfo = _fileSystem.GetFileInfo(cachePath);
+ if (cacheInfo.Length == 0)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
+ {
+ return true;
+ }
+
+ var metaPath = GetCacheMetaPath(cachePath);
+ if (!File.Exists(metaPath))
+ {
+ // Pre-existing cache from before metadata tracking - regenerate so we can record the source state.
+ return false;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc);
+ var actual = File.ReadAllText(metaPath);
+ return string.Equals(expected, actual, StringComparison.Ordinal);
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ }
+
+ private void WriteCacheMeta(string cachePath, string? sourcePath)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ if (!sourceInfo.Exists)
+ {
+ return;
+ }
+
+ File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc));
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath);
+ }
+ }
+
/// <summary>
/// Converts the text subtitle to SRT internal.
/// </summary>
@@ -373,96 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
-
- var failed = false;
-
- if (exitCode == -1)
- {
- failed = true;
-
- if (File.Exists(outputPath))
- {
- try
- {
- _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
- }
- }
- }
- else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
- {
- failed = true;
-
- try
- {
- _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
- }
- }
-
- if (failed)
- {
- _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
- }
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ args,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
+ WriteCacheMeta(outputPath, inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -473,6 +464,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
return subtitleStream.Codec;
}
+ else if (MediaStream.IsVobSubFormat(subtitleStream.Codec))
+ {
+ return "mks";
+ }
else
{
return "srt";
@@ -486,6 +481,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
return "sup";
}
+ else if (MediaStream.IsVobSubFormat(subtitleStream.Codec))
+ {
+ // FFmpeg cannot mux VobSub subtitle streams back into the .idx/.sub pair, so we use .mks container instead.
+ return "mks";
+ }
else
{
return GetExtractableSubtitleFormat(subtitleStream);
@@ -498,7 +498,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|| string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
+ || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase)
+ || MediaStream.IsVobSubFormat(codec);
}
/// <inheritdoc />
@@ -514,16 +515,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
foreach (var subtitleStream in subtitleStreams)
{
- if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
+ if (subtitleStream.IsExternal
+ && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
+ if (outputPath is null)
+ {
+ continue;
+ }
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
- if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
+ var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path;
+ if (IsCachedSubtitleFresh(outputPath, sourcePath))
{
releaser.Dispose();
continue;
@@ -580,7 +587,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
- "-i {0}",
+ "-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -591,7 +598,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
+ if (outputPath is null)
+ {
+ continue;
+ }
+
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
+ // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
+ var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
if (streamIndex == -1)
@@ -605,13 +619,19 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath);
args += string.Format(
CultureInfo.InvariantCulture,
- " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
+ " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"",
streamIndex,
outputCodec,
+ outputFormatOption,
outputPath);
}
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mksFile);
+ }
}
}
@@ -624,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
- "-i {0}",
+ "-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -636,7 +656,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
+ if (outputPath is null)
+ {
+ continue;
+ }
+
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
+ // FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
+ var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
if (streamIndex == -1)
@@ -650,67 +677,31 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath);
args += string.Format(
CultureInfo.InvariantCulture,
- " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
+ " -map 0:{0} -an -vn -c:s {1}{2} -flush_packets 1 \"{3}\"",
streamIndex,
outputCodec,
+ outputFormatOption,
outputPath);
}
- if (outputPaths.Count == 0)
+ if (outputPaths.Count > 0)
{
- return;
- }
+ await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
- await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mediaSource.Path);
+ }
+ }
}
private async Task ExtractSubtitlesForFile(
string inputPath,
string args,
- List<string> outputPaths,
+ IReadOnlyList<string> outputPaths,
CancellationToken cancellationToken)
{
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = args,
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
+ var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
var failed = false;
@@ -770,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (failed)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrWhiteSpace(ffmpegError))
+ {
+ _logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
+ }
+
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
@@ -827,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
-
var processArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
+ "-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
outputPath);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ processArgs,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
+ /// </summary>
+ /// <remarks>
+ /// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
+ /// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
+ /// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
+ /// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
+ /// unused for subtitle extraction.
+ /// </remarks>
+ /// <param name="arguments">The ffmpeg command line arguments.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
+ private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
+ {
int exitCode;
+ var standardError = string.Empty;
using (var process = new Process
{
@@ -844,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
CreateNoWindow = true,
UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardError = true,
FileName = _mediaEncoder.EncoderPath,
- Arguments = processArgs,
+ Arguments = "-nostdin " + arguments,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -861,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
-
throw;
}
+ // Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
+ process.StandardInput.Close();
+
+ // Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
+ var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
+ var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
+ using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
+
try
{
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
+ await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
@@ -876,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
process.Kill(true);
exitCode = -1;
}
- }
-
- var failed = false;
-
- if (exitCode == -1)
- {
- failed = true;
-
- try
- {
- _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
- }
- }
- else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
- {
- failed = true;
try
{
- _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
+ standardError = await standardErrorTask.ConfigureAwait(false);
}
- catch (FileNotFoundException)
- {
- }
- catch (IOException ex)
+ catch (OperationCanceledException)
{
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ // Reading ffmpeg output was cancelled; nothing more to capture.
}
}
- if (failed)
- {
- _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
- }
-
- _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
- {
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
- }
+ return (exitCode, standardError);
}
/// <summary>
@@ -968,7 +956,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
- private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
+ private string? GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
{
return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
}
@@ -981,12 +969,16 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
- path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
- await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
- .ConfigureAwait(false);
+ var cachePath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
+ if (cachePath is not null)
+ {
+ path = cachePath;
+ await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
+ .ConfigureAwait(false);
+ }
}
- var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
@@ -1002,28 +994,29 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return charset;
}
- private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
+ private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken)
{
+ var protocol = _mediaSourceManager.GetPathProtocol(path);
switch (protocol)
{
case MediaProtocol.Http:
- {
- using var stream = await _httpClientFactory
- .CreateClient(NamedClient.Default)
- .GetStreamAsync(new Uri(path), cancellationToken)
- .ConfigureAwait(false);
+ {
+ using var stream = await _httpClientFactory
+ .CreateClient(NamedClient.Default)
+ .GetStreamAsync(new Uri(path), cancellationToken)
+ .ConfigureAwait(false);
- return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
- }
+ return await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
+ }
case MediaProtocol.File:
- {
- return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
- .ConfigureAwait(false);
- }
+ {
+ return await CharsetDetector.DetectFromFileAsync(path, cancellationToken)
+ .ConfigureAwait(false);
+ }
default:
- throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
+ throw new NotSupportedException($"Unsupported protocol: {protocol}");
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs
deleted file mode 100644
index ea45f2070a..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// TTML subtitle writer.
- /// </summary>
- public partial class TtmlWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewLineEscapeRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- // Example: https://github.com/zmalltalker/ttml2vtt/blob/master/data/sample.xml
- // Parser example: https://github.com/mozilla/popcorn-js/blob/master/parsers/parserTTML/popcorn.parserTTML.js
-
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- writer.WriteLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
- writer.WriteLine("<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:tts=\"http://www.w3.org/2006/04/ttaf1#styling\" lang=\"no\">");
-
- writer.WriteLine("<head>");
- writer.WriteLine("<styling>");
- writer.WriteLine("<style id=\"italic\" tts:fontStyle=\"italic\" />");
- writer.WriteLine("<style id=\"left\" tts:textAlign=\"left\" />");
- writer.WriteLine("<style id=\"center\" tts:textAlign=\"center\" />");
- writer.WriteLine("<style id=\"right\" tts:textAlign=\"right\" />");
- writer.WriteLine("</styling>");
- writer.WriteLine("</head>");
-
- writer.WriteLine("<body>");
- writer.WriteLine("<div>");
-
- foreach (var trackEvent in info.TrackEvents)
- {
- var text = trackEvent.Text;
-
- text = NewLineEscapeRegex().Replace(text, "<br/>");
-
- writer.WriteLine(
- "<p begin=\"{0}\" dur=\"{1}\">{2}</p>",
- trackEvent.StartPositionTicks,
- trackEvent.EndPositionTicks - trackEvent.StartPositionTicks,
- text);
- }
-
- writer.WriteLine("</div>");
- writer.WriteLine("</body>");
-
- writer.WriteLine("</tt>");
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs
deleted file mode 100644
index 3e0f47b5ae..0000000000
--- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using MediaBrowser.Model.MediaInfo;
-
-namespace MediaBrowser.MediaEncoding.Subtitles
-{
- /// <summary>
- /// Subtitle writer for the WebVTT format.
- /// </summary>
- public partial class VttWriter : ISubtitleWriter
- {
- [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
- private static partial Regex NewlineEscapeRegex();
-
- /// <inheritdoc />
- public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
- {
- using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
- {
- writer.WriteLine("WEBVTT");
- writer.WriteLine();
- writer.WriteLine("Region: id:subtitle width:80% lines:3 regionanchor:50%,100% viewportanchor:50%,90%");
- writer.WriteLine();
- foreach (var trackEvent in info.TrackEvents)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks);
- var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks);
-
- // make sure the start and end times are different and sequential
- if (endTime.TotalMilliseconds <= startTime.TotalMilliseconds)
- {
- endTime = startTime.Add(TimeSpan.FromMilliseconds(1));
- }
-
- writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle line:90%", startTime, endTime);
-
- var text = trackEvent.Text;
-
- // TODO: Not sure how to handle these
- text = NewlineEscapeRegex().Replace(text, " ");
-
- writer.WriteLine(text);
- writer.WriteLine();
- }
- }
- }
- }
-}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index defd855ec0..78bb881ec2 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// Must consume both stdout and stderr or deadlocks may occur
// RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,