diff --git a/MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs b/MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs index 9818898181..d7533b7d20 100644 --- a/MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs +++ b/MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs @@ -43,6 +43,14 @@ public sealed class ScanLeaderOptions "TaskExtractMediaSegments", "KeyframeExtraction", "CleanupUserDataTask", - "OptimizeDatabaseTask" + "OptimizeDatabaseTask", + "DownloadLyrics", + "DownloadSubtitles", + "TmdbRefreshUpcomingEpisodes", + "RefreshTrickplayImages", + "MoveTrickplayImages", + "RefreshInternetChannels", + "RefreshGuide", + "PluginUpdates" }; } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj index f8d35c39af..dc06861cba 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj +++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj @@ -31,6 +31,8 @@ + + diff --git a/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/ScanLeaderOptionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/ScanLeaderOptionsTests.cs index 6642775a3a..b7a67dfe06 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/ScanLeaderOptionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/ScheduledTasks/ScanLeaderOptionsTests.cs @@ -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 + }; + /// /// 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 DiscoverScheduledTaskKeys() + /// + /// 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. + /// + [Fact] + public void DefaultGatedTaskKeys_Should_BeTheExpectedSet() { - var keys = new HashSet(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)}."); + } + + /// + /// 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. + /// + [Fact] + public void TaskAssemblies_Should_CoverEveryAssemblyDeclaringScheduledTasks() + { + var scanned = _taskAssemblies.Select(a => a.GetName().Name).ToHashSet(StringComparer.Ordinal); + var missing = new List(); + + 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 keys) + => keys.Count == 0 ? "none" : string.Join(", ", keys.Order(StringComparer.Ordinal)); + + private static HashSet DiscoverScheduledTaskKeys(IEnumerable assemblies) + { + var keys = new HashSet(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 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!); + } }