mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-02 05:48:47 +01:00
Merge pull request #16967 from Shadowghost/fix-recently-added-posters
Some checks are pending
CodeQL / Analyze (csharp) (push) Waiting to run
Format / format-check (push) Waiting to run
Tests / run-tests (macos-latest) (push) Waiting to run
Tests / run-tests (ubuntu-latest) (push) Waiting to run
Tests / run-tests (windows-latest) (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Artifact (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Blocked by required conditions
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Blocked by required conditions
Project Automation / Project board (push) Waiting to run
Merge Conflict Labeler / Labeling (push) Waiting to run
Some checks are pending
CodeQL / Analyze (csharp) (push) Waiting to run
Format / format-check (push) Waiting to run
Tests / run-tests (macos-latest) (push) Waiting to run
Tests / run-tests (ubuntu-latest) (push) Waiting to run
Tests / run-tests (windows-latest) (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Artifact (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Blocked by required conditions
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Blocked by required conditions
Project Automation / Project board (push) Waiting to run
Merge Conflict Labeler / Labeling (push) Waiting to run
Fix recently added episode links and posters
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Options that control which fields and images are populated when building a <see cref="MediaBrowser.Model.Dto.BaseItemDto"/>.
|
||||
/// </summary>
|
||||
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<ImageType>();
|
||||
|
||||
@@ -22,11 +23,18 @@ namespace MediaBrowser.Controller.Dto
|
||||
.Except(DefaultExcludedFields)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DtoOptions"/> class with all fields enabled.
|
||||
/// </summary>
|
||||
public DtoOptions()
|
||||
: this(true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DtoOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="allFields">Whether to populate all available fields.</param>
|
||||
public DtoOptions(bool allFields)
|
||||
{
|
||||
ImageTypeLimit = int.MaxValue;
|
||||
@@ -38,23 +46,61 @@ namespace MediaBrowser.Controller.Dto
|
||||
ImageTypes = AllImageTypes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the fields to populate on the DTO.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ItemFields> Fields { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image types to populate on the DTO.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ImageType> ImageTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of images to return per image type.
|
||||
/// </summary>
|
||||
public int ImageTypeLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image information is populated.
|
||||
/// </summary>
|
||||
public bool EnableImages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether program recording information is populated.
|
||||
/// </summary>
|
||||
public bool AddProgramRecordingInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether user data is populated.
|
||||
/// </summary>
|
||||
public bool EnableUserData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the currently airing program is populated.
|
||||
/// </summary>
|
||||
public bool AddCurrentProgram { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public bool PreferEpisodeParentPoster { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the specified field is populated.
|
||||
/// </summary>
|
||||
/// <param name="field">The field to check.</param>
|
||||
/// <returns><c>true</c> if the field is populated; otherwise, <c>false</c>.</returns>
|
||||
public bool ContainsField(ItemFields field)
|
||||
=> Fields.Contains(field);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of images to return for the specified image type.
|
||||
/// </summary>
|
||||
/// <param name="type">The image type.</param>
|
||||
/// <returns>The image limit for the type, or 0 if the type is not enabled.</returns>
|
||||
public int GetImageLimit(ImageType type)
|
||||
{
|
||||
if (EnableImages && ImageTypes.Contains(type))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user