using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Data; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.DbConfiguration; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.PostgreSQL; using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Tests.Migrations; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.TV; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Npgsql; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; using User = Jellyfin.Database.Implementations.Entities.User; namespace Jellyfin.Server.Tests.Item; /// /// Drives the Next Up cutoff from the controller into a real PostgreSQL. The model binder hands a /// query-string date over as , and Npgsql refuses to write anything /// but to timestamp with time zone; SQLite takes every kind, so an /// unnormalised cutoff only ever fails here. /// [Trait("Category", "RequiresDocker")] public sealed class PostgreSqlNextUpServiceTests : IAsyncLifetime { private static readonly Guid _libraryId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); private static readonly Guid _otherLibraryId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"); private static readonly Guid _userId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); private static readonly Guid _recentWatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000001"); private static readonly Guid _recentOlderId = Guid.Parse("cccccccc-0000-0000-0000-000000000002"); private static readonly Guid _staleWatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000003"); private static readonly Guid _unwatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000004"); private static readonly Guid _foreignLibraryId = Guid.Parse("cccccccc-0000-0000-0000-000000000005"); private static readonly DateTime _recentPlayedAt = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime _stalePlayedAt = new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc); private readonly ItemTypeLookup _itemTypeLookup = new(); private readonly User _user = new User("next-up", "auth", "reset") { Id = _userId }; private PostgreSqlTestServer _server = null!; private NpgsqlDataSource _dataSource = null!; private NextUpService _service = null!; public async ValueTask InitializeAsync() { _server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false); var connectionString = await _server.CreateDatabaseAsync("next_up_service", TestContext.Current.CancellationToken).ConfigureAwait(false); _dataSource = new NpgsqlDataSourceBuilder(connectionString).Build(); var context = CreateDbContext(); await using (context.ConfigureAwait(false)) { await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken).ConfigureAwait(false); } var factory = new Mock>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); factory.Setup(f => f.CreateDbContextAsync(It.IsAny())).ReturnsAsync(CreateDbContext); _service = new NextUpService(factory.Object, _itemTypeLookup, new Mock().Object); await SeedAsync().ConfigureAwait(false); } public async ValueTask DisposeAsync() { await _dataSource.DisposeAsync().ConfigureAwait(false); await _server.DisposeAsync().ConfigureAwait(false); } /// /// A cutoff on the query string, which the model binder leaves unspecified. /// [Fact] public void GetNextUpSeriesKeys_WithSuppliedCutoff_DropsSeriesPlayedBeforeIt() { var cutoff = RunController(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified)).NextUpDateCutoff; var keys = _service.GetNextUpSeriesKeys(CreateFilter(), cutoff); Assert.Equal(new[] { "series-recent" }, keys); } /// /// The home-screen row, where the client sends no cutoff and the query default stands in. /// [Fact] public void GetNextUpSeriesKeys_WithoutSuppliedCutoff_ReturnsWatchedSeriesNewestFirst() { var cutoff = RunController(null).NextUpDateCutoff; var keys = _service.GetNextUpSeriesKeys(CreateFilter(), cutoff); Assert.Equal(new[] { "series-recent", "series-stale" }, keys); } /// /// Calls GET /Shows/NextUp and hands back the query it built for the series lookup. /// private NextUpQuery RunController(DateTime? nextUpDateCutoff) { var userManager = new Mock(); userManager.Setup(m => m.GetUserById(_userId)).Returns(_user); var dtoService = new Mock(); dtoService.Setup(s => s.GetBaseItemDtos( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns([]); NextUpQuery? captured = null; var tvSeriesManager = new Mock(); tvSeriesManager.Setup(m => m.GetNextUp(It.IsAny(), It.IsAny())) .Callback((query, _) => captured = query) .Returns(new QueryResult()); var controller = new TvShowsController( userManager.Object, new Mock().Object, dtoService.Object, tvSeriesManager.Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(InternalClaimTypes.UserId, _userId.ToString("D"))], "Test")) } } }; controller.GetNextUp(null, null, null, [], null, null, null, null, [], null, nextUpDateCutoff); return captured!; } private InternalItemsQuery CreateFilter() { return new InternalItemsQuery(_user) { TopParentIds = [_libraryId] }; } private JellyfinDbContext CreateDbContext() { var optionsBuilder = new DbContextOptionsBuilder(); var provider = new PostgreSqlDatabaseProvider(_dataSource); provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" }); return new JellyfinDbContext( optionsBuilder.Options, NullLogger.Instance, provider, new NoLockBehavior(NullLogger.Instance)); } private async Task SeedAsync() { var context = CreateDbContext(); await using (context.ConfigureAwait(false)) { context.Users.Add(_user); // The newest play of a series decides its place, so the older episode must not pull it down. var recentWatched = AddEpisode(context, _recentWatchedId, "series-recent", _libraryId); AddUserData(context, recentWatched, _recentPlayedAt); var recentOlder = AddEpisode(context, _recentOlderId, "series-recent", _libraryId); AddUserData(context, recentOlder, _stalePlayedAt); var staleWatched = AddEpisode(context, _staleWatchedId, "series-stale", _libraryId); AddUserData(context, staleWatched, _stalePlayedAt); // Never played, and played but outside the requested libraries: both stay out. AddEpisode(context, _unwatchedId, "series-unwatched", _libraryId); var foreign = AddEpisode(context, _foreignLibraryId, "series-foreign", _otherLibraryId); AddUserData(context, foreign, _recentPlayedAt); await context.SaveChangesAsync(TestContext.Current.CancellationToken).ConfigureAwait(false); } } private BaseItemEntity AddEpisode(JellyfinDbContext context, Guid id, string seriesKey, Guid topParentId) { var episode = new BaseItemEntity { Id = id, Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode], Name = seriesKey + "-" + id.ToString("N"), SeriesPresentationUniqueKey = seriesKey, PresentationUniqueKey = id.ToString("N"), TopParentId = topParentId, MediaType = "Video", IsFolder = false, IsVirtualItem = false }; context.BaseItems.Add(episode); return episode; } private void AddUserData(JellyfinDbContext context, BaseItemEntity item, DateTime lastPlayedDate) { context.UserData.Add(new UserData { CustomDataKey = item.Id.ToString("N"), ItemId = item.Id, Item = item, UserId = _userId, User = _user, LastPlayedDate = lastPlayedDate, Played = true, PlayCount = 1 }); } }