|
|
|
@@ -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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|