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:
Shadowghost
2026-07-21 07:14:47 +02:00
79 changed files with 1930 additions and 518 deletions
@@ -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>
@@ -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>
@@ -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)
@@ -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;
}
}
}