From 37983c943a7f65850cc2bd4854f22dbe2ae92c28 Mon Sep 17 00:00:00 2001 From: Piotr Niełacny Date: Fri, 13 Mar 2026 16:34:44 +0100 Subject: Respect EnableSubtitleExtraction setting in subtitle delivery Wire up EnableSubtitleExtraction config to MediaEncoder.CanExtractSubtitles so the setting is actually respected. Gate subtitle extraction check behind PlayMethod.Transcode since DirectPlay has no competing ffmpeg process. Add parameterized tests for StreamBuilder.GetSubtitleProfile covering text and graphical codecs, profile format matching, and extraction setting behavior. Remove misplaced SubtitleEncoder extraction test. --- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 73c5b88c8b..770965cab3 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -1331,8 +1331,7 @@ namespace MediaBrowser.MediaEncoding.Encoder public bool CanExtractSubtitles(string codec) { - // TODO is there ever a case when a subtitle can't be extracted?? - return true; + return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction; } private sealed class ProcessWrapper : IDisposable -- cgit v1.2.3 From 6ea77f484d6c1b3faf160aee41d1ea099e64b85e Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Sun, 29 Mar 2026 12:38:01 +0200 Subject: Fix attachment extraction of files without video or audio stream (#16312) * Fix attachment extraction of files without video or audio stream * Apply review suggestions --- .../Attachments/AttachmentExtractor.cs | 59 +++++++++++++--------- 1 file changed, 35 insertions(+), 24 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 48a0654bb1..f7a1581a76 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -115,7 +115,6 @@ namespace MediaBrowser.MediaEncoding.Attachments await ExtractAllAttachmentsInternal( inputFile, mediaSource, - false, cancellationToken).ConfigureAwait(false); } } @@ -123,7 +122,6 @@ namespace MediaBrowser.MediaEncoding.Attachments private async Task ExtractAllAttachmentsInternal( string inputFile, MediaSourceInfo mediaSource, - bool isExternal, CancellationToken cancellationToken) { var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource); @@ -142,11 +140,19 @@ namespace MediaBrowser.MediaEncoding.Attachments return; } + // Files without video/audio streams (e.g. MKS subtitle files) don't need a dummy + // output since there are no streams to process. Omit "-t 0 -f null null" so ffmpeg + // doesn't fail trying to open an output with no streams. It will exit with code 1 + // ("at least one output file must be specified") which is expected and harmless + // since we only need the -dump_attachment side effect. + var hasVideoOrAudioStream = mediaSource.MediaStreams + .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio); var processArgs = string.Format( CultureInfo.InvariantCulture, - "-dump_attachment:t \"\" -y {0} -i {1} -t 0 -f null null", + "-dump_attachment:t \"\" -y {0} -i {1} {2}", inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty, - inputPath); + inputPath, + hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); int exitCode; @@ -185,12 +191,7 @@ namespace MediaBrowser.MediaEncoding.Attachments if (exitCode != 0) { - if (isExternal && exitCode == 1) - { - // ffmpeg returns exitCode 1 because there is no video or audio stream - // this can be ignored - } - else + if (hasVideoOrAudioStream || exitCode != 1) { failed = true; @@ -205,7 +206,8 @@ namespace MediaBrowser.MediaEncoding.Attachments } } } - else if (!Directory.Exists(outputFolder)) + + if (!failed && !Directory.Exists(outputFolder)) { failed = true; } @@ -246,6 +248,7 @@ namespace MediaBrowser.MediaEncoding.Attachments { await ExtractAttachmentInternal( _mediaEncoder.GetInputArgument(inputFile, mediaSource), + mediaSource, mediaAttachment.Index, attachmentPath, cancellationToken).ConfigureAwait(false); @@ -257,6 +260,7 @@ namespace MediaBrowser.MediaEncoding.Attachments private async Task ExtractAttachmentInternal( string inputPath, + MediaSourceInfo mediaSource, int attachmentStreamIndex, string outputPath, CancellationToken cancellationToken) @@ -267,12 +271,15 @@ namespace MediaBrowser.MediaEncoding.Attachments Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputPath))); + var hasVideoOrAudioStream = mediaSource.MediaStreams + .Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio); var processArgs = string.Format( CultureInfo.InvariantCulture, - "-dump_attachment:{1} \"{2}\" -i {0} -t 0 -f null null", + "-dump_attachment:{1} \"{2}\" -i {0} {3}", inputPath, attachmentStreamIndex, - EncodingUtils.NormalizePath(outputPath)); + EncodingUtils.NormalizePath(outputPath), + hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); int exitCode; @@ -310,22 +317,26 @@ namespace MediaBrowser.MediaEncoding.Attachments if (exitCode != 0) { - failed = true; - - _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCode); - try + if (hasVideoOrAudioStream || exitCode != 1) { - if (File.Exists(outputPath)) + failed = true; + + _logger.LogWarning("Deleting extracted attachment {Path} due to failure: {ExitCode}", outputPath, exitCode); + try { - _fileSystem.DeleteFile(outputPath); + if (File.Exists(outputPath)) + { + _fileSystem.DeleteFile(outputPath); + } + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath); } - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting extracted attachment {Path}", outputPath); } } - else if (!File.Exists(outputPath)) + + if (!failed && !File.Exists(outputPath)) { failed = true; } -- cgit v1.2.3 From a6da575785e678e64ed03978d1f4f60a80423121 Mon Sep 17 00:00:00 2001 From: Bond_009 Date: Sun, 29 Mar 2026 14:16:26 +0200 Subject: Only set IsAvc for video streams Also enables nullable for MediaStreamInfo Makes more properties nullable that aren't always present --- .../Probing/FFProbeHelpers.cs | 4 +- .../Probing/MediaStreamInfo.cs | 82 +++++++++++----------- .../Probing/ProbeResultNormalizer.cs | 23 ++---- MediaBrowser.Model/Entities/MediaStream.cs | 1 - .../Probing/ProbeResultNormalizerTests.cs | 4 +- 5 files changed, 50 insertions(+), 64 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs index 6f51e1a6ab..975c2b8161 100644 --- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs +++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs @@ -74,9 +74,9 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The dict. /// Dictionary{System.StringSystem.String}. - private static Dictionary ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary dict) + private static Dictionary ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary dict) { - return new Dictionary(dict, StringComparer.OrdinalIgnoreCase); + return new Dictionary(dict, StringComparer.OrdinalIgnoreCase); } } } diff --git a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs index 2944423248..f631c471f6 100644 --- a/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs +++ b/MediaBrowser.MediaEncoding/Probing/MediaStreamInfo.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Collections.Generic; using System.Text.Json.Serialization; @@ -22,21 +20,21 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The profile. [JsonPropertyName("profile")] - public string Profile { get; set; } + public string? Profile { get; set; } /// /// Gets or sets the codec_name. /// /// The codec_name. [JsonPropertyName("codec_name")] - public string CodecName { get; set; } + public string? CodecName { get; set; } /// /// Gets or sets the codec_long_name. /// /// The codec_long_name. [JsonPropertyName("codec_long_name")] - public string CodecLongName { get; set; } + public string? CodecLongName { get; set; } /// /// Gets or sets the codec_type. @@ -50,49 +48,49 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The sample_rate. [JsonPropertyName("sample_rate")] - public string SampleRate { get; set; } + public string? SampleRate { get; set; } /// /// Gets or sets the channels. /// /// The channels. [JsonPropertyName("channels")] - public int Channels { get; set; } + public int? Channels { get; set; } /// /// Gets or sets the channel_layout. /// /// The channel_layout. [JsonPropertyName("channel_layout")] - public string ChannelLayout { get; set; } + public string? ChannelLayout { get; set; } /// /// Gets or sets the avg_frame_rate. /// /// The avg_frame_rate. [JsonPropertyName("avg_frame_rate")] - public string AverageFrameRate { get; set; } + public string? AverageFrameRate { get; set; } /// /// Gets or sets the duration. /// /// The duration. [JsonPropertyName("duration")] - public string Duration { get; set; } + public string? Duration { get; set; } /// /// Gets or sets the bit_rate. /// /// The bit_rate. [JsonPropertyName("bit_rate")] - public string BitRate { get; set; } + public string? BitRate { get; set; } /// /// Gets or sets the width. /// /// The width. [JsonPropertyName("width")] - public int Width { get; set; } + public int? Width { get; set; } /// /// Gets or sets the refs. @@ -106,21 +104,21 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The height. [JsonPropertyName("height")] - public int Height { get; set; } + public int? Height { get; set; } /// /// Gets or sets the display_aspect_ratio. /// /// The display_aspect_ratio. [JsonPropertyName("display_aspect_ratio")] - public string DisplayAspectRatio { get; set; } + public string? DisplayAspectRatio { get; set; } /// /// Gets or sets the tags. /// /// The tags. [JsonPropertyName("tags")] - public IReadOnlyDictionary Tags { get; set; } + public IReadOnlyDictionary? Tags { get; set; } /// /// Gets or sets the bits_per_sample. @@ -141,7 +139,7 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The r_frame_rate. [JsonPropertyName("r_frame_rate")] - public string RFrameRate { get; set; } + public string? RFrameRate { get; set; } /// /// Gets or sets the has_b_frames. @@ -155,70 +153,70 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The sample_aspect_ratio. [JsonPropertyName("sample_aspect_ratio")] - public string SampleAspectRatio { get; set; } + public string? SampleAspectRatio { get; set; } /// /// Gets or sets the pix_fmt. /// /// The pix_fmt. [JsonPropertyName("pix_fmt")] - public string PixelFormat { get; set; } + public string? PixelFormat { get; set; } /// /// Gets or sets the level. /// /// The level. [JsonPropertyName("level")] - public int Level { get; set; } + public int? Level { get; set; } /// /// Gets or sets the time_base. /// /// The time_base. [JsonPropertyName("time_base")] - public string TimeBase { get; set; } + public string? TimeBase { get; set; } /// /// Gets or sets the start_time. /// /// The start_time. [JsonPropertyName("start_time")] - public string StartTime { get; set; } + public string? StartTime { get; set; } /// /// Gets or sets the codec_time_base. /// /// The codec_time_base. [JsonPropertyName("codec_time_base")] - public string CodecTimeBase { get; set; } + public string? CodecTimeBase { get; set; } /// /// Gets or sets the codec_tag. /// /// The codec_tag. [JsonPropertyName("codec_tag")] - public string CodecTag { get; set; } + public string? CodecTag { get; set; } /// - /// Gets or sets the codec_tag_string. + /// Gets or sets the codec_tag_string?. /// - /// The codec_tag_string. - [JsonPropertyName("codec_tag_string")] - public string CodecTagString { get; set; } + /// The codec_tag_string?. + [JsonPropertyName("codec_tag_string?")] + public string? CodecTagString { get; set; } /// /// Gets or sets the sample_fmt. /// /// The sample_fmt. [JsonPropertyName("sample_fmt")] - public string SampleFmt { get; set; } + public string? SampleFmt { get; set; } /// /// Gets or sets the dmix_mode. /// /// The dmix_mode. [JsonPropertyName("dmix_mode")] - public string DmixMode { get; set; } + public string? DmixMode { get; set; } /// /// Gets or sets the start_pts. @@ -232,90 +230,90 @@ namespace MediaBrowser.MediaEncoding.Probing /// /// The is_avc. [JsonPropertyName("is_avc")] - public bool IsAvc { get; set; } + public bool? IsAvc { get; set; } /// /// Gets or sets the nal_length_size. /// /// The nal_length_size. [JsonPropertyName("nal_length_size")] - public string NalLengthSize { get; set; } + public string? NalLengthSize { get; set; } /// /// Gets or sets the ltrt_cmixlev. /// /// The ltrt_cmixlev. [JsonPropertyName("ltrt_cmixlev")] - public string LtrtCmixlev { get; set; } + public string? LtrtCmixlev { get; set; } /// /// Gets or sets the ltrt_surmixlev. /// /// The ltrt_surmixlev. [JsonPropertyName("ltrt_surmixlev")] - public string LtrtSurmixlev { get; set; } + public string? LtrtSurmixlev { get; set; } /// /// Gets or sets the loro_cmixlev. /// /// The loro_cmixlev. [JsonPropertyName("loro_cmixlev")] - public string LoroCmixlev { get; set; } + public string? LoroCmixlev { get; set; } /// /// Gets or sets the loro_surmixlev. /// /// The loro_surmixlev. [JsonPropertyName("loro_surmixlev")] - public string LoroSurmixlev { get; set; } + public string? LoroSurmixlev { get; set; } /// /// Gets or sets the field_order. /// /// The field_order. [JsonPropertyName("field_order")] - public string FieldOrder { get; set; } + public string? FieldOrder { get; set; } /// /// Gets or sets the disposition. /// /// The disposition. [JsonPropertyName("disposition")] - public IReadOnlyDictionary Disposition { get; set; } + public IReadOnlyDictionary? Disposition { get; set; } /// /// Gets or sets the color range. /// /// The color range. [JsonPropertyName("color_range")] - public string ColorRange { get; set; } + public string? ColorRange { get; set; } /// /// Gets or sets the color space. /// /// The color space. [JsonPropertyName("color_space")] - public string ColorSpace { get; set; } + public string? ColorSpace { get; set; } /// /// Gets or sets the color transfer. /// /// The color transfer. [JsonPropertyName("color_transfer")] - public string ColorTransfer { get; set; } + public string? ColorTransfer { get; set; } /// /// Gets or sets the color primaries. /// /// The color primaries. [JsonPropertyName("color_primaries")] - public string ColorPrimaries { get; set; } + public string? ColorPrimaries { get; set; } /// /// Gets or sets the side_data_list. /// /// The side_data_list. [JsonPropertyName("side_data_list")] - public IReadOnlyList SideDataList { get; set; } + public IReadOnlyList? SideDataList { get; set; } } } diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 127bdd380d..d3e7b52315 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -697,24 +697,18 @@ namespace MediaBrowser.MediaEncoding.Probing /// MediaStream. private MediaStream GetMediaStream(bool isAudio, MediaStreamInfo streamInfo, MediaFormatInfo formatInfo, IReadOnlyList frameInfoList) { - // These are mp4 chapters - if (string.Equals(streamInfo.CodecName, "mov_text", StringComparison.OrdinalIgnoreCase)) - { - // Edit: but these are also sometimes subtitles? - // return null; - } - var stream = new MediaStream { Codec = streamInfo.CodecName, Profile = streamInfo.Profile, + Width = streamInfo.Width, + Height = streamInfo.Height, Level = streamInfo.Level, Index = streamInfo.Index, PixelFormat = streamInfo.PixelFormat, NalLengthSize = streamInfo.NalLengthSize, TimeBase = streamInfo.TimeBase, - CodecTimeBase = streamInfo.CodecTimeBase, - IsAVC = streamInfo.IsAvc + CodecTimeBase = streamInfo.CodecTimeBase }; // Filter out junk @@ -774,10 +768,6 @@ namespace MediaBrowser.MediaEncoding.Probing stream.LocalizedExternal = _localization.GetLocalizedString("External"); stream.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired"); - // Graphical subtitle may have width and height info - stream.Width = streamInfo.Width; - stream.Height = streamInfo.Height; - if (string.IsNullOrEmpty(stream.Title)) { // mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler" @@ -790,6 +780,7 @@ namespace MediaBrowser.MediaEncoding.Probing } else if (streamInfo.CodecType == CodecType.Video) { + stream.IsAVC = streamInfo.IsAvc; stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate); stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate); @@ -822,8 +813,6 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Type = MediaStreamType.Video; } - stream.Width = streamInfo.Width; - stream.Height = streamInfo.Height; stream.AspectRatio = GetAspectRatio(streamInfo); if (streamInfo.BitsPerSample > 0) @@ -1091,8 +1080,8 @@ namespace MediaBrowser.MediaEncoding.Probing && width > 0 && height > 0)) { - width = info.Width; - height = info.Height; + width = info.Width.Value; + height = info.Height.Value; } if (width > 0 && height > 0) diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index c443af32cf..11f81ff7d8 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -2,7 +2,6 @@ #pragma warning disable CS1591 using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 8ebbd029ac..3369af0e84 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -209,8 +209,8 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.Equal("mkv,webm", res.Container); Assert.Equal(2, res.MediaStreams.Count); - - Assert.False(res.MediaStreams[0].IsAVC); + Assert.Equal(540, res.MediaStreams[0].Width); + Assert.Equal(360, res.MediaStreams[0].Height); } [Fact] -- cgit v1.2.3 From 42e8a780ca937b0f49b5e61b60adfda4abd465ec Mon Sep 17 00:00:00 2001 From: Molier Date: Mon, 30 Mar 2026 14:08:05 -0400 Subject: Backport pull request #16440 from jellyfin/release-10.11.z Remove -copyts and add -flush_packets 1 to subtitle extraction Original-merge: ec33c74ec44693a9ddb1e2f13bea90ef3c22267e Merged-by: Bond-009 Backported-by: Bond_009 --- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index aeaf7f4423..9aeac7221e 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -577,7 +577,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts", + "-i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -602,7 +602,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} \"{2}\"", + " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", streamIndex, outputCodec, outputPath); @@ -621,7 +621,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var outputPaths = new List(); var args = string.Format( CultureInfo.InvariantCulture, - "-i {0} -copyts", + "-i {0}", inputPath); foreach (var subtitleStream in subtitleStreams) @@ -647,7 +647,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles outputPaths.Add(outputPath); args += string.Format( CultureInfo.InvariantCulture, - " -map 0:{0} -an -vn -c:s {1} \"{2}\"", + " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"", streamIndex, outputCodec, outputPath); -- cgit v1.2.3 From 553f38a2377cf843404cd4d3b3602e8a72bc75f8 Mon Sep 17 00:00:00 2001 From: Lasath Fernando Date: Sat, 4 Apr 2026 16:10:07 +0000 Subject: Fix language display for ISO 639-2-only codes (e.g. mul, und) LoadCultures() in LocalizationManager skipped all iso6392.txt entries without a two-letter ISO 639-1 code, dropping 302 of 496 languages including mul (Multiple languages), und (Undetermined), mis (Uncoded languages), zxx, and many real languages like Achinese, Akkadian, etc. This caused FindLanguageInfo() to return null for these codes, which meant: - ExternalPathParser could not recognize them as valid language codes in subtitle filenames, so the Language field was never set - DisplayTitle fell back to the raw code string (e.g. "Mul") Fix by allowing entries without two-letter codes to be loaded with an empty TwoLetterISOLanguageName. Also set LocalizedLanguage in ProbeResultNormalizer for ffprobe-detected streams (the DB repository path was already handled on master). --- .../Localization/LocalizationManager.cs | 2 +- .../Probing/ProbeResultNormalizer.cs | 6 ++++++ MediaBrowser.Model/Entities/MediaStream.cs | 1 - .../Localization/LocalizationManagerTests.cs | 21 ++++++++++++++++++++- 4 files changed, 27 insertions(+), 3 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index bc80c2b405..6fca5bc1ba 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -138,7 +138,7 @@ namespace Emby.Server.Implementations.Localization string twoCharName = parts[2]; if (string.IsNullOrWhiteSpace(twoCharName)) { - continue; + twoCharName = string.Empty; } else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase)) { diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index d3e7b52315..203e72de36 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -729,6 +729,9 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Type = MediaStreamType.Audio; stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); + stream.LocalizedLanguage = !string.IsNullOrEmpty(stream.Language) + ? _localization.FindLanguageInfo(stream.Language)?.DisplayName + : null; stream.Channels = streamInfo.Channels; @@ -767,6 +770,9 @@ 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) + ? _localization.FindLanguageInfo(stream.Language)?.DisplayName + : null; if (string.IsNullOrEmpty(stream.Title)) { diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 11f81ff7d8..4491fb5ace 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; -using System.Linq; using System.Text; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index e60522bf78..700ac5dced 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -41,7 +41,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var cultures = localizationManager.GetCultures().ToList(); - Assert.Equal(194, cultures.Count); + Assert.Equal(496, cultures.Count); var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal)); Assert.NotNull(germany); @@ -99,6 +99,25 @@ namespace Jellyfin.Server.Implementations.Tests.Localization Assert.Contains("ger", germany.ThreeLetterISOLanguageNames); } + [Theory] + [InlineData("mul", "Multiple languages")] + [InlineData("und", "Undetermined")] + [InlineData("mis", "Uncoded languages")] + [InlineData("zxx", "No linguistic content; Not applicable")] + public async Task FindLanguageInfo_ISO6392Only_Success(string code, string expectedDisplayName) + { + var localizationManager = Setup(new ServerConfiguration + { + UICulture = "en-US" + }); + await localizationManager.LoadAll(); + + var culture = localizationManager.FindLanguageInfo(code); + Assert.NotNull(culture); + Assert.Equal(expectedDisplayName, culture.DisplayName); + Assert.Equal(code, culture.ThreeLetterISOLanguageName); + } + [Fact] public async Task GetParentalRatings_Default_Success() { -- cgit v1.2.3 From c300651d0ddae2e2a3b3cde55989284f5972a79a Mon Sep 17 00:00:00 2001 From: Lasath Fernando Date: Thu, 9 Apr 2026 13:53:38 -0500 Subject: Simplify null-check ternary style in ProbeResultNormalizer Co-authored-by: Bond-009 --- MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 203e72de36..3c6a03713f 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -729,9 +729,9 @@ namespace MediaBrowser.MediaEncoding.Probing stream.Type = MediaStreamType.Audio; stream.LocalizedDefault = _localization.GetLocalizedString("Default"); stream.LocalizedExternal = _localization.GetLocalizedString("External"); - stream.LocalizedLanguage = !string.IsNullOrEmpty(stream.Language) - ? _localization.FindLanguageInfo(stream.Language)?.DisplayName - : null; + stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) + ? null + : _localization.FindLanguageInfo(stream.Language)?.DisplayName; stream.Channels = streamInfo.Channels; @@ -770,9 +770,9 @@ 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) - ? _localization.FindLanguageInfo(stream.Language)?.DisplayName - : null; + stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language) + ? null + : _localization.FindLanguageInfo(stream.Language)?.DisplayName; if (string.IsNullOrEmpty(stream.Title)) { -- cgit v1.2.3 From 8ba9319f27bb6d2e93640b9c8426c4e6e9470133 Mon Sep 17 00:00:00 2001 From: Hilmar Gústafsson Date: Wed, 15 Apr 2026 19:11:29 +0200 Subject: fix: retain subtitles spanning HLS segment boundaries (#16594) fix: retain subtitles spanning HLS segment boundaries --- CONTRIBUTORS.md | 1 + .../Subtitles/SubtitleEncoder.cs | 10 +- .../Subtitles/FilterEventsTests.cs | 282 +++++++++++++++++++++ 3 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs (limited to 'MediaBrowser.MediaEncoding') diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b0cec3c92..01f968690f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -212,6 +212,7 @@ - [martenumberto](https://github.com/martenumberto) - [ZeusCraft10](https://github.com/ZeusCraft10) - [MarcoCoreDuo](https://github.com/MarcoCoreDuo) + - [LiHRaM](https://github.com/LiHRaM) # Emby Contributors diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 9aeac7221e..5920fe3289 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -101,11 +101,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles return ms; } - private void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) + internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps) { - // Drop subs that are earlier than what we're looking for + // 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) + .SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0) .ToArray(); if (endTimeTicks > 0) @@ -119,8 +119,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles { foreach (var trackEvent in track.TrackEvents) { - trackEvent.EndPositionTicks -= startPositionTicks; - trackEvent.StartPositionTicks -= startPositionTicks; + trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks); + trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs new file mode 100644 index 0000000000..5f84e85592 --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs @@ -0,0 +1,282 @@ +using System; +using AutoFixture; +using AutoFixture.AutoMoq; +using MediaBrowser.MediaEncoding.Subtitles; +using MediaBrowser.Model.MediaInfo; +using Xunit; + +namespace Jellyfin.MediaEncoding.Subtitles.Tests +{ + public class FilterEventsTests + { + private readonly SubtitleEncoder _encoder; + + public FilterEventsTests() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + _encoder = fixture.Create(); + } + + [Fact] + public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained() + { + // Subtitle starts at 5s, ends at 15s. + // Segment requested from 10s to 20s. + // The subtitle is still on screen at 10s and should NOT be dropped. + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Still on screen") + { + StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(15).Ticks + }, + new SubtitleTrackEvent("2", "Next subtitle") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(17).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(20).Ticks, + preserveTimestamps: true); + + Assert.Equal(2, track.TrackEvents.Count); + Assert.Equal("1", track.TrackEvents[0].Id); + Assert.Equal("2", track.TrackEvents[1].Id); + } + + [Fact] + public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped() + { + // Subtitle starts at 2s, ends at 5s. + // Segment requested from 10s. + // The subtitle ended before the segment — should be dropped. + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Already gone") + { + StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(5).Ticks + }, + new SubtitleTrackEvent("2", "Visible") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(17).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(20).Ticks, + preserveTimestamps: true); + + Assert.Single(track.TrackEvents); + Assert.Equal("2", track.TrackEvents[0].Id); + } + + [Fact] + public void FilterEvents_SubtitleAfterSegment_IsDropped() + { + // Segment is 10s-20s, subtitle starts at 25s. + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "In range") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(15).Ticks + }, + new SubtitleTrackEvent("2", "After segment") + { + StartPositionTicks = TimeSpan.FromSeconds(25).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(30).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(20).Ticks, + preserveTimestamps: true); + + Assert.Single(track.TrackEvents); + Assert.Equal("1", track.TrackEvents[0].Id); + } + + [Fact] + public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps() + { + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Subtitle") + { + StartPositionTicks = TimeSpan.FromSeconds(15).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(20).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(30).Ticks, + preserveTimestamps: false); + + Assert.Single(track.TrackEvents); + // Timestamps should be shifted back by 10s + Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks); + Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks); + } + + [Fact] + public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps() + { + var startTicks = TimeSpan.FromSeconds(15).Ticks; + var endTicks = TimeSpan.FromSeconds(20).Ticks; + + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Subtitle") + { + StartPositionTicks = startTicks, + EndPositionTicks = endTicks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(30).Ticks, + preserveTimestamps: true); + + Assert.Single(track.TrackEvents); + Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks); + Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks); + } + + [Fact] + public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained() + { + // Subtitle ends exactly when the segment begins. + // EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0, + // so SkipWhile stops and the subtitle is retained. + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Boundary subtitle") + { + StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(10).Ticks + }, + new SubtitleTrackEvent("2", "In range") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(15).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(20).Ticks, + preserveTimestamps: true); + + Assert.Equal(2, track.TrackEvents.Count); + Assert.Equal("1", track.TrackEvents[0].Id); + } + + [Fact] + public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps() + { + // Subtitle starts at 5s, ends at 15s. + // Segment requested from 10s to 20s, preserveTimestamps = false. + // The subtitle spans the boundary and is retained, but shifting + // StartPositionTicks by -10s would produce -5s (negative). + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Spans boundary") + { + StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(15).Ticks + }, + new SubtitleTrackEvent("2", "Fully in range") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(17).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: TimeSpan.FromSeconds(20).Ticks, + preserveTimestamps: false); + + Assert.Equal(2, track.TrackEvents.Count); + // Subtitle 1: start should be clamped to 0, not -5s + Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative"); + Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks); + // Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s) + Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks); + Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks); + } + + [Fact] + public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition() + { + var track = new SubtitleTrackInfo + { + TrackEvents = new[] + { + new SubtitleTrackEvent("1", "Before") + { + StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(4).Ticks + }, + new SubtitleTrackEvent("2", "After") + { + StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(15).Ticks + }, + new SubtitleTrackEvent("3", "Much later") + { + StartPositionTicks = TimeSpan.FromSeconds(500).Ticks, + EndPositionTicks = TimeSpan.FromSeconds(505).Ticks + } + } + }; + + _encoder.FilterEvents( + track, + startPositionTicks: TimeSpan.FromSeconds(10).Ticks, + endTimeTicks: 0, + preserveTimestamps: true); + + Assert.Equal(2, track.TrackEvents.Count); + Assert.Equal("2", track.TrackEvents[0].Id); + Assert.Equal("3", track.TrackEvents[1].Id); + } + } +} -- cgit v1.2.3 From e75f7f1b28f8a6813421efcf0db4162daf5cf6d8 Mon Sep 17 00:00:00 2001 From: nyanmisaka Date: Sat, 2 May 2026 21:36:34 +0800 Subject: Avoid SSA to ASS conversion and loss of styles Signed-off-by: nyanmisaka --- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 5920fe3289..894d0a3574 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -147,7 +147,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles // Return the original if the same format is being requested // Character encoding was already handled in GetSubtitleStream - if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)) + // 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) + && string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))) { return stream; } -- cgit v1.2.3 From f5f75ed2e1b10dc1f4e55d5cdd9dd7fd69ea8f2b Mon Sep 17 00:00:00 2001 From: Seven Rats <79296037+sevenrats@users.noreply.github.com> Date: Sun, 3 May 2026 06:18:20 -0400 Subject: feat/audiobook_chapters (#16518) feat/audiobook_chapters --- .../Chapters/ChapterManager.cs | 21 ++- Emby.Server.Implementations/Dto/DtoService.cs | 10 +- .../Library/Resolvers/Books/BookResolver.cs | 2 +- Jellyfin.Api/Controllers/LibraryController.cs | 2 +- Jellyfin.Data/Enums/PersonKind.cs | 7 +- .../Chapters/IChapterManager.cs | 11 +- MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs | 2 +- .../Probing/ProbeResultNormalizer.cs | 10 +- .../Books/OpenPackagingFormat/OpfReader.cs | 2 + .../MediaInfo/AudioFileProber.cs | 144 +++++++++++++++++---- MediaBrowser.Providers/MediaInfo/ProbeProvider.cs | 3 +- 11 files changed, 167 insertions(+), 47 deletions(-) (limited to 'MediaBrowser.MediaEncoding') diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs index d09ed30ae3..79ab29b87c 100644 --- a/Emby.Server.Implementations/Chapters/ChapterManager.cs +++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Jellyfin.Extensions; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; @@ -232,12 +233,22 @@ public class ChapterManager : IChapterManager } /// - public void SaveChapters(Video video, IReadOnlyList chapters) + public bool Supports(BaseItem item) + => item is Video or Audio; + + /// + public void SaveChapters(BaseItem item, IReadOnlyList chapters) { - // Remove any chapters that are outside of the runtime of the video - var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList(); - _chapterRepository.SaveChapters(video.Id, validChapters); - } + if (!Supports(item)) + { + _logger.LogWarning("Attempted to save chapters for unsupported item type {Type}: {Name} ({Id})", item.GetType().Name, item.Name, item.Id); + return; + } + + // Remove any chapters that are outside of the runtime of the item + var validChapters = chapters.Where(c => c.StartPositionTicks < item.RunTimeTicks).ToList(); + _chapterRepository.SaveChapters(item.Id, validChapters); +} /// public ChapterInfo? GetChapter(Guid baseItemId, int index) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 08ced387b8..9f62ad5a91 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1132,11 +1132,6 @@ namespace Emby.Server.Implementations.Dto } } - if (options.ContainsField(ItemFields.Chapters)) - { - dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); - } - if (options.ContainsField(ItemFields.Trickplay)) { var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult(); @@ -1150,6 +1145,11 @@ namespace Emby.Server.Implementations.Dto dto.ExtraType = video.ExtraType; } + if (options.ContainsField(ItemFields.Chapters)) + { + dto.Chapters = _chapterManager.GetChapters(item.Id).ToList(); + } + if (options.ContainsField(ItemFields.MediaStreams)) { // Add VideoInfo diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 3ee1c757f2..1e885aad6e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -16,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books { public class BookResolver : ItemResolver { - private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf", ".m4b", ".m4a", ".aac", ".flac", ".mp3", ".opus" }; + private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; protected override Book Resolve(ItemResolveArgs args) { diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 6ef40a1898..3e483d09df 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -976,7 +976,7 @@ public class LibraryController : BaseJellyfinApiController CollectionType.playlists => new[] { "Playlist" }, CollectionType.movies => new[] { "Movie" }, CollectionType.tvshows => new[] { "Series", "Season", "Episode" }, - CollectionType.books => new[] { "Book" }, + CollectionType.books => new[] { "Book", "AudioBook" }, CollectionType.music => new[] { "MusicArtist", "MusicAlbum", "Audio", "MusicVideo" }, CollectionType.homevideos => new[] { "Video", "Photo" }, CollectionType.photos => new[] { "Video", "Photo" }, diff --git a/Jellyfin.Data/Enums/PersonKind.cs b/Jellyfin.Data/Enums/PersonKind.cs index 29308789a0..54eac5ff3b 100644 --- a/Jellyfin.Data/Enums/PersonKind.cs +++ b/Jellyfin.Data/Enums/PersonKind.cs @@ -129,5 +129,10 @@ public enum PersonKind /// /// A person who renders a text from one language into another. /// - Translator + Translator, + + /// + /// A person who narrates a book or other work. + /// + Narrator } diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 25656fd625..edc20205aa 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -13,12 +13,19 @@ namespace MediaBrowser.Controller.Chapters; /// public interface IChapterManager { + /// + /// Gets a value indicating whether the specified item type is supported for chapter operations. + /// + /// The item to check. + /// true if the item type supports chapters; otherwise, false. + bool Supports(BaseItem item); + /// /// Saves the chapters. /// - /// The video. + /// The item. /// The set of chapters. - void SaveChapters(Video video, IReadOnlyList chapters); + void SaveChapters(BaseItem item, IReadOnlyList chapters); /// /// Gets a single chapter of a BaseItem on a specific index. diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 770965cab3..f34e911a05 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -414,7 +414,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// public Task GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) { - var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; + var extractChapters = request.ExtractChapters; var extraArgs = GetExtraArguments(request); return GetMediaInfoInternal( diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 3c6a03713f..a4d17e4f9d 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -194,6 +194,11 @@ namespace MediaBrowser.MediaEncoding.Probing info.ProductionYear = info.PremiereDate.Value.Year; } + if (data.Chapters is not null) + { + info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray(); + } + // Set mediaType-specific metadata if (isAudio) { @@ -238,11 +243,6 @@ namespace MediaBrowser.MediaEncoding.Probing FetchWtvInfo(info, data); - if (data.Chapters is not null) - { - info.Chapters = data.Chapters.Select(GetChapterInfo).ToArray(); - } - ExtractTimestamp(info); if (tags.TryGetValue("stereo_mode", out var stereoMode) && string.Equals(stereoMode, "left_right", StringComparison.OrdinalIgnoreCase)) diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs index 5d202c59e1..15ea2ce5ab 100644 --- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs +++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs @@ -260,6 +260,8 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat return PersonKind.Lyricist; case "mus": return PersonKind.AlbumArtist; + case "nrt": + return PersonKind.Narrator; case "oth": return PersonKind.Unknown; case "trl": diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 869e3f292e..0ecbb6f068 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using ATL; using Jellyfin.Data.Enums; using Jellyfin.Extensions; +using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -38,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly LyricResolver _lyricResolver; private readonly ILyricManager _lyricManager; private readonly IMediaStreamRepository _mediaStreamRepository; + private readonly IChapterManager _chapterManager; /// /// Initializes a new instance of the class. @@ -49,6 +51,7 @@ namespace MediaBrowser.Providers.MediaInfo /// Instance of the interface. /// Instance of the interface. /// Instance of the . + /// Instance of the interface. public AudioFileProber( ILogger logger, IMediaSourceManager mediaSourceManager, @@ -56,7 +59,8 @@ namespace MediaBrowser.Providers.MediaInfo ILibraryManager libraryManager, LyricResolver lyricResolver, ILyricManager lyricManager, - IMediaStreamRepository mediaStreamRepository) + IMediaStreamRepository mediaStreamRepository, + IChapterManager chapterManager) { _mediaEncoder = mediaEncoder; _libraryManager = libraryManager; @@ -65,6 +69,7 @@ namespace MediaBrowser.Providers.MediaInfo _lyricResolver = lyricResolver; _lyricManager = lyricManager; _mediaStreamRepository = mediaStreamRepository; + _chapterManager = chapterManager; ATL.Settings.DisplayValueSeparator = InternalValueSeparator; ATL.Settings.UseFileNameWhenNoTitle = false; ATL.Settings.ID3v2_separatev2v3Values = false; @@ -99,6 +104,7 @@ namespace MediaBrowser.Providers.MediaInfo new MediaInfoRequest { MediaType = DlnaProfileType.Audio, + ExtractChapters = item is AudioBook, MediaSource = new MediaSourceInfo { Path = path, @@ -151,6 +157,11 @@ namespace MediaBrowser.Providers.MediaInfo audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); _mediaStreamRepository.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken); + + if (audio is AudioBook && mediaInfo.Chapters is { Length: > 0 }) + { + _chapterManager.SaveChapters(audio, mediaInfo.Chapters); + } } /// @@ -212,18 +223,6 @@ namespace MediaBrowser.Providers.MediaInfo albumArtists = albumArtists.SelectMany(a => SplitWithCustomDelimiter(a, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); } - foreach (var albumArtist in albumArtists) - { - if (!string.IsNullOrWhiteSpace(albumArtist)) - { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = albumArtist, - Type = PersonKind.AlbumArtist - }); - } - } - string[]? performers = null; if (libraryOptions.PreferNonstandardArtistsTag) { @@ -244,31 +243,99 @@ namespace MediaBrowser.Providers.MediaInfo performers = performers.SelectMany(p => SplitWithCustomDelimiter(p, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist)).ToArray(); } - foreach (var performer in performers) + var isAudioBook = audio is AudioBook; + + if (isAudioBook) { - if (!string.IsNullOrWhiteSpace(performer)) + // For audiobooks: AlbumArtists/Performers = Author, NARRATOR tag = Narrator, + // ILLUSTRATOR tag = Illustrator, Composer = fallback Narrator, other performers = Cast. + // If album_artist is missing, fall back to artist/performers for the author role. + var authorSource = albumArtists.Length > 0 ? albumArtists : performers; + var authorNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var author in authorSource) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrWhiteSpace(author)) { - Name = performer, - Type = PersonKind.Artist - }); + authorNames.Add(author.Trim()); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = author.Trim(), + Type = PersonKind.Author + }); + } + } + + // Composer tag = Narrator (Audiobookshelf and other tools use Composer for narrator) + if (!string.IsNullOrWhiteSpace(trackComposer)) + { + foreach (var composer in trackComposer.Split(InternalValueSeparator)) + { + if (!string.IsNullOrWhiteSpace(composer)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = composer.Trim(), + Type = PersonKind.Narrator + }); + } + } } - } - if (!string.IsNullOrWhiteSpace(trackComposer)) + // Any performers not already listed as authors get added as cast + foreach (var performer in performers) + { + if (!string.IsNullOrWhiteSpace(performer) && !authorNames.Contains(performer.Trim())) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = performer.Trim(), + Type = PersonKind.Actor + }); + } + } + } + else { - foreach (var composer in trackComposer.Split(InternalValueSeparator)) + // Standard music track handling + foreach (var albumArtist in albumArtists) + { + if (!string.IsNullOrWhiteSpace(albumArtist)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = albumArtist, + Type = PersonKind.AlbumArtist + }); + } + } + + foreach (var performer in performers) { - if (!string.IsNullOrWhiteSpace(composer)) + if (!string.IsNullOrWhiteSpace(performer)) { PeopleHelper.AddPerson(people, new PersonInfo { - Name = composer, - Type = PersonKind.Composer + Name = performer, + Type = PersonKind.Artist }); } } + + if (!string.IsNullOrWhiteSpace(trackComposer)) + { + foreach (var composer in trackComposer.Split(InternalValueSeparator)) + { + if (!string.IsNullOrWhiteSpace(composer)) + { + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = composer, + Type = PersonKind.Composer + }); + } + } + } } _libraryManager.UpdatePeople(audio, people); @@ -359,6 +426,33 @@ namespace MediaBrowser.Providers.MediaInfo } } + // Audiobook-specific metadata: Overview, Publisher, Series + if (audio is AudioBook audioBook) + { + if (!audio.LockedFields.Contains(MetadataField.Overview)) + { + var trackDescription = GetSanitizedStringTag(track.Description, audio.Path); + var trackComment = GetSanitizedStringTag(track.Comment, audio.Path); + var overview = !string.IsNullOrWhiteSpace(trackDescription) ? trackDescription : trackComment; + + if (!string.IsNullOrWhiteSpace(overview)) + { + if (options.ReplaceAllMetadata || string.IsNullOrEmpty(audio.Overview)) + { + audio.Overview = overview; + } + } + } + + // Publisher → Studio + var trackPublisher = GetSanitizedStringTag(track.Publisher, audio.Path); + if (!string.IsNullOrWhiteSpace(trackPublisher) + && (options.ReplaceAllMetadata || audio.Studios is null || audio.Studios.Length == 0)) + { + audio.SetStudios(new[] { trackPublisher! }); + } + } + TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag); if (trackGainTag is not null) diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index c3ff26202f..789df8f061 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -110,7 +110,8 @@ namespace MediaBrowser.Providers.MediaInfo libraryManager, _lyricResolver, lyricManager, - mediaStreamRepository); + mediaStreamRepository, + chapterManager); } /// -- cgit v1.2.3