Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c98f4a074 | |||
| 393994a454 | |||
| ad50c4e433 | |||
| 44b62dcc64 | |||
| face8ac653 | |||
| 1965c68a76 |
@@ -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
|
||||
},
|
||||
|
||||
@@ -43,6 +43,14 @@ public sealed class ScanLeaderOptions
|
||||
"TaskExtractMediaSegments",
|
||||
"KeyframeExtraction",
|
||||
"CleanupUserDataTask",
|
||||
"OptimizeDatabaseTask"
|
||||
"OptimizeDatabaseTask",
|
||||
"DownloadLyrics",
|
||||
"DownloadSubtitles",
|
||||
"TmdbRefreshUpcomingEpisodes",
|
||||
"RefreshTrickplayImages",
|
||||
"MoveTrickplayImages",
|
||||
"RefreshInternetChannels",
|
||||
"RefreshGuide",
|
||||
"PluginUpdates"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
+2
@@ -31,6 +31,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
|
||||
<ProjectReference Include="..\..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
|
||||
<ProjectReference Include="..\..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj" />
|
||||
<ProjectReference Include="..\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+100
-9
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
@@ -11,6 +13,14 @@ namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
|
||||
|
||||
public class ScanLeaderOptionsTests
|
||||
{
|
||||
private static readonly Assembly[] _taskAssemblies =
|
||||
{
|
||||
typeof(DeleteTranscodeFileTask).Assembly,
|
||||
typeof(MediaBrowser.Providers.Lyric.LyricScheduledTask).Assembly,
|
||||
typeof(Jellyfin.LiveTv.Guide.RefreshGuideScheduledTask).Assembly,
|
||||
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A gated key that matches no registered task silently stops gating anything, so the default
|
||||
/// set is pinned to the task keys that actually exist in the build.
|
||||
@@ -18,28 +28,92 @@ public class ScanLeaderOptionsTests
|
||||
[Fact]
|
||||
public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks()
|
||||
{
|
||||
var registeredKeys = DiscoverScheduledTaskKeys();
|
||||
var registeredKeys = DiscoverScheduledTaskKeys(_taskAssemblies);
|
||||
|
||||
Assert.NotEmpty(registeredKeys);
|
||||
Assert.Empty(new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal));
|
||||
|
||||
var unmatched = new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal).ToList();
|
||||
Assert.True(
|
||||
unmatched.Count == 0,
|
||||
$"Gated keys match no scheduled task: {string.Join(", ", unmatched)}. Known keys: {string.Join(", ", registeredKeys.Order(StringComparer.Ordinal))}");
|
||||
}
|
||||
|
||||
private static HashSet<string> DiscoverScheduledTaskKeys()
|
||||
/// <summary>
|
||||
/// A key dropped from the default set silently un-gates that task on every replica, so the whole
|
||||
/// set is pinned against a hand-maintained expectation rather than read back from the options.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultGatedTaskKeys_Should_BeTheExpectedSet()
|
||||
{
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
var assemblies = new[]
|
||||
string[] expected =
|
||||
{
|
||||
typeof(DeleteTranscodeFileTask).Assembly,
|
||||
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
|
||||
"AudioNormalization",
|
||||
"CleanupUserDataTask",
|
||||
"DownloadLyrics",
|
||||
"DownloadSubtitles",
|
||||
"KeyframeExtraction",
|
||||
"MoveTrickplayImages",
|
||||
"OptimizeDatabaseTask",
|
||||
"PluginUpdates",
|
||||
"RefreshChapterImages",
|
||||
"RefreshGuide",
|
||||
"RefreshInternetChannels",
|
||||
"RefreshLibrary",
|
||||
"RefreshPeople",
|
||||
"RefreshTrickplayImages",
|
||||
"TaskExtractMediaSegments",
|
||||
"TmdbRefreshUpcomingEpisodes"
|
||||
};
|
||||
|
||||
foreach (var type in assemblies.SelectMany(a => a.GetTypes()))
|
||||
var actual = new ScanLeaderOptions().GatedTaskKeys;
|
||||
var missing = expected.Except(actual, StringComparer.Ordinal).ToList();
|
||||
var unexpected = actual.Except(expected, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(
|
||||
missing.Count == 0 && unexpected.Count == 0,
|
||||
$"Default gated task keys drifted. Missing: {Describe(missing)}. Unexpected: {Describe(unexpected)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key universe is only as complete as the assemblies it is read from, so a task added to an
|
||||
/// unscanned assembly must fail here rather than narrow what the previous test can catch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TaskAssemblies_Should_CoverEveryAssemblyDeclaringScheduledTasks()
|
||||
{
|
||||
var scanned = _taskAssemblies.Select(a => a.GetName().Name).ToHashSet(StringComparer.Ordinal);
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
|
||||
{
|
||||
if (type.IsAbstract || type.IsInterface || !typeof(IScheduledTask).IsAssignableFrom(type))
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
if (scanned.Contains(name)
|
||||
|| name.EndsWith(".Tests", StringComparison.Ordinal)
|
||||
|| !(name.StartsWith("Jellyfin.", StringComparison.Ordinal)
|
||||
|| name.StartsWith("Emby.", StringComparison.Ordinal)
|
||||
|| name.StartsWith("MediaBrowser.", StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GetScheduledTaskTypes(Assembly.LoadFrom(path)).Any())
|
||||
{
|
||||
missing.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(missing.Count == 0, $"Assemblies declaring scheduled tasks but not scanned: {string.Join(", ", missing)}");
|
||||
}
|
||||
|
||||
private static string Describe(IReadOnlyCollection<string> keys)
|
||||
=> keys.Count == 0 ? "none" : string.Join(", ", keys.Order(StringComparer.Ordinal));
|
||||
|
||||
private static HashSet<string> DiscoverScheduledTaskKeys(IEnumerable<Assembly> assemblies)
|
||||
{
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var type in assemblies.SelectMany(GetScheduledTaskTypes))
|
||||
{
|
||||
// Task keys are constant expressions, so an uninitialised instance is enough to read
|
||||
// them without standing up each task's dependency graph.
|
||||
var task = (IScheduledTask)RuntimeHelpers.GetUninitializedObject(type);
|
||||
@@ -48,4 +122,21 @@ public class ScanLeaderOptionsTests
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static IEnumerable<Type> GetScheduledTaskTypes(Assembly assembly)
|
||||
{
|
||||
Type?[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types;
|
||||
}
|
||||
|
||||
return types
|
||||
.Where(t => t is not null && !t.IsAbstract && !t.IsInterface && typeof(IScheduledTask).IsAssignableFrom(t))
|
||||
.Select(t => t!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user