diff options
15 files changed, 412 insertions, 40 deletions
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9012e35f1d..a01162a3b4 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -99,6 +99,7 @@ - [mitchfizz05](https://github.com/mitchfizz05) - [mohd-akram](https://github.com/mohd-akram) - [MrTimscampi](https://github.com/MrTimscampi) + - [myristate](https://github.com/myristate) - [n8225](https://github.com/n8225) - [Nalsai](https://github.com/Nalsai) - [Narfinger](https://github.com/Narfinger) @@ -146,6 +147,7 @@ - [Soumyadip Auddy](https://github.com/SoumyadipAuddy) - [st7105](https://github.com/st7105) - [sparky8251](https://github.com/sparky8251) + - [Speenah](https://github.com/Speenah) - [spookbits](https://github.com/spookbits) - [ssenart](https://github.com/ssenart) - [stanionascu](https://github.com/stanionascu) @@ -249,6 +251,7 @@ - [TheDreadPirate](https://github.com/thedreaddpirate) - [Daniel Aquino](https://github.com/chino) - [klizas](https://github.com/klizas) + - [p27ZuH6CNAdzr](https://github.com/p27ZuH6CNAdzr) # Emby Contributors diff --git a/Directory.Packages.props b/Directory.Packages.props index 17ae5ac125..a68ab68934 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,28 +26,28 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.11" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.11" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.12" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.9.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.11" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.11" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.670" /> <PackageVersion Include="Moq" Version="4.18.4" /> @@ -78,7 +78,7 @@ <PackageVersion Include="Svg.Skia" Version="3.7.0" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" /> - <PackageVersion Include="System.Text.Json" Version="10.0.11" /> + <PackageVersion Include="System.Text.Json" Version="10.0.13" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="z440.atl.core" Version="7.18.0" /> <PackageVersion Include="TMDbLib" Version="3.0.0" /> diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json index d43b7c15f4..280d96effe 100644 --- a/Emby.Server.Implementations/Localization/Core/ko.json +++ b/Emby.Server.Implementations/Localization/Core/ko.json @@ -116,5 +116,6 @@ "NameExtraSample": "샘플", "NameExtraScene": "장면", "NameExtraTrailer": "예고편", - "NameExtraUnknown": "부가영상" + "NameExtraUnknown": "부가영상", + "NameExtraThemeSong": "주제가" } diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index 6721cd6c7e..3c20bda6a6 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -50,6 +50,12 @@ public class SubtitleController : BaseJellyfinApiController private readonly ILogger<SubtitleController> _logger; /// <summary> + /// The default MPEGTS value used in the WebVTT X-TIMESTAMP-MAP header, matching the legacy MPEG-TS + /// segment container's 10 second PTS offset. + /// </summary> + internal const long DefaultVttTimestampMapMpegts = 900000; + + /// <summary> /// Initializes a new instance of the <see cref="SubtitleController"/> class. /// </summary> /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> @@ -202,6 +208,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <param name="startPositionTicks">The start position of the subtitle in ticks.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> @@ -220,6 +227,7 @@ public class SubtitleController : BaseJellyfinApiController [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, [FromQuery] bool addVttTimeMap = false, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts, [FromQuery] long startPositionTicks = 0) { // Set parameters to route value if not provided via query. @@ -255,7 +263,7 @@ public class SubtitleController : BaseJellyfinApiController var text = await reader.ReadToEndAsync().ConfigureAwait(false); - text = text.Replace("WEBVTT", "WEBVTT\nX-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000", StringComparison.Ordinal); + text = text.Replace("WEBVTT", "WEBVTT\n" + GetVttTimestampMap(vttTimestampMapMpegts), StringComparison.Ordinal); return File(Encoding.UTF8.GetBytes(text), MimeTypes.GetMimeType("file." + format)); } @@ -289,6 +297,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param> /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param> /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <response code="200">File returned.</response> /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns> [HttpGet("Videos/{routeItemId}/{routeMediaSourceId}/Subtitles/{routeIndex}/{routeStartPositionTicks}/Stream.{routeFormat}")] @@ -307,7 +316,8 @@ public class SubtitleController : BaseJellyfinApiController [FromQuery, ParameterObsolete] string? format, [FromQuery] long? endPositionTicks, [FromQuery] bool copyTimestamps = false, - [FromQuery] bool addVttTimeMap = false) + [FromQuery] bool addVttTimeMap = false, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts) { return GetSubtitle( routeItemId, @@ -321,6 +331,7 @@ public class SubtitleController : BaseJellyfinApiController endPositionTicks, copyTimestamps, addVttTimeMap, + vttTimestampMapMpegts, startPositionTicks ?? routeStartPositionTicks); } @@ -331,6 +342,7 @@ public class SubtitleController : BaseJellyfinApiController /// <param name="index">The subtitle stream index.</param> /// <param name="mediaSourceId">The media source id.</param> /// <param name="segmentLength">The subtitle segment length.</param> + /// <param name="vttTimestampMapMpegts">Optional. The VTT timestamp map MPEGTS value.</param> /// <response code="200">Subtitle playlist retrieved.</response> /// <response code="404">Item not found.</response> /// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns> @@ -344,7 +356,8 @@ public class SubtitleController : BaseJellyfinApiController [FromRoute, Required] Guid itemId, [FromRoute, Required] int index, [FromRoute, Required] string mediaSourceId, - [FromQuery, Required] int segmentLength) + [FromQuery, Required] int segmentLength, + [FromQuery][Range(typeof(long), "0", "9223372036854775807")] long vttTimestampMapMpegts = DefaultVttTimestampMapMpegts) { var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId()); if (item is null) @@ -392,12 +405,7 @@ public class SubtitleController : BaseJellyfinApiController var endPositionTicks = Math.Min(runtime, positionTicks + segmentLengthTicks); - var url = string.Format( - CultureInfo.InvariantCulture, - "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2}", - positionTicks.ToString(CultureInfo.InvariantCulture), - endPositionTicks.ToString(CultureInfo.InvariantCulture), - accessToken); + var url = GetSubtitleSegmentUrl(positionTicks, endPositionTicks, accessToken, vttTimestampMapMpegts); builder.AppendLine(url); @@ -409,6 +417,38 @@ public class SubtitleController : BaseJellyfinApiController } /// <summary> + /// Builds the WebVTT X-TIMESTAMP-MAP header for the given MPEGTS offset. + /// </summary> + /// <param name="mpegTimestamp">The MPEGTS offset to embed.</param> + /// <returns>The X-TIMESTAMP-MAP header line.</returns> + internal static string GetVttTimestampMap(long mpegTimestamp) + { + return string.Format( + CultureInfo.InvariantCulture, + "X-TIMESTAMP-MAP=MPEGTS:{0},LOCAL:00:00:00.000", + mpegTimestamp); + } + + /// <summary> + /// Builds the relative URL for a single WebVTT subtitle segment referenced from a subtitle HLS playlist. + /// </summary> + /// <param name="positionTicks">The segment start position, in ticks.</param> + /// <param name="endPositionTicks">The segment end position, in ticks.</param> + /// <param name="accessToken">The access token to include on the segment request.</param> + /// <param name="vttTimestampMapMpegts">The MPEGTS offset to request for this segment.</param> + /// <returns>The relative stream.vtt segment URL.</returns> + internal static string GetSubtitleSegmentUrl(long positionTicks, long endPositionTicks, string? accessToken, long vttTimestampMapMpegts) + { + return string.Format( + CultureInfo.InvariantCulture, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks={0}&EndPositionTicks={1}&ApiKey={2}&VttTimestampMapMpegts={3}", + positionTicks.ToString(CultureInfo.InvariantCulture), + endPositionTicks.ToString(CultureInfo.InvariantCulture), + accessToken, + vttTimestampMapMpegts.ToString(CultureInfo.InvariantCulture)); + } + + /// <summary> /// Upload an external subtitle file. /// </summary> /// <param name="itemId">The item the subtitle belongs to.</param> diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 4995d37f4b..f6b6c98c0c 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Api.Controllers; using Jellyfin.Api.Extensions; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; @@ -691,11 +692,12 @@ public class DynamicHlsHelper var url = string.Format( CultureInfo.InvariantCulture, - "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&ApiKey={3}", + "{0}/Subtitles/{1}/subtitles.m3u8?SegmentLength={2}&ApiKey={3}&VttTimestampMapMpegts={4}", state.Request.MediaSourceId, stream.Index.ToString(CultureInfo.InvariantCulture), 30.ToString(CultureInfo.InvariantCulture), - user.GetToken()); + user.GetToken(), + GetVttTimestampMapMpegts(state.Request.SegmentContainer).ToString(CultureInfo.InvariantCulture)); var line = string.Format( CultureInfo.InvariantCulture, @@ -711,6 +713,20 @@ public class DynamicHlsHelper } /// <summary> + /// Gets the WebVTT X-TIMESTAMP-MAP MPEGTS offset appropriate for the given HLS segment container. + /// fMP4 segments start at PTS 0, unlike legacy MPEG-TS segments which start at the historical 10 second offset. + /// </summary> + /// <param name="segmentContainer">The HLS segment container, e.g. "ts" or "mp4".</param> + /// <returns>The MPEGTS offset to embed in the WebVTT X-TIMESTAMP-MAP header.</returns> + internal static long GetVttTimestampMapMpegts(string? segmentContainer) + { + var segmentExtension = EncodingHelper.GetSegmentFileExtension(segmentContainer); + return string.Equals(segmentExtension, ".mp4", StringComparison.OrdinalIgnoreCase) + ? 0 + : SubtitleController.DefaultVttTimestampMapMpegts; + } + + /// <summary> /// Appends EXT-X-IMAGE-STREAM-INF playlists for each available trickplay resolution. /// </summary> /// <param name="state">StreamState of the current stream.</param> diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 5533e55223..c9d618c84a 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -1110,6 +1110,12 @@ namespace MediaBrowser.MediaEncoding.Probing && width > 0 && height > 0)) { + // Live TV and other early probes can report neither a usable display aspect ratio nor the frame size + if (info.Width is not > 0 || info.Height is not > 0) + { + return original; + } + width = info.Width.Value; height = info.Height.Value; } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index fba644b74a..27a987fefe 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -188,6 +188,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path); + // If encoding is not supported by .NET (e.g., ISO-8859-16), return stream as-is + // The charset name will be passed to ffmpeg via -sub_charenc if needed + if (detected.Encoding is null) + { + _logger.LogWarning("Encoding {Encoding} is not supported by .NET for {Path}, returning stream as-is", detected.EncodingName, fileInfo.Path); + return stream; + } + await using (stream.ConfigureAwait(false)) { using var reader = new StreamReader(stream, detected.Encoding); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index c8db90799d..4ef8c53b30 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -41,8 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private const int TitleExactScore = 8; private const int TitlePrefixScore = 4; - private const int YearExactScore = 2; - private const int YearAdjacentScore = 1; + private const int YearScore = 2; /// <summary> /// The crew types to keep. @@ -251,13 +250,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return 0; } - return Math.Abs(candidateYear - year) switch - { - 0 => YearExactScore, - // Regional release dates routinely straddle a new year. - 1 => YearAdjacentScore, - _ => 0 - }; + // Regional and festival release dates routinely straddle a new year, so a year off by one is as good as an exact one. + return Math.Abs(candidateYear - year) <= 1 ? YearScore : 0; } /// <summary> diff --git a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs index 77321f0b05..36b4e7156e 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs @@ -134,4 +134,44 @@ public class SubtitleControllerTests fileSystem.Object, NullLogger<SubtitleController>.Instance); } + + [Theory] + [MemberData(nameof(GetVttTimestampMap_Success_TestData))] + public static void GetVttTimestampMap_Success(long mpegTimestamp, string expected) + { + Assert.Equal(expected, SubtitleController.GetVttTimestampMap(mpegTimestamp)); + } + + public static TheoryData<long, string> GetVttTimestampMap_Success_TestData() + { + var data = new TheoryData<long, string>(); + data.Add(900000, "X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000"); + data.Add(0, "X-TIMESTAMP-MAP=MPEGTS:0,LOCAL:00:00:00.000"); + return data; + } + + [Theory] + [MemberData(nameof(GetSubtitleSegmentUrl_Success_TestData))] + public static void GetSubtitleSegmentUrl_Success(long positionTicks, long endPositionTicks, string accessToken, long vttTimestampMapMpegts, string expected) + { + Assert.Equal(expected, SubtitleController.GetSubtitleSegmentUrl(positionTicks, endPositionTicks, accessToken, vttTimestampMapMpegts)); + } + + public static TheoryData<long, long, string, long, string> GetSubtitleSegmentUrl_Success_TestData() + { + var data = new TheoryData<long, long, string, long, string>(); + data.Add( + 0, + 300000000, + "abc123", + 0, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks=0&EndPositionTicks=300000000&ApiKey=abc123&VttTimestampMapMpegts=0"); + data.Add( + 300000000, + 600000000, + "abc123", + 900000, + "stream.vtt?CopyTimestamps=true&AddVttTimeMap=true&StartPositionTicks=300000000&EndPositionTicks=600000000&ApiKey=abc123&VttTimestampMapMpegts=900000"); + return data; + } } diff --git a/tests/Jellyfin.Api.Tests/Helpers/DynamicHlsHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/DynamicHlsHelperTests.cs new file mode 100644 index 0000000000..d17ce0f896 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Helpers/DynamicHlsHelperTests.cs @@ -0,0 +1,26 @@ +using Jellyfin.Api.Helpers; +using Xunit; + +namespace Jellyfin.Api.Tests.Helpers +{ + public static class DynamicHlsHelperTests + { + [Theory] + [MemberData(nameof(GetVttTimestampMapMpegts_Success_TestData))] + public static void GetVttTimestampMapMpegts_Success(string? segmentContainer, long expected) + { + Assert.Equal(expected, DynamicHlsHelper.GetVttTimestampMapMpegts(segmentContainer)); + } + + public static TheoryData<string?, long> GetVttTimestampMapMpegts_Success_TestData() + { + var data = new TheoryData<string?, long>(); + data.Add(null, 900000); + data.Add(string.Empty, 900000); + data.Add("ts", 900000); + data.Add("mp4", 0); + data.Add("MP4", 0); + return data; + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs index 52e0b19700..cf9930abe1 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs @@ -242,6 +242,27 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Assert.False(res.MediaStreams[0].IsAVC); } + [Theory] + [InlineData("Test Data/Probing/video_ts_no_dimensions.json", null)] + [InlineData("Test Data/Probing/video_ts_no_dimensions_zero_dar.json", "0:1")] + public void GetMediaInfo_TSWithoutDimensions_Success(string path, string? expectedAspectRatio) + { + var bytes = File.ReadAllBytes(path); + var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions); + + MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "http://192.168.1.19:5004/auto/v4", MediaProtocol.Http); + + Assert.Equal(2, res.MediaStreams.Count); + + var video = res.MediaStreams[0]; + Assert.Equal(MediaStreamType.Video, video.Type); + Assert.Equal("mpeg2video", video.Codec); + Assert.Null(video.Width); + Assert.Null(video.Height); + Assert.Equal(expectedAspectRatio, video.AspectRatio); + Assert.False(video.IsAnamorphic); + } + [Fact] public void GetMediaInfo_WebM_Success() { diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index 48850b2f67..13779851a9 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -269,6 +269,46 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests } } + [Fact] + public async Task GetSubtitleStream_UnsupportedEncoding_DoesNotThrow() + { + // Regression test for issue #2200 + // When the charset detector identifies an encoding not supported by .NET (e.g., ISO-8859-16), + // GetSubtitleStream should handle it gracefully instead of throwing ArgumentNullException + var cancellationToken = TestContext.Current.CancellationToken; + var srt = BuildGreekSrt(); + var path = Path.GetTempFileName(); + try + { + // Write as ISO-8859-7 (Greek) to create a non-UTF-8 file + await File.WriteAllTextAsync(path, srt, Encoding.GetEncoding("iso-8859-7"), cancellationToken); + + var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + var subtitleEncoder = fixture.Create<SubtitleEncoder>(); + + var fileInfo = new SubtitleEncoder.SubtitleInfo + { + Path = path, + Protocol = MediaProtocol.File, + Format = "srt", + IsExternal = true + }; + + // This should not throw even if the detected encoding was unsupported + using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken); + Assert.NotNull(stream); + + // Verify we can read from the stream without error + using var reader = new StreamReader(stream); + var text = await reader.ReadToEndAsync(cancellationToken); + Assert.NotEmpty(text); + } + finally + { + File.Delete(path); + } + } + private static SubtitleEncoder CreateEncoder() { var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions.json new file mode 100644 index 0000000000..cc45b48fff --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions.json @@ -0,0 +1,81 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "mpeg2video", + "codec_long_name": "MPEG-2 video", + "profile": "Main", + "codec_type": "video", + "codec_tag_string": "[2][0][0][0]", + "codec_tag": "0x0002", + "pix_fmt": "yuv420p", + "level": 8, + "id": "0x259", + "r_frame_rate": "25/1", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 6181452600, + "start_time": "68682.806667", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "mp2", + "codec_long_name": "MP2 (MPEG audio layer 2)", + "codec_type": "audio", + "codec_tag_string": "[3][0][0][0]", + "codec_tag": "0x0003", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "id": "0x25a", + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 6181460000, + "start_time": "68682.888889", + "bit_rate": "256000", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng" + } + } + ], + "format": { + "filename": "http://192.168.1.19:5004/auto/v4", + "nb_streams": 2, + "nb_programs": 1, + "format_name": "mpegts", + "format_long_name": "MPEG-TS (MPEG-2 Transport Stream)", + "start_time": "68682.806667", + "probe_score": 50 + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions_zero_dar.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions_zero_dar.json new file mode 100644 index 0000000000..c249efda3c --- /dev/null +++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions_zero_dar.json @@ -0,0 +1,83 @@ +{ + "streams": [ + { + "index": 0, + "codec_name": "mpeg2video", + "codec_long_name": "MPEG-2 video", + "profile": "Main", + "codec_type": "video", + "codec_tag_string": "[2][0][0][0]", + "codec_tag": "0x0002", + "sample_aspect_ratio": "0:1", + "display_aspect_ratio": "0:1", + "pix_fmt": "yuv420p", + "level": 8, + "id": "0x259", + "r_frame_rate": "25/1", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 6181452600, + "start_time": "68682.806667", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + } + }, + { + "index": 1, + "codec_name": "mp2", + "codec_long_name": "MP2 (MPEG audio layer 2)", + "codec_type": "audio", + "codec_tag_string": "[3][0][0][0]", + "codec_tag": "0x0003", + "sample_fmt": "fltp", + "sample_rate": "48000", + "channels": 2, + "channel_layout": "stereo", + "bits_per_sample": 0, + "id": "0x25a", + "r_frame_rate": "0/0", + "avg_frame_rate": "0/0", + "time_base": "1/90000", + "start_pts": 6181460000, + "start_time": "68682.888889", + "bit_rate": "256000", + "disposition": { + "default": 0, + "dub": 0, + "original": 0, + "comment": 0, + "lyrics": 0, + "karaoke": 0, + "forced": 0, + "hearing_impaired": 0, + "visual_impaired": 0, + "clean_effects": 0, + "attached_pic": 0, + "timed_thumbnails": 0 + }, + "tags": { + "language": "eng" + } + } + ], + "format": { + "filename": "http://192.168.1.19:5004/auto/v4", + "nb_streams": 2, + "nb_programs": 1, + "format_name": "mpegts", + "format_long_name": "MPEG-TS (MPEG-2 Transport Stream)", + "start_time": "68682.806667", + "probe_score": 50 + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index 8342621d97..6e36c08064 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -219,6 +219,19 @@ namespace Jellyfin.Providers.Tests.Tmdb [Movie(1, "Some Movie", "Some Movie", 2015), Movie(2, "Some Movie", "Some Movie", 2010)], 2 }, + // M. Night Shyamalan's Split premiered at a festival in 2016 and was released in 2017, so the obscure + // 2016 film of the same name must not outrank it. Years one apart tie and TMDb's own ordering decides. + { + "Split (2016)", "Split", 2016, + [Movie(381288, "Split", "Split", 2017), Movie(358364, "Split", "Split", 2016)], + 381288 + }, + // Two years apart is a different film, so the tie only covers a year off by one. + { + "Two years apart", "Some Movie", 2016, + [Movie(1, "Some Movie", "Some Movie", 2018), Movie(2, "Some Movie", "Some Movie", 2016)], + 2 + }, // Nothing matches the name, so TMDb's own ordering is kept. { "A Christmas No. 1 (2021)", "A Christmas No. 1", 2021, |
