From 5feb70f489670808be682e1f2f80c4780651c57b Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 29 May 2026 10:41:50 +0200 Subject: [PATCH] Fix recently added episode links and posters --- Emby.Server.Implementations/Dto/DtoService.cs | 35 +++++ .../Controllers/UserLibraryController.cs | 4 +- MediaBrowser.Controller/Dto/DtoOptions.cs | 56 +++++++- .../Dto/DtoServiceTests.cs | 131 ++++++++++++++++++ 4 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 321c7da1c4..f53328c7dd 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1366,6 +1366,41 @@ namespace Emby.Server.Implementations.Dto } } + if (options.PreferEpisodeParentPoster) + { + var episodeSeason = episode.Season; + var seasonPrimaryTag = episodeSeason is not null + ? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary) + : null; + + BaseItem? posterParent = null; + if (seasonPrimaryTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeason!.Id; + dto.ParentPrimaryImageTag = seasonPrimaryTag; + posterParent = episodeSeason; + } + else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null) + { + dto.ParentPrimaryImageItemId = episodeSeries.Id; + dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag; + posterParent = episodeSeries; + } + + if (posterParent is not null) + { + if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag)) + { + // Only drop the episode's own primary blurhash; keep the poster parent's. + dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag); + } + + dto.SeriesPrimaryImageTag = null; + dto.PrimaryImageAspectRatio = null; + AttachPrimaryImageAspectRatio(dto, posterParent); + } + } + if (options.ContainsField(ItemFields.SeriesStudio)) { episodeSeries ??= episode.Series; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index 779186942a..9e3933f2d4 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -557,6 +557,8 @@ public class UserLibraryController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + dtoOptions.PreferEpisodeParentPoster = true; + var list = _userViewManager.GetLatestItems( new LatestItemsQuery { @@ -577,7 +579,7 @@ public class UserLibraryController : BaseJellyfinApiController var item = tuple.Item2[0]; var childCount = 0; - if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum || tuple.Item1 is Series)) + if (tuple.Item1 is not null && (tuple.Item2.Count > 1 || tuple.Item1 is MusicAlbum)) { item = tuple.Item1; childCount = tuple.Item2.Count; diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index a71cdbd62c..d319feb6b2 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Linq; @@ -8,13 +6,16 @@ using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Dto { + /// + /// Options that control which fields and images are populated when building a . + /// public class DtoOptions { - private static readonly ItemFields[] DefaultExcludedFields = new[] - { + private static readonly ItemFields[] DefaultExcludedFields = + [ ItemFields.SeasonUserData, ItemFields.RefreshState - }; + ]; private static readonly ImageType[] AllImageTypes = Enum.GetValues(); @@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto .Except(DefaultExcludedFields) .ToArray(); + /// + /// Initializes a new instance of the class with all fields enabled. + /// public DtoOptions() : this(true) { } + /// + /// Initializes a new instance of the class. + /// + /// Whether to populate all available fields. public DtoOptions(bool allFields) { ImageTypeLimit = int.MaxValue; @@ -38,23 +46,61 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// + /// Gets or sets the fields to populate on the DTO. + /// public IReadOnlyList Fields { get; set; } + /// + /// Gets or sets the image types to populate on the DTO. + /// public IReadOnlyList ImageTypes { get; set; } + /// + /// Gets or sets the maximum number of images to return per image type. + /// public int ImageTypeLimit { get; set; } + /// + /// Gets or sets a value indicating whether image information is populated. + /// public bool EnableImages { get; set; } + /// + /// Gets or sets a value indicating whether program recording information is populated. + /// public bool AddProgramRecordingInfo { get; set; } + /// + /// Gets or sets a value indicating whether user data is populated. + /// public bool EnableUserData { get; set; } + /// + /// Gets or sets a value indicating whether the currently airing program is populated. + /// public bool AddCurrentProgram { get; set; } + /// + /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary + /// image, falling back to the series') should replace the episode's own (16:9) primary image. + /// Used by views that render episodes as poster cards, e.g. "Latest". + /// + public bool PreferEpisodeParentPoster { get; set; } + + /// + /// Gets a value indicating whether the specified field is populated. + /// + /// The field to check. + /// true if the field is populated; otherwise, false. public bool ContainsField(ItemFields field) => Fields.Contains(field); + /// + /// Gets the number of images to return for the specified image type. + /// + /// The image type. + /// The image limit for the type, or 0 if the type is not enabled. public int GetImageLimit(ImageType type) { if (EnableImages && ImageTypes.Contains(type)) 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 _libraryManagerMock; + private readonly DtoService _dtoService; + + public DtoServiceTests() + { + _libraryManagerMock = new Mock(); + + var imageProcessor = new Mock(); + // Deterministic tag derived from the image so each item gets a distinct, assertable tag. + imageProcessor + .Setup(x => x.GetImageCacheTag(It.IsAny(), It.IsAny())) + .Returns((BaseItem _, ItemImageInfo image) => "tag:" + image.Path); + + var appHost = new Mock(); + 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().Object; + + _dtoService = new DtoService( + NullLogger.Instance, + _libraryManagerMock.Object, + new Mock().Object, + imageProcessor.Object, + new Mock().Object, + new Mock().Object, + appHost.Object, + new Mock().Object, + new Lazy(() => new Mock().Object), + new Mock().Object, + new Mock().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); + } +}