Compare commits

...

1 Commits

Author SHA1 Message Date
unkin-agent face8ac653 fix(api): normalise the Next Up cutoff to UTC
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The query-string cutoff binds as DateTimeKind.Unspecified, which Npgsql
refuses to write to timestamp with time zone, so /Shows/NextUp 500s.
2026-09-20 23:47:23 +10:00
3 changed files with 237 additions and 3 deletions
@@ -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
},
+2 -2
View File
@@ -12,7 +12,7 @@ public class NextUpQuery
{
EnableImageTypes = Array.Empty<ImageType>();
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; }
/// <summary>
/// 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.
/// </summary>
public DateTime NextUpDateCutoff { get; set; }
@@ -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;
/// <summary>
/// Drives the Next Up cutoff from the controller into a real PostgreSQL. The model binder hands a
/// query-string date over as <see cref="DateTimeKind.Unspecified"/>, and Npgsql refuses to write anything
/// but <see cref="DateTimeKind.Utc"/> to <c>timestamp with time zone</c>; SQLite takes every kind, so an
/// unnormalised cutoff only ever fails here.
/// </summary>
[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<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
_service = new NextUpService(factory.Object, _itemTypeLookup, new Mock<IItemQueryHelpers>().Object);
await SeedAsync().ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
await _server.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A cutoff on the query string, which the model binder leaves unspecified.
/// </summary>
[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);
}
/// <summary>
/// The home-screen row, where the client sends no cutoff and the query default stands in.
/// </summary>
[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);
}
/// <summary>
/// Calls <c>GET /Shows/NextUp</c> and hands back the query it built for the series lookup.
/// </summary>
private NextUpQuery RunController(DateTime? nextUpDateCutoff)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(m => m.GetUserById(_userId)).Returns(_user);
var dtoService = new Mock<IDtoService>();
dtoService.Setup(s => s.GetBaseItemDtos(
It.IsAny<IReadOnlyList<BaseItem>>(),
It.IsAny<DtoOptions>(),
It.IsAny<User>(),
It.IsAny<BaseItem>(),
It.IsAny<bool>()))
.Returns([]);
NextUpQuery? captured = null;
var tvSeriesManager = new Mock<ITVSeriesManager>();
tvSeriesManager.Setup(m => m.GetNextUp(It.IsAny<NextUpQuery>(), It.IsAny<DtoOptions>()))
.Callback<NextUpQuery, DtoOptions>((query, _) => captured = query)
.Returns(new QueryResult<BaseItem>());
var controller = new TvShowsController(
userManager.Object,
new Mock<ILibraryManager>().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<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(_dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.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
});
}
}