diff --git a/Kyoo.Common/Controllers/IFileManager.cs b/Kyoo.Common/Controllers/IFileManager.cs index 03f22e79..33d1dd76 100644 --- a/Kyoo.Common/Controllers/IFileManager.cs +++ b/Kyoo.Common/Controllers/IFileManager.cs @@ -54,8 +54,10 @@ namespace Kyoo.Controllers /// List files in a directory. /// /// The path of the directory + /// Should the search be recursive or not. /// A list of files's path. - public Task> ListFiles([NotNull] string path); + public Task> ListFiles([NotNull] string path, + SearchOption options = SearchOption.TopDirectoryOnly); /// /// Check if a file exists at the given path. diff --git a/Kyoo.Common/Controllers/IMetadataProvider.cs b/Kyoo.Common/Controllers/IMetadataProvider.cs index 7f1bac18..33e4cd2b 100644 --- a/Kyoo.Common/Controllers/IMetadataProvider.cs +++ b/Kyoo.Common/Controllers/IMetadataProvider.cs @@ -1,5 +1,4 @@ -using System; -using Kyoo.Models; +using Kyoo.Models; using System.Collections.Generic; using System.Threading.Tasks; using JetBrains.Annotations; @@ -31,10 +30,7 @@ namespace Kyoo.Controllers /// Merging metadata is the job of Kyoo, a complex is given /// to make a precise search and give you every available properties, not to discard properties. /// - /// - /// If this metadata provider does not support . - /// - /// A new containing metadata from your provider + /// A new containing metadata from your provider or null [ItemCanBeNull] Task Get([NotNull] T item) where T : class, IResource; @@ -43,15 +39,10 @@ namespace Kyoo.Controllers /// Search for a specific type of items with a given query. /// /// The search query to use. - /// - /// If this metadata provider does not support . - /// /// The list of items that could be found on this specific provider. [ItemNotNull] Task> Search(string query) where T : class, IResource; - - Task> GetPeople(Show show); } /// diff --git a/Kyoo.Common/Controllers/IPlugin.cs b/Kyoo.Common/Controllers/IPlugin.cs index 83cc70a4..ea072c39 100644 --- a/Kyoo.Common/Controllers/IPlugin.cs +++ b/Kyoo.Common/Controllers/IPlugin.cs @@ -11,8 +11,10 @@ namespace Kyoo.Controllers /// /// A common interface used to discord plugins /// - /// You can inject services in the IPlugin constructor. - /// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. + /// + /// You can inject services in the IPlugin constructor. + /// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. + /// [UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] public interface IPlugin { @@ -84,7 +86,10 @@ namespace Kyoo.Controllers /// An optional configuration step to allow a plugin to change asp net configurations. /// WARNING: This is only called on Kyoo's startup so you must restart the app to apply this changes. /// - /// The Asp.Net application builder. On most case it is not needed but you can use it to add asp net functionalities. + /// + /// The Asp.Net application builder. On most case it is not needed but you can use it to + /// add asp net functionalities. + /// void ConfigureAspNet(IApplicationBuilder app) { // Skipped diff --git a/Kyoo.Common/Controllers/ITask.cs b/Kyoo.Common/Controllers/ITask.cs index 75277dd2..3267837b 100644 --- a/Kyoo.Common/Controllers/ITask.cs +++ b/Kyoo.Common/Controllers/ITask.cs @@ -3,12 +3,13 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using Kyoo.Models.Attributes; namespace Kyoo.Controllers { /// - /// A single task parameter. This struct contains metadata to display and utility functions to get them in the taks. + /// A single task parameter. This struct contains metadata to display and utility functions to get them in the task. /// /// This struct will be used to generate the swagger documentation of the task. public record TaskParameter @@ -60,6 +61,24 @@ namespace Kyoo.Controllers }; } + /// + /// Create a new required task parameter. + /// + /// The name of the parameter + /// The description of the parameter + /// The type of the parameter. + /// A new task parameter. + public static TaskParameter CreateRequired(string name, string description) + { + return new() + { + Name = name, + Description = description, + Type = typeof(T), + IsRequired = true + }; + } + /// /// Create a parameter's value to give to a task. /// @@ -162,27 +181,37 @@ namespace Kyoo.Controllers public int Priority { get; } /// - /// Start this task. + /// true if this task should not be displayed to the user, false otherwise. /// - /// The list of parameters. - /// A token to request the task's cancellation. - /// If this task is not cancelled quickly, it might be killed by the runner. - /// - /// Your task can have any service as a public field and use the , - /// they will be set to an available service from the service container before calling this method. - /// - public Task Run(TaskParameters arguments, CancellationToken cancellationToken); - + public bool IsHidden { get; } + /// /// The list of parameters /// - /// All parameters that this task as. Every one of them will be given to the run function with a value. + /// + /// All parameters that this task as. Every one of them will be given to the run function with a value. + /// public TaskParameters GetParameters(); /// - /// If this task is running, return the percentage of completion of this task or null if no percentage can be given. + /// Start this task. /// - /// The percentage of completion of the task. - public int? Progress(); + /// + /// The list of parameters. + /// + /// + /// The progress reporter. Used to inform the sender the percentage of completion of this task + /// . + /// A token to request the task's cancellation. + /// If this task is not cancelled quickly, it might be killed by the runner. + /// + /// + /// Your task can have any service as a public field and use the , + /// they will be set to an available service from the service container before calling this method. + /// They also will be removed after this method return (or throw) to prevent dangling services. + /// + public Task Run([NotNull] TaskParameters arguments, + [NotNull] IProgress progress, + CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/Kyoo.Common/Controllers/ITaskManager.cs b/Kyoo.Common/Controllers/ITaskManager.cs index 392355d3..ffcb1c75 100644 --- a/Kyoo.Common/Controllers/ITaskManager.cs +++ b/Kyoo.Common/Controllers/ITaskManager.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; using Kyoo.Models.Exceptions; namespace Kyoo.Controllers @@ -13,11 +15,56 @@ namespace Kyoo.Controllers /// /// Start a new task (or queue it). /// - /// The slug of the task to run - /// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit. - /// If the number of arguments is invalid or if an argument can't be converted. - /// The task could not be found. - void StartTask(string taskSlug, Dictionary arguments = null); + /// + /// The slug of the task to run. + /// + /// + /// A progress reporter to know the percentage of completion of the task. + /// + /// + /// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit. + /// + /// + /// A custom cancellation token for the task. + /// + /// + /// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument + /// invalid. + /// + /// + /// The task could not be found. + /// + void StartTask(string taskSlug, + [NotNull] IProgress progress, + Dictionary arguments = null, + CancellationToken? cancellationToken = null); + + /// + /// Start a new task (or queue it). + /// + /// + /// A progress reporter to know the percentage of completion of the task. + /// + /// + /// A list of arguments to pass to the task. An automatic conversion will be made if arguments to not fit. + /// + /// + /// The type of the task to start. + /// + /// + /// A custom cancellation token for the task. + /// + /// + /// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument + /// invalid. + /// + /// + /// The task could not be found. + /// + void StartTask([NotNull] IProgress progress, + Dictionary arguments = null, + CancellationToken? cancellationToken = null) + where T : ITask, new(); /// /// Get all currently running tasks diff --git a/Kyoo.Common/Utility/Merger.cs b/Kyoo.Common/Utility/Merger.cs index a1f35756..cd860ea3 100644 --- a/Kyoo.Common/Utility/Merger.cs +++ b/Kyoo.Common/Utility/Merger.cs @@ -112,7 +112,7 @@ namespace Kyoo /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. /// Fields of T will be merged /// - [ContractAnnotation("=> null; first:notnull => notnull; second:notnull => notnull")] + [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] public static T Merge([CanBeNull] T first, [CanBeNull] T second) { if (first == null) diff --git a/Kyoo.TheTvdb/ProviderTVDB.cs b/Kyoo.TheTvdb/ProviderTVDB.cs index 44841326..5497cac1 100644 --- a/Kyoo.TheTvdb/ProviderTVDB.cs +++ b/Kyoo.TheTvdb/ProviderTVDB.cs @@ -56,7 +56,7 @@ namespace Kyoo.TheTvdb { Show show => await _GetShow(show) as T, Episode episode => await _GetEpisode(episode) as T, - _ => throw new NotSupportedException() + _ => null }; } @@ -66,7 +66,11 @@ namespace Kyoo.TheTvdb if (!int.TryParse(show.GetID(Provider.Slug), out int id)) return (await _SearchShow(show.Title)).FirstOrDefault(); TvDbResponse series = await _client.Series.GetAsync(id); - return series.Data.ToShow(Provider); + Show ret = series.Data.ToShow(Provider); + + TvDbResponse people = await _client.Series.GetActorsAsync(id); + ret.People = people.Data.Select(x => x.ToPeopleRole(Provider)).ToArray(); + return ret; } [ItemCanBeNull] @@ -88,7 +92,7 @@ namespace Kyoo.TheTvdb await _Authenticate(); if (typeof(T) == typeof(Show)) return (await _SearchShow(query) as ICollection)!; - throw new NotImplementedException(); + return ArraySegment.Empty; } [ItemNotNull] @@ -97,15 +101,5 @@ namespace Kyoo.TheTvdb TvDbResponse shows = await _client.Search.SearchSeriesByNameAsync(query); return shows.Data.Select(x => x.ToShow(Provider)).ToArray(); } - - /// - public async Task> GetPeople(Show show) - { - if (!int.TryParse(show?.GetID(Provider.Name), out int id)) - return null; - await _Authenticate(); - TvDbResponse people = await _client.Series.GetActorsAsync(id); - return people.Data.Select(x => x.ToPeopleRole(Provider)).ToArray(); - } } } \ No newline at end of file diff --git a/Kyoo/Controllers/FileManager.cs b/Kyoo/Controllers/FileManager.cs index 43b808b8..f6669c32 100644 --- a/Kyoo/Controllers/FileManager.cs +++ b/Kyoo/Controllers/FileManager.cs @@ -70,13 +70,14 @@ namespace Kyoo.Controllers } /// - public Task> ListFiles(string path) + public Task> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly) { if (path == null) throw new ArgumentNullException(nameof(path)); - return Task.FromResult>(Directory.Exists(path) - ? Directory.GetFiles(path) - : Array.Empty()); + string[] ret = Directory.Exists(path) + ? Directory.GetFiles(path, "*", options) + : Array.Empty(); + return Task.FromResult>(ret); } /// diff --git a/Kyoo/Controllers/ProviderComposite.cs b/Kyoo/Controllers/ProviderComposite.cs index 7d62266e..88ad7529 100644 --- a/Kyoo/Controllers/ProviderComposite.cs +++ b/Kyoo/Controllers/ProviderComposite.cs @@ -68,17 +68,13 @@ namespace Kyoo.Controllers public async Task Get(T item) where T : class, IResource { - T ret = null; - + T ret = item; + foreach (IMetadataProvider provider in _GetProviders()) { try { - ret = Merger.Merge(ret, await provider.Get(ret ?? item)); - } - catch (NotSupportedException) - { - // Silenced + ret = Merger.Merge(ret, await provider.Get(ret)); } catch (Exception ex) { @@ -87,7 +83,7 @@ namespace Kyoo.Controllers } } - return Merger.Merge(ret, item); + return ret; } /// @@ -102,10 +98,6 @@ namespace Kyoo.Controllers { ret.AddRange(await provider.Search(query)); } - catch (NotSupportedException) - { - // Silenced - } catch (Exception ex) { _logger.LogError(ex, "The provider {Provider} could not search for {Type}", @@ -115,108 +107,5 @@ namespace Kyoo.Controllers return ret; } - - public Task> GetPeople(Show show) - { - throw new NotImplementedException(); - } - - // public async Task GetCollectionFromName(string name, Library library) - // { - // Collection collection = await GetMetadata( - // provider => provider.GetCollectionFromName(name), - // library, - // $"the collection {name}"); - // collection.Name ??= name; - // collection.Slug ??= Utility.ToSlug(name); - // return collection; - // } - // - // public async Task CompleteShow(Show show, Library library) - // { - // return await GetMetadata(provider => provider.GetShowByID(show), library, $"the show {show.Title}"); - // } - // - // public async Task SearchShow(string showName, bool isMovie, Library library) - // { - // Show show = await GetMetadata(async provider => - // { - // Show searchResult = (await provider.SearchShows(showName, isMovie))?.FirstOrDefault(); - // if (searchResult == null) - // return null; - // return await provider.GetShowByID(searchResult); - // }, library, $"the show {showName}"); - // show.Slug = Utility.ToSlug(showName); - // show.Title ??= showName; - // show.IsMovie = isMovie; - // show.Genres = show.Genres?.GroupBy(x => x.Slug).Select(x => x.First()).ToList(); - // show.People = show.People?.GroupBy(x => x.Slug).Select(x => x.First()).ToList(); - // return show; - // } - // - // public async Task> SearchShows(string showName, bool isMovie, Library library) - // { - // IEnumerable shows = await GetMetadata( - // provider => provider.SearchShows(showName, isMovie), - // library, - // $"the show {showName}"); - // return shows.Select(show => - // { - // show.Slug = Utility.ToSlug(showName); - // show.Title ??= showName; - // show.IsMovie = isMovie; - // return show; - // }); - // } - // - // public async Task GetSeason(Show show, int seasonNumber, Library library) - // { - // Season season = await GetMetadata( - // provider => provider.GetSeason(show, seasonNumber), - // library, - // $"the season {seasonNumber} of {show.Title}"); - // season.Show = show; - // season.ShowID = show.ID; - // season.ShowSlug = show.Slug; - // season.Title ??= $"Season {season.SeasonNumber}"; - // return season; - // } - // - // public async Task GetEpisode(Show show, - // string episodePath, - // int? seasonNumber, - // int? episodeNumber, - // int? absoluteNumber, - // Library library) - // { - // Episode episode = await GetMetadata( - // provider => provider.GetEpisode(show, seasonNumber, episodeNumber, absoluteNumber), - // library, - // "an episode"); - // episode.Show = show; - // episode.ShowID = show.ID; - // episode.ShowSlug = show.Slug; - // episode.Path = episodePath; - // episode.SeasonNumber ??= seasonNumber; - // episode.EpisodeNumber ??= episodeNumber; - // episode.AbsoluteNumber ??= absoluteNumber; - // return episode; - // } - // - // public async Task> GetPeople(Show show, Library library) - // { - // List people = await GetMetadata( - // provider => provider.GetPeople(show), - // library, - // $"a cast member of {show.Title}"); - // return people?.GroupBy(x => x.Slug) - // .Select(x => x.First()) - // .Select(x => - // { - // x.Show = show; - // x.ShowID = show.ID; - // return x; - // }).ToList(); - // } } } diff --git a/Kyoo/Controllers/TaskManager.cs b/Kyoo/Controllers/TaskManager.cs index caa7a01d..c850a516 100644 --- a/Kyoo/Controllers/TaskManager.cs +++ b/Kyoo/Controllers/TaskManager.cs @@ -41,7 +41,7 @@ namespace Kyoo.Controllers /// /// The queue of tasks that should be run as soon as possible. /// - private readonly Queue<(ITask, Dictionary)> _queuedTasks = new(); + private readonly Queue<(ITask, IProgress, Dictionary)> _queuedTasks = new(); /// /// The currently running task. /// @@ -106,11 +106,11 @@ namespace Kyoo.Controllers { if (_queuedTasks.Any()) { - (ITask task, Dictionary arguments) = _queuedTasks.Dequeue(); + (ITask task, IProgress progress, Dictionary args) = _queuedTasks.Dequeue(); _runningTask = task; try { - await RunTask(task, arguments); + await RunTask(task, progress, args); } catch (Exception e) { @@ -129,9 +129,15 @@ namespace Kyoo.Controllers /// Parse parameters, inject a task and run it. /// /// The task to run + /// A progress reporter to know the percentage of completion of the task. /// The arguments to pass to the function - /// There was an invalid argument or a required argument was not found. - private async Task RunTask(ITask task, Dictionary arguments) + /// + /// If the number of arguments is invalid, if an argument can't be converted or if the task finds the argument + /// invalid. + /// + private async Task RunTask(ITask task, + [NotNull] IProgress progress, + Dictionary arguments) { _logger.LogInformation("Task starting: {Task}", task.Name); @@ -160,7 +166,7 @@ namespace Kyoo.Controllers using IServiceScope scope = _provider.CreateScope(); InjectServices(task, x => scope.ServiceProvider.GetRequiredService(x)); - await task.Run(args, _taskToken.Token); + await task.Run(args, progress, _taskToken.Token); InjectServices(task, _ => null); _logger.LogInformation("Task finished: {Task}", task.Name); } @@ -190,7 +196,7 @@ namespace Kyoo.Controllers foreach (string task in tasksToQueue) { _logger.LogDebug("Queuing task scheduled for running: {Task}", task); - StartTask(task, new Dictionary()); + StartTask(task, new Progress(), new Dictionary()); } } @@ -203,21 +209,33 @@ namespace Kyoo.Controllers .Where(x => x.RunOnStartup) .OrderByDescending(x => x.Priority); foreach (ITask task in startupTasks) - _queuedTasks.Enqueue((task, new Dictionary())); + _queuedTasks.Enqueue((task, new Progress(), new Dictionary())); } /// - public void StartTask(string taskSlug, Dictionary arguments = null) + public void StartTask(string taskSlug, + IProgress progress, + Dictionary arguments = null, + CancellationToken? cancellationToken = null) { arguments ??= new Dictionary(); int index = _tasks.FindIndex(x => x.task.Slug == taskSlug); if (index == -1) throw new ItemNotFoundException($"No task found with the slug {taskSlug}"); - _queuedTasks.Enqueue((_tasks[index].task, arguments)); + _queuedTasks.Enqueue((_tasks[index].task, progress, arguments)); _tasks[index] = (_tasks[index].task, GetNextTaskDate(taskSlug)); } + /// + public void StartTask(IProgress progress, + Dictionary arguments = null, + CancellationToken? cancellationToken = null) + where T : ITask, new() + { + StartTask(new T().Slug, progress, arguments, cancellationToken); + } + /// /// Get the next date of the execution of the given task. /// diff --git a/Kyoo/Tasks/Crawler.cs b/Kyoo/Tasks/Crawler.cs index e9d99010..a96f470e 100644 --- a/Kyoo/Tasks/Crawler.cs +++ b/Kyoo/Tasks/Crawler.cs @@ -1,6 +1,5 @@ using System; using Kyoo.Models; -using Microsoft.Extensions.Configuration; using System.Collections.Generic; using System.IO; using System.Linq; @@ -11,26 +10,56 @@ using Kyoo.Controllers; using Kyoo.Models.Attributes; using Kyoo.Models.Exceptions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Kyoo.Tasks { + /// + /// A task to add new video files. + /// public class Crawler : ITask { + /// public string Slug => "scan"; + + /// public string Name => "Scan libraries"; - public string Description => "Scan your libraries, load data for new shows and remove shows that don't exist anymore."; - public string HelpMessage => "Reloading all libraries is a long process and may take up to 24 hours if it is the first scan in a while."; + + /// + public string Description => "Scan your libraries and load data for new shows."; + + /// + public string HelpMessage => "Reloading all libraries is a long process and may take up to" + + " 24 hours if it is the first scan in a while."; + + /// public bool RunOnStartup => true; + + /// public int Priority => 0; - - [Injected] public IServiceProvider ServiceProvider { private get; set; } - [Injected] public IThumbnailsManager ThumbnailsManager { private get; set; } - [Injected] public IMetadataProvider MetadataProvider { private get; set; } - [Injected] public ITranscoder Transcoder { private get; set; } - [Injected] public IConfiguration Config { private get; set; } - private int _parallelTasks; + /// + public bool IsHidden => false; + + /// + /// The library manager used to get libraries and providers to use. + /// + [Injected] public ILibraryManager LibraryManager { private get; set; } + /// + /// The file manager used walk inside directories and check they existences. + /// + [Injected] public IFileManager FileManager { private get; set; } + /// + /// A task manager used to create sub tasks for each episode to add to the database. + /// + [Injected] public ITaskManager TaskManager { private get; set; } + /// + /// The logger used to inform the current status to the console. + /// + [Injected] public ILogger Logger { private get; set; } + + /// public TaskParameters GetParameters() { return new() @@ -39,354 +68,83 @@ namespace Kyoo.Tasks }; } - public int? Progress() + /// + public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) { - // TODO implement this later. - return null; - } - - public async Task Run(TaskParameters parameters, - CancellationToken cancellationToken) - { - string argument = parameters["slug"].As(); - - _parallelTasks = Config.GetValue("parallelTasks"); - if (_parallelTasks <= 0) - _parallelTasks = 30; - - using IServiceScope serviceScope = ServiceProvider.CreateScope(); - ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); - - foreach (Show show in await libraryManager!.GetAll()) - if (!Directory.Exists(show.Path)) - await libraryManager.Delete(show); - - ICollection episodes = await libraryManager.GetAll(); - foreach (Episode episode in episodes) - if (!File.Exists(episode.Path)) - await libraryManager.Delete(episode); - - ICollection tracks = await libraryManager.GetAll(); - foreach (Track track in tracks) - if (!File.Exists(track.Path)) - await libraryManager.Delete(track); - + string argument = arguments["slug"].As(); ICollection libraries = argument == null - ? await libraryManager.GetAll() - : new [] { await libraryManager.GetOrDefault(argument)}; - + ? await LibraryManager.GetAll() + : new [] { await LibraryManager.GetOrDefault(argument)}; + if (argument != null && libraries.First() == null) throw new ArgumentException($"No library found with the name {argument}"); - + foreach (Library library in libraries) - await libraryManager.Load(library, x => x.Providers); + await LibraryManager.Load(library, x => x.Providers); + + progress.Report(0); + float percent = 0; + ICollection episodes = await LibraryManager.GetAll(); foreach (Library library in libraries) - await Scan(library, episodes, tracks, cancellationToken); - Console.WriteLine("Scan finished!"); + { + IProgress reporter = new Progress(x => + { + // ReSharper disable once AccessToModifiedClosure + progress.Report(percent + x / libraries.Count); + }); + await Scan(library, episodes, reporter, cancellationToken); + percent += 100f / libraries.Count; + } + + progress.Report(100); } - private async Task Scan(Library library, IEnumerable episodes, IEnumerable tracks, CancellationToken cancellationToken) + private async Task Scan(Library library, + IEnumerable episodes, + IProgress progress, + CancellationToken cancellationToken) { - Console.WriteLine($"Scanning library {library.Name} at {string.Join(", ", library.Paths)}."); + Logger.LogInformation("Scanning library {Library} at {Paths}", library.Name, library.Paths); foreach (string path in library.Paths) { if (cancellationToken.IsCancellationRequested) - continue; - - string[] files; - try - { - files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); - } - catch (DirectoryNotFoundException) - { - await Console.Error.WriteLineAsync($"The library's directory {path} could not be found (library slug: {library.Slug})"); - continue; - } - catch (PathTooLongException) - { - await Console.Error.WriteLineAsync($"The library's directory {path} is too long for this system. (library slug: {library.Slug})"); - continue; - } - catch (ArgumentException) - { - await Console.Error.WriteLineAsync($"The library's directory {path} is invalid. (library slug: {library.Slug})"); - continue; - } - catch (UnauthorizedAccessException ex) - { - await Console.Error.WriteLineAsync($"{ex.Message} (library slug: {library.Slug})"); - continue; - } + return; - List> shows = files - .Where(x => IsVideo(x) && episodes.All(y => y.Path != x)) + ICollection files = await FileManager.ListFiles(path, SearchOption.AllDirectories); + + // We try to group episodes by shows to register one episode of each show first. + // This speeds up the scan process because further episodes of a show are registered when all metadata + // of the show has already been fetched. + List> shows = files + .Where(IsVideo) + .Where(x => episodes.All(y => y.Path != x)) .GroupBy(Path.GetDirectoryName) .ToList(); + string[] paths = shows.Select(x => x.First()) + .Concat(shows.SelectMany(x => x.Skip(1))) + .ToArray(); - // TODO If the library's path end with a /, the regex is broken. - IEnumerable tasks = shows.Select(x => x.First()); - foreach (string[] showTasks in tasks.BatchBy(_parallelTasks)) - await Task.WhenAll(showTasks - .Select(x => RegisterFile(x, x.Substring(path.Length), library, cancellationToken))); - - tasks = shows.SelectMany(x => x.Skip(1)); - foreach (string[] episodeTasks in tasks.BatchBy(_parallelTasks * 2)) - await Task.WhenAll(episodeTasks - .Select(x => RegisterFile(x, x.Substring(path.Length), library, cancellationToken))); + float percent = 0; + IProgress reporter = new Progress(x => + { + // ReSharper disable once AccessToModifiedClosure + progress.Report((percent + x / paths.Length) / library.Paths.Length); + }); - await Task.WhenAll(files.Where(x => IsSubtitle(x) && tracks.All(y => y.Path != x)) - .Select(x => RegisterExternalSubtitle(x, cancellationToken))); - } - } - - private async Task RegisterExternalSubtitle(string path, CancellationToken token) - { - try - { - if (token.IsCancellationRequested || path.Split(Path.DirectorySeparatorChar).Contains("Subtitles")) - return; - using IServiceScope serviceScope = ServiceProvider.CreateScope(); - ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); - - string patern = Config.GetValue("subtitleRegex"); - Regex regex = new(patern, RegexOptions.IgnoreCase); - Match match = regex.Match(path); - - if (!match.Success) + foreach (string episodePath in paths) { - await Console.Error.WriteLineAsync($"The subtitle at {path} does not match the subtitle's regex."); - return; + TaskManager.StartTask(reporter, new Dictionary + { + ["path"] = episodePath[path.Length..], + ["library"] = library + }, cancellationToken); + percent += 100f / paths.Length; } - string episodePath = match.Groups["Episode"].Value; - Episode episode = await libraryManager!.Get(x => x.Path.StartsWith(episodePath)); - Track track = new() - { - Type = StreamType.Subtitle, - Language = match.Groups["Language"].Value, - IsDefault = match.Groups["Default"].Value.Length > 0, - IsForced = match.Groups["Forced"].Value.Length > 0, - Codec = SubtitleExtensions[Path.GetExtension(path)], - IsExternal = true, - Path = path, - Episode = episode - }; - - await libraryManager.Create(track); - Console.WriteLine($"Registering subtitle at: {path}."); + // await Task.WhenAll(files.Where(x => IsSubtitle(x) && tracks.All(y => y.Path != x)) + // .Select(x => RegisterExternalSubtitle(x, cancellationToken))); } - catch (ItemNotFoundException) - { - await Console.Error.WriteLineAsync($"No episode found for subtitle at: ${path}."); - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Unknown error while registering subtitle: {ex.Message}"); - } - } - - private async Task RegisterFile(string path, string relativePath, Library library, CancellationToken token) - { - if (token.IsCancellationRequested) - return; - - try - { - using IServiceScope serviceScope = ServiceProvider.CreateScope(); - ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); - - string patern = Config.GetValue("regex"); - Regex regex = new(patern, RegexOptions.IgnoreCase); - Match match = regex.Match(relativePath); - - if (!match.Success) - { - await Console.Error.WriteLineAsync($"The episode at {path} does not match the episode's regex."); - return; - } - - string showPath = Path.GetDirectoryName(path); - string collectionName = match.Groups["Collection"].Value; - string showName = match.Groups["Show"].Value; - int? seasonNumber = int.TryParse(match.Groups["Season"].Value, out int tmp) ? tmp : null; - int? episodeNumber = int.TryParse(match.Groups["Episode"].Value, out tmp) ? tmp : null; - int? absoluteNumber = int.TryParse(match.Groups["Absolute"].Value, out tmp) ? tmp : null; - - Collection collection = await GetCollection(libraryManager, collectionName, library); - bool isMovie = seasonNumber == null && episodeNumber == null && absoluteNumber == null; - Show show = await GetShow(libraryManager, showName, showPath, isMovie, library); - if (isMovie) - await libraryManager!.Create(await GetMovie(show, path)); - else - { - Season season = seasonNumber != null - ? await GetSeason(libraryManager, show, seasonNumber.Value, library) - : null; - Episode episode = await GetEpisode(libraryManager, - show, - season, - episodeNumber, - absoluteNumber, - path, - library); - await libraryManager!.Create(episode); - } - - await libraryManager.AddShowLink(show, library, collection); - Console.WriteLine($"Episode at {path} registered."); - } - catch (DuplicatedItemException ex) - { - await Console.Error.WriteLineAsync($"{path}: {ex.Message}"); - } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Unknown exception thrown while registering episode at {path}." + - $"\nException: {ex.Message}" + - $"\n{ex.StackTrace}"); - } - } - - private async Task GetCollection(ILibraryManager libraryManager, - string collectionName, - Library library) - { - if (string.IsNullOrEmpty(collectionName)) - return null; - Collection collection = await libraryManager.GetOrDefault(Utility.ToSlug(collectionName)); - if (collection != null) - return collection; - // collection = await MetadataProvider.GetCollectionFromName(collectionName, library); - - try - { - await libraryManager.Create(collection); - return collection; - } - catch (DuplicatedItemException) - { - return await libraryManager.GetOrDefault(collection.Slug); - } - } - - private async Task GetShow(ILibraryManager libraryManager, - string showTitle, - string showPath, - bool isMovie, - Library library) - { - Show old = await libraryManager.GetOrDefault(x => x.Path == showPath); - if (old != null) - { - await libraryManager.Load(old, x => x.ExternalIDs); - return old; - } - - Show show = new();//await MetadataProvider.SearchShow(showTitle, isMovie, library); - show.Path = showPath; - // show.People = await MetadataProvider.GetPeople(show, library); - - try - { - show = await libraryManager.Create(show); - } - catch (DuplicatedItemException) - { - old = await libraryManager.GetOrDefault(show.Slug); - if (old != null && old.Path == showPath) - { - await libraryManager.Load(old, x => x.ExternalIDs); - return old; - } - - if (show.StartAir != null) - { - show.Slug += $"-{show.StartAir.Value.Year}"; - await libraryManager.Create(show); - } - else - throw; - } - await ThumbnailsManager.Validate(show); - return show; - } - - private async Task GetSeason(ILibraryManager libraryManager, - Show show, - int seasonNumber, - Library library) - { - try - { - Season season = await libraryManager.Get(show.Slug, seasonNumber); - season.Show = show; - return season; - } - catch (ItemNotFoundException) - { - Season season = new();//await MetadataProvider.GetSeason(show, seasonNumber, library); - try - { - await libraryManager.Create(season); - await ThumbnailsManager.Validate(season); - } - catch (DuplicatedItemException) - { - season = await libraryManager.Get(show.Slug, seasonNumber); - } - season.Show = show; - return season; - } - } - - private async Task GetEpisode(ILibraryManager libraryManager, - Show show, - Season season, - int? episodeNumber, - int? absoluteNumber, - string episodePath, - Library library) - { - Episode episode = new();/*await MetadataProvider.GetEpisode(show, - episodePath, - season?.SeasonNumber, - episodeNumber, - absoluteNumber, - library);*/ - - if (episode.SeasonNumber != null) - { - season ??= await GetSeason(libraryManager, show, episode.SeasonNumber.Value, library); - episode.Season = season; - episode.SeasonID = season?.ID; - } - await ThumbnailsManager.Validate(episode); - await GetTracks(episode); - return episode; - } - - private async Task GetMovie(Show show, string episodePath) - { - Episode episode = new() - { - Title = show.Title, - Path = episodePath, - Show = show, - ShowID = show.ID, - ShowSlug = show.Slug - }; - episode.Tracks = await GetTracks(episode); - return episode; - } - - private async Task> GetTracks(Episode episode) - { - episode.Tracks = (await Transcoder.ExtractInfos(episode, false)) - .Where(x => x.Type != StreamType.Attachment) - .ToArray(); - return episode.Tracks; } private static readonly string[] VideoExtensions = diff --git a/Kyoo/Tasks/CreateDatabase.cs b/Kyoo/Tasks/CreateDatabase.cs deleted file mode 100644 index 8d54cd87..00000000 --- a/Kyoo/Tasks/CreateDatabase.cs +++ /dev/null @@ -1,66 +0,0 @@ -// using System; -// using System.Collections.Generic; -// using System.Linq; -// using System.Threading; -// using System.Threading.Tasks; -// using IdentityServer4.EntityFramework.DbContexts; -// using IdentityServer4.EntityFramework.Mappers; -// using IdentityServer4.Models; -// using Kyoo.Models; -// using Microsoft.EntityFrameworkCore; -// using Microsoft.Extensions.DependencyInjection; -// -// namespace Kyoo.Tasks -// { -// public class CreateDatabase : ITask -// { -// public string Slug => "create-database"; -// public string Name => "Create the database"; -// public string Description => "Create the database if it does not exit and initialize it with defaults value."; -// public string HelpMessage => null; -// public bool RunOnStartup => true; -// public int Priority => int.MaxValue; -// -// public Task Run(IServiceProvider serviceProvider, CancellationToken cancellationToken, string arguments = null) -// { -// using IServiceScope serviceScope = serviceProvider.CreateScope(); -// DatabaseContext databaseContext = serviceScope.ServiceProvider.GetService(); -// IdentityDatabase identityDatabase = serviceScope.ServiceProvider.GetService(); -// ConfigurationDbContext identityContext = serviceScope.ServiceProvider.GetService(); -// -// databaseContext!.Database.Migrate(); -// identityDatabase!.Database.Migrate(); -// identityContext!.Database.Migrate(); -// -// if (!identityContext.Clients.Any()) -// { -// foreach (Client client in IdentityContext.GetClients()) -// identityContext.Clients.Add(client.ToEntity()); -// identityContext.SaveChanges(); -// } -// if (!identityContext.IdentityResources.Any()) -// { -// foreach (IdentityResource resource in IdentityContext.GetIdentityResources()) -// identityContext.IdentityResources.Add(resource.ToEntity()); -// identityContext.SaveChanges(); -// } -// if (!identityContext.ApiResources.Any()) -// { -// foreach (ApiResource resource in IdentityContext.GetApis()) -// identityContext.ApiResources.Add(resource.ToEntity()); -// identityContext.SaveChanges(); -// } -// return Task.CompletedTask; -// } -// -// public Task> GetPossibleParameters() -// { -// return Task.FromResult>(null); -// } -// -// public int? Progress() -// { -// return null; -// } -// } -// } \ No newline at end of file diff --git a/Kyoo/Tasks/Housekeeping.cs b/Kyoo/Tasks/Housekeeping.cs new file mode 100644 index 00000000..661f0be0 --- /dev/null +++ b/Kyoo/Tasks/Housekeeping.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.Models.Attributes; + +namespace Kyoo.Tasks +{ + public class Housekeeping : ITask + { + /// + public string Slug => "housekeeping"; + + /// + public string Name => "Housekeeping"; + + /// + public string Description => "Remove orphaned episode and series."; + + /// + public string HelpMessage => null; + + /// + public bool RunOnStartup => true; + + /// + public int Priority => 0; + + /// + public bool IsHidden => false; + + + /// + /// The library manager used to get libraries or remove deleted episodes + /// + [Injected] public ILibraryManager LibraryManager { private get; set; } + /// + /// The file manager used walk inside directories and check they existences. + /// + [Injected] public IFileManager FileManager { private get; set; } + + + /// + public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) + { + int count = 0; + int delCount = await LibraryManager.GetCount() + await LibraryManager.GetCount(); + progress.Report(0); + + foreach (Show show in await LibraryManager.GetAll()) + { + progress.Report(count / delCount * 100); + count++; + + if (await FileManager.Exists(show.Path)) + continue; + await LibraryManager.Delete(show); + } + + foreach (Episode episode in await LibraryManager.GetAll()) + { + progress.Report(count / delCount * 100); + count++; + + if (await FileManager.Exists(episode.Path)) + continue; + await LibraryManager.Delete(episode); + } + + progress.Report(100); + } + + /// + public TaskParameters GetParameters() + { + return new(); + } + } +} \ No newline at end of file diff --git a/Kyoo/Tasks/PluginInitializer.cs b/Kyoo/Tasks/PluginInitializer.cs index 55907649..31dd324e 100644 --- a/Kyoo/Tasks/PluginInitializer.cs +++ b/Kyoo/Tasks/PluginInitializer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Kyoo.Controllers; @@ -28,8 +29,11 @@ namespace Kyoo.Tasks /// public int Priority => int.MaxValue; - - + + /// + public bool IsHidden => true; + + /// /// The plugin manager used to retrieve plugins to initialize them. /// @@ -40,21 +44,28 @@ namespace Kyoo.Tasks [Injected] public IServiceProvider Provider { private get; set; } /// - public Task Run(TaskParameters arguments, CancellationToken cancellationToken) + public Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) { - foreach (IPlugin plugin in PluginManager.GetAllPlugins()) + ICollection plugins = PluginManager.GetAllPlugins(); + int count = 0; + progress.Report(0); + + foreach (IPlugin plugin in plugins) + { plugin.Initialize(Provider); + + progress.Report(count / plugins.Count * 100); + count++; + } + + progress.Report(100); return Task.CompletedTask; } + /// public TaskParameters GetParameters() { return new(); } - - public int? Progress() - { - return null; - } } } \ No newline at end of file diff --git a/Kyoo/Tasks/PluginLoader.cs b/Kyoo/Tasks/PluginLoader.cs deleted file mode 100644 index 839e2f1e..00000000 --- a/Kyoo/Tasks/PluginLoader.cs +++ /dev/null @@ -1,37 +0,0 @@ -// using System; -// using System.Collections.Generic; -// using System.Threading; -// using System.Threading.Tasks; -// using Kyoo.Controllers; -// using Kyoo.Models; -// using Microsoft.Extensions.DependencyInjection; -// -// namespace Kyoo.Tasks -// { -// public class PluginLoader : ITask -// { -// public string Slug => "reload-plugin"; -// public string Name => "Reload plugins"; -// public string Description => "Reload all plugins from the plugin folder."; -// public string HelpMessage => null; -// public bool RunOnStartup => true; -// public int Priority => Int32.MaxValue; -// public Task Run(IServiceProvider serviceProvider, CancellationToken cancellationToken, string arguments = null) -// { -// using IServiceScope serviceScope = serviceProvider.CreateScope(); -// IPluginManager pluginManager = serviceScope.ServiceProvider.GetService(); -// pluginManager.ReloadPlugins(); -// return Task.CompletedTask; -// } -// -// public Task> GetPossibleParameters() -// { -// return Task.FromResult>(null); -// } -// -// public int? Progress() -// { -// return null; -// } -// } -// } \ No newline at end of file diff --git a/Kyoo/Tasks/RegisterEpisode.cs b/Kyoo/Tasks/RegisterEpisode.cs new file mode 100644 index 00000000..7b0a4eba --- /dev/null +++ b/Kyoo/Tasks/RegisterEpisode.cs @@ -0,0 +1,305 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Controllers; +using Kyoo.Models; + +namespace Kyoo.Tasks +{ + /// + /// A task to register a new episode + /// + public class RegisterEpisode : ITask + { + /// + public string Slug => "register"; + + /// + public string Name => "Register episode"; + + /// + public string Description => "Register a new episode"; + + /// + public string HelpMessage => null; + + /// + public bool RunOnStartup => false; + + /// + public int Priority => 0; + + /// + public bool IsHidden => false; + + /// + public TaskParameters GetParameters() + { + return new() + { + TaskParameter.CreateRequired("path", "The path of the episode file"), + TaskParameter.CreateRequired("library", "The library in witch the episode is") + }; + } + + /// + public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) + { + string path = arguments["path"].As(); + Library library = arguments["library"].As(); + + + } + + /* + * private async Task RegisterExternalSubtitle(string path, CancellationToken token) + { + try + { + if (token.IsCancellationRequested || path.Split(Path.DirectorySeparatorChar).Contains("Subtitles")) + return; + using IServiceScope serviceScope = ServiceProvider.CreateScope(); + ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); + + string patern = Config.GetValue("subtitleRegex"); + Regex regex = new(patern, RegexOptions.IgnoreCase); + Match match = regex.Match(path); + + if (!match.Success) + { + await Console.Error.WriteLineAsync($"The subtitle at {path} does not match the subtitle's regex."); + return; + } + + string episodePath = match.Groups["Episode"].Value; + Episode episode = await libraryManager!.Get(x => x.Path.StartsWith(episodePath)); + Track track = new() + { + Type = StreamType.Subtitle, + Language = match.Groups["Language"].Value, + IsDefault = match.Groups["Default"].Value.Length > 0, + IsForced = match.Groups["Forced"].Value.Length > 0, + Codec = SubtitleExtensions[Path.GetExtension(path)], + IsExternal = true, + Path = path, + Episode = episode + }; + + await libraryManager.Create(track); + Console.WriteLine($"Registering subtitle at: {path}."); + } + catch (ItemNotFoundException) + { + await Console.Error.WriteLineAsync($"No episode found for subtitle at: ${path}."); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Unknown error while registering subtitle: {ex.Message}"); + } + } + + private async Task RegisterFile(string path, string relativePath, Library library, CancellationToken token) + { + if (token.IsCancellationRequested) + return; + + try + { + using IServiceScope serviceScope = ServiceProvider.CreateScope(); + ILibraryManager libraryManager = serviceScope.ServiceProvider.GetService(); + + string patern = Config.GetValue("regex"); + Regex regex = new(patern, RegexOptions.IgnoreCase); + Match match = regex.Match(relativePath); + + if (!match.Success) + { + await Console.Error.WriteLineAsync($"The episode at {path} does not match the episode's regex."); + return; + } + + string showPath = Path.GetDirectoryName(path); + string collectionName = match.Groups["Collection"].Value; + string showName = match.Groups["Show"].Value; + int? seasonNumber = int.TryParse(match.Groups["Season"].Value, out int tmp) ? tmp : null; + int? episodeNumber = int.TryParse(match.Groups["Episode"].Value, out tmp) ? tmp : null; + int? absoluteNumber = int.TryParse(match.Groups["Absolute"].Value, out tmp) ? tmp : null; + + Collection collection = await GetCollection(libraryManager, collectionName, library); + bool isMovie = seasonNumber == null && episodeNumber == null && absoluteNumber == null; + Show show = await GetShow(libraryManager, showName, showPath, isMovie, library); + if (isMovie) + await libraryManager!.Create(await GetMovie(show, path)); + else + { + Season season = seasonNumber != null + ? await GetSeason(libraryManager, show, seasonNumber.Value, library) + : null; + Episode episode = await GetEpisode(libraryManager, + show, + season, + episodeNumber, + absoluteNumber, + path, + library); + await libraryManager!.Create(episode); + } + + await libraryManager.AddShowLink(show, library, collection); + Console.WriteLine($"Episode at {path} registered."); + } + catch (DuplicatedItemException ex) + { + await Console.Error.WriteLineAsync($"{path}: {ex.Message}"); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Unknown exception thrown while registering episode at {path}." + + $"\nException: {ex.Message}" + + $"\n{ex.StackTrace}"); + } + } + + private async Task GetCollection(ILibraryManager libraryManager, + string collectionName, + Library library) + { + if (string.IsNullOrEmpty(collectionName)) + return null; + Collection collection = await libraryManager.GetOrDefault(Utility.ToSlug(collectionName)); + if (collection != null) + return collection; + // collection = await MetadataProvider.GetCollectionFromName(collectionName, library); + + try + { + await libraryManager.Create(collection); + return collection; + } + catch (DuplicatedItemException) + { + return await libraryManager.GetOrDefault(collection.Slug); + } + } + + private async Task GetShow(ILibraryManager libraryManager, + string showTitle, + string showPath, + bool isMovie, + Library library) + { + Show old = await libraryManager.GetOrDefault(x => x.Path == showPath); + if (old != null) + { + await libraryManager.Load(old, x => x.ExternalIDs); + return old; + } + + Show show = new();//await MetadataProvider.SearchShow(showTitle, isMovie, library); + show.Path = showPath; + // show.People = await MetadataProvider.GetPeople(show, library); + + try + { + show = await libraryManager.Create(show); + } + catch (DuplicatedItemException) + { + old = await libraryManager.GetOrDefault(show.Slug); + if (old != null && old.Path == showPath) + { + await libraryManager.Load(old, x => x.ExternalIDs); + return old; + } + + if (show.StartAir != null) + { + show.Slug += $"-{show.StartAir.Value.Year}"; + await libraryManager.Create(show); + } + else + throw; + } + await ThumbnailsManager.Validate(show); + return show; + } + + private async Task GetSeason(ILibraryManager libraryManager, + Show show, + int seasonNumber, + Library library) + { + try + { + Season season = await libraryManager.Get(show.Slug, seasonNumber); + season.Show = show; + return season; + } + catch (ItemNotFoundException) + { + Season season = new();//await MetadataProvider.GetSeason(show, seasonNumber, library); + try + { + await libraryManager.Create(season); + await ThumbnailsManager.Validate(season); + } + catch (DuplicatedItemException) + { + season = await libraryManager.Get(show.Slug, seasonNumber); + } + season.Show = show; + return season; + } + } + + private async Task GetEpisode(ILibraryManager libraryManager, + Show show, + Season season, + int? episodeNumber, + int? absoluteNumber, + string episodePath, + Library library) + { + Episode episode = new(); + //await MetadataProvider.GetEpisode(show, + // episodePath, + // season?.SeasonNumber, + // episodeNumber, + // absoluteNumber, + // library); + + if (episode.SeasonNumber != null) + { + season ??= await GetSeason(libraryManager, show, episode.SeasonNumber.Value, library); + episode.Season = season; + episode.SeasonID = season?.ID; + } + await ThumbnailsManager.Validate(episode); + await GetTracks(episode); + return episode; + } + + private async Task GetMovie(Show show, string episodePath) + { + Episode episode = new() + { + Title = show.Title, + Path = episodePath, + Show = show, + ShowID = show.ID, + ShowSlug = show.Slug + }; + episode.Tracks = await GetTracks(episode); + return episode; + } + + private async Task> GetTracks(Episode episode) + { + episode.Tracks = (await Transcoder.ExtractInfos(episode, false)) + .Where(x => x.Type != StreamType.Attachment) + .ToArray(); + return episode.Tracks; + } + */ + } +} \ No newline at end of file diff --git a/Kyoo/Views/LibraryApi.cs b/Kyoo/Views/LibraryApi.cs index 93cd57d3..83d0e712 100644 --- a/Kyoo/Views/LibraryApi.cs +++ b/Kyoo/Views/LibraryApi.cs @@ -33,7 +33,9 @@ namespace Kyoo.Api { ActionResult result = await base.Create(resource); if (result.Value != null) - _taskManager.StartTask("scan", new Dictionary {{"slug", result.Value.Slug}}); + _taskManager.StartTask("scan", + new Progress(), + new Dictionary {{"slug", result.Value.Slug}}); return result; } diff --git a/Kyoo/Views/TaskApi.cs b/Kyoo/Views/TaskApi.cs index b75033d7..dfad76da 100644 --- a/Kyoo/Views/TaskApi.cs +++ b/Kyoo/Views/TaskApi.cs @@ -34,7 +34,7 @@ namespace Kyoo.Api { try { - _taskManager.StartTask(taskSlug, args); + _taskManager.StartTask(taskSlug, new Progress(), args); return Ok(); } catch (ItemNotFoundException)