diff options
Diffstat (limited to 'tests')
6 files changed, 482 insertions, 282 deletions
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs new file mode 100644 index 0000000000..a003be4d96 --- /dev/null +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using Jellyfin.Api.Helpers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Jellyfin.Api.Tests.Helpers +{ + public class MediaInfoHelperTests + { + private static MediaInfoHelper CreateHelper() + { + return new MediaInfoHelper( + Mock.Of<IUserManager>(), + Mock.Of<ILibraryManager>(), + Mock.Of<IMediaSourceManager>(), + Mock.Of<IMediaEncoder>(), + Mock.Of<IServerConfigurationManager>(), + Mock.Of<ILogger<MediaInfoHelper>>(), + Mock.Of<INetworkManager>(), + Mock.Of<IDeviceManager>()); + } + + private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true) + { + return new MediaSourceInfo + { + Id = itemId.ToString("N", CultureInfo.InvariantCulture), + Protocol = MediaProtocol.File, + Bitrate = bitrate, + SupportsDirectPlay = supportsDirectPlay, + SupportsDirectStream = true, + SupportsTranscoding = true + }; + } + + [Fact] + public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault() + { + // The version the user was watching (the queried item) must stay the default + // even when a sibling version fits the bitrate limit better, since the resume + // position belongs to that exact version. + var preferredItemId = Guid.NewGuid(); + var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false); + var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + + var result = new PlaybackInfoResponse + { + MediaSources = [siblingSource, preferredSource] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId); + + Assert.Equal(preferredSource.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_NoPreferredItem_OrdersByPlayability() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + + [Fact] + public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder() + { + var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000); + var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false); + transcodeOnly.SupportsDirectStream = false; + + var result = new PlaybackInfoResponse + { + MediaSources = [transcodeOnly, directPlay] + }; + + CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid()); + + Assert.Equal(directPlay.Id, result.MediaSources[0].Id); + } + } +} diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs deleted file mode 100644 index 5f84e85592..0000000000 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/FilterEventsTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using AutoFixture; -using AutoFixture.AutoMoq; -using MediaBrowser.MediaEncoding.Subtitles; -using MediaBrowser.Model.MediaInfo; -using Xunit; - -namespace Jellyfin.MediaEncoding.Subtitles.Tests -{ - public class FilterEventsTests - { - private readonly SubtitleEncoder _encoder; - - public FilterEventsTests() - { - var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); - _encoder = fixture.Create<SubtitleEncoder>(); - } - - [Fact] - public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained() - { - // Subtitle starts at 5s, ends at 15s. - // Segment requested from 10s to 20s. - // The subtitle is still on screen at 10s and should NOT be dropped. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Still on screen") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "Next subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("1", track.TrackEvents[0].Id); - Assert.Equal("2", track.TrackEvents[1].Id); - } - - [Fact] - public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped() - { - // Subtitle starts at 2s, ends at 5s. - // Segment requested from 10s. - // The subtitle ended before the segment — should be dropped. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Already gone") - { - StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(5).Ticks - }, - new SubtitleTrackEvent("2", "Visible") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal("2", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_SubtitleAfterSegment_IsDropped() - { - // Segment is 10s-20s, subtitle starts at 25s. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "In range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "After segment") - { - StartPositionTicks = TimeSpan.FromSeconds(25).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(30).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal("1", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps() - { - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(15).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(20).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(30).Ticks, - preserveTimestamps: false); - - Assert.Single(track.TrackEvents); - // Timestamps should be shifted back by 10s - Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks); - Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks); - } - - [Fact] - public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps() - { - var startTicks = TimeSpan.FromSeconds(15).Ticks; - var endTicks = TimeSpan.FromSeconds(20).Ticks; - - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Subtitle") - { - StartPositionTicks = startTicks, - EndPositionTicks = endTicks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(30).Ticks, - preserveTimestamps: true); - - Assert.Single(track.TrackEvents); - Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks); - Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks); - } - - [Fact] - public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained() - { - // Subtitle ends exactly when the segment begins. - // EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0, - // so SkipWhile stops and the subtitle is retained. - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Boundary subtitle") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(10).Ticks - }, - new SubtitleTrackEvent("2", "In range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("1", track.TrackEvents[0].Id); - } - - [Fact] - public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps() - { - // Subtitle starts at 5s, ends at 15s. - // Segment requested from 10s to 20s, preserveTimestamps = false. - // The subtitle spans the boundary and is retained, but shifting - // StartPositionTicks by -10s would produce -5s (negative). - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Spans boundary") - { - StartPositionTicks = TimeSpan.FromSeconds(5).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("2", "Fully in range") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(17).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: TimeSpan.FromSeconds(20).Ticks, - preserveTimestamps: false); - - Assert.Equal(2, track.TrackEvents.Count); - // Subtitle 1: start should be clamped to 0, not -5s - Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative"); - Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks); - // Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s) - Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks); - Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks); - } - - [Fact] - public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition() - { - var track = new SubtitleTrackInfo - { - TrackEvents = new[] - { - new SubtitleTrackEvent("1", "Before") - { - StartPositionTicks = TimeSpan.FromSeconds(2).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(4).Ticks - }, - new SubtitleTrackEvent("2", "After") - { - StartPositionTicks = TimeSpan.FromSeconds(12).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(15).Ticks - }, - new SubtitleTrackEvent("3", "Much later") - { - StartPositionTicks = TimeSpan.FromSeconds(500).Ticks, - EndPositionTicks = TimeSpan.FromSeconds(505).Ticks - } - } - }; - - _encoder.FilterEvents( - track, - startPositionTicks: TimeSpan.FromSeconds(10).Ticks, - endTimeTicks: 0, - preserveTimestamps: true); - - Assert.Equal(2, track.TrackEvents.Count); - Assert.Equal("2", track.TrackEvents[0].Id); - Assert.Equal("3", track.TrackEvents[1].Id); - } - } -} diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs index 4dbe769bf4..2035140f00 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs @@ -83,4 +83,26 @@ public class SeasonPathParserTests Assert.Equal(seasonNumber, result.SeasonNumber); Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); } + + [Theory] + [InlineData("/Drive/300 Collection/300 (2006)", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Rise of an Empire", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/300 Collection/300 Disc 1", "/Drive/300 Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Days Later", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Weeks Later (2007)", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/28 Years Later Collection/28 Years Later 2025", "/Drive/28 Years Later Collection", null, false)] + [InlineData("/Drive/300 Collection/Season 1", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/28 Years Later Collection/Season 01", "/Drive/28 Years Later Collection", 1, true)] + [InlineData("/Drive/300 Collection/S01", "/Drive/300 Collection", 1, true)] + [InlineData("/Drive/300 Collection/S1", "/Drive/300 Collection", 1, true)] + + public void GetSeasonNumberFromPathMixedLibraryTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory) + { + var result = SeasonPathParser.Parse(path, parentPath, false, false); + + Assert.Equal(result.SeasonNumber is not null, result.Success); + Assert.Equal(seasonNumber, result.SeasonNumber); + Assert.Equal(isSeasonDirectory, result.IsSeasonFolder); + } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs new file mode 100644 index 0000000000..96625ae670 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs @@ -0,0 +1,137 @@ +using System; +using Emby.Server.Implementations.Dto; +using Emby.Server.Implementations.Playlists; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceImageInheritanceTests +{ + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/playlists-custom.jpg", + DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.NotNull(dto.ParentPrimaryImageItemId); + Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId); + Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag); + Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary)); + } + + [Fact] + public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage() + { + var displayParent = new PlaylistsFolder + { + Id = Guid.NewGuid(), + ImageInfos = [] + }; + + var userView = new UserView + { + Id = Guid.NewGuid(), + ViewType = CollectionType.playlists, + DisplayParentId = displayParent.Id, + ImageInfos = + [ + new ItemImageInfo + { + Type = ImageType.Primary, + Path = "/images/generated.png", + DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc) + } + ] + }; + + var dtoService = BuildDtoService(displayParent); + + var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false)); + + Assert.Null(dto.ParentPrimaryImageItemId); + Assert.Null(dto.ParentPrimaryImageTag); + Assert.NotNull(dto.ImageTags); + Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]); + } + + private static DtoService BuildDtoService(BaseItem displayParent) + { + var libraryManager = new Mock<ILibraryManager>(); + var userDataManager = new Mock<IUserDataManager>(); + var imageProcessor = new Mock<IImageProcessor>(); + var providerManager = new Mock<IProviderManager>(); + var recordingsManager = new Mock<IRecordingsManager>(); + var appHost = new Mock<IApplicationHost>(); + var mediaSourceManager = new Mock<IMediaSourceManager>(); + var liveTvManager = new Mock<ILiveTvManager>(); + var trickplayManager = new Mock<ITrickplayManager>(); + var chapterManager = new Mock<IChapterManager>(); + var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>(); + + libraryManager + .Setup(x => x.GetItemById(displayParent.Id)) + .Returns(displayParent); + + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) + .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path); + + return new DtoService( + logger.Object, + libraryManager.Object, + userDataManager.Object, + imageProcessor.Object, + providerManager.Object, + recordingsManager.Object, + appHost.Object, + mediaSourceManager.Object, + new Lazy<ILiveTvManager>(() => liveTvManager.Object), + trickplayManager.Object, + chapterManager.Object); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs new file mode 100644 index 0000000000..a5de0a4416 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -0,0 +1,131 @@ +using System; +using Emby.Server.Implementations.Dto; +using MediaBrowser.Common; +using MediaBrowser.Controller.Chapters; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Trickplay; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Dto; + +public class DtoServiceTests +{ + private readonly Mock<ILibraryManager> _libraryManagerMock; + private readonly DtoService _dtoService; + + public DtoServiceTests() + { + _libraryManagerMock = new Mock<ILibraryManager>(); + + var imageProcessor = new Mock<IImageProcessor>(); + // Deterministic tag derived from the image so each item gets a distinct, assertable tag. + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>())) + .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path); + + var appHost = new Mock<IApplicationHost>(); + appHost.Setup(x => x.SystemId).Returns("test-server"); + + // Video.SourceType probes the active-recording manager; provide one so it doesn't NRE. + Video.RecordingsManager = new Mock<IRecordingsManager>().Object; + + _dtoService = new DtoService( + NullLogger<DtoService>.Instance, + _libraryManagerMock.Object, + new Mock<IUserDataManager>().Object, + imageProcessor.Object, + new Mock<IProviderManager>().Object, + new Mock<IRecordingsManager>().Object, + appHost.Object, + new Mock<IMediaSourceManager>().Object, + new Lazy<ILiveTvManager>(() => new Mock<ILiveTvManager>().Object), + new Mock<ITrickplayManager>().Object, + new Mock<IChapterManager>().Object); + + // Episode.Series / Episode.Season resolve through the static BaseItem.LibraryManager. + BaseItem.LibraryManager = _libraryManagerMock.Object; + } + + [Fact] + public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries() + { + var (episode, season, series) = BuildEpisode(seasonHasPoster: true); + var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + + 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); + 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); + } + + [Fact] + public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster() + { + var (episode, _, series) = BuildEpisode(seasonHasPoster: false); + var options = new DtoOptions(false) { PreferEpisodeParentPoster = true }; + + var dto = _dtoService.GetBaseItemDto(episode, options); + + Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary)); + Assert.Null(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() + { + var (episode, _, _) = BuildEpisode(seasonHasPoster: true); + 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. + 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) + { + // 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); + + var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id }; + if (seasonHasPoster) + { + season.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/season.jpg" }, 0); + } + + var episode = new Episode + { + Id = Guid.NewGuid(), + Name = "Episode", + SeasonId = season.Id, + SeriesId = series.Id + }; + episode.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/episode.jpg" }, 0); + + _libraryManagerMock.Setup(x => x.GetItemById(season.Id)).Returns(season); + _libraryManagerMock.Setup(x => x.GetItemById(series.Id)).Returns(series); + + return (episode, season, series); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs new file mode 100644 index 0000000000..8149938b4d --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.Users; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users +{ + public class UserManagerLockHelperTests + { + [Fact] + public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + Assert.True(helper.ShouldLock()); + + var outerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + var innerHandle = await helper.LockAsync(key); + Assert.False(helper.ShouldLock()); + + innerHandle.Dispose(); + Assert.False(helper.ShouldLock()); + + outerHandle.Dispose(); + Assert.True(helper.ShouldLock()); + } + + [Fact] + public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + var key = Guid.NewGuid(); + + var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); + var secondEntered = false; + + var firstTask = Task.Run( + async () => + { + using var firstHandle = await helper.LockAsync(key); + firstAcquired.SetResult(true); + await releaseFirst.Task; + }, + TestContext.Current.CancellationToken); + + await firstAcquired.Task; + + var secondTask = Task.Run( + async () => + { + using var secondHandle = await helper.LockAsync(key); + secondEntered = true; + }, + TestContext.Current.CancellationToken); + + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.False(secondEntered); + + releaseFirst.SetResult(true); + + await Task.WhenAll(firstTask, secondTask); + Assert.True(secondEntered); + } + + [Fact] + public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + helper.Dispose(); + + await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid())); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_DoesNotThrow() + { + UserManager.LockHelper.IsNestedLock.Value = 0; + using var helper = new UserManager.LockHelper(); + + helper.Dispose(); + var ex = Record.Exception(() => helper.Dispose()); + + Assert.Null(ex); + } + } +} |
