Merge remote-tracking branch 'upstream/master' into security-path-traversal-fixes
# Conflicts: # Jellyfin.Api/Controllers/HlsSegmentController.cs # Jellyfin.Api/Controllers/PluginsController.cs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -175,6 +175,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
|
||||
Assert.Equal("Series", showImagesDtos[0].Data[0].Tier);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+17
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+1
@@ -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."}]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
Assert.Equal(2, parsed.Paragraphs.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 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 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);
|
||||
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);
|
||||
Assert.Equal(2, parsed.Paragraphs.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 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 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +11,16 @@ 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
|
||||
{
|
||||
private const int StreamCount = 8;
|
||||
private const int CueCount = 500;
|
||||
|
||||
public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
|
||||
{
|
||||
var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>();
|
||||
@@ -103,5 +112,90 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
|
||||
Assert.Equal(subtitleInfo.Format, result.Format);
|
||||
Assert.Equal(subtitleInfo.IsExternal, result.IsExternal);
|
||||
}
|
||||
|
||||
[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 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
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.Users;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users
|
||||
{
|
||||
public sealed class UserManagerProfileImageTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly UserManager _userManager;
|
||||
|
||||
public UserManagerProfileImageTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Create the schema
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateDbContext);
|
||||
|
||||
var cryptoProvider = new Mock<ICryptoProvider>();
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
var appPaths = new Mock<IServerApplicationPaths>();
|
||||
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
|
||||
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
|
||||
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
|
||||
var defaultAuthProvider = new DefaultAuthenticationProvider(
|
||||
NullLogger<DefaultAuthenticationProvider>.Instance,
|
||||
cryptoProvider.Object);
|
||||
var invalidAuthProvider = new InvalidAuthProvider();
|
||||
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
|
||||
configManager.Object,
|
||||
appHost.Object);
|
||||
|
||||
_userManager = new UserManager(
|
||||
factory.Object,
|
||||
new NoopEventManager(),
|
||||
new Mock<INetworkManager>().Object,
|
||||
appHost.Object,
|
||||
new Mock<IImageProcessor>().Object,
|
||||
NullLogger<UserManager>.Instance,
|
||||
configManager.Object,
|
||||
new IPasswordResetProvider[] { defaultPasswordResetProvider },
|
||||
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_userManager.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync("profileimageuser");
|
||||
|
||||
// Assign a profile image the same way the image endpoint does and persist it.
|
||||
// UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity,
|
||||
// so the in-memory instance below is never assigned the database generated key.
|
||||
user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png"));
|
||||
await _userManager.UpdateUserAsync(user);
|
||||
|
||||
// Precondition reproducing the bug: the in-memory image still carries the default,
|
||||
// never-persisted (temporary) key, while a real image row exists in the database.
|
||||
Assert.Equal(0, user.ProfileImage.Id);
|
||||
Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage);
|
||||
|
||||
// This used to throw InvalidOperationException:
|
||||
// "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'."
|
||||
var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Null(user.ProfileImage);
|
||||
Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing()
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync("noprofileimageuser");
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Null(user.ProfileImage);
|
||||
}
|
||||
|
||||
private sealed class NoopEventManager : IEventManager
|
||||
{
|
||||
public void Publish<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
{
|
||||
}
|
||||
|
||||
public Task PublishAsync<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user