From face8ac6535fd8862ea0c5238d002dda6e3cc24a Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sun, 20 Sep 2026 23:47:23 +1000 Subject: [PATCH] fix(api): normalise the Next Up cutoff to UTC The query-string cutoff binds as DateTimeKind.Unspecified, which Npgsql refuses to write to timestamp with time zone, so /Shows/NextUp 500s. --- Jellyfin.Api/Controllers/TvShowsController.cs | 2 +- MediaBrowser.Model/Querying/NextUpQuery.cs | 4 +- .../Item/PostgreSqlNextUpServiceTests.cs | 234 ++++++++++++++++++ 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 tests/Jellyfin.Server.Tests/Item/PostgreSqlNextUpServiceTests.cs diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 6b0f10e02a..5ce861e65d 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -108,7 +108,7 @@ public class TvShowsController : BaseJellyfinApiController StartIndex = startIndex, User = user, EnableTotalRecordCount = enableTotalRecordCount, - NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, + NextUpDateCutoff = nextUpDateCutoff?.ToUniversalTime() ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), EnableResumable = enableResumable, EnableRewatching = enableRewatching }, diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index a2a3a9d1bb..f779dd34e0 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -12,7 +12,7 @@ public class NextUpQuery { EnableImageTypes = Array.Empty(); EnableTotalRecordCount = true; - NextUpDateCutoff = DateTime.MinValue; + NextUpDateCutoff = DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); EnableResumable = false; EnableRewatching = false; } @@ -56,7 +56,7 @@ public class NextUpQuery public bool EnableTotalRecordCount { get; set; } /// - /// Gets or sets a value indicating the oldest date for a show to appear in Next Up. + /// Gets or sets a value indicating the oldest date, in UTC, for a show to appear in Next Up. /// public DateTime NextUpDateCutoff { get; set; } diff --git a/tests/Jellyfin.Server.Tests/Item/PostgreSqlNextUpServiceTests.cs b/tests/Jellyfin.Server.Tests/Item/PostgreSqlNextUpServiceTests.cs new file mode 100644 index 0000000000..8ce931a65d --- /dev/null +++ b/tests/Jellyfin.Server.Tests/Item/PostgreSqlNextUpServiceTests.cs @@ -0,0 +1,234 @@ +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 + }); + } +}