diff options
Diffstat (limited to 'MediaBrowser.MediaEncoding')
10 files changed, 216 insertions, 306 deletions
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 9dd3dcecba..12a5ab877c 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -8,6 +8,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; @@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CancellationToken cancellationToken) { var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) - && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); + && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal)); if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { await ExtractAllAttachmentsIndividuallyInternal( @@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) { - var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!; + var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture); + var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName) + ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!; if (!File.Exists(attachmentPath)) { await ExtractAttachmentInternal( 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..1f84b46a2b 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; @@ -184,8 +185,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { "libavdevice", new Version(58, 13) }, { "libavfilter", new Version(7, 110) }, { "libswscale", new Version(5, 9) }, - { "libswresample", new Version(3, 9) }, - { "libpostproc", new Version(55, 9) } + { "libswresample", new Version(3, 9) } }; private readonly ILogger _logger; @@ -645,7 +645,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 66bf6ebd24..0ddd378352 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; @@ -528,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, @@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters."); } + // Normalize invalid PTS from containers for non keyframe only mode + if (!enableKeyFrameOnlyExtraction) + { + var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal); + if (fpsFilterIndex >= 0) + { + var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0) + ? imageStream.ReferenceFrameRate.Value : 30; + + var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,"); + + filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter); + } + else + { + throw new InvalidOperationException("EncodingHelper returned invalid filter parameters."); + } + } + try { return await ExtractVideoImagesOnIntervalInternal( 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/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 06060988e2..989701350c 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 @@ -730,9 +732,10 @@ namespace MediaBrowser.MediaEncoding.Probing 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; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } stream.Channels = streamInfo.Channels; @@ -771,9 +774,10 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedForced = _localization.GetLocalizedString("Forced"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) - ? null - : _localization.FindLanguageInfo(stream.Language)?.DisplayName; + if (!string.IsNullOrEmpty(stream.Language)) + { + stream.LocalizedLanguage = _localization.GetLanguageDisplayName(stream.Language); + } if (string.IsNullOrEmpty(stream.Title)) { @@ -972,10 +976,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)) @@ -1260,9 +1266,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; @@ -1630,7 +1643,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; } @@ -1764,5 +1777,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/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs index bd13437fb6..7566616f70 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs @@ -1,7 +1,7 @@ #pragma warning disable CS1591 using System.IO; -using MediaBrowser.Model.MediaInfo; +using Nikse.SubtitleEdit.Core.Common; namespace MediaBrowser.MediaEncoding.Subtitles { @@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// </summary> /// <param name="stream">The stream.</param> /// <param name="fileExtension">The file extension.</param> - /// <returns>SubtitleTrackInfo.</returns> - SubtitleTrackInfo Parse(Stream stream, string fileExtension); + /// <returns>The parsed subtitle.</returns> + Subtitle Parse(Stream stream, string fileExtension); /// <summary> /// Determines whether the file extension is supported by the parser. diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs index d060b247da..d75eea5904 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using Jellyfin.Extensions; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; using Nikse.SubtitleEdit.Core.Common; using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat; @@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } /// <inheritdoc /> - public SubtitleTrackInfo Parse(Stream stream, string fileExtension) + public Subtitle Parse(Stream stream, string fileExtension) { var subtitle = new Subtitle(); var lines = stream.ReadAllLines().ToList(); @@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw new ArgumentException("Unsupported format: " + fileExtension); } - var trackInfo = new SubtitleTrackInfo(); - int len = subtitle.Paragraphs.Count; - var trackEvents = new SubtitleTrackEvent[len]; - for (int i = 0; i < len; i++) - { - var p = subtitle.Paragraphs[i]; - trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text) - { - StartPositionTicks = p.StartTime.TimeSpan.Ticks, - EndPositionTicks = p.EndTime.TimeSpan.Ticks - }; - } - - trackInfo.TrackEvents = trackEvents; - return trackInfo; + return subtitle; } /// <inheritdoc /> diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 67e323177b..bd516f0a9f 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles _serverConfigurationManager = serverConfigurationManager; } - private MemoryStream ConvertSubtitles( + internal MemoryStream ConvertSubtitles( Stream stream, SubtitleInfo inputInfo, string outputFormat, @@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles long endTimeTicks, bool preserveOriginalTimestamps) { - var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path)); + var subtitle = _subtitleParser.Parse(stream, inputInfo.Format); FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps); @@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles return (stream, fileInfo); } - private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) + internal async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken) { - if (fileInfo.Protocol == MediaProtocol.Http) + if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format)) { - 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) + var stream = fileInfo.Protocol == MediaProtocol.Http + ? await _httpClientFactory.CreateClient(NamedClient.Default) + .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) + .ConfigureAwait(false) + : AsyncFile.OpenRead(fileInfo.Path); + + // Short-circuit when the file is already UTF-8/ASCII. + if (detected is null + || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase) + || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + return stream; + } - using var stream = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken) - .ConfigureAwait(false); + _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); - await using (stream.ConfigureAwait(false)) - { - using var reader = new StreamReader(stream, detected.Encoding); - var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await using (stream.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)); } } @@ -445,98 +453,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, "-y {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; + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); - 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)); - } - - await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); + await ExtractSubtitlesForFile( + inputPath, + args, + [outputPath], + cancellationToken).ConfigureAwait(false); WriteCacheMeta(outputPath, inputPath); - - _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } private string GetExtractableSubtitleFormat(MediaStream subtitleStream) @@ -727,7 +652,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) @@ -781,50 +706,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles 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; @@ -884,6 +769,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)); } @@ -941,16 +833,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 { @@ -958,8 +872,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 }, @@ -975,14 +891,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) @@ -990,59 +913,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); - } - catch (FileNotFoundException) - { + standardError = await standardErrorTask.ConfigureAwait(false); } - 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> @@ -1104,7 +986,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - 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 @@ -1120,8 +1002,9 @@ 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: @@ -1141,7 +1024,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } default: - throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol"); + throw new NotSupportedException($"Unsupported protocol: {protocol}"); } } 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, |
