aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorPiotr Niełacny <piotr.nielacny@gmail.com>2026-08-25 15:19:38 +0200
committerPiotr Niełacny <piotr.nielacny@gmail.com>2026-08-25 15:19:38 +0200
commit5e621d0e3f2102177210f162e72a5d73378063fb (patch)
tree0dc45a48ce637dde27d1c118d798eeae8ea092ca /tests
parent422b2bb3d9193bfe0813d3d654919151eb355d82 (diff)
Order IsPlayed and IsUnplayed by the played state the filter reports
Ordering mapped both keys to the item's own stored UserData row. Folders do not have one: a series, season or box set counts as played when no descendant is left unplayed, which is what the isPlayed filter and the DTO both report. A mixed library therefore sorted every series and box set into the unplayed group, and a query could filter and sort by two different notions of "played". Extract the filter's predicate into BuildIsPlayedFilter and route both sort keys through it so the two cannot drift apart again.
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs189
1 files changed, 189 insertions, 0 deletions
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!
+ });
+}