aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs161
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs77
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs129
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs70
-rw-r--r--tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs44
-rw-r--r--tests/Jellyfin.Extensions.Tests/PathHelperTests.cs60
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs57
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs59
-rw-r--r--tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs24
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml18
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json1
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs9
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs12
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs52
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs24
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs338
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs42
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs43
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs182
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs12
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs63
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs209
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs34
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs2
33 files changed, 1713 insertions, 138 deletions
diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
new file mode 100644
index 0000000000..a248664928
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio
+// and video segment endpoints are not authenticated. These tests pin down that requests escaping
+// the transcode directory are rejected while legitimate ones still serve a file.
+public sealed class HlsSegmentControllerTests
+{
+ private readonly Mock<IFileSystem> _fileSystem = new();
+ private readonly Mock<IServerConfigurationManager> _config = new();
+ private readonly Mock<ITranscodeManager> _transcodeManager = new();
+ private readonly string _transcodePath;
+
+ public HlsSegmentControllerTests()
+ {
+ _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests");
+ Directory.CreateDirectory(_transcodePath);
+
+ _config.Setup(c => c.GetConfiguration("encoding"))
+ .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath });
+ _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>());
+ }
+
+ private HlsSegmentController CreateController(string requestPath)
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Path = requestPath;
+
+ return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = httpContext }
+ };
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "segment");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // A rooted segment id makes Path.GetFullPath discard the transcode base.
+ var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd";
+ var result = controller.GetHlsAudioSegmentLegacy("abc", rooted);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix.
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest()
+ {
+ // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir.
+ var controller = CreateController("/Videos/abc/hls/list/stream.mp4");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId)
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", playlistId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") });
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(Array.Empty<string>());
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<NotFoundObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ _fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
new file mode 100644
index 0000000000..1a91efe4f2
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/ItemUpdateControllerTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class ItemUpdateControllerTests
+{
+ private readonly ItemUpdateController _subject;
+
+ public ItemUpdateControllerTests()
+ {
+ _subject = new ItemUpdateController(
+ Mock.Of<IFileSystem>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IProviderManager>(),
+ Mock.Of<ILocalizationManager>(),
+ Mock.Of<IServerConfigurationManager>());
+ }
+
+ [Fact]
+ public async Task UpdateItem_WhenOnlyTagsFieldSupplied_DoesNotThrowAndAppliesTags()
+ {
+ // Regression test for https://github.com/jellyfin/jellyfin/issues/17366
+ // A partial update payload that only sets "Tags" leaves every other
+ // BaseItemDto collection property null (they have no default
+ // initializer). Genres and ProviderIds used to be fed straight into
+ // Distinct()/ToList() without a null check, so this call used to throw
+ // ArgumentNullException before the fix below was applied.
+ var movie = new Movie();
+ var request = new BaseItemDto
+ {
+ Tags = new[] { "new-tag-1", "new-tag-2" }
+ };
+
+ await InvokeUpdateItem(request, movie);
+
+ Assert.Equal(new[] { "new-tag-1", "new-tag-2" }, movie.Tags);
+ Assert.Empty(movie.Genres);
+ Assert.Empty(movie.ProviderIds);
+ }
+
+ [Fact]
+ public async Task UpdateItem_WhenGenresAndProviderIdsOmitted_LeavesExistingValuesUnchanged()
+ {
+ var movie = new Movie
+ {
+ Genres = new[] { "Action" }
+ };
+ movie.ProviderIds["Imdb"] = "tt1234567";
+
+ var request = new BaseItemDto
+ {
+ Tags = Array.Empty<string>()
+ };
+
+ await InvokeUpdateItem(request, movie);
+
+ Assert.Equal(new[] { "Action" }, movie.Genres);
+ Assert.Equal("tt1234567", movie.ProviderIds["Imdb"]);
+ }
+
+ private Task InvokeUpdateItem(BaseItemDto request, BaseItem item)
+ {
+ return _subject.UpdateItem(request, item);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
new file mode 100644
index 0000000000..f040a328bb
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
@@ -0,0 +1,129 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath
+// must resolve to a file inside the plugin's own directory.
+public sealed class PluginsControllerTests
+{
+ private readonly Mock<IPluginManager> _pluginManager = new();
+ private readonly string _pluginPath;
+
+ public PluginsControllerTests()
+ {
+ _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests");
+ Directory.CreateDirectory(_pluginPath);
+ }
+
+ private PluginsController CreateController() =>
+ new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
+ };
+
+ private void SetupPlugin(Guid id, Version version, string? imagePath)
+ {
+ var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath };
+ _pluginManager.Setup(p => p.GetPlugin(id, version))
+ .Returns(new LocalPlugin(_pluginPath, true, manifest));
+ }
+
+ [Fact]
+ public void GetPluginImage_UnknownPlugin_ReturnsNotFound()
+ {
+ var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0));
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPath_ReturnsFile()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, "does-not-exist.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ // Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix.
+ // The file is created so the check fails on the boundary, not on File.Exists.
+ var siblingDir = _pluginPath + "-evil";
+ Directory.CreateDirectory(siblingDir);
+ File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
new file mode 100644
index 0000000000..bad11a9257
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/StartupControllerTests.cs
@@ -0,0 +1,70 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Controllers;
+using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+public class StartupControllerTests
+{
+ private readonly StartupController _subject;
+ private readonly Mock<IUserManager> _mockUserManager;
+ private readonly Mock<IServerConfigurationManager> _mockConfig;
+
+ public StartupControllerTests()
+ {
+ _mockUserManager = new Mock<IUserManager>();
+ _mockConfig = new Mock<IServerConfigurationManager>();
+ _subject = new StartupController(_mockConfig.Object, _mockUserManager.Object);
+ }
+
+ private static User CreateUser()
+ => new User(
+ "jellyfin",
+ typeof(DefaultAuthenticationProvider).FullName!,
+ typeof(DefaultPasswordResetProvider).FullName!);
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoUserExists_ReturnsNotFound()
+ {
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns((User?)null);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "admin", Password = "pw" });
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenPasswordAlreadyConfigured_ReturnsForbidden()
+ {
+ var user = CreateUser();
+ user.Password = "already-set-hash";
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "attacker", Password = "new-pw" });
+
+ // The startup wizard must never overwrite the password of an already-provisioned
+ // account, even if IsStartupWizardCompleted has been cleared.
+ Assert.IsType<ForbidResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(It.IsAny<System.Guid>(), It.IsAny<string>()), Times.Never);
+ }
+
+ [Fact]
+ public async Task UpdateStartupUser_WhenNoPasswordYet_SetsPassword()
+ {
+ var user = CreateUser();
+ Assert.True(string.IsNullOrEmpty(user.Password));
+ _mockUserManager.Setup(m => m.GetFirstUser()).Returns(user);
+
+ var result = await _subject.UpdateStartupUser(new StartupUserDto { Name = "jellyfin", Password = "first-pw" });
+
+ Assert.IsType<NoContentResult>(result);
+ _mockUserManager.Verify(m => m.ChangePassword(user.Id, "first-pw"), Times.Once);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
new file mode 100644
index 0000000000..5132e529dd
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.ClientEvent;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests
+{
+ public class ClientEventLoggerTests
+ {
+ [Theory]
+ [InlineData("../../../../etc/passwd", "1.0")]
+ [InlineData("..\\..\\windows\\system32", "1.0")]
+ [InlineData("normal-client", "../../../etc/passwd")]
+ [InlineData("/absolute/path", "1.0")]
+ public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
+ {
+ var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var paths = new Mock<IServerApplicationPaths>();
+ paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
+
+ var logger = new ClientEventLogger(paths.Object);
+ using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
+
+ var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
+
+ var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
+ var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
+ Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
+ Assert.True(File.Exists(resolved));
+ }
+ finally
+ {
+ Directory.Delete(logDir, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
new file mode 100644
index 0000000000..71fd853ba2
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
@@ -0,0 +1,60 @@
+using System.IO;
+using Jellyfin.Extensions;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests
+{
+ public static class PathHelperTests
+ {
+ [Theory]
+ [InlineData("file.txt", "file.txt")]
+ [InlineData("sub/file.txt", "file.txt")]
+ [InlineData("../../etc/passwd", "passwd")]
+ public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected)
+ {
+ Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(".")]
+ [InlineData("..")]
+ public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input)
+ {
+ Assert.Null(PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Fact]
+ public static void IsContainedIn_ChildPath_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var child = Path.Combine(root, "sub", "file.txt");
+ Assert.True(PathHelper.IsContainedIn(root, child));
+ }
+
+ [Fact]
+ public static void IsContainedIn_RootItself_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ Assert.True(PathHelper.IsContainedIn(root, root));
+ }
+
+ [Fact]
+ public static void IsContainedIn_TraversalEscape_ReturnsFalse()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var escape = Path.Combine(root, "..", "..", "etc", "passwd");
+ Assert.False(PathHelper.IsContainedIn(root, escape));
+ }
+
+ [Fact]
+ public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse()
+ {
+ // "/var/data" must not be accepted as a parent of "/var/dataset".
+ var root = Path.Combine(Path.GetTempPath(), "data");
+ var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt");
+ Assert.False(PathHelper.IsContainedIn(root, sibling));
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index b71dc15201..f698edc637 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Moq;
using Moq.Protected;
@@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests
Assert.True(program.HasImage);
Assert.Equal("https://domain.tld/image.png", program.ImageUrl);
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
}
[Theory]
@@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests
var program = programsList[0];
Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g));
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_Etag_SameContentIsStable()
+ {
+ var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+
+ Assert.Equal(first.Etag, second.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")]
+ public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var changed = await GetSingleProgramAsync(changedPath);
+
+ Assert.NotEqual(original.Etag, changed.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")]
+ public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var equivalent = await GetSingleProgramAsync(equivalentPath);
+
+ Assert.Equal(original.Etag, equivalent.Etag);
+ }
+
+ private async Task<ProgramInfo> GetSingleProgramAsync(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = Path.GetFileNameWithoutExtension(path),
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return Assert.Single(programs.ToList());
+ }
+
+ private static void AssertXmlTvEtag(string? etag)
+ {
+ Assert.NotNull(etag);
+ Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal);
}
}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
new file mode 100644
index 0000000000..b8d1c60e1a
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
@@ -0,0 +1,59 @@
+using System;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class XmlTvProgramEtagTests
+{
+ [Fact]
+ public void TryCreate_GenreOrderIsSignificant()
+ {
+ // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order,
+ // so the same genres in a different order is a real mapped-content change.
+ var first = NewProgram();
+ first.Genres = new() { "Drama", "Action" };
+
+ var second = NewProgram();
+ second.Genres = new() { "Action", "Drama" };
+
+ Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _));
+ Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _));
+ Assert.NotEqual(firstEtag, secondEtag);
+ }
+
+ [Fact]
+ public void MatchesStored_EqualXmlTvEtags_ReturnsTrue()
+ {
+ const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789";
+ Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ [Fact]
+ public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse()
+ {
+ Assert.False(XmlTvProgramEtag.MatchesStored(
+ XmlTvProgramEtag.Prefix + "AAAA",
+ XmlTvProgramEtag.Prefix + "BBBB"));
+ }
+
+ [Fact]
+ public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse()
+ {
+ // Other providers (e.g. Schedules Direct) use their own etag schemes.
+ // The IsXmlTvEtag gate must keep them on the field-by-field update path
+ // even when their incoming and stored values happen to match exactly.
+ const string Etag = "sd-abc123";
+ Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ private static ProgramInfo NewProgram() => new()
+ {
+ Id = "program-id",
+ ChannelId = "channel-id",
+ Name = "Program Name",
+ StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
+ EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc),
+ };
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
index 59cd42c05b..1bc42d5fe5 100644
--- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
@@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
}
/// <summary>
+ /// /metadata/programs response where the daily image limit is hit mid-batch,
+ /// so individual entries carry an error code inside an otherwise successful response.
+ /// </summary>
+ [Fact]
+ public void Deserialize_Metadata_Programs_Image_Limit_Response_Success()
+ {
+ var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json");
+ var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions);
+
+ Assert.NotNull(showImagesDtos);
+ Assert.Equal(2, showImagesDtos!.Count);
+
+ // First entry is a normal result with image data and no error code.
+ Assert.Equal("SH00712240", showImagesDtos[0].ProgramId);
+ Assert.Null(showImagesDtos[0].Code);
+ Assert.Single(showImagesDtos[0].Data);
+
+ // Second entry is a per-entry trial image download limit error (SD code 5003).
+ Assert.Equal("SH00712241", showImagesDtos[1].ProgramId);
+ Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code);
+ Assert.Empty(showImagesDtos[1].Data);
+ }
+
+ /// <summary>
/// /headends response.
/// </summary>
[Fact]
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
new file mode 100644
index 0000000000..15f85f57e6
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
new file mode 100644
index 0000000000..2b49c3bccd
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">sports</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
new file mode 100644
index 0000000000..090273ac98
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Changed description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
new file mode 100644
index 0000000000..532b91da20
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/changed.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
new file mode 100644
index 0000000000..db0d5e86de
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789013</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
new file mode 100644
index 0000000000..168c0a643b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000">
+ <icon src="https://domain.tld/base.png"/>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <category lang="en">series</category>
+ <desc lang="en">Base description.</desc>
+ <sub-title lang="en">Base Episode</sub-title>
+ <title lang="en">Base Program</title>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
new file mode 100644
index 0000000000..73288e7c57
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Changed Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
new file mode 100644
index 0000000000..d0ff1b82f5
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
@@ -0,0 +1,18 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
new file mode 100644
index 0000000000..34931aa769
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
@@ -0,0 +1 @@
+[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
index 988073074b..bfe6ade1fe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
@@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests
[InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
[InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)]
+ [InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)]
public void ValidateVersionInternalTest(string versionOutput, bool valid)
{
@@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests
Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4));
+ Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null);
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
index 1f2d618aa4..604b862fbe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
@@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100
libswresample 3. 9.100 / 3. 9.100
libpostproc 55. 9.100 / 55. 9.100";
+ public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers
+libavutil 60. 26.102 / 60. 26.102
+libavcodec 62. 28.102 / 62. 28.102
+libavformat 62. 12.102 / 62. 12.102
+libavdevice 62. 3.102 / 62. 3.102
+libavfilter 11. 14.102 / 11. 14.102
+libswscale 9. 5.102 / 9. 5.102
+libswresample 6. 3.102 / 6. 3.102";
+
public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers
built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516
configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
index 1f908d7e0e..b03651e5e9 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
@@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ass");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
index b7152961cd..01a35e6cb0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
@@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("1", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("2", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("Very good, Lieutenant.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(1, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(2, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Very good, Lieutenant.", paragraph2.Text);
}
[Fact]
@@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example2.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("311", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("312", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(311, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(312, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
index 5b7aa7eaa9..d814088593 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
@@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa));
- SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa");
+ var subtitle = _parser.Parse(stream, "ssa");
- Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count);
+ Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count);
for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i)
{
SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i];
- SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i];
+ var actual = subtitle.Paragraphs[i];
- Assert.Equal(expected.Id, actual.Id);
+ Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture));
Assert.Equal(expected.Text, actual.Text);
- Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks);
- Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks);
+ Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks);
+ Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks);
}
}
@@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ssa");
var parsed = _parser.Parse(stream, "ssa");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
index ce1f005f40..48850b2f67 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
@@ -6,87 +11,120 @@ using MediaBrowser.MediaEncoding.Subtitles;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
public class SubtitleEncoderTests
{
- public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ private const int StreamCount = 8;
+ private const int CueCount = 500;
+
+ // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented
+ // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7,
+ // so a Greek-vs-Greek charset misdetection still round-trips correctly.
+ private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος.";
+
+ static SubtitleEncoderTests()
{
- var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>();
+ // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available.
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ass",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ // Enough Greek text to give the charset detector a strong, unambiguous signal.
+ private static string BuildGreekSrt()
+ {
+ var builder = new StringBuilder();
+ for (var i = 1; i <= 8; i++)
+ {
+ builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture))
+ .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n");
+ builder.Append(GreekText).Append('\n');
+ builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n");
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ssa",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ssa",
- Protocol = MediaProtocol.File,
- Format = "ssa",
- IsExternal = true
- });
+ return builder.ToString();
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
+ public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ {
+ var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>
+ {
{
- Path = "/media/sub.srt",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.srt",
- Protocol = MediaProtocol.File,
- Format = "srt",
- IsExternal = true
- });
-
- data.Add(
- new MediaSourceInfo()
{
- Protocol = MediaProtocol.Http
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ssa",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ssa",
+ Protocol = MediaProtocol.File,
+ Format = "ssa",
+ IsExternal = true
+ }
},
- new MediaStream()
{
- Path = "/media/sub.ass",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.srt",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.srt",
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
{
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.Http
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
+ }
+ };
return data;
}
@@ -103,5 +141,177 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Assert.Equal(subtitleInfo.Format, result.Format);
Assert.Equal(subtitleInfo.IsExternal, result.IsExternal);
}
+
+ public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData()
+ {
+ return
+ [
+ // Greek legacy encodings – the exact scenario reported in issue #17267.
+ Encoding.GetEncoding("windows-1253"),
+ Encoding.GetEncoding("iso-8859-7"),
+ // Wide encoding with a BOM.
+ new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
+ ];
+ }
+
+ [Theory]
+ [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))]
+ public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, sourceEncoding, 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
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ // The Greek text must survive round-trip and contain no replacement characters.
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ Assert.DoesNotContain('�', text);
+ Assert.DoesNotContain('?', text);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ConvertSubtitles_SequentialCalls_AreDeterministic()
+ {
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+
+ var first = ConvertAllSequential(encoder, sources);
+ var second = ConvertAllSequential(encoder, sources);
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal);
+ Assert.Equal(first[i], second[i]);
+ }
+ }
+
+ [Fact]
+ public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), 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
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+
+ // An already-UTF-8 file must be short-circuited and served directly from disk,
+ // not read into memory and re-encoded (which would produce a MemoryStream).
+ Assert.IsNotType<MemoryStream>(stream);
+
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline()
+ {
+ const int Iterations = 10;
+
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+ var baseline = ConvertAllSequential(encoder, sources);
+
+ for (var iteration = 0; iteration < Iterations; iteration++)
+ {
+ var results = await Task.WhenAll(Enumerable.Range(0, StreamCount)
+ .Select(i => Task.Run(() => Convert(encoder, sources[i], i)))
+ .ToArray());
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.True(
+ string.Equals(baseline[i], results[i], StringComparison.Ordinal),
+ $"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)");
+ }
+ }
+ }
+
+ private static SubtitleEncoder CreateEncoder()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance));
+ return fixture.Create<SubtitleEncoder>();
+ }
+
+ private static byte[][] GenerateSources()
+ {
+ return Enumerable.Range(0, StreamCount)
+ .Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount)))
+ .ToArray();
+ }
+
+ private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex)
+ {
+ using var input = new MemoryStream(source);
+ var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" };
+ using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false);
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources)
+ {
+ return sources.Select((source, i) => Convert(encoder, source, i)).ToArray();
+ }
+
+ private static string GenerateSrt(int streamIndex, int cueCount)
+ {
+ var builder = new StringBuilder();
+ for (var i = 0; i < cueCount; i++)
+ {
+ var start = TimeSpan.FromSeconds(i * 4);
+ var end = start + TimeSpan.FromSeconds(2);
+ builder.Append(i + 1).AppendLine()
+ .Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append(" --> ")
+ .AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append('S').Append(streamIndex).Append('C').Append(i).AppendLine()
+ .AppendLine();
+ }
+
+ return builder.ToString();
+ }
}
}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index d94d56bc20..5ba061296a 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -677,6 +677,48 @@ namespace Jellyfin.Model.Tests
}
[Theory]
+ [InlineData(false, null, true, SubtitleDeliveryMethod.External)]
+ [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)]
+ [InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)]
+ [InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)]
+ [InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)]
+ public void GetSubtitleProfile_MatchesVobSubMksProfileOnlyWhenDeliveredAsMks(
+ bool isExternal,
+ string? path,
+ bool enableSubtitleExtraction,
+ SubtitleDeliveryMethod expectedMethod)
+ {
+ var mediaSource = new MediaSourceInfo();
+ var subtitleStream = new MediaStream
+ {
+ Type = MediaStreamType.Subtitle,
+ Index = 0,
+ IsExternal = isExternal,
+ Path = path,
+ Codec = "vobsub"
+ };
+
+ var subtitleProfiles = new[]
+ {
+ new SubtitleProfile { Format = "vobsub", Container = "mks", Method = SubtitleDeliveryMethod.External }
+ };
+
+ var transcoderSupport = new Mock<ITranscoderSupport>();
+ transcoderSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction);
+
+ var result = StreamBuilder.GetSubtitleProfile(
+ mediaSource,
+ subtitleStream,
+ subtitleProfiles,
+ PlayMethod.Transcode,
+ transcoderSupport.Object,
+ null,
+ null);
+
+ Assert.Equal(expectedMethod, result.Method);
+ }
+
+ [Theory]
// External text subs embedded into MKV when transcoding (#16403)
[InlineData("srt", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
[InlineData("ass", true, PlayMethod.Transcode, "mkv", MediaStreamProtocol.http, SubtitleDeliveryMethod.Embed)]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index a5de0a4416..9c247d54b9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -56,57 +57,63 @@ public class DtoServiceTests
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
+ public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
{
- var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
- var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+ var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
var dto = _dtoService.GetBaseItemDto(episode, options);
- // The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
- Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.Null(dto.SeriesPrimaryImageTag);
+ // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
+ // and clients decide per view whether to prefer the parent/series poster over the episode still.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
- // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
- Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ // Aspect ratio stays the episode's own image, not the poster's.
+ Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
+ public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
{
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
- var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+ var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
- Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.Null(dto.SeriesPrimaryImageTag);
+ // Episode image is retained; ParentPrimaryImage falls back to the series poster.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
}
[Fact]
- public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
+ public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
{
- var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
- // Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
+ // With no season or series poster there is nothing to attach; the episode keeps its own primary.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Null(dto.ParentPrimaryImageItemId);
}
- private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
{
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
- series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ if (seriesHasPoster)
+ {
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ }
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
if (seasonHasPoster)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
new file mode 100644
index 0000000000..66c392a6ad
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.FullSystemBackup;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup;
+
+/// <summary>
+/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt
+/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort
+/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216.
+/// </summary>
+public sealed class BackupServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly string _testRoot;
+ private readonly string _backupPath;
+ private readonly string _configurationDirectoryPath;
+
+ public BackupServiceTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted
+ // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have
+ // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory
+ // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms.
+ _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N"));
+ _backupPath = Path.Combine(_testRoot, "Backup");
+ _configurationDirectoryPath = Path.Combine(_testRoot, "Config");
+ Directory.CreateDirectory(_backupPath);
+ Directory.CreateDirectory(_configurationDirectoryPath);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+
+ if (Directory.Exists(_testRoot))
+ {
+ Directory.Delete(_testRoot, true);
+ }
+ }
+
+ [Fact]
+ public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+
+ await using (var ctx = CreateDbContext())
+ {
+ // A healthy item + keyframe row, written the normal way.
+ ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie"));
+ ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie"));
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ ctx.KeyframeData.Add(new KeyframeData
+ {
+ ItemId = validItemId,
+ TotalDuration = 60_000,
+ KeyframeTicks = [0, 1000, 2000]
+ });
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ // Simulate a corrupted database row: truncated JSON array for KeyframeTicks,
+ // written directly via SQL to bypass EF's normal (well-formed) write path.
+ await ctx.Database.ExecuteSqlInterpolatedAsync(
+ $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})",
+ cancellationToken).ConfigureAwait(true);
+ }
+
+ var backupService = CreateBackupService();
+
+ var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true);
+
+ Assert.True(File.Exists(manifest.Path));
+
+ using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true);
+ var keyframeEntry = archive.GetEntry("Database/KeyframeData.json");
+ Assert.NotNull(keyframeEntry);
+
+ await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true);
+ using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true);
+
+ var rows = document.RootElement.EnumerateArray().ToList();
+
+ // The corrupt row must be skipped, but the valid row must still make it into the backup.
+ var singleRow = Assert.Single(rows);
+ Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid());
+ }
+
+ private BackupService CreateBackupService()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ var applicationHost = new Mock<IServerApplicationHost>();
+ applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0));
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath);
+ applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath);
+ applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data"));
+ applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root"));
+ applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata"));
+ applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault"));
+
+ var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>();
+ jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
+ jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask);
+
+ var applicationLifetime = new Mock<IHostApplicationLifetime>();
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.IsScanRunning).Returns(false);
+
+ return new BackupService(
+ NullLogger<BackupService>.Instance,
+ factory.Object,
+ applicationHost.Object,
+ applicationPaths.Object,
+ jellyfinDatabaseProvider.Object,
+ applicationLifetime.Object,
+ libraryManager.Object);
+ }
+
+ private static BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Movie",
+ Name = name,
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
index c8aa14af58..b7fca74310 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -60,7 +60,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
- .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
@@ -110,7 +112,9 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
- .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index b788fb304e..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -150,7 +150,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library
}
[Fact]
- public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
+ public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
SetupUserDataBatch(new Dictionary<Guid, UserItemData>
@@ -161,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
- // Each version carries its own resume point; the primary has none.
- Assert.Equal((long?)10, sources.First(s => s.Id == alt1.Id.ToString("N")).PlaybackPositionTicks);
- Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
- Assert.Null(sources.First(s => s.Id == primary.Id.ToString("N")).PlaybackPositionTicks);
-
// The most recently played version is the default source, so resuming plays the right file.
+ // Per-user positions live in each version's UserData, not on the source.
Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
}
@@ -182,9 +178,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
// An explicitly opened version keeps its own source first, even when a sibling was
- // played more recently, but the sibling's resume point is still populated.
+ // played more recently.
Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
- Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
Assert.Equal(3, sources.Count);
}
@@ -197,7 +192,6 @@ namespace Jellyfin.Server.Implementations.Tests.Library
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
- Assert.All(sources, s => Assert.Null(s.PlaybackPositionTicks));
}
[Fact]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdb-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
+ [InlineData("Superman: Red Son [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
- [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
+ [InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
+ [InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
+ [InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
+ [InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
+ [InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
+ [InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
+ [InlineData("tmdb", "tmdbid", null)]
+ [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..bd14ca008d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public sealed class UserDataManagerTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserDataManager _userDataManager;
+ private readonly User _user;
+
+ public UserDataManagerTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _userDataManager = new UserDataManager(config.Object, factory.Object);
+ _user = new User("user", "auth-provider", "reset-provider")
+ {
+ Id = Guid.NewGuid()
+ };
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ private AudioBook CreateAudioBook()
+ {
+ // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
+ return new AudioBook
+ {
+ Id = Guid.NewGuid(),
+ Name = "Book Title",
+ Album = "Series",
+ AlbumArtists = new[] { "Author" },
+ IndexNumber = 1
+ };
+ }
+
+ private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
+ {
+ return new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = _user.Id,
+ User = null,
+ CustomDataKey = key,
+ PlaybackPositionTicks = positionTicks
+ };
+ }
+
+ [Fact]
+ public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ // the retired-key row comes first to ensure selection is by key, not row order
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(currentKey, userData.Key);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var idKey = item.GetUserDataKeys()[1];
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, idKey, 333)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(idKey, userData.Key);
+ Assert.Equal(333, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
+ {
+ var item = CreateAudioBook();
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(111, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
+ {
+ var item = CreateAudioBook();
+ item.UserData = new List<UserData>();
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
+ Assert.Equal(0, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_RowsForOtherUsers_AreIgnored()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ var otherUserRow = CreateUserDataRow(item, currentKey, 999);
+ otherUserRow.UserId = Guid.NewGuid();
+
+ item.UserData = new List<UserData>
+ {
+ otherUserRow,
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
+ {
+ // no preloaded navigation data, so the batch takes the database fallback
+ var fossilItem = CreateAudioBook();
+ var retiredItem = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
+
+ // the stale id-key row is inserted first so selection by row order would return it
+ ctx.UserData.AddRange(
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
+ CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
+ ctx.SaveChanges();
+ }
+
+ var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
+
+ Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
+ Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index bdb726f06d..2ed880ed9c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -119,6 +119,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Equal(code, culture.ThreeLetterISOLanguageName);
}
+ [Theory]
+ [InlineData("ell", "Greek")] // Comma truncation
+ [InlineData("nld", "Dutch")] // Semicolon truncation
+ [InlineData("ron", "Romanian")] // Semicolon truncation, multiple
+ [InlineData("eng", "English")] // No truncation
+ [InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses
+ public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("xyz")]
+ public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ var result = localizationManager.GetLanguageDisplayName(language!);
+ Assert.Null(result);
+ }
+
[Fact]
public async Task GetParentalRatings_Default_Success()
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
index 4cea53bd3d..2bf1d1d05b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
@@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users
[InlineData(" thishasaspaceatthestart")]
[InlineData(" thishasaspaceatbothends ")]
[InlineData(" this has a space at both ends and inbetween ")]
+ [InlineData(".")]
+ [InlineData("..")]
public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username)
{
Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username));