#nullable disable #pragma warning disable CS1591 using System; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.ScheduledTasks.Triggers; using Jellyfin.Data.Events; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks { /// /// Class ScheduledTaskWorker. /// public class ScheduledTaskWorker : IScheduledTaskWorker { /// /// The options for the json Serializer. /// private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; /// /// Gets or sets the application paths. /// /// The application paths. private readonly IApplicationPaths _applicationPaths; /// /// Gets or sets the logger. /// /// The logger. private readonly ILogger _logger; /// /// Gets or sets the task manager. /// /// The task manager. private readonly ITaskManager _taskManager; /// /// The _last execution result sync lock. /// private readonly object _lastExecutionResultSyncLock = new object(); private bool _readFromFile = false; /// /// The _last execution result. /// private TaskResult _lastExecutionResult; private Task _currentTask; /// /// The _triggers. /// private Tuple[] _triggers; /// /// The _id. /// private string _id; /// /// Initializes a new instance of the class. /// /// The scheduled task. /// The application paths. /// The task manager. /// The logger. /// /// scheduledTask /// or /// applicationPaths /// or /// taskManager /// or /// jsonSerializer /// or /// logger. /// public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger) { ArgumentNullException.ThrowIfNull(scheduledTask); ArgumentNullException.ThrowIfNull(applicationPaths); ArgumentNullException.ThrowIfNull(taskManager); ArgumentNullException.ThrowIfNull(logger); ScheduledTask = scheduledTask; _applicationPaths = applicationPaths; _taskManager = taskManager; _logger = logger; InitTriggerEvents(); } public event EventHandler> TaskProgress; /// /// Gets the scheduled task. /// /// The scheduled task. public IScheduledTask ScheduledTask { get; private set; } /// /// Gets the last execution result. /// /// The last execution result. public TaskResult LastExecutionResult { get { var path = GetHistoryFilePath(); lock (_lastExecutionResultSyncLock) { if (_lastExecutionResult is null && !_readFromFile) { if (File.Exists(path)) { var bytes = File.ReadAllBytes(path); if (bytes.Length > 0) { try { _lastExecutionResult = JsonSerializer.Deserialize(bytes, _jsonOptions); } catch (JsonException ex) { _logger.LogError(ex, "Error deserializing {File}", path); } } else { _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path); } } _readFromFile = true; } } return _lastExecutionResult; } private set { _lastExecutionResult = value; var path = GetHistoryFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); lock (_lastExecutionResultSyncLock) { using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream); JsonSerializer.Serialize(jsonStream, value, _jsonOptions); } } } /// /// Gets the name. /// /// The name. public string Name => ScheduledTask.Name; /// /// Gets the description. /// /// The description. public string Description => ScheduledTask.Description; /// /// Gets the category. /// /// The category. public string Category => ScheduledTask.Category; /// /// Gets or sets the current cancellation token. /// /// The current cancellation token source. private CancellationTokenSource CurrentCancellationTokenSource { get; set; } /// /// Gets or sets the current execution start time. /// /// The current execution start time. private DateTime CurrentExecutionStartTime { get; set; } /// /// Gets the state. /// /// The state. public TaskState State { get { if (CurrentCancellationTokenSource is not null) { return CurrentCancellationTokenSource.IsCancellationRequested ? TaskState.Cancelling : TaskState.Running; } return TaskState.Idle; } } /// /// Gets the current progress. /// /// The current progress. public double? CurrentProgress { get; private set; } /// /// Gets or sets the triggers that define when the task will run. /// /// The triggers. private Tuple[] InternalTriggers { get => _triggers; set { ArgumentNullException.ThrowIfNull(value); // Cleanup current triggers if (_triggers is not null) { DisposeTriggers(); } _triggers = value.ToArray(); ReloadTriggerEvents(false); } } /// /// Gets or sets the triggers that define when the task will run. /// /// The triggers. /// value is null. public TaskTriggerInfo[] Triggers { get { var triggers = InternalTriggers; return triggers.Select(i => i.Item1).ToArray(); } set { ArgumentNullException.ThrowIfNull(value); // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly var triggerList = value.Where(i => i is not null).ToArray(); SaveTriggers(triggerList); InternalTriggers = triggerList.Select(i => new Tuple(i, GetTrigger(i))).ToArray(); } } /// /// Gets the unique id. /// /// The unique id. public string Id { get { return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture); } } private void InitTriggerEvents() { _triggers = LoadTriggers(); ReloadTriggerEvents(true); } public void ReloadTriggerEvents() { ReloadTriggerEvents(false); } /// /// Reloads the trigger events. /// /// if set to true [is application startup]. private void ReloadTriggerEvents(bool isApplicationStartup) { foreach (var triggerInfo in InternalTriggers) { var trigger = triggerInfo.Item2; trigger.Stop(); trigger.Triggered -= OnTriggerTriggered; trigger.Triggered += OnTriggerTriggered; trigger.Start(LastExecutionResult, _logger, Name, isApplicationStartup); } } /// /// Handles the Triggered event of the trigger control. /// /// The source of the event. /// The instance containing the event data. private async void OnTriggerTriggered(object sender, EventArgs e) { var trigger = (ITaskTrigger)sender; if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled) { return; } _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name); trigger.Stop(); _taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions); await Task.Delay(1000).ConfigureAwait(false); trigger.Start(LastExecutionResult, _logger, Name, false); } /// /// Executes the task. /// /// Task options. /// Task. /// Cannot execute a Task that is already running. public async Task Execute(TaskOptions options) { var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false)); _currentTask = task; try { await task.ConfigureAwait(false); } finally { _currentTask = null; GC.Collect(); } } private async Task ExecuteInternal(TaskOptions options) { // Cancel the current execution, if any if (CurrentCancellationTokenSource is not null) { throw new InvalidOperationException("Cannot execute a Task that is already running"); } var progress = new SimpleProgress(); CurrentCancellationTokenSource = new CancellationTokenSource(); _logger.LogDebug("Executing {0}", Name); ((TaskManager)_taskManager).OnTaskExecuting(this); progress.ProgressChanged += OnProgressChanged; TaskCompletionStatus status; CurrentExecutionStartTime = DateTime.UtcNow; Exception failureException = null; try { if (options is not null && options.MaxRuntimeTicks.HasValue) { CurrentCancellationTokenSource.CancelAfter(TimeSpan.FromTicks(options.MaxRuntimeTicks.Value)); } await ScheduledTask.ExecuteAsync(progress, CurrentCancellationTokenSource.Token).ConfigureAwait(false); status = TaskCompletionStatus.Completed; } catch (OperationCanceledException) { status = TaskCompletionStatus.Cancelled; } catch (Exception ex) { _logger.LogError(ex, "Error executing Scheduled Task"); failureException = ex; status = TaskCompletionStatus.Failed; } var startTime = CurrentExecutionStartTime; var endTime = DateTime.UtcNow; progress.ProgressChanged -= OnProgressChanged; CurrentCancellationTokenSource.Dispose(); CurrentCancellationTokenSource = null; CurrentProgress = null; OnTaskCompleted(startTime, endTime, status, failureException); } /// /// Progress_s the progress changed. /// /// The sender. /// The e. private void OnProgressChanged(object sender, double e) { e = Math.Min(e, 100); CurrentProgress = e; TaskProgress?.Invoke(this, new GenericEventArgs(e)); } /// /// Stops the task if it is currently executing. /// /// Cannot cancel a Task unless it is in the Running state. public void Cancel() { if (State != TaskState.Running) { throw new InvalidOperationException("Cannot cancel a Task unless it is in the Running state."); } CancelIfRunning(); } /// /// Cancels if running. /// public void CancelIfRunning() { if (State == TaskState.Running) { _logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name); CurrentCancellationTokenSource.Cancel(); } } /// /// Gets the scheduled tasks configuration directory. /// /// System.String. private string GetScheduledTasksConfigurationDirectory() { return Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"); } /// /// Gets the scheduled tasks data directory. /// /// System.String. private string GetScheduledTasksDataDirectory() { return Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"); } /// /// Gets the history file path. /// /// The history file path. private string GetHistoryFilePath() { return Path.Combine(GetScheduledTasksDataDirectory(), new Guid(Id) + ".js"); } /// /// Gets the configuration file path. /// /// System.String. private string GetConfigurationFilePath() { return Path.Combine(GetScheduledTasksConfigurationDirectory(), new Guid(Id) + ".js"); } /// /// Loads the triggers. /// /// IEnumerable{BaseTaskTrigger}. private Tuple[] LoadTriggers() { // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly var settings = LoadTriggerSettings().Where(i => i is not null).ToArray(); return settings.Select(i => new Tuple(i, GetTrigger(i))).ToArray(); } private TaskTriggerInfo[] LoadTriggerSettings() { string path = GetConfigurationFilePath(); TaskTriggerInfo[] list = null; if (File.Exists(path)) { var bytes = File.ReadAllBytes(path); list = JsonSerializer.Deserialize(bytes, _jsonOptions); } // Return defaults if file doesn't exist. return list ?? GetDefaultTriggers(); } private TaskTriggerInfo[] GetDefaultTriggers() { try { return ScheduledTask.GetDefaultTriggers().ToArray(); } catch { return new TaskTriggerInfo[] { new TaskTriggerInfo { IntervalTicks = TimeSpan.FromDays(1).Ticks, Type = TaskTriggerInfo.TriggerInterval } }; } } /// /// Saves the triggers. /// /// The triggers. private void SaveTriggers(TaskTriggerInfo[] triggers) { var path = GetConfigurationFilePath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None); using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream); JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions); } /// /// Called when [task completed]. /// /// The start time. /// The end time. /// The status. /// The exception. private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex) { var elapsedTime = endTime - startTime; _logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.TotalMinutes), elapsedTime.Seconds); var result = new TaskResult { StartTimeUtc = startTime, EndTimeUtc = endTime, Status = status, Name = Name, Id = Id }; result.Key = ScheduledTask.Key; if (ex is not null) { result.ErrorMessage = ex.Message; result.LongErrorMessage = ex.StackTrace; } LastExecutionResult = result; ((TaskManager)_taskManager).OnTaskCompleted(this, result); } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool dispose) { if (dispose) { DisposeTriggers(); var wassRunning = State == TaskState.Running; var startTime = CurrentExecutionStartTime; var token = CurrentCancellationTokenSource; if (token is not null) { try { _logger.LogInformation("{Name}: Cancelling", Name); token.Cancel(); } catch (Exception ex) { _logger.LogError(ex, "Error calling CancellationToken.Cancel();"); } } var task = _currentTask; if (task is not null) { try { _logger.LogInformation("{Name}: Waiting on Task", Name); var exited = task.Wait(2000); if (exited) { _logger.LogInformation("{Name}: Task exited", Name); } else { _logger.LogInformation("{Name}: Timed out waiting for task to stop", Name); } } catch (Exception ex) { _logger.LogError(ex, "Error calling Task.WaitAll();"); } } if (token is not null) { try { _logger.LogDebug("{Name}: Disposing CancellationToken", Name); token.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error calling CancellationToken.Dispose();"); } } if (wassRunning) { OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null); } } } /// /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger. /// /// The info. /// BaseTaskTrigger. /// Invalid trigger type: + info.Type. private ITaskTrigger GetTrigger(TaskTriggerInfo info) { var options = new TaskOptions { MaxRuntimeTicks = info.MaxRuntimeTicks }; if (info.Type.Equals(nameof(DailyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info)); } return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options); } if (info.Type.Equals(nameof(WeeklyTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.TimeOfDayTicks.HasValue) { throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info)); } if (!info.DayOfWeek.HasValue) { throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info)); } return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options); } if (info.Type.Equals(nameof(IntervalTrigger), StringComparison.OrdinalIgnoreCase)) { if (!info.IntervalTicks.HasValue) { throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info)); } return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options); } if (info.Type.Equals(nameof(StartupTrigger), StringComparison.OrdinalIgnoreCase)) { return new StartupTrigger(options); } throw new ArgumentException("Unrecognized trigger type: " + info.Type); } /// /// Disposes each trigger. /// private void DisposeTriggers() { foreach (var triggerInfo in InternalTriggers) { var trigger = triggerInfo.Item2; trigger.Triggered -= OnTriggerTriggered; trigger.Stop(); if (trigger is IDisposable disposable) { disposable.Dispose(); } } } } }