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!);
+ }
}