aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTORS.md3
-rw-r--r--Directory.Packages.props38
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json3
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs58
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs12
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/SubtitleControllerTests.cs40
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/DynamicHlsHelperTests.cs26
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs21
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs40
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions.json81
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_ts_no_dimensions_zero_dar.json83
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs13
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,