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 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