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; using MediaBrowser.Model.Tasks; using Xunit; 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. /// [Fact] public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks() { var registeredKeys = DiscoverScheduledTaskKeys(_taskAssemblies); Assert.NotEmpty(registeredKeys); 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))}"); } /// /// 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() { string[] expected = { "AudioNormalization", "CleanupUserDataTask", "DownloadLyrics", "DownloadSubtitles", "KeyframeExtraction", "MoveTrickplayImages", "OptimizeDatabaseTask", "PluginUpdates", "RefreshChapterImages", "RefreshGuide", "RefreshInternetChannels", "RefreshLibrary", "RefreshPeople", "RefreshTrickplayImages", "TaskExtractMediaSegments", "TmdbRefreshUpcomingEpisodes" }; 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")) { 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); keys.Add(task.Key); } 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!); } }