diff options
Diffstat (limited to 'tests')
6 files changed, 501 insertions, 1 deletions
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index e34eb0bda3..86bac4256a 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; @@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities; public class BaseItemTests { + [Fact] + public void GetItemByNameFolderName_ShortName_IsKeptAsIs() + { + SetupPassThroughFileSystem(); + + Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott.")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent() + { + SetupPassThroughFileSystem(); + + // What a provider result that concatenated a whole credit list into one name looks like. + var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20)); + + var folderName = BaseItem.GetItemByNameFolderName(name); + + Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128); + Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal); + } + + [Fact] + public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart() + { + SetupPassThroughFileSystem(); + + var prefix = new string('a', 200); + + Assert.NotEqual( + BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"), + BaseItem.GetItemByNameFolderName(prefix + "Bob Kane")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_IsStable() + { + SetupPassThroughFileSystem(); + + var name = new string('a', 300); + + Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name)); + } + + private static void SetupPassThroughFileSystem() + { + var fileSystem = new Mock<IFileSystem>(); + fileSystem.Setup(x => x.GetValidFilename(It.IsAny<string>())).Returns((string name) => name); + BaseItem.FileSystem = fileSystem.Object; + } + [Theory] [InlineData("", "")] [InlineData("1", "0000000001")] diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs new file mode 100644 index 0000000000..bd50a903f1 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs @@ -0,0 +1,81 @@ +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Providers.Plugins.Omdb; +using Xunit; + +namespace Jellyfin.Providers.Tests.Omdb +{ + public class OmdbProviderTests + { + [Fact] + public void AddPeople_CommaSeparatedList_SplitsIntoIndividualPeople() + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, "Philip G. Epstein, Julius J. Epstein, Howard Koch", PersonKind.Writer); + + Assert.Equal( + new[] { "Philip G. Epstein", "Julius J. Epstein", "Howard Koch" }, + result.People!.Select(p => p.Name)); + Assert.All(result.People!, p => Assert.Equal(PersonKind.Writer, p.Type)); + } + + [Fact] + public void AddPeople_RoleAnnotations_AreStrippedAndDeduplicated() + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, "Mari Okada (screenplay), Mari Okada (story), Jun'ichi Satô (screenplay), Jun'ichi Satô (story)", PersonKind.Writer); + + Assert.Equal( + new[] { "Mari Okada", "Jun'ichi Satô" }, + result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData( + "Jerry Siegel (created by: Superman, Superboy), Bob Kane (created by: Batman)", + "Jerry Siegel|Bob Kane")] + [InlineData("Alan Moore (created by: John Constantine)", "Alan Moore")] + public void AddPeople_CommaInsideAnAnnotation_StaysOneCredit(string credits, string expected) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Writer); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")] + [InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")] + [InlineData("Tom Hanks, Sammy Davis, Jr", "Tom Hanks|Sammy Davis, Jr")] + [InlineData("Harold Ramis, Ken Griffey, III (voice)", "Harold Ramis|Ken Griffey, III")] + [InlineData("Robert Downey Jr., Gwyneth Paltrow", "Robert Downey Jr.|Gwyneth Paltrow")] + [InlineData("Jr., Tom Hanks", "Jr.|Tom Hanks")] + public void AddPeople_GenerationalSuffix_StaysWithItsName(string credits, string expected) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Actor); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("(uncredited)")] + public void AddPeople_NoUsableName_AddsNothing(string? credits) + { + var result = new MetadataResult<Movie>(); + + OmdbProvider.AddPeople(result, credits!, PersonKind.Actor); + + Assert.Null(result.People); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs new file mode 100644 index 0000000000..182e7c52eb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.TvShows; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb +{ + public class TmdbUtilsCastTests + { + private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 }; + + [Fact] + public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole() + { + var cast = new List<CastAggregate> + { + CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12)) + }; + + var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray(); + + // The character they played the longest comes first, which is their own billing. + Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role)); + Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name)); + Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type)); + Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb))); + } + + [Fact] + public void MapAggregateCast_MemberWithoutARole_IsStillCredited() + { + var cast = new List<CastAggregate> { CreateAggregate("Uncredited Actor", 2, 0) }; + + var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null)); + + Assert.Equal(string.Empty, person.Role); + } + + [Fact] + public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled() + { + var cast = Enumerable.Range(0, 5) + .Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1))) + .ToList(); + + var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null); + + Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name)); + } + + [Fact] + public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile() + { + var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1)); + withProfile.ProfilePath = "/profile.jpg"; + var cast = new List<CastAggregate> { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) }; + + var people = TmdbUtils.MapAggregateCast( + cast, + new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true }, + _ => null); + + Assert.Equal(["Has Profile"], people.Select(p => p.Name)); + } + + [Fact] + public void MapCast_FlatCredits_YieldOneCreditEach() + { + var cast = new List<Cast> + { + new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " }, + new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" } + }; + + var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null)); + + Assert.Equal("Kevin Conroy", person.Name); + Assert.Equal("Batman (voice)", person.Role); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MapCast_NoCast_YieldsNothing(bool aggregate) + { + Assert.Empty(aggregate + ? TmdbUtils.MapAggregateCast(null, _config, _ => null) + : TmdbUtils.MapCast(null, _config, _ => null)); + } + + private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles) + { + return new CastAggregate + { + Name = name, + Id = id, + Order = order, + Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList() + }; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs new file mode 100644 index 0000000000..c2518f13b2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; +using ItemSortBy = Jellyfin.Data.Enums.ItemSortBy; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// <summary> +/// Covers ordering by <see cref="ItemSortBy.IsPlayed"/> and <see cref="ItemSortBy.IsUnplayed"/>, which +/// has to read the played state the isPlayed filter reports: folders hold none of their own and count +/// as played once no descendant is left unplayed. +/// </summary> +public sealed class BaseItemRepositoryPlayedOrderingTests : SqliteDbTestFixture +{ + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + // Names run A..F so name order interleaves the two groups: a dropped or inverted played key shows + // up as a different sequence rather than as the expected one by luck. + private readonly Guid _watchedSeries = Guid.NewGuid(); + private readonly Guid _unwatchedSeries = Guid.NewGuid(); + private readonly Guid _partiallyWatchedSeries = Guid.NewGuid(); + private readonly Guid _secondUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _thirdUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _secondWatchedSeries = Guid.NewGuid(); + + // Box sets reach their children through LinkedChildren instead of the ancestor chain. + private readonly Guid _watchedBoxSet = Guid.NewGuid(); + private readonly Guid _unwatchedBoxSet = Guid.NewGuid(); + + private readonly HashSet<Guid> _unwatchedSeriesIds; + + public BaseItemRepositoryPlayedOrderingTests() + { + _unwatchedSeriesIds = [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries]; + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void IsPlayed_OrdersUnwatchedSeriesBeforeWatchedOnes() + { + Assert.Equal( + [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries, _watchedSeries, _secondWatchedSeries], + SeriesIds(ItemSortBy.IsPlayed)); + } + + [Fact] + public void IsPlayed_CountsAPartiallyWatchedSeriesAsUnwatched() + { + var ids = SeriesIds(ItemSortBy.IsPlayed); + + Assert.True(ids.IndexOf(_partiallyWatchedSeries) < ids.IndexOf(_watchedSeries)); + } + + [Fact] + public void IsUnplayed_ReversesTheGroups() + { + Assert.Equal( + [_watchedSeries, _secondWatchedSeries, _unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries], + SeriesIds(ItemSortBy.IsUnplayed)); + } + + [Fact] + public void IsPlayed_OrdersAnUnwatchedBoxSetBeforeAWatchedOne() + { + var ids = _repository + .GetItemList(Query(BaseItemKind.BoxSet, (ItemSortBy.IsPlayed, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal([_unwatchedBoxSet, _watchedBoxSet], ids); + } + + [Fact] + public void IsPlayedThenRandom_StillPlacesEveryUnwatchedSeriesFirst() + { + var order = _repository + .GetItemList(Query(BaseItemKind.Series, (ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal(_unwatchedSeriesIds, order.Take(_unwatchedSeriesIds.Count).ToHashSet()); + } + + [Fact] + public void IsPlayedThenRandom_FillsAPageWithUnwatchedSeries() + { + var page = _repository.GetItems(new InternalItemsQuery(_user) + { + IncludeItemTypes = [BaseItemKind.Series], + OrderBy = [(ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending)], + Limit = 4, + EnableTotalRecordCount = true + }); + + Assert.Equal(6, page.TotalRecordCount); + Assert.Equal(_unwatchedSeriesIds, page.Items.Select(i => i.Id).ToHashSet()); + } + + private List<Guid> SeriesIds(ItemSortBy sortBy) + => _repository + .GetItemList(Query(BaseItemKind.Series, (sortBy, SortOrder.Ascending))) + .Select(i => i.Id) + .ToList(); + + private InternalItemsQuery Query(BaseItemKind kind, params (ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy) + => new(_user) + { + IncludeItemTypes = [kind], + OrderBy = orderBy + }; + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddSeries(context, _watchedSeries, "A watched", playedEpisodes: 1, unplayedEpisodes: 0); + AddSeries(context, _unwatchedSeries, "B unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _partiallyWatchedSeries, "C partially watched", playedEpisodes: 1, unplayedEpisodes: 1); + AddSeries(context, _secondUnwatchedSeries, "D unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _thirdUnwatchedSeries, "E unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _secondWatchedSeries, "F watched", playedEpisodes: 1, unplayedEpisodes: 0); + + AddBoxSet(context, _watchedBoxSet, "A watched set", played: true); + AddBoxSet(context, _unwatchedBoxSet, "B unwatched set", played: false); + + context.SaveChanges(); + } + + private void AddSeries(JellyfinDbContext context, Guid id, string name, int playedEpisodes, int unplayedEpisodes) + { + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = SeriesType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + + for (var i = 0; i < playedEpisodes + unplayedEpisodes; i++) + { + var episodeId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity { Id = episodeId, Type = EpisodeType, Name = $"{name} {i}", PresentationUniqueKey = episodeId.ToString("N"), SeriesId = id }); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = id, Item = null!, ParentItem = null! }); + + if (i < playedEpisodes) + { + AddPlayedUserData(context, episodeId); + } + } + } + + private void AddBoxSet(JellyfinDbContext context, Guid id, string name, bool played) + { + var movieId = Guid.NewGuid(); + + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = BoxSetType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = MovieType, Name = $"{name} movie", PresentationUniqueKey = movieId.ToString("N") }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = id, ChildId = movieId, ChildType = LinkedChildType.Manual, SortOrder = 0 }); + + if (played) + { + AddPlayedUserData(context, movieId); + } + } + + private void AddPlayedUserData(JellyfinDbContext context, Guid itemId) + => context.UserData.Add(new UserData + { + ItemId = itemId, + UserId = _user.Id, + CustomDataKey = itemId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs index 54565c5787..649458f733 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -142,6 +142,79 @@ public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture Assert.Equal("Hero", map.Role); } + [Fact] + public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Villain") + ]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + using var ctx = CreateDbContext(); + Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + _repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, []); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + Assert.Single(after.PeopleBaseItemMap); + } + + [Fact] + public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + using (var ctx = CreateDbContext()) + { + // The state a credit was left in before UpdatePeople cleaned up after itself. + ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap); + ctx.SaveChanges(); + } + + Assert.Equal(1, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Empty(after.Peoples); + } + + [Fact] + public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + Assert.Equal(0, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + } + + private Guid AddMovie(string name) + { + var id = Guid.NewGuid(); + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie], + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + return id; + } + private static PersonInfo CreatePerson(string name, PersonKind type, string role) { return new PersonInfo diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs index 133a3f7d47..feb2d8a625 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library { var localizationMock = new Mock<ILocalizationManager>(); localizationMock - .Setup(l => l.GetLocalizedString(It.IsAny<string>())) + .Setup(l => l.GetServerLocalizedString(It.IsAny<string>())) .Returns("Season {0}"); _resolver = new SeasonResolver( |
