diff --git a/Kyoo.Authentication/AuthenticationModule.cs b/Kyoo.Authentication/AuthenticationModule.cs index 8e2c78c4..1e9dcd01 100644 --- a/Kyoo.Authentication/AuthenticationModule.cs +++ b/Kyoo.Authentication/AuthenticationModule.cs @@ -9,6 +9,7 @@ using IdentityServer4.Services; using Kyoo.Authentication.Models; using Kyoo.Authentication.Views; using Kyoo.Controllers; +using Kyoo.Models.Attributes; using Kyoo.Models.Permissions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -64,6 +65,11 @@ namespace Kyoo.Authentication /// The environment information to check if the app runs in debug mode /// private readonly IWebHostEnvironment _environment; + + /// + /// The configuration manager used to register typed/untyped implementations. + /// + [Injected] public IConfigurationManager ConfigurationManager { private get; set; } /// @@ -98,9 +104,7 @@ namespace Kyoo.Authentication services.Configure(_configuration.GetSection(PermissionOption.Path)); services.Configure(_configuration.GetSection(CertificateOption.Path)); services.Configure(_configuration.GetSection(AuthenticationOption.Path)); - services.AddConfiguration(AuthenticationOption.Path); - - + List clients = new(); _configuration.GetSection("authentication:clients").Bind(clients); CertificateOption certificateOptions = new(); @@ -139,6 +143,8 @@ namespace Kyoo.Authentication /// public void ConfigureAspNet(IApplicationBuilder app) { + ConfigurationManager.AddTyped(AuthenticationOption.Path); + app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict diff --git a/Kyoo.Authentication/Views/AccountApi.cs b/Kyoo.Authentication/Views/AccountApi.cs index 59d964f0..4288250e 100644 --- a/Kyoo.Authentication/Views/AccountApi.cs +++ b/Kyoo.Authentication/Views/AccountApi.cs @@ -36,7 +36,7 @@ namespace Kyoo.Authentication.Views /// /// A file manager to send profile pictures /// - private readonly IFileManager _files; + private readonly IFileSystem _files; /// /// Options about authentication. Those options are monitored and reloads are supported. /// @@ -50,7 +50,7 @@ namespace Kyoo.Authentication.Views /// A file manager to send profile pictures /// Authentication options (this may be hot reloaded) public AccountApi(IUserRepository users, - IFileManager files, + IFileSystem files, IOptions options) { _users = users; @@ -205,8 +205,8 @@ namespace Kyoo.Authentication.Views user.Username = data.Username; if (data.Picture?.Length > 0) { - string path = Path.Combine(_options.Value.ProfilePicturePath, user.ID.ToString()); - await using Stream file = _files.NewFile(path); + string path = _files.Combine(_options.Value.ProfilePicturePath, user.ID.ToString()); + await using Stream file = await _files.NewFile(path); await data.Picture.CopyToAsync(file); } return await _users.Edit(user, false); diff --git a/Kyoo.Common/Controllers/IConfigurationManager.cs b/Kyoo.Common/Controllers/IConfigurationManager.cs index 9159d92c..02430b10 100644 --- a/Kyoo.Common/Controllers/IConfigurationManager.cs +++ b/Kyoo.Common/Controllers/IConfigurationManager.cs @@ -12,6 +12,21 @@ namespace Kyoo.Controllers /// public interface IConfigurationManager { + /// + /// Add an editable configuration to the editable configuration list + /// + /// The root path of the editable configuration. It should not be a nested type. + /// The type of the configuration + void AddTyped(string path); + + /// + /// Add an editable configuration to the editable configuration list. + /// WARNING: this method allow you to add an unmanaged type. This type won't be editable. This can be used + /// for external libraries or variable arguments. + /// + /// The root path of the editable configuration. It should not be a nested type. + void AddUntyped(string path); + /// /// Get the value of a setting using it's path. /// diff --git a/Kyoo.Common/Controllers/IFileManager.cs b/Kyoo.Common/Controllers/IFileSystem.cs similarity index 76% rename from Kyoo.Common/Controllers/IFileManager.cs rename to Kyoo.Common/Controllers/IFileSystem.cs index 03f22e79..2fb1c6d7 100644 --- a/Kyoo.Common/Controllers/IFileManager.cs +++ b/Kyoo.Common/Controllers/IFileSystem.cs @@ -10,7 +10,7 @@ namespace Kyoo.Controllers /// /// A service to abstract the file system to allow custom file systems (like distant file systems or external providers) /// - public interface IFileManager + public interface IFileSystem { // TODO find a way to handle Transmux/Transcode with this system. @@ -41,21 +41,37 @@ namespace Kyoo.Controllers /// The path of the file /// If the file could not be found. /// A reader to read the file. - public Stream GetReader([NotNull] string path); + public Task GetReader([NotNull] string path); /// /// Create a new file at . /// /// The path of the new file. /// A writer to write to the new file. - public Stream NewFile([NotNull] string path); + public Task NewFile([NotNull] string path); + + /// + /// Create a new directory at the given path + /// + /// The path of the directory + /// The path of the newly created directory is returned. + public Task CreateDirectory([NotNull] string path); + + /// + /// Combine multiple paths. + /// + /// The paths to combine + /// The combined path. + public string Combine(params string[] paths); /// /// 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. @@ -71,24 +87,6 @@ namespace Kyoo.Controllers /// /// The show to proceed /// The extra directory of the show - public string GetExtraDirectory(Show show); - - /// - /// Get the extra directory of a season. - /// This method is in this system to allow a filesystem to use a different metadata policy for one. - /// It can be useful if the filesystem is readonly. - /// - /// The season to proceed - /// The extra directory of the season - public string GetExtraDirectory(Season season); - - /// - /// Get the extra directory of an episode. - /// This method is in this system to allow a filesystem to use a different metadata policy for one. - /// It can be useful if the filesystem is readonly. - /// - /// The episode to proceed - /// The extra directory of the episode - public string GetExtraDirectory(Episode episode); + public string GetExtraDirectory([NotNull] Show show); } } \ No newline at end of file diff --git a/Kyoo.Common/Controllers/IIdentifier.cs b/Kyoo.Common/Controllers/IIdentifier.cs new file mode 100644 index 00000000..5d11c53b --- /dev/null +++ b/Kyoo.Common/Controllers/IIdentifier.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Kyoo.Models; +using Kyoo.Models.Exceptions; + +namespace Kyoo.Controllers +{ + /// + /// An interface to identify episodes, shows and metadata based on the episode file. + /// + public interface IIdentifier + { + /// + /// Identify a path and return the parsed metadata. + /// + /// The path of the episode file to parse. + /// + /// The identifier could not work for the given path. + /// + /// + /// A tuple of models representing parsed metadata. + /// If no metadata could be parsed for a type, null can be returned. + /// + Task<(Collection, Show, Season, Episode)> Identify(string path); + + /// + /// Identify an external subtitle or track file from it's path and return the parsed metadata. + /// + /// The path of the external track file to parse. + /// + /// The identifier could not work for the given path. + /// + /// + /// The metadata of the track identified. + /// + Task IdentifyTrack(string path); + } +} \ No newline at end of file diff --git a/Kyoo.Common/Controllers/IMetadataProvider.cs b/Kyoo.Common/Controllers/IMetadataProvider.cs index a0c30cbb..ed6ab40d 100644 --- a/Kyoo.Common/Controllers/IMetadataProvider.cs +++ b/Kyoo.Common/Controllers/IMetadataProvider.cs @@ -1,21 +1,76 @@ using Kyoo.Models; using System.Collections.Generic; using System.Threading.Tasks; +using JetBrains.Annotations; namespace Kyoo.Controllers { + /// + /// An interface to automatically retrieve metadata from external providers. + /// public interface IMetadataProvider { + /// + /// The corresponding to this provider. + /// This allow to map metadata to a provider, keep metadata links and + /// know witch is used for a specific . + /// Provider Provider { get; } - Task GetCollectionFromName(string name); + /// + /// Return a new item with metadata from your provider. + /// + /// + /// The item to retrieve metadata from. Most of the time, only the name will be available but other + /// properties may be filed by other providers before a call to this method. This can allow you to identify + /// the collection on your provider. + /// + /// + /// You must not use metadata from the given . + /// 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. + /// + /// A new containing metadata from your provider or null + [ItemCanBeNull] + Task Get([NotNull] T item) + where T : class, IResource; - Task GetShowByID(Show show); - Task> SearchShows(string showName, bool isMovie); - Task> GetPeople(Show show); + /// + /// Search for a specific type of items with a given query. + /// + /// The search query to use. + /// The list of items that could be found on this specific provider. + [ItemNotNull] + Task> Search(string query) + where T : class, IResource; + } - Task GetSeason(Show show, int seasonNumber); + /// + /// A special that merge results. + /// This interface exists to specify witch provider to use but it can be used like any other metadata provider. + /// + public abstract class AProviderComposite : IMetadataProvider + { + /// + [ItemNotNull] + public abstract Task Get(T item) + where T : class, IResource; - Task GetEpisode(Show show, int? seasonNumber, int? episodeNumber, int? absoluteNumber); + /// + public abstract Task> Search(string query) + where T : class, IResource; + + /// + /// Since this is a composite and not a real provider, no metadata is available. + /// It is not meant to be stored or selected. This class will handle merge based on what is required. + /// + public Provider Provider => null; + + /// + /// Select witch providers to use. + /// The associated with the given will be used. + /// + /// The list of providers to use + public abstract void UseProviders(IEnumerable providers); } } diff --git a/Kyoo.Common/Controllers/IPlugin.cs b/Kyoo.Common/Controllers/IPlugin.cs index 3201df83..ea072c39 100644 --- a/Kyoo.Common/Controllers/IPlugin.cs +++ b/Kyoo.Common/Controllers/IPlugin.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Autofac; using JetBrains.Annotations; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -10,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 { @@ -55,6 +58,15 @@ namespace Kyoo.Controllers /// ICollection Requires { get; } + /// + /// A configure method that will be run on plugin's startup. + /// + /// The autofac service container to register services. + void Configure(ContainerBuilder builder) + { + // Skipped + } + /// /// A configure method that will be run on plugin's startup. /// @@ -64,21 +76,34 @@ namespace Kyoo.Controllers /// or > /// You can't simply check on the service collection because some dependencies might be registered after your plugin. /// - void Configure(IServiceCollection services, ICollection availableTypes); + void Configure(IServiceCollection services, ICollection availableTypes) + { + // Skipped + } + /// /// 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. - void ConfigureAspNet(IApplicationBuilder app) {} - + /// + /// 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 + } + /// /// An optional function to execute and initialize your plugin. /// It can be used to initialize a database connection, fill initial data or anything. /// /// A service provider to request services - void Initialize(IServiceProvider provider) {} + void Initialize(IServiceProvider provider) + { + // Skipped + } } /// diff --git a/Kyoo.Common/Controllers/IPluginManager.cs b/Kyoo.Common/Controllers/IPluginManager.cs index bd4ef513..04d308f3 100644 --- a/Kyoo.Common/Controllers/IPluginManager.cs +++ b/Kyoo.Common/Controllers/IPluginManager.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Autofac; using Kyoo.Models.Exceptions; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -42,7 +43,13 @@ namespace Kyoo.Controllers public void LoadPlugins(ICollection plugins); /// - /// Configure services adding or removing services as the plugins wants. + /// Configure container adding or removing services as the plugins wants. + /// + /// The container to populate + void ConfigureContainer(ContainerBuilder builder); + + /// + /// Configure services via the microsoft way. This allow libraries to add their services. /// /// The service collection to populate public void ConfigureServices(IServiceCollection services); diff --git a/Kyoo.Common/Controllers/IProviderManager.cs b/Kyoo.Common/Controllers/IProviderManager.cs deleted file mode 100644 index dd83a283..00000000 --- a/Kyoo.Common/Controllers/IProviderManager.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Kyoo.Models; - -namespace Kyoo.Controllers -{ - public interface IProviderManager - { - Task GetCollectionFromName(string name, Library library); - Task CompleteShow(Show show, Library library); - Task SearchShow(string showName, bool isMovie, Library library); - Task> SearchShows(string showName, bool isMovie, Library library); - Task GetSeason(Show show, int seasonNumber, Library library); - Task GetEpisode(Show show, string episodePath, int? seasonNumber, int? episodeNumber, int? absoluteNumber, Library library); - Task> GetPeople(Show show, Library library); - } -} \ No newline at end of file diff --git a/Kyoo.Common/Controllers/IRepository.cs b/Kyoo.Common/Controllers/IRepository.cs index f7be69ad..a83d17fa 100644 --- a/Kyoo.Common/Controllers/IRepository.cs +++ b/Kyoo.Common/Controllers/IRepository.cs @@ -128,6 +128,7 @@ namespace Kyoo.Controllers /// The id of the resource /// If the item could not be found. /// The resource found + [ItemNotNull] Task Get(int id); /// /// Get a resource from it's slug. @@ -135,6 +136,7 @@ namespace Kyoo.Controllers /// The slug of the resource /// If the item could not be found. /// The resource found + [ItemNotNull] Task Get(string slug); /// /// Get the first resource that match the predicate. @@ -142,6 +144,7 @@ namespace Kyoo.Controllers /// A predicate to filter the resource. /// If the item could not be found. /// The resource found + [ItemNotNull] Task Get(Expression> where); /// @@ -149,18 +152,21 @@ namespace Kyoo.Controllers /// /// The id of the resource /// The resource found + [ItemCanBeNull] Task GetOrDefault(int id); /// /// Get a resource from it's slug or null if it is not found. /// /// The slug of the resource /// The resource found + [ItemCanBeNull] Task GetOrDefault(string slug); /// /// Get the first resource that match the predicate or null if it is not found. /// /// A predicate to filter the resource. /// The resource found + [ItemCanBeNull] Task GetOrDefault(Expression> where); /// @@ -168,6 +174,7 @@ namespace Kyoo.Controllers /// /// The query string. /// A list of resources found + [ItemNotNull] Task> Search(string query); /// @@ -177,6 +184,7 @@ namespace Kyoo.Controllers /// Sort information about the query (sort by, sort order) /// How pagination should be done (where to start and how many to return) /// A list of resources that match every filters + [ItemNotNull] Task> GetAll(Expression> where = null, Sort sort = default, Pagination limit = default); @@ -187,6 +195,7 @@ namespace Kyoo.Controllers /// A sort by predicate. The order is ascending. /// How pagination should be done (where to start and how many to return) /// A list of resources that match every filters + [ItemNotNull] Task> GetAll([Optional] Expression> where, Expression> sort, Pagination limit = default @@ -205,6 +214,7 @@ namespace Kyoo.Controllers /// /// The item to register /// The resource registers and completed by database's information (related items & so on) + [ItemNotNull] Task Create([NotNull] T obj); /// @@ -212,6 +222,7 @@ namespace Kyoo.Controllers /// /// The object to create /// The newly created item or the existing value if it existed. + [ItemNotNull] Task CreateIfNotExists([NotNull] T obj); /// @@ -221,6 +232,7 @@ namespace Kyoo.Controllers /// Should old properties of the resource be discarded or should null values considered as not changed? /// If the item is not found /// The resource edited and completed by database's information (related items & so on) + [ItemNotNull] Task Edit([NotNull] T edited, bool resetOld); /// diff --git a/Kyoo.Common/Controllers/ITask.cs b/Kyoo.Common/Controllers/ITask.cs index 75277dd2..e8d07000 100644 --- a/Kyoo.Common/Controllers/ITask.cs +++ b/Kyoo.Common/Controllers/ITask.cs @@ -3,12 +3,15 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; +using Kyoo.Models; using Kyoo.Models.Attributes; +using Kyoo.Models.Exceptions; 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 +63,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. /// @@ -94,6 +115,17 @@ namespace Kyoo.Controllers /// The value of this parameter. public T As() { + if (typeof(T) == typeof(object)) + return (T)Value; + + if (Value is IResource resource) + { + if (typeof(T) == typeof(string)) + return (T)(object)resource.Slug; + if (typeof(T) == typeof(int)) + return (T)(object)resource.ID; + } + return (T)Convert.ChangeType(Value, typeof(T)); } } @@ -131,58 +163,38 @@ namespace Kyoo.Controllers public interface ITask { /// - /// The slug of the task, used to start it. + /// The list of parameters /// - public string Slug { get; } - - /// - /// The name of the task that will be displayed to the user. - /// - public string Name { get; } - - /// - /// A quick description of what this task will do. - /// - public string Description { get; } - - /// - /// An optional message to display to help the user. - /// - public string HelpMessage { get; } - - /// - /// Should this task be automatically run at app startup? - /// - public bool RunOnStartup { get; } - - /// - /// The priority of this task. Only used if is true. - /// It allow one to specify witch task will be started first as tasked are run on a Priority's descending order. - /// - public int Priority { get; } + /// + /// All parameters that this task as. Every one of them will be given to the run function with a value. + /// + public TaskParameters GetParameters(); /// /// Start this task. /// - /// The list of parameters. + /// + /// 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. + /// 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(TaskParameters arguments, CancellationToken cancellationToken); - - /// - /// The list of parameters - /// - /// 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. - /// - /// The percentage of completion of the task. - public int? Progress(); + /// + /// An exception meaning that the task has failed for handled reasons like invalid arguments, + /// invalid environment, missing plugins or failures not related to a default in the code. + /// This exception allow the task to display a failure message to the end user while others exceptions + /// will be displayed as unhandled exceptions and display a stack trace. + /// + 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..37b40f12 100644 --- a/Kyoo.Common/Controllers/ITaskManager.cs +++ b/Kyoo.Common/Controllers/ITaskManager.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Kyoo.Common.Models.Attributes; using Kyoo.Models.Exceptions; namespace Kyoo.Controllers @@ -13,22 +16,67 @@ 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; /// /// Get all currently running tasks /// /// A list of currently running tasks. - ICollection GetRunningTasks(); + ICollection<(TaskMetadataAttribute, ITask)> GetRunningTasks(); /// /// Get all available tasks /// /// A list of every tasks that this instance know. - ICollection GetAllTasks(); + ICollection GetAllTasks(); } } \ No newline at end of file diff --git a/Kyoo.Common/Controllers/IThumbnailsManager.cs b/Kyoo.Common/Controllers/IThumbnailsManager.cs index ee31498a..465d4c62 100644 --- a/Kyoo.Common/Controllers/IThumbnailsManager.cs +++ b/Kyoo.Common/Controllers/IThumbnailsManager.cs @@ -1,23 +1,59 @@ -using Kyoo.Models; +using System; +using Kyoo.Models; using System.Threading.Tasks; using JetBrains.Annotations; namespace Kyoo.Controllers { + /// + /// Download images and retrieve the path of those images for a resource. + /// public interface IThumbnailsManager { - Task Validate(Show show, bool alwaysDownload = false); - Task Validate(Season season, bool alwaysDownload = false); - Task Validate(Episode episode, bool alwaysDownload = false); - Task Validate(People actors, bool alwaysDownload = false); - Task Validate(Provider actors, bool alwaysDownload = false); + /// + /// Download images of a specified item. + /// If no images is available to download, do nothing and silently return. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// The type of the item + /// true if an image has been downloaded, false otherwise. + Task DownloadImages([NotNull] T item, bool alwaysDownload = false) + where T : IResource; + - Task GetShowPoster([NotNull] Show show); - Task GetShowLogo([NotNull] Show show); - Task GetShowBackdrop([NotNull] Show show); - Task GetSeasonPoster([NotNull] Season season); - Task GetEpisodeThumb([NotNull] Episode episode); - Task GetPeoplePoster([NotNull] People people); - Task GetProviderLogo([NotNull] Provider provider); + /// + /// Retrieve the local path of the poster of the given item. + /// + /// The item to retrieve the poster from. + /// The type of the item + /// If the type does not have a poster + /// The path of the poster for the given resource (it might or might not exists). + Task GetPoster([NotNull] T item) + where T : IResource; + + /// + /// Retrieve the local path of the logo of the given item. + /// + /// The item to retrieve the logo from. + /// The type of the item + /// If the type does not have a logo + /// The path of the logo for the given resource (it might or might not exists). + Task GetLogo([NotNull] T item) + where T : IResource; + + /// + /// Retrieve the local path of the thumbnail of the given item. + /// + /// The item to retrieve the thumbnail from. + /// The type of the item + /// If the type does not have a thumbnail + /// The path of the thumbnail for the given resource (it might or might not exists). + Task GetThumbnail([NotNull] T item) + where T : IResource; } } diff --git a/Kyoo.Common/Kyoo.Common.csproj b/Kyoo.Common/Kyoo.Common.csproj index 349ef6a0..844be997 100644 --- a/Kyoo.Common/Kyoo.Common.csproj +++ b/Kyoo.Common/Kyoo.Common.csproj @@ -21,11 +21,13 @@ + + diff --git a/Kyoo.Common/Models/Attributes/FileSystemMetadataAttribute.cs b/Kyoo.Common/Models/Attributes/FileSystemMetadataAttribute.cs new file mode 100644 index 00000000..d2b997a5 --- /dev/null +++ b/Kyoo.Common/Models/Attributes/FileSystemMetadataAttribute.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using Kyoo.Controllers; + +namespace Kyoo.Common.Models.Attributes +{ + /// + /// An attribute to inform how a works. + /// + [MetadataAttribute] + [AttributeUsage(AttributeTargets.Class)] + public class FileSystemMetadataAttribute : Attribute + { + /// + /// The scheme(s) used to identify this path. + /// It can be something like http, https, ftp, file and so on. + /// + /// + /// If multiples files with the same schemes exists, an exception will be thrown. + /// + public string[] Scheme { get; } + + /// + /// true if the scheme should be removed from the path before calling + /// methods of this , false otherwise. + /// + public bool StripScheme { get; set; } + + + /// + /// Create a new using the specified schemes. + /// + /// The schemes to use. + public FileSystemMetadataAttribute(string[] schemes) + { + Scheme = schemes; + } + + /// + /// Create a new using a dictionary of metadata. + /// + /// + /// The dictionary of metadata. This method expect the dictionary to contain a field + /// per property in this attribute, with the same types as the properties of this attribute. + /// + public FileSystemMetadataAttribute(IDictionary metadata) + { + Scheme = (string[])metadata[nameof(Scheme)]; + StripScheme = (bool)metadata[nameof(StripScheme)]; + } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Models/Attributes/InjectedAttribute.cs b/Kyoo.Common/Models/Attributes/InjectedAttribute.cs index b036acdf..d517300f 100644 --- a/Kyoo.Common/Models/Attributes/InjectedAttribute.cs +++ b/Kyoo.Common/Models/Attributes/InjectedAttribute.cs @@ -8,8 +8,8 @@ namespace Kyoo.Models.Attributes /// An attribute to inform that the service will be injected automatically by a service provider. /// /// - /// It should only be used on and will be injected before calling . - /// It can also be used on and it will be injected before calling . + /// It should only be used on and it will be injected before + /// calling . /// [AttributeUsage(AttributeTargets.Property)] [MeansImplicitUse(ImplicitUseKindFlags.Assign)] diff --git a/Kyoo.Common/Models/Attributes/TaskMetadataAttribute.cs b/Kyoo.Common/Models/Attributes/TaskMetadataAttribute.cs new file mode 100644 index 00000000..0ebe7a34 --- /dev/null +++ b/Kyoo.Common/Models/Attributes/TaskMetadataAttribute.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using Kyoo.Controllers; + +namespace Kyoo.Common.Models.Attributes +{ + /// + /// An attribute to inform how a works. + /// + [MetadataAttribute] + [AttributeUsage(AttributeTargets.Class)] + public class TaskMetadataAttribute : Attribute + { + /// + /// The slug of the task, used to start it. + /// + public string Slug { get; } + + /// + /// The name of the task that will be displayed to the user. + /// + public string Name { get; } + + /// + /// A quick description of what this task will do. + /// + public string Description { get; } + + /// + /// Should this task be automatically run at app startup? + /// + public bool RunOnStartup { get; set; } + + /// + /// The priority of this task. Only used if is true. + /// It allow one to specify witch task will be started first as tasked are run on a Priority's descending order. + /// + public int Priority { get; set; } + + /// + /// true if this task should not be displayed to the user, false otherwise. + /// + public bool IsHidden { get; set; } + + + /// + /// Create a new with the given slug, name and description. + /// + /// The slug of the task, used to start it. + /// The name of the task that will be displayed to the user. + /// A quick description of what this task will do. + public TaskMetadataAttribute(string slug, string name, string description) + { + Slug = slug; + Name = name; + Description = description; + } + + /// + /// Create a new using a dictionary of metadata. + /// + /// + /// The dictionary of metadata. This method expect the dictionary to contain a field + /// per property in this attribute, with the same types as the properties of this attribute. + /// + public TaskMetadataAttribute(IDictionary metadata) + { + Slug = (string)metadata[nameof(Slug)]; + Name = (string)metadata[nameof(Name)]; + Description = (string)metadata[nameof(Description)]; + RunOnStartup = (bool)metadata[nameof(RunOnStartup)]; + Priority = (int)metadata[nameof(Priority)]; + IsHidden = (bool)metadata[nameof(IsHidden)]; + } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Models/Exceptions/IdentificationFailedException.cs b/Kyoo.Common/Models/Exceptions/IdentificationFailedException.cs new file mode 100644 index 00000000..7c820f5a --- /dev/null +++ b/Kyoo.Common/Models/Exceptions/IdentificationFailedException.cs @@ -0,0 +1,37 @@ +using System; +using System.Runtime.Serialization; +using Kyoo.Controllers; + +namespace Kyoo.Models.Exceptions +{ + /// + /// An exception raised when an failed. + /// + [Serializable] + public class IdentificationFailedException : Exception + { + /// + /// Create a new with a default message. + /// + public IdentificationFailedException() + : base("An identification failed.") + {} + + /// + /// Create a new with a custom message. + /// + /// The message to use. + public IdentificationFailedException(string message) + : base(message) + {} + + /// + /// The serialization constructor + /// + /// Serialization infos + /// The serialization context + protected IdentificationFailedException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Models/Exceptions/TaskFailedException.cs b/Kyoo.Common/Models/Exceptions/TaskFailedException.cs new file mode 100644 index 00000000..5fe65f7c --- /dev/null +++ b/Kyoo.Common/Models/Exceptions/TaskFailedException.cs @@ -0,0 +1,45 @@ +using System; +using System.Runtime.Serialization; +using Kyoo.Controllers; + +namespace Kyoo.Models.Exceptions +{ + /// + /// An exception raised when an failed. + /// + [Serializable] + public class TaskFailedException : AggregateException + { + /// + /// Create a new with a default message. + /// + public TaskFailedException() + : base("A task failed.") + {} + + /// + /// Create a new with a custom message. + /// + /// The message to use. + public TaskFailedException(string message) + : base(message) + {} + + /// + /// Create a new wrapping another exception. + /// + /// The exception to wrap. + public TaskFailedException(Exception exception) + : base(exception) + {} + + /// + /// The serialization constructor + /// + /// Serialization infos + /// The serialization context + protected TaskFailedException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } + } +} \ No newline at end of file diff --git a/Kyoo.Common/Models/LibraryItem.cs b/Kyoo.Common/Models/LibraryItem.cs index 4df07770..6bc61c2e 100644 --- a/Kyoo.Common/Models/LibraryItem.cs +++ b/Kyoo.Common/Models/LibraryItem.cs @@ -58,7 +58,7 @@ namespace Kyoo.Models /// By default, the http path for this poster is returned from the public API. /// This can be disabled using the internal query flag. /// - [SerializeAs("{HOST}/api/{Type}/{Slug}/poster")] public string Poster { get; set; } + [SerializeAs("{HOST}/api/{Type:l}/{Slug}/poster")] public string Poster { get; set; } /// /// The type of this item (ether a collection, a show or a movie). diff --git a/Kyoo.Common/Models/Link.cs b/Kyoo.Common/Models/Link.cs index 6d815af9..09c519da 100644 --- a/Kyoo.Common/Models/Link.cs +++ b/Kyoo.Common/Models/Link.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using Kyoo.Models.Attributes; namespace Kyoo.Models { @@ -107,12 +108,12 @@ namespace Kyoo.Models /// /// A reference of the first resource. /// - public T1 First { get; set; } + [SerializeIgnore] public T1 First { get; set; } /// /// A reference to the second resource. /// - public T2 Second { get; set; } + [SerializeIgnore] public T2 Second { get; set; } /// diff --git a/Kyoo.Common/Models/MetadataID.cs b/Kyoo.Common/Models/MetadataID.cs index 34872314..de6ac817 100644 --- a/Kyoo.Common/Models/MetadataID.cs +++ b/Kyoo.Common/Models/MetadataID.cs @@ -19,6 +19,12 @@ namespace Kyoo.Models /// The URL of the resource on the external provider. /// public string Link { get; set; } + + /// + /// A shortcut to access the provider of this metadata. + /// Unlike the property, this is serializable. + /// + public Provider Provider => Second; /// /// The expression to retrieve the unique ID of a MetadataID. This is an aggregate of the two resources IDs. diff --git a/Kyoo.Common/Models/Resources/Episode.cs b/Kyoo.Common/Models/Resources/Episode.cs index cdb7fb8f..7f76a8a4 100644 --- a/Kyoo.Common/Models/Resources/Episode.cs +++ b/Kyoo.Common/Models/Resources/Episode.cs @@ -20,9 +20,11 @@ namespace Kyoo.Models { get { - if (ShowSlug == null && Show == null) - return GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); - return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); + if (ShowSlug != null || Show != null) + return GetSlug(ShowSlug ?? Show.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); + return ShowID != 0 + ? GetSlug(ShowID.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber) + : null; } [UsedImplicitly] [NotNull] private set { @@ -93,7 +95,7 @@ namespace Kyoo.Models public int? AbsoluteNumber { get; set; } /// - /// The path of the video file for this episode. Any format supported by a is allowed. + /// The path of the video file for this episode. Any format supported by a is allowed. /// [SerializeIgnore] public string Path { get; set; } diff --git a/Kyoo.Common/Models/Resources/Show.cs b/Kyoo.Common/Models/Resources/Show.cs index ffb2ae49..57c9fcef 100644 --- a/Kyoo.Common/Models/Resources/Show.cs +++ b/Kyoo.Common/Models/Resources/Show.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using Kyoo.Common.Models.Attributes; using Kyoo.Controllers; using Kyoo.Models.Attributes; @@ -30,7 +31,7 @@ namespace Kyoo.Models /// /// The path of the root directory of this show. - /// This can be any kind of path supported by + /// This can be any kind of path supported by /// [SerializeIgnore] public string Path { get; set; } @@ -42,10 +43,10 @@ namespace Kyoo.Models /// /// Is this show airing, not aired yet or finished? /// - public Status? Status { get; set; } + public Status Status { get; set; } /// - /// An URL to a trailer. This could be any path supported by the . + /// An URL to a trailer. This could be any path supported by the . /// /// TODO for now, this is set to a youtube url. It should be cached and converted to a local file. public string TrailerUrl { get; set; } @@ -157,6 +158,7 @@ namespace Kyoo.Models /// This method will never return anything if the are not loaded. /// The slug of the provider /// The field of the asked provider. + [CanBeNull] public string GetID(string provider) { return ExternalIDs?.FirstOrDefault(x => x.Second.Slug == provider)?.DataID; @@ -183,5 +185,5 @@ namespace Kyoo.Models /// /// The enum containing show's status. /// - public enum Status { Finished, Airing, Planned, Unknown } + public enum Status { Unknown, Finished, Airing, Planned } } diff --git a/Kyoo.Common/Models/Resources/Track.cs b/Kyoo.Common/Models/Resources/Track.cs index f093699b..d82cdbe1 100644 --- a/Kyoo.Common/Models/Resources/Track.cs +++ b/Kyoo.Common/Models/Resources/Track.cs @@ -35,8 +35,8 @@ namespace Kyoo.Models { string type = Type.ToString().ToLower(); string index = TrackIndex != 0 ? $"-{TrackIndex}" : string.Empty; - string episode = EpisodeSlug ?? Episode.Slug ?? EpisodeID.ToString(); - return $"{episode}.{Language}{index}{(IsForced ? ".forced" : "")}.{type}"; + string episode = EpisodeSlug ?? Episode?.Slug ?? EpisodeID.ToString(); + return $"{episode}.{Language ?? "und"}{index}{(IsForced ? ".forced" : "")}.{type}"; } [UsedImplicitly] private set { @@ -47,11 +47,13 @@ namespace Kyoo.Models if (!match.Success) throw new ArgumentException("Invalid track slug. " + - "Format: {episodeSlug}.{language}[-{index}][-forced].{type}[.{extension}]"); + "Format: {episodeSlug}.{language}[-{index}][.forced].{type}[.{extension}]"); EpisodeSlug = match.Groups["ep"].Value; Language = match.Groups["lang"].Value; - TrackIndex = int.Parse(match.Groups["index"].Value); + if (Language == "und") + Language = null; + TrackIndex = match.Groups["index"].Success ? int.Parse(match.Groups["index"].Value) : 0; IsForced = match.Groups["forced"].Success; Type = Enum.Parse(match.Groups["type"].Value, true); } @@ -154,30 +156,17 @@ namespace Kyoo.Models } /// - /// Utility method to edit a track slug (this only return a slug with the modification, nothing is stored) + /// Utility method to create a track slug from a incomplete slug (only add the type of the track). /// /// The slug to edit /// The new type of this - /// - /// - /// /// - public static string EditSlug(string baseSlug, - StreamType type = StreamType.Unknown, - string language = null, - int? index = null, - bool? forced = null) + public static string BuildSlug(string baseSlug, + StreamType type) { - Track track = new() {Slug = baseSlug}; - if (type != StreamType.Unknown) - track.Type = type; - if (language != null) - track.Language = language; - if (index != null) - track.TrackIndex = index.Value; - if (forced != null) - track.IsForced = forced.Value; - return track.Slug; + return baseSlug.EndsWith($".{type}", StringComparison.InvariantCultureIgnoreCase) + ? baseSlug + : $"{baseSlug}.{type.ToString().ToLowerInvariant()}"; } } } diff --git a/Kyoo.Common/Models/WatchItem.cs b/Kyoo.Common/Models/WatchItem.cs index 3b64d54e..158563a8 100644 --- a/Kyoo.Common/Models/WatchItem.cs +++ b/Kyoo.Common/Models/WatchItem.cs @@ -62,7 +62,7 @@ namespace Kyoo.Models public DateTime? ReleaseDate { get; set; } /// - /// The path of the video file for this episode. Any format supported by a is allowed. + /// The path of the video file for this episode. Any format supported by a is allowed. /// [SerializeIgnore] public string Path { get; set; } @@ -176,13 +176,16 @@ namespace Kyoo.Models return new WatchItem { EpisodeID = ep.ID, + Slug = ep.Slug, ShowSlug = ep.Show.Slug, + ShowTitle = ep.Show.Title, SeasonNumber = ep.SeasonNumber, EpisodeNumber = ep.EpisodeNumber, AbsoluteNumber = ep.AbsoluteNumber, Title = ep.Title, ReleaseDate = ep.ReleaseDate, Path = ep.Path, + Container = PathIO.GetExtension(ep.Path)![1..], Video = ep.Tracks.FirstOrDefault(x => x.Type == StreamType.Video), Audios = ep.Tracks.Where(x => x.Type == StreamType.Audio).ToArray(), Subtitles = ep.Tracks.Where(x => x.Type == StreamType.Subtitle).ToArray(), diff --git a/Kyoo.Common/Module.cs b/Kyoo.Common/Module.cs index a8a81b88..7fb9bdbc 100644 --- a/Kyoo.Common/Module.cs +++ b/Kyoo.Common/Module.cs @@ -1,9 +1,7 @@ -using System; -using System.Linq; +using Autofac; +using Autofac.Builder; using Kyoo.Controllers; -using Kyoo.Models; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; namespace Kyoo { @@ -15,86 +13,63 @@ namespace Kyoo /// /// Register a new task to the container. /// - /// The container + /// The container /// The type of the task - /// The initial container. - public static IServiceCollection AddTask(this IServiceCollection services) + /// The registration builder of this new task. That can be used to edit the registration. + public static IRegistrationBuilder + RegisterTask(this ContainerBuilder builder) where T : class, ITask { - services.AddSingleton(); - return services; + return builder.RegisterType().As(); + } + + /// + /// Register a new metadata provider to the container. + /// + /// The container + /// The type of the task + /// The registration builder of this new provider. That can be used to edit the registration. + public static IRegistrationBuilder + RegisterProvider(this ContainerBuilder builder) + where T : class, IMetadataProvider + { + return builder.RegisterType().As().InstancePerLifetimeScope(); } /// /// Register a new repository to the container. /// - /// The container - /// The lifetime of the repository. The default is scoped. + /// The container /// The type of the repository. /// - /// If your repository implements a special interface, please use + /// If your repository implements a special interface, please use /// /// The initial container. - public static IServiceCollection AddRepository(this IServiceCollection services, - ServiceLifetime lifetime = ServiceLifetime.Scoped) + public static IRegistrationBuilder + RegisterRepository(this ContainerBuilder builder) where T : IBaseRepository { - Type repository = Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>)); - - if (repository != null) - services.Add(ServiceDescriptor.Describe(repository, typeof(T), lifetime)); - services.Add(ServiceDescriptor.Describe(typeof(IBaseRepository), typeof(T), lifetime)); - return services; + return builder.RegisterType() + .As() + .As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))) + .InstancePerLifetimeScope(); } /// /// Register a new repository with a custom mapping to the container. /// - /// - /// The lifetime of the repository. The default is scoped. + /// The container /// The custom mapping you have for your repository. /// The type of the repository. /// - /// If your repository does not implements a special interface, please use + /// If your repository does not implements a special interface, please use /// /// The initial container. - public static IServiceCollection AddRepository(this IServiceCollection services, - ServiceLifetime lifetime = ServiceLifetime.Scoped) + public static IRegistrationBuilder + RegisterRepository(this ContainerBuilder builder) where T2 : IBaseRepository, T { - services.Add(ServiceDescriptor.Describe(typeof(T), typeof(T2), lifetime)); - return services.AddRepository(lifetime); - } - - /// - /// Add an editable configuration to the editable configuration list - /// - /// The service collection to edit - /// The root path of the editable configuration. It should not be a nested type. - /// The type of the configuration - /// The given service collection is returned. - public static IServiceCollection AddConfiguration(this IServiceCollection services, string path) - where T : class - { - if (services.Any(x => x.ServiceType == typeof(T))) - return services; - foreach (ConfigurationReference confRef in ConfigurationReference.CreateReference(path)) - services.AddSingleton(confRef); - return services; - } - - /// - /// Add an editable configuration to the editable configuration list. - /// WARNING: this method allow you to add an unmanaged type. This type won't be editable. This can be used - /// for external libraries or variable arguments. - /// - /// The service collection to edit - /// The root path of the editable configuration. It should not be a nested type. - /// The given service collection is returned. - public static IServiceCollection AddUntypedConfiguration(this IServiceCollection services, string path) - { - services.AddSingleton(ConfigurationReference.CreateUntyped(path)); - return services; + return builder.RegisterRepository().As(); } /// diff --git a/Kyoo.Common/Utility/Merger.cs b/Kyoo.Common/Utility/Merger.cs index 55cc17e3..cd860ea3 100644 --- a/Kyoo.Common/Utility/Merger.cs +++ b/Kyoo.Common/Utility/Merger.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Kyoo.Models; using Kyoo.Models.Attributes; namespace Kyoo @@ -111,7 +112,8 @@ 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 /// - public static T Merge(T first, T second) + [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] + public static T Merge([CanBeNull] T first, [CanBeNull] T second) { if (first == null) return second; @@ -127,9 +129,7 @@ namespace Kyoo { object oldValue = property.GetValue(first); object newValue = property.GetValue(second); - object defaultValue = property.PropertyType.IsValueType - ? Activator.CreateInstance(property.PropertyType) - : null; + object defaultValue = property.PropertyType.GetClrDefault(); if (oldValue?.Equals(defaultValue) != false) property.SetValue(first, newValue); @@ -139,11 +139,14 @@ namespace Kyoo Type enumerableType = Utility.GetGenericDefinition(property.PropertyType, typeof(IEnumerable<>)) .GenericTypeArguments .First(); + Func equalityComparer = enumerableType.IsAssignableTo(typeof(IResource)) + ? (x, y) => x.Slug == y.Slug + : null; property.SetValue(first, Utility.RunGenericMethod( - typeof(Utility), + typeof(Merger), nameof(MergeLists), - enumerableType, - oldValue, newValue, null)); + enumerableType, + oldValue, newValue, equalityComparer)); } } diff --git a/Kyoo.Common/Utility/Utility.cs b/Kyoo.Common/Utility/Utility.cs index f7041c51..2f7e14ad 100644 --- a/Kyoo.Common/Utility/Utility.cs +++ b/Kyoo.Common/Utility/Utility.cs @@ -72,7 +72,7 @@ namespace Kyoo /// /// The string to slugify /// The slug version of the given string - public static string ToSlug(string str) + public static string ToSlug([CanBeNull] string str) { if (str == null) return null; @@ -182,15 +182,49 @@ namespace Kyoo return types.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); } - public static MethodInfo GetMethod(Type type, BindingFlags flag, string name, Type[] generics, object[] args) + /// + /// Retrieve a method from an with the given name and respect the + /// amount of parameters and generic parameters. This works for polymorphic methods. + /// + /// + /// The type owning the method. For non static methods, this is the this. + /// + /// + /// The binding flags of the method. This allow you to specify public/private and so on. + /// + /// + /// The name of the method. + /// + /// + /// The list of generic parameters. + /// + /// + /// The list of parameters. + /// + /// No method match the given constraints. + /// The method handle of the matching method. + [PublicAPI] + [NotNull] + public static MethodInfo GetMethod([NotNull] Type type, + BindingFlags flag, + string name, + [NotNull] Type[] generics, + [NotNull] object[] args) { + if (type == null) + throw new ArgumentNullException(nameof(type)); + if (generics == null) + throw new ArgumentNullException(nameof(generics)); + if (args == null) + throw new ArgumentNullException(nameof(args)); + MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public) .Where(x => x.Name == name) .Where(x => x.GetGenericArguments().Length == generics.Length) .Where(x => x.GetParameters().Length == args.Length) - .IfEmpty(() => throw new NullReferenceException($"A method named {name} with " + - $"{args.Length} arguments and {generics.Length} generic " + - $"types could not be found on {type.Name}.")) + .IfEmpty(() => throw new ArgumentException($"A method named {name} with " + + $"{args.Length} arguments and {generics.Length} generic " + + $"types could not be found on {type.Name}.")) // TODO this won't work but I don't know why. // .Where(x => // { @@ -211,9 +245,34 @@ namespace Kyoo if (methods.Length == 1) return methods[0]; - throw new NullReferenceException($"Multiple methods named {name} match the generics and parameters constraints."); + throw new ArgumentException($"Multiple methods named {name} match the generics and parameters constraints."); } + /// + /// Run a generic static method for a runtime . + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The generic type to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] Type owner, [NotNull] string methodName, @@ -223,6 +282,34 @@ namespace Kyoo return RunGenericMethod(owner, methodName, new[] {type}, args); } + /// + /// Run a generic static method for a multiple runtime . + /// If your generic method only needs one type, see + /// + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The list of generic types to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// + [PublicAPI] public static T RunGenericMethod( [NotNull] Type owner, [NotNull] string methodName, @@ -238,9 +325,34 @@ namespace Kyoo if (types.Length < 1) throw new ArgumentException($"The {nameof(types)} array is empty. At least one type is needed."); MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args); - return (T)method.MakeGenericMethod(types).Invoke(null, args?.ToArray()); + return (T)method.MakeGenericMethod(types).Invoke(null, args.ToArray()); } + /// + /// Run a generic method for a runtime . + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The this of the method to run. + /// The name of the method. You should use the nameof keyword. + /// The generic type to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] object instance, [NotNull] string methodName, @@ -250,6 +362,33 @@ namespace Kyoo return RunGenericMethod(instance, methodName, new[] {type}, args); } + /// + /// Run a generic method for a multiple runtime . + /// If your generic method only needs one type, see + /// + /// + /// + /// To run for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The this of the method to run. + /// The name of the method. You should use the nameof keyword. + /// The list of generic types to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + /// public static T RunGenericMethod( [NotNull] object instance, [NotNull] string methodName, @@ -263,7 +402,7 @@ namespace Kyoo if (types == null || types.Length == 0) throw new ArgumentNullException(nameof(types)); MethodInfo method = GetMethod(instance.GetType(), BindingFlags.Instance, methodName, types, args); - return (T)method.MakeGenericMethod(types).Invoke(instance, args?.ToArray()); + return (T)method.MakeGenericMethod(types).Invoke(instance, args.ToArray()); } public static string ToQueryString(this Dictionary query) diff --git a/Kyoo.CommonAPI/CrudApi.cs b/Kyoo.CommonAPI/CrudApi.cs index 336d3226..a6834c31 100644 --- a/Kyoo.CommonAPI/CrudApi.cs +++ b/Kyoo.CommonAPI/CrudApi.cs @@ -38,7 +38,7 @@ namespace Kyoo.CommonApi [PartialPermission(Kind.Read)] public virtual async Task> Get(string slug) { - T ret = await _repository.Get(slug); + T ret = await _repository.GetOrDefault(slug); if (ret == null) return NotFound(); return ret; diff --git a/Kyoo.CommonAPI/JsonSerializer.cs b/Kyoo.CommonAPI/JsonSerializer.cs index bf65a32a..3a2caac7 100644 --- a/Kyoo.CommonAPI/JsonSerializer.cs +++ b/Kyoo.CommonAPI/JsonSerializer.cs @@ -115,9 +115,10 @@ namespace Kyoo.Controllers public object GetValue(object target) { - return Regex.Replace(_format, @"(? + return Regex.Replace(_format, @"(? { string value = x.Groups[1].Value; + string modifier = x.Groups[3].Value; if (value == "HOST") return _host; @@ -127,9 +128,22 @@ namespace Kyoo.Controllers .FirstOrDefault(y => y.Name == value); if (properties == null) return null; - if (properties.GetValue(target) is string ret) - return ret; - throw new ArgumentException($"Invalid serializer replacement {value}"); + object objValue = properties.GetValue(target); + if (objValue is not string ret) + ret = objValue?.ToString(); + if (ret == null) + throw new ArgumentException($"Invalid serializer replacement {value}"); + + foreach (char modification in modifier) + { + ret = modification switch + { + 'l' => ret.ToLowerInvariant(), + 'u' => ret.ToUpperInvariant(), + _ => throw new ArgumentException($"Invalid serializer modificator {modification}.") + }; + } + return ret; }); } diff --git a/Kyoo.CommonAPI/LocalRepository.cs b/Kyoo.CommonAPI/LocalRepository.cs index 8ab23443..0187e0dd 100644 --- a/Kyoo.CommonAPI/LocalRepository.cs +++ b/Kyoo.CommonAPI/LocalRepository.cs @@ -208,7 +208,7 @@ namespace Kyoo.Controllers } catch (DuplicatedItemException) { - return await GetOrDefault(obj.Slug); + return await Get(obj.Slug); } } diff --git a/Kyoo.CommonAPI/ResourceViewAttribute.cs b/Kyoo.CommonAPI/ResourceViewAttribute.cs index 06198663..487373c4 100644 --- a/Kyoo.CommonAPI/ResourceViewAttribute.cs +++ b/Kyoo.CommonAPI/ResourceViewAttribute.cs @@ -43,22 +43,31 @@ namespace Kyoo.CommonApi PropertyInfo[] properties = type.GetProperties() .Where(x => x.GetCustomAttribute() != null) .ToArray(); - fields = fields.Select(x => - { - string property = properties - .FirstOrDefault(y => string.Equals(x, y.Name, StringComparison.InvariantCultureIgnoreCase)) - ?.Name; - if (property != null) - return property; - context.Result = new BadRequestObjectResult(new + if (fields.Count == 1 && fields.Contains("all")) + { + fields = properties.Select(x => x.Name).ToList(); + } + else + { + fields = fields + .Select(x => { - Error = $"{x} does not exist on {type.Name}." - }); - return null; - }) - .ToList(); - if (context.Result != null) - return; + string property = properties + .FirstOrDefault(y + => string.Equals(x, y.Name, StringComparison.InvariantCultureIgnoreCase)) + ?.Name; + if (property != null) + return property; + context.Result = new BadRequestObjectResult(new + { + Error = $"{x} does not exist on {type.Name}." + }); + return null; + }) + .ToList(); + if (context.Result != null) + return; + } } context.HttpContext.Items["fields"] = fields; base.OnActionExecuting(context); diff --git a/Kyoo.Postgresql/Migrations/20210627141933_Initial.Designer.cs b/Kyoo.Postgresql/Migrations/20210723224326_Initial.Designer.cs similarity index 95% rename from Kyoo.Postgresql/Migrations/20210627141933_Initial.Designer.cs rename to Kyoo.Postgresql/Migrations/20210723224326_Initial.Designer.cs index f1733a77..3016a040 100644 --- a/Kyoo.Postgresql/Migrations/20210627141933_Initial.Designer.cs +++ b/Kyoo.Postgresql/Migrations/20210723224326_Initial.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Kyoo.Postgresql.Migrations { [DbContext(typeof(PostgresContext))] - [Migration("20210627141933_Initial")] + [Migration("20210723224326_Initial")] partial class Initial { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -20,10 +20,10 @@ namespace Kyoo.Postgresql.Migrations #pragma warning disable 612, 618 modelBuilder .HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" }) - .HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" }) + .HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" }) .HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" }) .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("ProductVersion", "5.0.8") .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); modelBuilder.Entity("Kyoo.Models.Collection", b => @@ -189,6 +189,51 @@ namespace Kyoo.Postgresql.Migrations b.ToTable("libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("EndAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_air"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Poster") + .HasColumnType("text") + .HasColumnName("poster"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("item_type") + .HasColumnName("type"); + + b.HasKey("ID") + .HasName("pk_library_items"); + + b.ToView("library_items"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -621,7 +666,7 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("timestamp without time zone") .HasColumnName("start_air"); - b.Property("Status") + b.Property("Status") .HasColumnType("status") .HasColumnName("status"); @@ -1078,7 +1123,8 @@ namespace Kyoo.Postgresql.Migrations b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") .HasForeignKey("StudioID") - .HasConstraintName("fk_shows_studios_studio_id"); + .HasConstraintName("fk_shows_studios_studio_id") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.Postgresql/Migrations/20210627141933_Initial.cs b/Kyoo.Postgresql/Migrations/20210723224326_Initial.cs similarity index 99% rename from Kyoo.Postgresql/Migrations/20210627141933_Initial.cs rename to Kyoo.Postgresql/Migrations/20210723224326_Initial.cs index 29b51490..2ba22c6a 100644 --- a/Kyoo.Postgresql/Migrations/20210627141933_Initial.cs +++ b/Kyoo.Postgresql/Migrations/20210723224326_Initial.cs @@ -12,7 +12,7 @@ namespace Kyoo.Postgresql.Migrations { migrationBuilder.AlterDatabase() .Annotation("Npgsql:Enum:item_type", "show,movie,collection") - .Annotation("Npgsql:Enum:status", "finished,airing,planned,unknown") + .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") .Annotation("Npgsql:Enum:stream_type", "unknown,video,audio,subtitle,attachment"); migrationBuilder.CreateTable( @@ -208,7 +208,7 @@ namespace Kyoo.Postgresql.Migrations aliases = table.Column(type: "text[]", nullable: true), path = table.Column(type: "text", nullable: true), overview = table.Column(type: "text", nullable: true), - status = table.Column(type: "status", nullable: true), + status = table.Column(type: "status", nullable: false), trailer_url = table.Column(type: "text", nullable: true), start_air = table.Column(type: "timestamp without time zone", nullable: true), end_air = table.Column(type: "timestamp without time zone", nullable: true), diff --git a/Kyoo.Postgresql/Migrations/20210627141941_Triggers.Designer.cs b/Kyoo.Postgresql/Migrations/20210723224335_Triggers.Designer.cs similarity index 95% rename from Kyoo.Postgresql/Migrations/20210627141941_Triggers.Designer.cs rename to Kyoo.Postgresql/Migrations/20210723224335_Triggers.Designer.cs index fc019baf..d377a09f 100644 --- a/Kyoo.Postgresql/Migrations/20210627141941_Triggers.Designer.cs +++ b/Kyoo.Postgresql/Migrations/20210723224335_Triggers.Designer.cs @@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace Kyoo.Postgresql.Migrations { [DbContext(typeof(PostgresContext))] - [Migration("20210627141941_Triggers")] + [Migration("20210723224335_Triggers")] partial class Triggers { protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -20,10 +20,10 @@ namespace Kyoo.Postgresql.Migrations #pragma warning disable 612, 618 modelBuilder .HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" }) - .HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" }) + .HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" }) .HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" }) .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("ProductVersion", "5.0.8") .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); modelBuilder.Entity("Kyoo.Models.Collection", b => @@ -189,6 +189,51 @@ namespace Kyoo.Postgresql.Migrations b.ToTable("libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("EndAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_air"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Poster") + .HasColumnType("text") + .HasColumnName("poster"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("item_type") + .HasColumnName("type"); + + b.HasKey("ID") + .HasName("pk_library_items"); + + b.ToView("library_items"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -621,7 +666,7 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("timestamp without time zone") .HasColumnName("start_air"); - b.Property("Status") + b.Property("Status") .HasColumnType("status") .HasColumnName("status"); @@ -1078,7 +1123,8 @@ namespace Kyoo.Postgresql.Migrations b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") .HasForeignKey("StudioID") - .HasConstraintName("fk_shows_studios_studio_id"); + .HasConstraintName("fk_shows_studios_studio_id") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.Postgresql/Migrations/20210627141941_Triggers.cs b/Kyoo.Postgresql/Migrations/20210723224335_Triggers.cs similarity index 98% rename from Kyoo.Postgresql/Migrations/20210627141941_Triggers.cs rename to Kyoo.Postgresql/Migrations/20210723224335_Triggers.cs index 16569748..a773e02b 100644 --- a/Kyoo.Postgresql/Migrations/20210627141941_Triggers.cs +++ b/Kyoo.Postgresql/Migrations/20210723224335_Triggers.cs @@ -91,7 +91,7 @@ namespace Kyoo.Postgresql.Migrations END, CASE (is_forced) WHEN false THEN '' - ELSE '-forced' + ELSE '.forced' END, '.', type ) WHERE episode_id = NEW.id; @@ -117,14 +117,14 @@ namespace Kyoo.Postgresql.Migrations END IF; NEW.slug := CONCAT( (SELECT slug FROM episodes WHERE id = NEW.episode_id), - '.', NEW.language, + '.', COALESCE(NEW.language, 'und'), CASE (NEW.track_index) WHEN 0 THEN '' ELSE CONCAT('-', NEW.track_index) END, CASE (NEW.is_forced) WHEN false THEN '' - ELSE '-forced' + ELSE '.forced' END, '.', NEW.type ); diff --git a/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs b/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs index e5044c60..f2d55f24 100644 --- a/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs +++ b/Kyoo.Postgresql/Migrations/PostgresContextModelSnapshot.cs @@ -18,10 +18,10 @@ namespace Kyoo.Postgresql.Migrations #pragma warning disable 612, 618 modelBuilder .HasPostgresEnum(null, "item_type", new[] { "show", "movie", "collection" }) - .HasPostgresEnum(null, "status", new[] { "finished", "airing", "planned", "unknown" }) + .HasPostgresEnum(null, "status", new[] { "unknown", "finished", "airing", "planned" }) .HasPostgresEnum(null, "stream_type", new[] { "unknown", "video", "audio", "subtitle", "attachment" }) .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.7") + .HasAnnotation("ProductVersion", "5.0.8") .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); modelBuilder.Entity("Kyoo.Models.Collection", b => @@ -187,6 +187,51 @@ namespace Kyoo.Postgresql.Migrations b.ToTable("libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .HasColumnType("integer") + .HasColumnName("id") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("EndAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("end_air"); + + b.Property("Overview") + .HasColumnType("text") + .HasColumnName("overview"); + + b.Property("Poster") + .HasColumnType("text") + .HasColumnName("poster"); + + b.Property("Slug") + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("StartAir") + .HasColumnType("timestamp without time zone") + .HasColumnName("start_air"); + + b.Property("Status") + .HasColumnType("status") + .HasColumnName("status"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("item_type") + .HasColumnName("type"); + + b.HasKey("ID") + .HasName("pk_library_items"); + + b.ToView("library_items"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -619,7 +664,7 @@ namespace Kyoo.Postgresql.Migrations .HasColumnType("timestamp without time zone") .HasColumnName("start_air"); - b.Property("Status") + b.Property("Status") .HasColumnType("status") .HasColumnName("status"); @@ -1076,7 +1121,8 @@ namespace Kyoo.Postgresql.Migrations b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") .HasForeignKey("StudioID") - .HasConstraintName("fk_shows_studios_studio_id"); + .HasConstraintName("fk_shows_studios_studio_id") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.Postgresql/PostgresModule.cs b/Kyoo.Postgresql/PostgresModule.cs index f0c8f23c..124df770 100644 --- a/Kyoo.Postgresql/PostgresModule.cs +++ b/Kyoo.Postgresql/PostgresModule.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Npgsql; namespace Kyoo.Postgresql { @@ -73,6 +74,10 @@ namespace Kyoo.Postgresql { DatabaseContext context = provider.GetRequiredService(); context.Database.Migrate(); + + using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection(); + conn.Open(); + conn.ReloadTypes(); } } } \ No newline at end of file diff --git a/Kyoo.SqLite/Migrations/20210626141337_Initial.Designer.cs b/Kyoo.SqLite/Migrations/20210723224542_Initial.Designer.cs similarity index 96% rename from Kyoo.SqLite/Migrations/20210626141337_Initial.Designer.cs rename to Kyoo.SqLite/Migrations/20210723224542_Initial.Designer.cs index 1337ce6b..eca501eb 100644 --- a/Kyoo.SqLite/Migrations/20210626141337_Initial.Designer.cs +++ b/Kyoo.SqLite/Migrations/20210723224542_Initial.Designer.cs @@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Kyoo.SqLite.Migrations { [DbContext(typeof(SqLiteContext))] - [Migration("20210626141337_Initial")] + [Migration("20210723224542_Initial")] partial class Initial { protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.7"); + .HasAnnotation("ProductVersion", "5.0.8"); modelBuilder.Entity("Kyoo.Models.Collection", b => { @@ -143,6 +143,41 @@ namespace Kyoo.SqLite.Migrations b.ToTable("Libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndAir") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("Slug") + .HasColumnType("TEXT"); + + b.Property("StartAir") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("ID"); + + b.ToView("LibraryItems"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -477,7 +512,7 @@ namespace Kyoo.SqLite.Migrations b.Property("StartAir") .HasColumnType("TEXT"); - b.Property("Status") + b.Property("Status") .HasColumnType("INTEGER"); b.Property("StudioID") @@ -864,7 +899,8 @@ namespace Kyoo.SqLite.Migrations { b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") - .HasForeignKey("StudioID"); + .HasForeignKey("StudioID") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.SqLite/Migrations/20210626141337_Initial.cs b/Kyoo.SqLite/Migrations/20210723224542_Initial.cs similarity index 99% rename from Kyoo.SqLite/Migrations/20210626141337_Initial.cs rename to Kyoo.SqLite/Migrations/20210723224542_Initial.cs index 88823571..1fa93860 100644 --- a/Kyoo.SqLite/Migrations/20210626141337_Initial.cs +++ b/Kyoo.SqLite/Migrations/20210723224542_Initial.cs @@ -200,7 +200,7 @@ namespace Kyoo.SqLite.Migrations Aliases = table.Column(type: "TEXT", nullable: true), Path = table.Column(type: "TEXT", nullable: true), Overview = table.Column(type: "TEXT", nullable: true), - Status = table.Column(type: "INTEGER", nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), TrailerUrl = table.Column(type: "TEXT", nullable: true), StartAir = table.Column(type: "TEXT", nullable: true), EndAir = table.Column(type: "TEXT", nullable: true), diff --git a/Kyoo.SqLite/Migrations/20210626141347_Triggers.Designer.cs b/Kyoo.SqLite/Migrations/20210723224550_Triggers.Designer.cs similarity index 96% rename from Kyoo.SqLite/Migrations/20210626141347_Triggers.Designer.cs rename to Kyoo.SqLite/Migrations/20210723224550_Triggers.Designer.cs index 02045e3f..059a3aa4 100644 --- a/Kyoo.SqLite/Migrations/20210626141347_Triggers.Designer.cs +++ b/Kyoo.SqLite/Migrations/20210723224550_Triggers.Designer.cs @@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Kyoo.SqLite.Migrations { [DbContext(typeof(SqLiteContext))] - [Migration("20210626141347_Triggers")] + [Migration("20210723224550_Triggers")] partial class Triggers { protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.7"); + .HasAnnotation("ProductVersion", "5.0.8"); modelBuilder.Entity("Kyoo.Models.Collection", b => { @@ -143,6 +143,41 @@ namespace Kyoo.SqLite.Migrations b.ToTable("Libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndAir") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("Slug") + .HasColumnType("TEXT"); + + b.Property("StartAir") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("ID"); + + b.ToView("LibraryItems"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -477,7 +512,7 @@ namespace Kyoo.SqLite.Migrations b.Property("StartAir") .HasColumnType("TEXT"); - b.Property("Status") + b.Property("Status") .HasColumnType("INTEGER"); b.Property("StudioID") @@ -864,7 +899,8 @@ namespace Kyoo.SqLite.Migrations { b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") - .HasForeignKey("StudioID"); + .HasForeignKey("StudioID") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.SqLite/Migrations/20210626141347_Triggers.cs b/Kyoo.SqLite/Migrations/20210723224550_Triggers.cs similarity index 98% rename from Kyoo.SqLite/Migrations/20210626141347_Triggers.cs rename to Kyoo.SqLite/Migrations/20210723224550_Triggers.cs index f3ae8325..370fdd37 100644 --- a/Kyoo.SqLite/Migrations/20210626141347_Triggers.cs +++ b/Kyoo.SqLite/Migrations/20210723224550_Triggers.cs @@ -61,14 +61,14 @@ namespace Kyoo.SqLite.Migrations AND Language = new.Language AND IsForced = new.IsForced ) WHERE ID = new.ID AND TrackIndex = 0; UPDATE Tracks SET Slug = (SELECT Slug FROM Episodes WHERE ID = EpisodeID) || - '.' || Language || + '.' || COALESCE(Language, 'und') || CASE (TrackIndex) WHEN 0 THEN '' ELSE '-' || (TrackIndex) END || CASE (IsForced) WHEN false THEN '' - ELSE '-forced' + ELSE '.forced' END || CASE (Type) WHEN 1 THEN '.video' @@ -98,7 +98,7 @@ namespace Kyoo.SqLite.Migrations END || CASE (IsForced) WHEN false THEN '' - ELSE '-forced' + ELSE '.forced' END || CASE (Type) WHEN 1 THEN '.video' @@ -123,7 +123,7 @@ namespace Kyoo.SqLite.Migrations END || CASE (IsForced) WHEN false THEN '' - ELSE '-forced' + ELSE '.forced' END || CASE (Type) WHEN 1 THEN '.video' diff --git a/Kyoo.SqLite/Migrations/SqLiteContextModelSnapshot.cs b/Kyoo.SqLite/Migrations/SqLiteContextModelSnapshot.cs index 58ded130..12f5d94b 100644 --- a/Kyoo.SqLite/Migrations/SqLiteContextModelSnapshot.cs +++ b/Kyoo.SqLite/Migrations/SqLiteContextModelSnapshot.cs @@ -14,7 +14,7 @@ namespace Kyoo.SqLite.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "5.0.7"); + .HasAnnotation("ProductVersion", "5.0.8"); modelBuilder.Entity("Kyoo.Models.Collection", b => { @@ -141,6 +141,41 @@ namespace Kyoo.SqLite.Migrations b.ToTable("Libraries"); }); + modelBuilder.Entity("Kyoo.Models.LibraryItem", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndAir") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("Slug") + .HasColumnType("TEXT"); + + b.Property("StartAir") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("ID"); + + b.ToView("LibraryItems"); + }); + modelBuilder.Entity("Kyoo.Models.Link", b => { b.Property("FirstID") @@ -475,7 +510,7 @@ namespace Kyoo.SqLite.Migrations b.Property("StartAir") .HasColumnType("TEXT"); - b.Property("Status") + b.Property("Status") .HasColumnType("INTEGER"); b.Property("StudioID") @@ -862,7 +897,8 @@ namespace Kyoo.SqLite.Migrations { b.HasOne("Kyoo.Models.Studio", "Studio") .WithMany("Shows") - .HasForeignKey("StudioID"); + .HasForeignKey("StudioID") + .OnDelete(DeleteBehavior.SetNull); b.Navigation("Studio"); }); diff --git a/Kyoo.Tests/Library/RepositoryActivator.cs b/Kyoo.Tests/Database/RepositoryActivator.cs similarity index 100% rename from Kyoo.Tests/Library/RepositoryActivator.cs rename to Kyoo.Tests/Database/RepositoryActivator.cs diff --git a/Kyoo.Tests/Library/RepositoryTests.cs b/Kyoo.Tests/Database/RepositoryTests.cs similarity index 99% rename from Kyoo.Tests/Library/RepositoryTests.cs rename to Kyoo.Tests/Database/RepositoryTests.cs index 51db3061..4cc72b4c 100644 --- a/Kyoo.Tests/Library/RepositoryTests.cs +++ b/Kyoo.Tests/Database/RepositoryTests.cs @@ -25,6 +25,7 @@ namespace Kyoo.Tests public void Dispose() { Repositories.Dispose(); + GC.SuppressFinalize(this); } public ValueTask DisposeAsync() diff --git a/Kyoo.Tests/Library/SpecificTests/CollectionsTests.cs b/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/CollectionsTests.cs rename to Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs index 7a5976de..73691bf7 100644 --- a/Kyoo.Tests/Library/SpecificTests/CollectionsTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/CollectionsTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/EpisodeTests.cs b/Kyoo.Tests/Database/SpecificTests/EpisodeTests.cs similarity index 99% rename from Kyoo.Tests/Library/SpecificTests/EpisodeTests.cs rename to Kyoo.Tests/Database/SpecificTests/EpisodeTests.cs index 6b1adf27..d9e0e9ff 100644 --- a/Kyoo.Tests/Library/SpecificTests/EpisodeTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/EpisodeTests.cs @@ -4,7 +4,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/GenreTests.cs b/Kyoo.Tests/Database/SpecificTests/GenreTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/GenreTests.cs rename to Kyoo.Tests/Database/SpecificTests/GenreTests.cs index d79dba5e..dc820187 100644 --- a/Kyoo.Tests/Library/SpecificTests/GenreTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/GenreTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/LibraryItemTest.cs b/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs similarity index 98% rename from Kyoo.Tests/Library/SpecificTests/LibraryItemTest.cs rename to Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs index b2db4f66..c5639dbb 100644 --- a/Kyoo.Tests/Library/SpecificTests/LibraryItemTest.cs +++ b/Kyoo.Tests/Database/SpecificTests/LibraryItemTest.cs @@ -5,7 +5,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/LibraryTests.cs b/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs similarity index 53% rename from Kyoo.Tests/Library/SpecificTests/LibraryTests.cs rename to Kyoo.Tests/Database/SpecificTests/LibraryTests.cs index fbed1793..079f50cf 100644 --- a/Kyoo.Tests/Library/SpecificTests/LibraryTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/LibraryTests.cs @@ -1,8 +1,11 @@ +using System.Linq; +using System.Threading.Tasks; using Kyoo.Controllers; +using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { @@ -23,7 +26,7 @@ namespace Kyoo.Tests.Library } } - public abstract class ALibraryTests : RepositoryTests + public abstract class ALibraryTests : RepositoryTests { private readonly ILibraryRepository _repository; @@ -32,5 +35,17 @@ namespace Kyoo.Tests.Library { _repository = Repositories.LibraryManager.LibraryRepository; } + + [Fact] + public async Task CreateWithProvider() + { + Library library = TestSample.GetNew(); + library.Providers = new[] { TestSample.Get() }; + await _repository.Create(library); + Library retrieved = await _repository.Get(2); + await Repositories.LibraryManager.Load(retrieved, x => x.Providers); + Assert.Equal(1, retrieved.Providers.Count); + Assert.Equal(TestSample.Get().Slug, retrieved.Providers.First().Slug); + } } } \ No newline at end of file diff --git a/Kyoo.Tests/Library/SpecificTests/PeopleTests.cs b/Kyoo.Tests/Database/SpecificTests/PeopleTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/PeopleTests.cs rename to Kyoo.Tests/Database/SpecificTests/PeopleTests.cs index fc8b788d..23d40bfe 100644 --- a/Kyoo.Tests/Library/SpecificTests/PeopleTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/PeopleTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/ProviderTests.cs b/Kyoo.Tests/Database/SpecificTests/ProviderTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/ProviderTests.cs rename to Kyoo.Tests/Database/SpecificTests/ProviderTests.cs index 853e34a1..9c022875 100644 --- a/Kyoo.Tests/Library/SpecificTests/ProviderTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/ProviderTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/SanityTests.cs b/Kyoo.Tests/Database/SpecificTests/SanityTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/SanityTests.cs rename to Kyoo.Tests/Database/SpecificTests/SanityTests.cs index 78637d35..933bbf82 100644 --- a/Kyoo.Tests/Library/SpecificTests/SanityTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/SanityTests.cs @@ -5,7 +5,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { public class GlobalTests : IDisposable, IAsyncDisposable { diff --git a/Kyoo.Tests/Library/SpecificTests/SeasonTests.cs b/Kyoo.Tests/Database/SpecificTests/SeasonTests.cs similarity index 98% rename from Kyoo.Tests/Library/SpecificTests/SeasonTests.cs rename to Kyoo.Tests/Database/SpecificTests/SeasonTests.cs index 39be8b82..b1692747 100644 --- a/Kyoo.Tests/Library/SpecificTests/SeasonTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/SeasonTests.cs @@ -4,7 +4,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/ShowTests.cs b/Kyoo.Tests/Database/SpecificTests/ShowTests.cs similarity index 99% rename from Kyoo.Tests/Library/SpecificTests/ShowTests.cs rename to Kyoo.Tests/Database/SpecificTests/ShowTests.cs index 8940f0c3..63207710 100644 --- a/Kyoo.Tests/Library/SpecificTests/ShowTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/ShowTests.cs @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/StudioTests.cs b/Kyoo.Tests/Database/SpecificTests/StudioTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/StudioTests.cs rename to Kyoo.Tests/Database/SpecificTests/StudioTests.cs index f5093b19..c727f67a 100644 --- a/Kyoo.Tests/Library/SpecificTests/StudioTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/StudioTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/SpecificTests/TrackTests.cs b/Kyoo.Tests/Database/SpecificTests/TrackTests.cs similarity index 74% rename from Kyoo.Tests/Library/SpecificTests/TrackTests.cs rename to Kyoo.Tests/Database/SpecificTests/TrackTests.cs index 3c2e2043..0ff0c156 100644 --- a/Kyoo.Tests/Library/SpecificTests/TrackTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/TrackTests.cs @@ -4,7 +4,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { @@ -47,5 +47,20 @@ namespace Kyoo.Tests.Library Track track = await _repository.Get(1); Assert.Equal("new-slug-s1e1.eng-1.subtitle", track.Slug); } + + [Fact] + public async Task UndefinedLanguageSlugTest() + { + await _repository.Create(new Track + { + ID = 5, + TrackIndex = 0, + Type = StreamType.Video, + Language = null, + EpisodeID = TestSample.Get().ID + }); + Track track = await _repository.Get(5); + Assert.Equal("anohana-s1e1.und.video", track.Slug); + } } } \ No newline at end of file diff --git a/Kyoo.Tests/Library/SpecificTests/UserTests.cs b/Kyoo.Tests/Database/SpecificTests/UserTests.cs similarity index 96% rename from Kyoo.Tests/Library/SpecificTests/UserTests.cs rename to Kyoo.Tests/Database/SpecificTests/UserTests.cs index be67296d..24bfc789 100644 --- a/Kyoo.Tests/Library/SpecificTests/UserTests.cs +++ b/Kyoo.Tests/Database/SpecificTests/UserTests.cs @@ -3,7 +3,7 @@ using Kyoo.Models; using Xunit; using Xunit.Abstractions; -namespace Kyoo.Tests.Library +namespace Kyoo.Tests.Database { namespace SqLite { diff --git a/Kyoo.Tests/Library/TestContext.cs b/Kyoo.Tests/Database/TestContext.cs similarity index 100% rename from Kyoo.Tests/Library/TestContext.cs rename to Kyoo.Tests/Database/TestContext.cs diff --git a/Kyoo.Tests/Library/TestSample.cs b/Kyoo.Tests/Database/TestSample.cs similarity index 95% rename from Kyoo.Tests/Library/TestSample.cs rename to Kyoo.Tests/Database/TestSample.cs index adbe7d84..e0ff955f 100644 --- a/Kyoo.Tests/Library/TestSample.cs +++ b/Kyoo.Tests/Database/TestSample.cs @@ -9,8 +9,14 @@ namespace Kyoo.Tests private static readonly Dictionary> NewSamples = new() { { - typeof(Show), - () => new Show() + typeof(Library), + () => new Library + { + ID = 2, + Slug = "new-library", + Name = "New Library", + Paths = new [] {"/a/random/path"} + } } }; @@ -18,8 +24,8 @@ namespace Kyoo.Tests private static readonly Dictionary> Samples = new() { { - typeof(Models.Library), - () => new Models.Library + typeof(Library), + () => new Library { ID = 1, Slug = "deck", @@ -227,7 +233,7 @@ namespace Kyoo.Tests provider.ID = 0; context.Providers.Add(provider); - Models.Library library = Get(); + Library library = Get(); library.ID = 0; library.Collections = new List {collection}; library.Providers = new List {provider}; diff --git a/Kyoo.Tests/Identifier/IdentifierTests.cs b/Kyoo.Tests/Identifier/IdentifierTests.cs new file mode 100644 index 00000000..36ad94b9 --- /dev/null +++ b/Kyoo.Tests/Identifier/IdentifierTests.cs @@ -0,0 +1,192 @@ +using System.Threading.Tasks; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.Models.Exceptions; +using Kyoo.Models.Options; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Kyoo.Tests.Identifier +{ + public class Identifier + { + private readonly Mock _manager; + private readonly IIdentifier _identifier; + + public Identifier() + { + Mock> options = new(); + options.Setup(x => x.CurrentValue).Returns(new MediaOptions + { + Regex = new [] + { + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? S(?\\d+)E(?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? (?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))?\\..*$" + }, + SubtitleRegex = new[] + { + "^(?.+)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$" + } + }); + + _manager = new Mock(); + _identifier = new RegexIdentifier(options.Object, _manager.Object); + } + + + [Fact] + public async Task EpisodeIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo/Library/"}} + }); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify( + "/kyoo/Library/Collection/Show (2000)/Show S01E01.extension"); + Assert.Equal("Collection", collection.Name); + Assert.Equal("collection", collection.Slug); + Assert.Equal("Show", show.Title); + Assert.Equal("show", show.Slug); + Assert.Equal(2000, show.StartAir!.Value.Year); + Assert.Equal(1, season.SeasonNumber); + Assert.Equal(1, episode.SeasonNumber); + Assert.Equal(1, episode.EpisodeNumber); + Assert.Null(episode.AbsoluteNumber); + } + + [Fact] + public async Task EpisodeIdentificationWithoutLibraryTrailingSlash() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo/Library"}} + }); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify( + "/kyoo/Library/Collection/Show (2000)/Show S01E01.extension"); + Assert.Equal("Collection", collection.Name); + Assert.Equal("collection", collection.Slug); + Assert.Equal("Show", show.Title); + Assert.Equal("show", show.Slug); + Assert.Equal(2000, show.StartAir!.Value.Year); + Assert.Equal(1, season.SeasonNumber); + Assert.Equal(1, episode.SeasonNumber); + Assert.Equal(1, episode.EpisodeNumber); + Assert.Null(episode.AbsoluteNumber); + } + + [Fact] + public async Task EpisodeIdentificationMultiplePaths() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify( + "/kyoo/Library/Collection/Show (2000)/Show S01E01.extension"); + Assert.Equal("Collection", collection.Name); + Assert.Equal("collection", collection.Slug); + Assert.Equal("Show", show.Title); + Assert.Equal("show", show.Slug); + Assert.Equal(2000, show.StartAir!.Value.Year); + Assert.Equal(1, season.SeasonNumber); + Assert.Equal(1, episode.SeasonNumber); + Assert.Equal(1, episode.EpisodeNumber); + Assert.Null(episode.AbsoluteNumber); + } + + [Fact] + public async Task AbsoluteEpisodeIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify( + "/kyoo/Library/Collection/Show (2000)/Show 100.extension"); + Assert.Equal("Collection", collection.Name); + Assert.Equal("collection", collection.Slug); + Assert.Equal("Show", show.Title); + Assert.Equal("show", show.Slug); + Assert.Equal(2000, show.StartAir!.Value.Year); + Assert.Null(season); + Assert.Null(episode.SeasonNumber); + Assert.Null(episode.EpisodeNumber); + Assert.Equal(100, episode.AbsoluteNumber); + } + + [Fact] + public async Task MovieEpisodeIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify( + "/kyoo/Library/Collection/Show (2000)/Show.extension"); + Assert.Equal("Collection", collection.Name); + Assert.Equal("collection", collection.Slug); + Assert.Equal("Show", show.Title); + Assert.Equal("show", show.Slug); + Assert.Equal(2000, show.StartAir!.Value.Year); + Assert.Null(season); + Assert.True(show.IsMovie); + Assert.Null(episode.SeasonNumber); + Assert.Null(episode.EpisodeNumber); + Assert.Null(episode.AbsoluteNumber); + } + + [Fact] + public async Task InvalidEpisodeIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + await Assert.ThrowsAsync(() => _identifier.Identify("/invalid/path")); + } + + [Fact] + public async Task SubtitleIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + Track track = await _identifier.IdentifyTrack("/kyoo/Library/Collection/Show (2000)/Show.eng.default.str"); + Assert.True(track.IsExternal); + Assert.Equal("eng", track.Language); + Assert.Equal("subrip", track.Codec); + Assert.True(track.IsDefault); + Assert.False(track.IsForced); + Assert.StartsWith("/kyoo/Library/Collection/Show (2000)/Show", track.Episode.Path); + } + + [Fact] + public async Task SubtitleIdentificationUnknownCodec() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + Track track = await _identifier.IdentifyTrack("/kyoo/Library/Collection/Show (2000)/Show.eng.default.extension"); + Assert.True(track.IsExternal); + Assert.Equal("eng", track.Language); + Assert.Equal("extension", track.Codec); + Assert.True(track.IsDefault); + Assert.False(track.IsForced); + Assert.StartsWith("/kyoo/Library/Collection/Show (2000)/Show", track.Episode.Path); + } + + [Fact] + public async Task InvalidSubtitleIdentification() + { + _manager.Setup(x => x.GetAll(null, new Sort(), default)).ReturnsAsync(new[] + { + new Library {Paths = new [] {"/kyoo", "/kyoo/Library/"}} + }); + await Assert.ThrowsAsync(() => _identifier.IdentifyTrack("/invalid/path")); + } + } +} \ No newline at end of file diff --git a/Kyoo.Tests/Identifier/ProviderTests.cs b/Kyoo.Tests/Identifier/ProviderTests.cs new file mode 100644 index 00000000..c49ca3b2 --- /dev/null +++ b/Kyoo.Tests/Identifier/ProviderTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Controllers; +using Kyoo.Models; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Kyoo.Tests.Identifier +{ + public class ProviderTests + { + private readonly ILoggerFactory _factory; + + public ProviderTests(ITestOutputHelper output) + { + _factory = LoggerFactory.Create(x => + { + x.ClearProviders(); + x.AddXunit(output); + }); + } + + [Fact] + public async Task NoProviderGetTest() + { + AProviderComposite provider = new ProviderComposite(Array.Empty(), + _factory.CreateLogger()); + Show show = new() + { + ID = 4, + Genres = new[] { new Genre("genre") } + }; + Show ret = await provider.Get(show); + KAssert.DeepEqual(show, ret); + } + + [Fact] + public async Task NoProviderSearchTest() + { + AProviderComposite provider = new ProviderComposite(Array.Empty(), + _factory.CreateLogger()); + ICollection ret = await provider.Search("show"); + Assert.Empty(ret); + } + + [Fact] + public async Task OneProviderGetTest() + { + Show show = new() + { + ID = 4, + Genres = new[] { new Genre("genre") } + }; + Mock mock = new(); + mock.Setup(x => x.Get(show)).ReturnsAsync(new Show + { + Title = "title", + Genres = new[] { new Genre("ToMerge")} + }); + AProviderComposite provider = new ProviderComposite(new [] + { + mock.Object + }, + _factory.CreateLogger()); + + Show ret = await provider.Get(show); + Assert.Equal(4, ret.ID); + Assert.Equal("title", ret.Title); + Assert.Equal(2, ret.Genres.Count); + Assert.Contains("genre", ret.Genres.Select(x => x.Slug)); + Assert.Contains("tomerge", ret.Genres.Select(x => x.Slug)); + } + + [Fact] + public async Task FailingProviderGetTest() + { + Show show = new() + { + ID = 4, + Genres = new[] { new Genre("genre") } + }; + Mock mock = new(); + mock.Setup(x => x.Provider).Returns(new Provider("mock", "")); + mock.Setup(x => x.Get(show)).ReturnsAsync(new Show + { + Title = "title", + Genres = new[] { new Genre("ToMerge")} + }); + + Mock mockTwo = new(); + mockTwo.Setup(x => x.Provider).Returns(new Provider("mockTwo", "")); + mockTwo.Setup(x => x.Get(show)).ReturnsAsync(new Show + { + Title = "title2", + Status = Status.Finished, + Genres = new[] { new Genre("ToMerge")} + }); + + Mock mockFailing = new(); + mockFailing.Setup(x => x.Provider).Returns(new Provider("mockFail", "")); + mockFailing.Setup(x => x.Get(show)).Throws(); + + AProviderComposite provider = new ProviderComposite(new [] + { + mock.Object, + mockTwo.Object, + mockFailing.Object + }, + _factory.CreateLogger()); + + Show ret = await provider.Get(show); + Assert.Equal(4, ret.ID); + Assert.Equal("title", ret.Title); + Assert.Equal(Status.Finished, ret.Status); + Assert.Equal(2, ret.Genres.Count); + Assert.Contains("genre", ret.Genres.Select(x => x.Slug)); + Assert.Contains("tomerge", ret.Genres.Select(x => x.Slug)); + } + } +} \ No newline at end of file diff --git a/Kyoo.Tests/Identifier/Tvdb/ConvertorTests.cs b/Kyoo.Tests/Identifier/Tvdb/ConvertorTests.cs new file mode 100644 index 00000000..35e389b6 --- /dev/null +++ b/Kyoo.Tests/Identifier/Tvdb/ConvertorTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Linq; +using Kyoo.Models; +using Kyoo.TheTvdb; +using TvDbSharper.Dto; +using Xunit; + +namespace Kyoo.Tests.Identifier.Tvdb +{ + public class ConvertorTests + { + [Fact] + public void SeriesSearchToShow() + { + SeriesSearchResult result = new() + { + Slug = "slug", + SeriesName = "name", + Aliases = new[] { "Aliases" }, + Overview = "overview", + Status = "Ended", + FirstAired = "2021-07-23", + Poster = "/poster", + Id = 5 + }; + Provider provider = TestSample.Get(); + Show show = result.ToShow(provider); + + Assert.Equal("slug", show.Slug); + Assert.Equal("name", show.Title); + Assert.Single(show.Aliases); + Assert.Equal("Aliases", show.Aliases[0]); + Assert.Equal("overview", show.Overview); + Assert.Equal(new DateTime(2021, 7, 23), show.StartAir); + Assert.Equal("https://www.thetvdb.com/poster", show.Poster); + Assert.Single(show.ExternalIDs); + Assert.Equal("5", show.ExternalIDs.First().DataID); + Assert.Equal(provider, show.ExternalIDs.First().Provider); + Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link); + Assert.Equal(Status.Finished, show.Status); + } + + [Fact] + public void SeriesSearchToShowInvalidDate() + { + SeriesSearchResult result = new() + { + Slug = "slug", + SeriesName = "name", + Aliases = new[] { "Aliases" }, + Overview = "overview", + Status = "ad", + FirstAired = "2e021-07-23", + Poster = "/poster", + Id = 5 + }; + Provider provider = TestSample.Get(); + Show show = result.ToShow(provider); + + Assert.Equal("slug", show.Slug); + Assert.Equal("name", show.Title); + Assert.Single(show.Aliases); + Assert.Equal("Aliases", show.Aliases[0]); + Assert.Equal("overview", show.Overview); + Assert.Null(show.StartAir); + Assert.Equal("https://www.thetvdb.com/poster", show.Poster); + Assert.Single(show.ExternalIDs); + Assert.Equal("5", show.ExternalIDs.First().DataID); + Assert.Equal(provider, show.ExternalIDs.First().Provider); + Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link); + Assert.Equal(Status.Unknown, show.Status); + } + + [Fact] + public void SeriesToShow() + { + Series result = new() + { + Slug = "slug", + SeriesName = "name", + Aliases = new[] { "Aliases" }, + Overview = "overview", + Status = "Continuing", + FirstAired = "2021-07-23", + Poster = "poster", + FanArt = "fanart", + Id = 5, + Genre = new [] + { + "Action", + "Test With Spéàacial characters" + } + }; + Provider provider = TestSample.Get(); + Show show = result.ToShow(provider); + + Assert.Equal("slug", show.Slug); + Assert.Equal("name", show.Title); + Assert.Single(show.Aliases); + Assert.Equal("Aliases", show.Aliases[0]); + Assert.Equal("overview", show.Overview); + Assert.Equal(new DateTime(2021, 7, 23), show.StartAir); + Assert.Equal("https://www.thetvdb.com/banners/poster", show.Poster); + Assert.Equal("https://www.thetvdb.com/banners/fanart", show.Backdrop); + Assert.Single(show.ExternalIDs); + Assert.Equal("5", show.ExternalIDs.First().DataID); + Assert.Equal(provider, show.ExternalIDs.First().Provider); + Assert.Equal("https://www.thetvdb.com/series/slug", show.ExternalIDs.First().Link); + Assert.Equal(Status.Airing, show.Status); + Assert.Equal(2, show.Genres.Count); + Assert.Equal("action", show.Genres.ToArray()[0].Slug); + Assert.Equal("Action", show.Genres.ToArray()[0].Name); + Assert.Equal("Test With Spéàacial characters", show.Genres.ToArray()[1].Name); + Assert.Equal("test-with-speaacial-characters", show.Genres.ToArray()[1].Slug); + } + + [Fact] + public void ActorToPeople() + { + Actor actor = new() + { + Id = 5, + Image = "image", + Name = "Name", + Role = "role" + }; + Provider provider = TestSample.Get(); + PeopleRole people = actor.ToPeopleRole(provider); + + Assert.Equal("name", people.Slug); + Assert.Equal("Name", people.People.Name); + Assert.Equal("role", people.Role); + Assert.Equal("https://www.thetvdb.com/banners/image", people.People.Poster); + } + + [Fact] + public void EpisodeRecordToEpisode() + { + EpisodeRecord record = new() + { + Id = 5, + AiredSeason = 2, + AiredEpisodeNumber = 3, + AbsoluteNumber = 23, + EpisodeName = "title", + Overview = "overview", + Filename = "thumb" + }; + Provider provider = TestSample.Get(); + Episode episode = record.ToEpisode(provider); + + Assert.Equal("title", episode.Title); + Assert.Equal(2, episode.SeasonNumber); + Assert.Equal(3, episode.EpisodeNumber); + Assert.Equal(23, episode.AbsoluteNumber); + Assert.Equal("overview", episode.Overview); + Assert.Equal("https://www.thetvdb.com/banners/thumb", episode.Thumb); + } + } +} \ No newline at end of file diff --git a/Kyoo.Tests/Kyoo.Tests.csproj b/Kyoo.Tests/Kyoo.Tests.csproj index d198dae6..120f8ba7 100644 --- a/Kyoo.Tests/Kyoo.Tests.csproj +++ b/Kyoo.Tests/Kyoo.Tests.csproj @@ -17,6 +17,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -32,6 +34,7 @@ + diff --git a/Kyoo.Tests/Utility/EnumerableTests.cs b/Kyoo.Tests/Utility/EnumerableTests.cs index 9cdd8a00..c03efa06 100644 --- a/Kyoo.Tests/Utility/EnumerableTests.cs +++ b/Kyoo.Tests/Utility/EnumerableTests.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Xunit; -namespace Kyoo.Tests +namespace Kyoo.Tests.Utility { public class EnumerableTests { diff --git a/Kyoo.Tests/Utility/MergerTests.cs b/Kyoo.Tests/Utility/MergerTests.cs index 614d328f..285532c2 100644 --- a/Kyoo.Tests/Utility/MergerTests.cs +++ b/Kyoo.Tests/Utility/MergerTests.cs @@ -1,7 +1,11 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Kyoo.Models; +using Kyoo.Models.Attributes; using Xunit; -namespace Kyoo.Tests +namespace Kyoo.Tests.Utility { public class MergerTests { @@ -17,5 +21,192 @@ namespace Kyoo.Tests Assert.Null(genre.Name); Assert.Null(genre.Slug); } + + [Fact] + public void MergeTest() + { + Genre genre = new() + { + ID = 5 + }; + Genre genre2 = new() + { + Name = "test" + }; + Genre ret = Merger.Merge(genre, genre2); + Assert.True(ReferenceEquals(genre, ret)); + Assert.Equal(5, ret.ID); + Assert.Equal("test", genre.Name); + Assert.Null(genre.Slug); + } + + [Fact] + [SuppressMessage("ReSharper", "ExpressionIsAlwaysNull")] + public void MergeNullTests() + { + Genre genre = new() + { + ID = 5 + }; + Assert.True(ReferenceEquals(genre, Merger.Merge(genre, null))); + Assert.True(ReferenceEquals(genre, Merger.Merge(null, genre))); + Assert.Null(Merger.Merge(null, null)); + } + + private class TestIOnMerge : IOnMerge + { + public void OnMerge(object other) + { + Exception exception = new(); + exception.Data[0] = other; + throw exception; + } + } + + [Fact] + public void OnMergeTest() + { + TestIOnMerge test = new(); + TestIOnMerge test2 = new(); + Assert.Throws(() => Merger.Merge(test, test2)); + try + { + Merger.Merge(test, test2); + } + catch (Exception ex) + { + Assert.True(ReferenceEquals(test2, ex.Data[0])); + } + } + + private class Test + { + public int ID { get; set; } + + public int[] Numbers { get; set; } + } + + [Fact] + public void GlobalMergeListTest() + { + Test test = new() + { + ID = 5, + Numbers = new [] { 1 } + }; + Test test2 = new() + { + Numbers = new [] { 3 } + }; + Test ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Numbers.Length); + Assert.Equal(1, ret.Numbers[0]); + Assert.Equal(3, ret.Numbers[1]); + } + + [Fact] + public void GlobalMergeListDuplicatesTest() + { + Test test = new() + { + ID = 5, + Numbers = new [] { 1 } + }; + Test test2 = new() + { + Numbers = new [] + { + 1, + 3, + 3 + } + }; + Test ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(4, ret.Numbers.Length); + Assert.Equal(1, ret.Numbers[0]); + Assert.Equal(1, ret.Numbers[1]); + Assert.Equal(3, ret.Numbers[2]); + Assert.Equal(3, ret.Numbers[3]); + } + + [Fact] + public void GlobalMergeListDuplicatesResourcesTest() + { + Show test = new() + { + ID = 5, + Genres = new [] { new Genre("test") } + }; + Show test2 = new() + { + Genres = new [] + { + new Genre("test"), + new Genre("test2") + } + }; + Show ret = Merger.Merge(test, test2); + Assert.True(ReferenceEquals(test, ret)); + Assert.Equal(5, ret.ID); + + Assert.Equal(2, ret.Genres.Count); + Assert.Equal("test", ret.Genres.ToArray()[0].Slug); + Assert.Equal("test2", ret.Genres.ToArray()[1].Slug); + } + + [Fact] + public void MergeListTest() + { + int[] first = { 1 }; + int[] second = { + 3, + 3 + }; + int[] ret = Merger.MergeLists(first, second); + + Assert.Equal(3, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(3, ret[1]); + Assert.Equal(3, ret[2]); + } + + [Fact] + public void MergeListDuplicateTest() + { + int[] first = { 1 }; + int[] second = { + 1, + 3, + 3 + }; + int[] ret = Merger.MergeLists(first, second); + + Assert.Equal(4, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(1, ret[1]); + Assert.Equal(3, ret[2]); + Assert.Equal(3, ret[3]); + } + + [Fact] + public void MergeListDuplicateCustomEqualityTest() + { + int[] first = { 1 }; + int[] second = { + 3, + 2 + }; + int[] ret = Merger.MergeLists(first, second, (x, y) => x % 2 == y % 2); + + Assert.Equal(2, ret.Length); + Assert.Equal(1, ret[0]); + Assert.Equal(2, ret[1]); + } } } \ No newline at end of file diff --git a/Kyoo.Tests/Utility/TaskTests.cs b/Kyoo.Tests/Utility/TaskTests.cs index 3a7baa48..3bcd723f 100644 --- a/Kyoo.Tests/Utility/TaskTests.cs +++ b/Kyoo.Tests/Utility/TaskTests.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Xunit; -namespace Kyoo.Tests +namespace Kyoo.Tests.Utility { public class TaskTests { diff --git a/Kyoo.Tests/Utility/UtilityTests.cs b/Kyoo.Tests/Utility/UtilityTests.cs index 15469411..f6f279aa 100644 --- a/Kyoo.Tests/Utility/UtilityTests.cs +++ b/Kyoo.Tests/Utility/UtilityTests.cs @@ -1,9 +1,12 @@ using System; using System.Linq.Expressions; +using System.Reflection; using Kyoo.Models; using Xunit; -namespace Kyoo.Tests +using Utils = Kyoo.Utility; + +namespace Kyoo.Tests.Utility { public class UtilityTests { @@ -13,12 +16,12 @@ namespace Kyoo.Tests Expression> member = x => x.ID; Expression> memberCast = x => x.ID; - Assert.False(Utility.IsPropertyExpression(null)); - Assert.True(Utility.IsPropertyExpression(member)); - Assert.True(Utility.IsPropertyExpression(memberCast)); + Assert.False(Utils.IsPropertyExpression(null)); + Assert.True(Utils.IsPropertyExpression(member)); + Assert.True(Utils.IsPropertyExpression(memberCast)); Expression> call = x => x.GetID("test"); - Assert.False(Utility.IsPropertyExpression(call)); + Assert.False(Utils.IsPropertyExpression(call)); } [Fact] @@ -27,9 +30,51 @@ namespace Kyoo.Tests Expression> member = x => x.ID; Expression> memberCast = x => x.ID; - Assert.Equal("ID", Utility.GetPropertyName(member)); - Assert.Equal("ID", Utility.GetPropertyName(memberCast)); - Assert.Throws(() => Utility.GetPropertyName(null)); + Assert.Equal("ID", Utils.GetPropertyName(member)); + Assert.Equal("ID", Utils.GetPropertyName(memberCast)); + Assert.Throws(() => Utils.GetPropertyName(null)); + } + + [Fact] + public void GetMethodTest() + { + MethodInfo method = Utils.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + Array.Empty(), + Array.Empty()); + Assert.Equal(MethodBase.GetCurrentMethod(), method); + } + + [Fact] + public void GetMethodInvalidGenericsTest() + { + Assert.Throws(() => Utils.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + new [] { typeof(Utils) }, + Array.Empty())); + } + + [Fact] + public void GetMethodInvalidParamsTest() + { + Assert.Throws(() => Utils.GetMethod(typeof(UtilityTests), + BindingFlags.Instance | BindingFlags.Public, + nameof(GetMethodTest), + Array.Empty(), + new object[] { this })); + } + + [Fact] + public void GetMethodTest2() + { + MethodInfo method = Utils.GetMethod(typeof(Merger), + BindingFlags.Static | BindingFlags.Public, + nameof(Merger.MergeLists), + new [] { typeof(string) }, + new object[] { "string", "string2", null }); + Assert.Equal(nameof(Merger.MergeLists), method.Name); } } } \ No newline at end of file diff --git a/Kyoo.TheTvdb/Convertors.cs b/Kyoo.TheTvdb/Convertors.cs new file mode 100644 index 00000000..bc31b5fd --- /dev/null +++ b/Kyoo.TheTvdb/Convertors.cs @@ -0,0 +1,160 @@ +using System; +using System.Globalization; +using System.Linq; +using Kyoo.Models; +using TvDbSharper.Dto; + +namespace Kyoo.TheTvdb +{ + /// + /// A set of extensions methods used to convert tvdb models to Kyoo models. + /// + public static class Convertors + { + /// + /// Convert the string representation of the status in the tvdb API to a Kyoo's enum. + /// + /// The string representing the status. + /// A kyoo value or null. + private static Status _GetStatus(string status) + { + return status switch + { + "Ended" => Status.Finished, + "Continuing" => Status.Airing, + _ => Status.Unknown + }; + } + + /// + /// Parse a TVDB date and return a or null if the string is invalid. + /// + /// The date string to parse + /// The parsed or null. + private static DateTime? _ParseDate(string date) + { + return DateTime.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTime parsed) + ? parsed + : null; + } + + /// + /// Convert a series search to a show. + /// + /// The search result + /// The provider representing the tvdb inside kyoo + /// A show representing the given search result. + public static Show ToShow(this SeriesSearchResult result, Provider provider) + { + return new() + { + Slug = result.Slug, + Title = result.SeriesName, + Aliases = result.Aliases, + Overview = result.Overview, + Status = _GetStatus(result.Status), + StartAir = _ParseDate(result.FirstAired), + Poster = result.Poster != null ? $"https://www.thetvdb.com{result.Poster}" : null, + ExternalIDs = new[] + { + new MetadataID + { + DataID = result.Id.ToString(), + Link = $"https://www.thetvdb.com/series/{result.Slug}", + Second = provider + } + } + }; + } + + /// + /// Convert a tvdb series to a kyoo show. + /// + /// The series to convert + /// The provider representing the tvdb inside kyoo + /// A show representing the given series. + public static Show ToShow(this Series series, Provider provider) + { + return new() + { + Slug = series.Slug, + Title = series.SeriesName, + Aliases = series.Aliases, + Overview = series.Overview, + Status = _GetStatus(series.Status), + StartAir = _ParseDate(series.FirstAired), + Poster = series.Poster != null ? $"https://www.thetvdb.com/banners/{series.Poster}" : null, + Backdrop = series.FanArt != null ? $"https://www.thetvdb.com/banners/{series.FanArt}" : null, + Genres = series.Genre.Select(y => new Genre(y)).ToList(), + ExternalIDs = new[] + { + new MetadataID + { + DataID = series.Id.ToString(), + Link = $"https://www.thetvdb.com/series/{series.Slug}", + Second = provider + } + } + }; + } + + /// + /// Convert a tvdb actor to a kyoo . + /// + /// The actor to convert + /// The provider representing the tvdb inside kyoo + /// A people role representing the given actor in the role they played. + public static PeopleRole ToPeopleRole(this Actor actor, Provider provider) + { + return new() + { + People = new People + { + Slug = Utility.ToSlug(actor.Name), + Name = actor.Name, + Poster = actor.Image != null ? $"https://www.thetvdb.com/banners/{actor.Image}" : null, + ExternalIDs = new [] + { + new MetadataID() + { + DataID = actor.Id.ToString(), + Link = $"https://www.thetvdb.com/people/{actor.Id}", + Second = provider + } + } + }, + Role = actor.Role, + Type = "Actor" + }; + } + + /// + /// Convert a tvdb episode to a kyoo . + /// + /// The episode to convert + /// The provider representing the tvdb inside kyoo + /// A episode representing the given tvdb episode. + public static Episode ToEpisode(this EpisodeRecord episode, Provider provider) + { + return new() + { + SeasonNumber = episode.AiredSeason, + EpisodeNumber = episode.AiredEpisodeNumber, + AbsoluteNumber = episode.AbsoluteNumber, + Title = episode.EpisodeName, + Overview = episode.Overview, + Thumb = episode.Filename != null ? $"https://www.thetvdb.com/banners/{episode.Filename}" : null, + ExternalIDs = new[] + { + new MetadataID + { + DataID = episode.Id.ToString(), + Link = $"https://www.thetvdb.com/series/{episode.SeriesId}/episodes/{episode.Id}", + Second = provider + } + } + }; + } + } +} \ No newline at end of file diff --git a/Kyoo.TheTvdb/Kyoo.TheTvdb.csproj b/Kyoo.TheTvdb/Kyoo.TheTvdb.csproj new file mode 100644 index 00000000..ac7dad56 --- /dev/null +++ b/Kyoo.TheTvdb/Kyoo.TheTvdb.csproj @@ -0,0 +1,34 @@ + + + net5.0 + + SDG + Zoe Roux + https://github.com/AnonymusRaccoon/Kyoo + default + Kyoo.TheTvdb + + + + ../Kyoo/bin/$(Configuration)/$(TargetFramework)/plugins/the-tvdb + false + false + false + false + true + + + + + + + + + + + all + false + runtime + + + diff --git a/Kyoo.TheTvdb/PluginTvdb.cs b/Kyoo.TheTvdb/PluginTvdb.cs new file mode 100644 index 00000000..e0b697ea --- /dev/null +++ b/Kyoo.TheTvdb/PluginTvdb.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Autofac; +using Kyoo.Controllers; +using Kyoo.Models.Attributes; +using Kyoo.TheTvdb.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TvDbSharper; + +namespace Kyoo.TheTvdb +{ + /// + /// A plugin that add a for The TVDB. + /// + public class PluginTvdb : IPlugin + { + /// + public string Slug => "the-tvdb"; + + /// + public string Name => "The TVDB Provider"; + + /// + public string Description => "A metadata provider for The TVDB."; + + /// + public ICollection Provides => new [] + { + typeof(IMetadataProvider) + }; + + /// + public ICollection ConditionalProvides => ArraySegment.Empty; + + /// + public ICollection Requires => ArraySegment.Empty; + + + /// + /// The configuration to use. + /// + private readonly IConfiguration _configuration; + + /// + /// The configuration manager used to register typed/untyped implementations. + /// + [Injected] public IConfigurationManager ConfigurationManager { private get; set; } + + + /// + /// Create a new tvdb module instance and use the given configuration. + /// + /// The configuration to use + public PluginTvdb(IConfiguration configuration) + { + _configuration = configuration; + } + + + /// + public void Configure(ContainerBuilder builder) + { + builder.RegisterType().As(); + builder.RegisterProvider(); + } + + /// + public void Configure(IServiceCollection services, ICollection availableTypes) + { + services.Configure(_configuration.GetSection(TvdbOption.Path)); + } + + /// + public void ConfigureAspNet(IApplicationBuilder app) + { + ConfigurationManager.AddTyped(TvdbOption.Path); + } + } +} \ No newline at end of file diff --git a/Kyoo.TheTvdb/ProviderTvdb.cs b/Kyoo.TheTvdb/ProviderTvdb.cs new file mode 100644 index 00000000..47183fb7 --- /dev/null +++ b/Kyoo.TheTvdb/ProviderTvdb.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.TheTvdb.Models; +using Microsoft.Extensions.Options; +using TvDbSharper; +using TvDbSharper.Dto; + +namespace Kyoo.TheTvdb +{ + /// + /// A metadata provider for The TVDB. + /// + public class ProviderTvdb : IMetadataProvider + { + /// + /// The internal tvdb client used to make requests. + /// + private readonly ITvDbClient _client; + + /// + /// The API key used to authenticate with the tvdb API. + /// + private readonly IOptions _apiKey; + + /// + public Provider Provider => new() + { + Slug = "the-tvdb", + Name = "TheTVDB", + LogoExtension = "png", + Logo = "https://www.thetvdb.com/images/logo.png" + }; + + + /// + /// Create a new using a tvdb client and an api key. + /// + /// The tvdb client to use + /// The api key + public ProviderTvdb(ITvDbClient client, IOptions apiKey) + { + _client = client; + _apiKey = apiKey; + } + + /// + /// Authenticate and refresh the token of the tvdb client. + /// + private Task _Authenticate() + { + if (_client.Authentication.Token == null) + return _client.Authentication.AuthenticateAsync(_apiKey.Value.ApiKey); + return _client.Authentication.RefreshTokenAsync(); + } + + /// + public async Task Get(T item) + where T : class, IResource + { + await _Authenticate(); + return item switch + { + Show show => await _GetShow(show) as T, + Episode episode => await _GetEpisode(episode) as T, + _ => null + }; + } + + /// + /// Retrieve metadata about a show. + /// + /// The base show to retrieve metadata for. + /// A new show filled with metadata from the tvdb. + [ItemCanBeNull] + private async Task _GetShow([NotNull] Show show) + { + if (show.IsMovie) + return null; + + if (!int.TryParse(show.GetID(Provider.Slug), out int id)) + { + Show found = (await _SearchShow(show.Title)).FirstOrDefault(); + if (found == null) + return null; + return await Get(found); + } + TvDbResponse series = await _client.Series.GetAsync(id); + 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; + } + + /// + /// Retrieve metadata about an episode. + /// + /// The base episode to retrieve metadata for. + /// A new episode filled with metadata from the tvdb. + [ItemCanBeNull] + private async Task _GetEpisode([NotNull] Episode episode) + { + if (!int.TryParse(episode.Show?.GetID(Provider.Slug), out int id)) + return null; + EpisodeQuery query = episode.AbsoluteNumber != null + ? new EpisodeQuery {AbsoluteNumber = episode.AbsoluteNumber} + : new EpisodeQuery {AiredSeason = episode.SeasonNumber, AiredEpisode = episode.EpisodeNumber}; + TvDbResponse episodes = await _client.Series.GetEpisodesAsync(id, 0, query); + return episodes.Data.FirstOrDefault()?.ToEpisode(Provider); + } + + /// + public async Task> Search(string query) + where T : class, IResource + { + await _Authenticate(); + if (typeof(T) == typeof(Show)) + return (await _SearchShow(query) as ICollection)!; + return ArraySegment.Empty; + } + + /// + /// Search for shows in the tvdb. + /// + /// The query to ask the tvdb about. + /// A list of shows that could be found on the tvdb. + [ItemNotNull] + private async Task> _SearchShow(string query) + { + try + { + TvDbResponse shows = await _client.Search.SearchSeriesByNameAsync(query); + return shows.Data.Select(x => x.ToShow(Provider)).ToArray(); + } + catch (TvDbServerException) + { + return ArraySegment.Empty; + } + } + } +} \ No newline at end of file diff --git a/Kyoo.TheTvdb/TvdbOption.cs b/Kyoo.TheTvdb/TvdbOption.cs new file mode 100644 index 00000000..9a884b24 --- /dev/null +++ b/Kyoo.TheTvdb/TvdbOption.cs @@ -0,0 +1,18 @@ +namespace Kyoo.TheTvdb.Models +{ + /// + /// The option containing the api key for the tvdb. + /// + public class TvdbOption + { + /// + /// The path to get this option from the root configuration. + /// + public const string Path = "tvdb"; + + /// + /// The api key of the tvdb. + /// + public string ApiKey { get; set; } + } +} \ No newline at end of file diff --git a/Kyoo.WebApp b/Kyoo.WebApp index 22a02671..c037270d 160000 --- a/Kyoo.WebApp +++ b/Kyoo.WebApp @@ -1 +1 @@ -Subproject commit 22a02671918201d6d9d4e80a76f01b59b216a82d +Subproject commit c037270d3339fcf0075984a089f353c5c332a751 diff --git a/Kyoo.sln b/Kyoo.sln index 60998b55..55aeb44c 100644 --- a/Kyoo.sln +++ b/Kyoo.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.Authentication", "Kyoo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.SqLite", "Kyoo.SqLite\Kyoo.SqLite.csproj", "{6515380E-1E57-42DA-B6E3-E1C8A848818A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kyoo.TheTvdb", "Kyoo.TheTvdb\Kyoo.TheTvdb.csproj", "{D06BF829-23F5-40F3-A62D-627D9F4B4D6C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -47,5 +49,9 @@ Global {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Debug|Any CPU.Build.0 = Debug|Any CPU {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.ActiveCfg = Release|Any CPU {6515380E-1E57-42DA-B6E3-E1C8A848818A}.Release|Any CPU.Build.0 = Release|Any CPU + {D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D06BF829-23F5-40F3-A62D-627D9F4B4D6C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/Kyoo/Controllers/ConfigurationManager.cs b/Kyoo/Controllers/ConfigurationManager.cs index c0a63895..32b2e7f7 100644 --- a/Kyoo/Controllers/ConfigurationManager.cs +++ b/Kyoo/Controllers/ConfigurationManager.cs @@ -36,7 +36,29 @@ namespace Kyoo.Controllers _references = references.ToDictionary(x => x.Path, x => x.Type, StringComparer.OrdinalIgnoreCase); } - private Type GetType(string path) + + /// + public void AddTyped(string path) + { + foreach (ConfigurationReference confRef in ConfigurationReference.CreateReference(path)) + _references.Add(confRef.Path, confRef.Type); + } + + /// + public void AddUntyped(string path) + { + ConfigurationReference config = ConfigurationReference.CreateUntyped(path); + _references.Add(config.Path, config.Type); + } + + /// + /// Get the type of the resource at the given path + /// + /// The path of the resource + /// The path is not editable or readable + /// No configuration exists for the given path + /// The type of the resource at the given path + private Type _GetType(string path) { path = path.Replace("__", ":"); @@ -59,7 +81,7 @@ namespace Kyoo.Controllers { path = path.Replace("__", ":"); // TODO handle lists and dictionaries. - Type type = GetType(path); + Type type = _GetType(path); object ret = _configuration.GetValue(type, path); if (ret != null) return ret; @@ -73,7 +95,7 @@ namespace Kyoo.Controllers { path = path.Replace("__", ":"); // TODO handle lists and dictionaries. - Type type = GetType(path); + Type type = _GetType(path); if (typeof(T).IsAssignableFrom(type)) throw new InvalidCastException($"The type {typeof(T).Name} is not valid for " + $"a resource of type {type.Name}."); @@ -84,12 +106,12 @@ namespace Kyoo.Controllers public async Task EditValue(string path, object value) { path = path.Replace("__", ":"); - Type type = GetType(path); + Type type = _GetType(path); value = JObject.FromObject(value).ToObject(type); if (value == null) throw new ArgumentException("Invalid value format."); - ExpandoObject config = ToObject(_configuration); + ExpandoObject config = _ToObject(_configuration); IDictionary configDic = config; configDic[path] = value; JObject obj = JObject.FromObject(config); @@ -104,7 +126,7 @@ namespace Kyoo.Controllers /// The configuration to transform /// A strongly typed representation of the configuration. [SuppressMessage("ReSharper", "RedundantJumpStatement")] - private ExpandoObject ToObject(IConfiguration config) + private ExpandoObject _ToObject(IConfiguration config) { ExpandoObject obj = new(); @@ -112,12 +134,12 @@ namespace Kyoo.Controllers { try { - Type type = GetType(section.Path); + Type type = _GetType(section.Path); obj.TryAdd(section.Key, section.Get(type)); } catch (ArgumentException) { - obj.TryAdd(section.Key, ToUntyped(section)); + obj.TryAdd(section.Key, _ToUntyped(section)); } catch { @@ -133,13 +155,13 @@ namespace Kyoo.Controllers /// /// The section to convert /// The converted section - private static object ToUntyped(IConfigurationSection config) + private static object _ToUntyped(IConfigurationSection config) { ExpandoObject obj = new(); foreach (IConfigurationSection section in config.GetChildren()) { - obj.TryAdd(section.Key, ToUntyped(section)); + obj.TryAdd(section.Key, _ToUntyped(section)); } if (!obj.Any()) diff --git a/Kyoo/Controllers/FileSystems/FileSystemComposite.cs b/Kyoo/Controllers/FileSystems/FileSystemComposite.cs new file mode 100644 index 00000000..da98539f --- /dev/null +++ b/Kyoo/Controllers/FileSystems/FileSystemComposite.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Autofac.Features.Metadata; +using JetBrains.Annotations; +using Kyoo.Common.Models.Attributes; +using Kyoo.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Controllers +{ + /// + /// A composite that merge every available + /// using . + /// + public class FileSystemComposite : IFileSystem + { + /// + /// The list of mapped to their metadata. + /// + private readonly ICollection, FileSystemMetadataAttribute>> _fileSystems; + + /// + /// Create a new from a list of mapped to their + /// metadata. + /// + /// The list of filesystem mapped to their metadata. + public FileSystemComposite(ICollection, FileSystemMetadataAttribute>> fileSystems) + { + _fileSystems = fileSystems; + } + + + /// + /// Retrieve the file system that should be used for a given path. + /// + /// + /// The path that was requested. + /// + /// + /// The path that the returned file system wants + /// (respecting ). + /// + /// No file system was registered for the given path. + /// The file system that should be used for a given path + [NotNull] + private IFileSystem _GetFileSystemForPath([NotNull] string path, [NotNull] out string usablePath) + { + Regex schemeMatcher = new(@"(.+)://(.*)", RegexOptions.Compiled); + Match match = schemeMatcher.Match(path); + + if (!match.Success) + { + usablePath = path; + Meta, FileSystemMetadataAttribute> defaultFs = _fileSystems + .SingleOrDefault(x => x.Metadata.Scheme.Contains("")); + if (defaultFs == null) + throw new ArgumentException($"No file system registered for the default scheme."); + return defaultFs.Value.Invoke(); + } + string scheme = match.Groups[1].Value; + Meta, FileSystemMetadataAttribute> ret = _fileSystems + .SingleOrDefault(x => x.Metadata.Scheme.Contains(scheme)); + if (ret == null) + throw new ArgumentException($"No file system registered for the scheme: {scheme}."); + usablePath = ret.Metadata.StripScheme ? match.Groups[2].Value : path; + return ret.Value.Invoke(); + } + + /// + public IActionResult FileResult(string path, bool rangeSupport = false, string type = null) + { + if (path == null) + return new NotFoundResult(); + return _GetFileSystemForPath(path, out string relativePath) + .FileResult(relativePath, rangeSupport, type); + } + + /// + public Task GetReader(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return _GetFileSystemForPath(path, out string relativePath) + .GetReader(relativePath); + } + + /// + public Task NewFile(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return _GetFileSystemForPath(path, out string relativePath) + .NewFile(relativePath); + } + + /// + public Task CreateDirectory(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return _GetFileSystemForPath(path, out string relativePath) + .CreateDirectory(relativePath); + } + + /// + public string Combine(params string[] paths) + { + return _GetFileSystemForPath(paths[0], out string relativePath) + .Combine(paths[1..].Prepend(relativePath).ToArray()); + } + + /// + public Task> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return _GetFileSystemForPath(path, out string relativePath) + .ListFiles(relativePath, options); + } + + /// + public Task Exists(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return _GetFileSystemForPath(path, out string relativePath) + .Exists(relativePath); + } + + /// + public string GetExtraDirectory(Show show) + { + if (show == null) + throw new ArgumentNullException(nameof(show)); + return _GetFileSystemForPath(show.Path, out string _) + .GetExtraDirectory(show); + } + } +} \ No newline at end of file diff --git a/Kyoo/Controllers/FileSystems/HttpFileSystem.cs b/Kyoo/Controllers/FileSystems/HttpFileSystem.cs new file mode 100644 index 00000000..3d2ea991 --- /dev/null +++ b/Kyoo/Controllers/FileSystems/HttpFileSystem.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; +using Kyoo.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Kyoo.Controllers +{ + /// + /// A for http/https links. + /// + [FileSystemMetadata(new [] {"http", "https"})] + public class HttpFileSystem : IFileSystem + { + /// + /// The http client factory used to create clients. + /// + private readonly IHttpClientFactory _clientFactory; + + /// + /// Create a using the given client factory. + /// + /// The http client factory used to create clients. + public HttpFileSystem(IHttpClientFactory factory) + { + _clientFactory = factory; + } + + + /// + public IActionResult FileResult(string path, bool rangeSupport = false, string type = null) + { + if (path == null) + return new NotFoundResult(); + return new HttpForwardResult(new Uri(path), rangeSupport, type); + } + + /// + public Task GetReader(string path) + { + HttpClient client = _clientFactory.CreateClient(); + return client.GetStreamAsync(path); + } + + /// + public Task NewFile(string path) + { + throw new NotSupportedException("An http filesystem is readonly, a new file can't be created."); + } + + /// + public Task CreateDirectory(string path) + { + throw new NotSupportedException("An http filesystem is readonly, a directory can't be created."); + } + + /// + public string Combine(params string[] paths) + { + return Path.Combine(paths); + } + + /// + public Task> ListFiles(string path, SearchOption options = SearchOption.TopDirectoryOnly) + { + throw new NotSupportedException("Listing files is not supported on an http filesystem."); + } + + /// + public Task Exists(string path) + { + throw new NotSupportedException("Checking if a file exists is not supported on an http filesystem."); + } + + /// + public string GetExtraDirectory(Show show) + { + throw new NotSupportedException("Extras can not be stored inside an http filesystem."); + } + } + + /// + /// An to proxy an http request. + /// + public class HttpForwardResult : IActionResult + { + /// + /// The path of the request to forward. + /// + private readonly Uri _path; + /// + /// Should the proxied result support ranges requests? + /// + private readonly bool _rangeSupport; + /// + /// If not null, override the content type of the resulting request. + /// + private readonly string _type; + + /// + /// Create a new . + /// + /// The path of the request to forward. + /// Should the proxied result support ranges requests? + /// If not null, override the content type of the resulting request. + public HttpForwardResult(Uri path, bool rangeSupport, string type = null) + { + _path = path; + _rangeSupport = rangeSupport; + _type = type; + } + + /// + public Task ExecuteResultAsync(ActionContext context) + { + // TODO implement that, example: https://github.com/twitchax/AspNetCore.Proxy/blob/14dd0f212d7abb43ca1bf8c890d5efb95db66acb/src/Core/Extensions/Http.cs#L15 + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/Kyoo/Controllers/FileManager.cs b/Kyoo/Controllers/FileSystems/LocalFileSystem.cs similarity index 65% rename from Kyoo/Controllers/FileManager.cs rename to Kyoo/Controllers/FileSystems/LocalFileSystem.cs index 43b808b8..3239c55f 100644 --- a/Kyoo/Controllers/FileManager.cs +++ b/Kyoo/Controllers/FileSystems/LocalFileSystem.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; using Kyoo.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; @@ -9,9 +10,10 @@ using Microsoft.AspNetCore.StaticFiles; namespace Kyoo.Controllers { /// - /// A for the local filesystem (using System.IO). + /// A for the local filesystem (using System.IO). /// - public class FileManager : IFileManager + [FileSystemMetadata(new [] {"", "file"}, StripScheme = true)] + public class LocalFileSystem : IFileSystem { /// /// An extension provider to get content types from files extensions. @@ -41,7 +43,7 @@ namespace Kyoo.Controllers } /// - public IActionResult FileResult(string path, bool range = false, string type = null) + public IActionResult FileResult(string path, bool rangeSupport = false, string type = null) { if (path == null) return new NotFoundResult(); @@ -49,40 +51,56 @@ namespace Kyoo.Controllers return new NotFoundResult(); return new PhysicalFileResult(Path.GetFullPath(path), type ?? _GetContentType(path)) { - EnableRangeProcessing = range + EnableRangeProcessing = rangeSupport }; } /// - public Stream GetReader(string path) + public Task GetReader(string path) { if (path == null) throw new ArgumentNullException(nameof(path)); - return File.OpenRead(path); + return Task.FromResult(File.OpenRead(path)); } /// - public Stream NewFile(string path) + public Task NewFile(string path) { if (path == null) throw new ArgumentNullException(nameof(path)); - return File.Create(path); + return Task.FromResult(File.Create(path)); + } + + /// + public Task CreateDirectory(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + Directory.CreateDirectory(path); + return Task.FromResult(path); } /// - public Task> ListFiles(string path) + public string Combine(params string[] paths) + { + return Path.Combine(paths); + } + + /// + 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); } /// public Task Exists(string path) { - return Task.FromResult(File.Exists(path)); + return Task.FromResult(File.Exists(path) || Directory.Exists(path)); } /// @@ -92,24 +110,5 @@ namespace Kyoo.Controllers Directory.CreateDirectory(path); return path; } - - /// - public string GetExtraDirectory(Season season) - { - if (season.Show == null) - throw new NotImplementedException("Can't get season's extra directory when season.Show == null."); - // TODO use a season.Path here. - string path = Path.Combine(season.Show.Path, "Extra"); - Directory.CreateDirectory(path); - return path; - } - - /// - public string GetExtraDirectory(Episode episode) - { - string path = Path.Combine(Path.GetDirectoryName(episode.Path)!, "Extra"); - Directory.CreateDirectory(path); - return path; - } } } \ No newline at end of file diff --git a/Kyoo/Controllers/PluginManager.cs b/Kyoo/Controllers/PluginManager.cs index 7d0c399b..46185f16 100644 --- a/Kyoo/Controllers/PluginManager.cs +++ b/Kyoo/Controllers/PluginManager.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Reflection; using System.Runtime.Loader; +using Autofac; using Kyoo.Models.Options; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -21,7 +22,7 @@ namespace Kyoo.Controllers /// /// The service provider. It allow plugin's activation. /// - private readonly IServiceProvider _provider; + private IServiceProvider _provider; /// /// The configuration to get the plugin's directory. /// @@ -51,6 +52,13 @@ namespace Kyoo.Controllers _logger = logger; } + public void SetProvider(IServiceProvider provider) + { + // TODO temporary bullshit to inject services before the configure asp net. + // TODO should rework this when the host will be reworked, as well as the asp net configure. + _provider = provider; + } + /// public T GetPlugin(string name) @@ -126,6 +134,13 @@ namespace Kyoo.Controllers else _logger.LogInformation("Plugin enabled: {Plugins}", _plugins.Select(x => x.Name)); } + + /// + public void ConfigureContainer(ContainerBuilder builder) + { + foreach (IPlugin plugin in _plugins) + plugin.Configure(builder); + } /// public void ConfigureServices(IServiceCollection services) @@ -139,7 +154,12 @@ namespace Kyoo.Controllers public void ConfigureAspnet(IApplicationBuilder app) { foreach (IPlugin plugin in _plugins) + { + using IServiceScope scope = _provider.CreateScope(); + Helper.InjectServices(plugin, x => scope.ServiceProvider.GetRequiredService(x)); plugin.ConfigureAspNet(app); + Helper.InjectServices(plugin, _ => null); + } } /// diff --git a/Kyoo/Controllers/ProviderComposite.cs b/Kyoo/Controllers/ProviderComposite.cs new file mode 100644 index 00000000..2474016c --- /dev/null +++ b/Kyoo/Controllers/ProviderComposite.cs @@ -0,0 +1,103 @@ +using System; +using Kyoo.Models; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Kyoo.Controllers +{ + /// + /// A metadata provider composite that merge results from all available providers. + /// + public class ProviderComposite : AProviderComposite + { + /// + /// The list of metadata providers + /// + private readonly ICollection _providers; + + /// + /// The list of selected providers. If no provider has been selected, this is null. + /// + private ICollection _selectedProviders; + + /// + /// The logger used to print errors. + /// + private readonly ILogger _logger; + + + /// + /// Create a new with a list of available providers. + /// + /// The list of providers to merge. + /// The logger used to print errors. + public ProviderComposite(IEnumerable providers, ILogger logger) + { + _providers = providers.ToArray(); + _logger = logger; + } + + + /// + public override void UseProviders(IEnumerable providers) + { + _selectedProviders = providers.ToArray(); + } + + /// + /// Return the list of providers that should be used for queries. + /// + /// The list of providers to use, respecting the . + private IEnumerable _GetProviders() + { + return _selectedProviders? + .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) + .Where(x => x != null) + ?? _providers; + } + + /// + public override async Task Get(T item) + { + T ret = item; + + foreach (IMetadataProvider provider in _GetProviders()) + { + try + { + ret = Merger.Merge(ret, await provider.Get(ret)); + } + catch (Exception ex) + { + _logger.LogError(ex, "The provider {Provider} could not get a {Type}", + provider.Provider.Name, typeof(T).Name); + } + } + + return ret; + } + + /// + public override async Task> Search(string query) + { + List ret = new(); + + foreach (IMetadataProvider provider in _GetProviders()) + { + try + { + ret.AddRange(await provider.Search(query)); + } + catch (Exception ex) + { + _logger.LogError(ex, "The provider {Provider} could not search for {Type}", + provider.Provider.Name, typeof(T).Name); + } + } + + return ret; + } + } +} diff --git a/Kyoo/Controllers/ProviderManager.cs b/Kyoo/Controllers/ProviderManager.cs deleted file mode 100644 index 6ed5f796..00000000 --- a/Kyoo/Controllers/ProviderManager.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using Kyoo.Models; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Kyoo.Controllers -{ - public class ProviderManager : IProviderManager - { - private readonly IEnumerable _providers; - - public ProviderManager(IPluginManager pluginManager) - { - _providers = pluginManager.GetPlugins(); - } - - private async Task GetMetadata(Func> providerCall, Library library, string what) - where T : new() - { - T ret = new(); - - IEnumerable providers = library?.Providers - .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) - .Where(x => x != null) - ?? _providers; - - foreach (IMetadataProvider provider in providers) - { - try - { - ret = Merger.Merge(ret, await providerCall(provider)); - } catch (Exception ex) - { - await Console.Error.WriteLineAsync( - $"The provider {provider.Provider.Name} could not work for {what}. Exception: {ex.Message}"); - } - } - return ret; - } - - private async Task> GetMetadata( - Func>> providerCall, - Library library, - string what) - { - List ret = new(); - - IEnumerable providers = library?.Providers - .Select(x => _providers.FirstOrDefault(y => y.Provider.Slug == x.Slug)) - .Where(x => x != null) - ?? _providers; - - foreach (IMetadataProvider provider in providers) - { - try - { - ret.AddRange(await providerCall(provider) ?? new List()); - } catch (Exception ex) - { - await Console.Error.WriteLineAsync( - $"The provider {provider.Provider.Name} coudln't work for {what}. Exception: {ex.Message}"); - } - } - return ret; - } - - 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/RegexIdentifier.cs b/Kyoo/Controllers/RegexIdentifier.cs new file mode 100644 index 00000000..c25e3e4f --- /dev/null +++ b/Kyoo/Controllers/RegexIdentifier.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Kyoo.Models; +using Kyoo.Models.Exceptions; +using Kyoo.Models.Options; +using Kyoo.Models.Watch; +using Microsoft.Extensions.Options; + +namespace Kyoo.Controllers +{ + /// + /// An identifier that use a regex to extract basics metadata. + /// + public class RegexIdentifier : IIdentifier + { + /// + /// The configuration of kyoo to retrieve the identifier regex. + /// + private readonly IOptionsMonitor _configuration; + /// + /// The library manager used to retrieve libraries paths. + /// + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new . + /// + /// The regex patterns to use. + /// The library manager used to retrieve libraries paths. + public RegexIdentifier(IOptionsMonitor configuration, ILibraryManager libraryManager) + { + _configuration = configuration; + _libraryManager = libraryManager; + } + + /// + /// Retrieve the relative path of an episode or subtitle. + /// + /// The full path of the episode + /// The path relative to the library root. + private async Task _GetRelativePath(string path) + { + string libraryPath = (await _libraryManager.GetAll()) + .SelectMany(x => x.Paths) + .Where(path.StartsWith) + .OrderByDescending(x => x.Length) + .FirstOrDefault(); + return path[(libraryPath?.Length ?? 0)..]; + } + + /// + public async Task<(Collection, Show, Season, Episode)> Identify(string path) + { + string relativePath = await _GetRelativePath(path); + Match match = _configuration.CurrentValue.Regex + .Select(x => new Regex(x, RegexOptions.IgnoreCase | RegexOptions.Compiled)) + .Select(x => x.Match(relativePath)) + .FirstOrDefault(x => x.Success); + + if (match == null) + throw new IdentificationFailedException($"The episode at {path} does not match the episode's regex."); + + (Collection collection, Show show, Season season, Episode episode) ret = ( + collection: new Collection + { + Slug = Utility.ToSlug(match.Groups["Collection"].Value), + Name = match.Groups["Collection"].Value + }, + show: new Show + { + Slug = Utility.ToSlug(match.Groups["Show"].Value), + Title = match.Groups["Show"].Value, + Path = Path.GetDirectoryName(path), + StartAir = match.Groups["StartYear"].Success + ? new DateTime(int.Parse(match.Groups["StartYear"].Value), 1, 1) + : null + }, + season: null, + episode: new Episode + { + SeasonNumber = match.Groups["Season"].Success + ? int.Parse(match.Groups["Season"].Value) + : null, + EpisodeNumber = match.Groups["Episode"].Success + ? int.Parse(match.Groups["Episode"].Value) + : null, + AbsoluteNumber = match.Groups["Absolute"].Success + ? int.Parse(match.Groups["Absolute"].Value) + : null, + Path = path + } + ); + + if (ret.episode.SeasonNumber.HasValue) + ret.season = new Season { SeasonNumber = ret.episode.SeasonNumber.Value }; + + + if (ret.episode.SeasonNumber == null && ret.episode.EpisodeNumber == null + && ret.episode.AbsoluteNumber == null) + { + ret.show.IsMovie = true; + ret.episode.Title = ret.show.Title; + } + + return ret; + } + + /// + public Task IdentifyTrack(string path) + { + Match match = _configuration.CurrentValue.SubtitleRegex + .Select(x => new Regex(x, RegexOptions.IgnoreCase | RegexOptions.Compiled)) + .Select(x => x.Match(path)) + .FirstOrDefault(x => x.Success); + + if (match == null) + throw new IdentificationFailedException($"The subtitle at {path} does not match the subtitle's regex."); + + string episodePath = match.Groups["Episode"].Value; + string extension = Path.GetExtension(path); + return Task.FromResult(new Track + { + Type = StreamType.Subtitle, + Language = match.Groups["Language"].Value, + IsDefault = match.Groups["Default"].Success, + IsForced = match.Groups["Forced"].Success, + Codec = FileExtensions.SubtitleExtensions.GetValueOrDefault(extension, extension[1..]), + IsExternal = true, + Path = path, + Episode = new Episode + { + Path = episodePath + } + }); + } + } +} \ No newline at end of file diff --git a/Kyoo/Controllers/Repositories/EpisodeRepository.cs b/Kyoo/Controllers/Repositories/EpisodeRepository.cs index 2cf7ff1f..3c1393eb 100644 --- a/Kyoo/Controllers/Repositories/EpisodeRepository.cs +++ b/Kyoo/Controllers/Repositories/EpisodeRepository.cs @@ -114,9 +114,6 @@ namespace Kyoo.Controllers obj.ExternalIDs.ForEach(x => _database.Entry(x).State = EntityState.Added); await _database.SaveChangesAsync($"Trying to insert a duplicated episode (slug {obj.Slug} already exists)."); return await ValidateTracks(obj); - // TODO check if this is needed - // obj.Slug = await _database.Entry(obj).Property(x => x.Slug). - // return obj; } /// diff --git a/Kyoo/Controllers/Repositories/LibraryItemRepository.cs b/Kyoo/Controllers/Repositories/LibraryItemRepository.cs index 37391c5d..b0ec916e 100644 --- a/Kyoo/Controllers/Repositories/LibraryItemRepository.cs +++ b/Kyoo/Controllers/Repositories/LibraryItemRepository.cs @@ -81,17 +81,28 @@ namespace Kyoo.Controllers } /// - public override Task Create(LibraryItem obj) => throw new InvalidOperationException(); + public override Task Create(LibraryItem obj) + => throw new InvalidOperationException(); + /// - public override Task CreateIfNotExists(LibraryItem obj) => throw new InvalidOperationException(); + public override Task CreateIfNotExists(LibraryItem obj) + => throw new InvalidOperationException(); + /// - public override Task Edit(LibraryItem obj, bool reset) => throw new InvalidOperationException(); + public override Task Edit(LibraryItem obj, bool resetOld) + => throw new InvalidOperationException(); + /// - public override Task Delete(int id) => throw new InvalidOperationException(); + public override Task Delete(int id) + => throw new InvalidOperationException(); + /// - public override Task Delete(string slug) => throw new InvalidOperationException(); + public override Task Delete(string slug) + => throw new InvalidOperationException(); + /// - public override Task Delete(LibraryItem obj) => throw new InvalidOperationException(); + public override Task Delete(LibraryItem obj) + => throw new InvalidOperationException(); /// /// Get a basic queryable for a library with the right mapping from shows & collections. diff --git a/Kyoo/Controllers/Repositories/LibraryRepository.cs b/Kyoo/Controllers/Repositories/LibraryRepository.cs index 02194b77..b7b782bc 100644 --- a/Kyoo/Controllers/Repositories/LibraryRepository.cs +++ b/Kyoo/Controllers/Repositories/LibraryRepository.cs @@ -30,7 +30,7 @@ namespace Kyoo.Controllers /// Create a new instance. /// /// The database handle - /// The providere repository + /// The provider repository public LibraryRepository(DatabaseContext database, IProviderRepository providers) : base(database) { @@ -53,8 +53,8 @@ namespace Kyoo.Controllers public override async Task Create(Library obj) { await base.Create(obj); - obj.ProviderLinks = obj.Providers?.Select(x => Link.Create(obj, x)).ToList(); _database.Entry(obj).State = EntityState.Added; + obj.ProviderLinks.ForEach(x => _database.Entry(x).State = EntityState.Added); await _database.SaveChangesAsync($"Trying to insert a duplicated library (slug {obj.Slug} already exists)."); return obj; } @@ -63,6 +63,9 @@ namespace Kyoo.Controllers protected override async Task Validate(Library resource) { await base.Validate(resource); + resource.ProviderLinks = resource.Providers? + .Select(x => Link.Create(resource, x)) + .ToList(); await resource.ProviderLinks.ForEachAsync(async id => { id.Second = await _providers.CreateIfNotExists(id.Second); diff --git a/Kyoo/Controllers/Repositories/SeasonRepository.cs b/Kyoo/Controllers/Repositories/SeasonRepository.cs index fe042e66..e0036982 100644 --- a/Kyoo/Controllers/Repositories/SeasonRepository.cs +++ b/Kyoo/Controllers/Repositories/SeasonRepository.cs @@ -96,7 +96,12 @@ namespace Kyoo.Controllers protected override async Task Validate(Season resource) { if (resource.ShowID <= 0) - throw new InvalidOperationException($"Can't store a season not related to any show (showID: {resource.ShowID})."); + { + if (resource.Show == null) + throw new InvalidOperationException( + $"Can't store a season not related to any show (showID: {resource.ShowID})."); + resource.ShowID = resource.Show.ID; + } await base.Validate(resource); await resource.ExternalIDs.ForEachAsync(async id => diff --git a/Kyoo/Controllers/TaskManager.cs b/Kyoo/Controllers/TaskManager.cs index caa7a01d..8b5650a6 100644 --- a/Kyoo/Controllers/TaskManager.cs +++ b/Kyoo/Controllers/TaskManager.cs @@ -4,11 +4,12 @@ using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Autofac.Features.Metadata; +using Autofac.Features.OwnedInstances; using JetBrains.Annotations; -using Kyoo.Models.Attributes; +using Kyoo.Common.Models.Attributes; using Kyoo.Models.Exceptions; using Kyoo.Models.Options; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -22,9 +23,52 @@ namespace Kyoo.Controllers public class TaskManager : BackgroundService, ITaskManager { /// - /// The service provider used to activate + /// The class representing task under this jurisdiction. /// - private readonly IServiceProvider _provider; + private class ManagedTask + { + /// + /// The metadata for this task (the slug, and other useful information). + /// + public TaskMetadataAttribute Metadata { get; set; } + + /// + /// The function used to create the task object. + /// + public Func> Factory { get; init; } + + /// + /// The next scheduled date for this task + /// + public DateTime ScheduledDate { get; set; } + } + + /// + /// A class representing a task inside the list. + /// + private class QueuedTask + { + /// + /// The task currently queued. + /// + public ManagedTask Task { get; init; } + + /// + /// The progress reporter that this task should use. + /// + public IProgress ProgressReporter { get; init; } + + /// + /// The arguments to give to run the task with. + /// + public Dictionary Arguments { get; init; } + + /// + /// A token informing the task that it should be cancelled or not. + /// + public CancellationToken? CancellationToken { get; init; } + } + /// /// The configuration instance used to get schedule information /// @@ -37,15 +81,15 @@ namespace Kyoo.Controllers /// /// The list of tasks and their next scheduled run. /// - private readonly List<(ITask task, DateTime scheduledDate)> _tasks; + private readonly List _tasks; /// /// The queue of tasks that should be run as soon as possible. /// - private readonly Queue<(ITask, Dictionary)> _queuedTasks = new(); + private readonly Queue _queuedTasks = new(); /// /// The currently running task. /// - private ITask _runningTask; + private (TaskMetadataAttribute, ITask)? _runningTask; /// /// The cancellation token used to cancel the running task when the runner should shutdown. /// @@ -55,22 +99,24 @@ namespace Kyoo.Controllers /// /// Create a new . /// - /// The list of tasks to manage - /// The service provider to request services for tasks + /// The list of tasks to manage with their metadata /// The configuration to load schedule information. /// The logger. - public TaskManager(IEnumerable tasks, - IServiceProvider provider, + public TaskManager(IEnumerable>, TaskMetadataAttribute>> tasks, IOptionsMonitor options, ILogger logger) { - _provider = provider; _options = options; _logger = logger; - _tasks = tasks.Select(x => (x, GetNextTaskDate(x.Slug))).ToList(); + _tasks = tasks.Select(x => new ManagedTask + { + Factory = x.Value, + Metadata = x.Metadata, + ScheduledDate = GetNextTaskDate(x.Metadata.Slug) + }).ToList(); if (_tasks.Any()) - _logger.LogTrace("Task manager initiated with: {Tasks}", _tasks.Select(x => x.task.Name)); + _logger.LogTrace("Task manager initiated with: {Tasks}", _tasks.Select(x => x.Metadata.Name)); else _logger.LogInformation("Task manager initiated without any tasks"); } @@ -100,21 +146,26 @@ namespace Kyoo.Controllers /// A token to stop the runner protected override async Task ExecuteAsync(CancellationToken cancellationToken) { - EnqueueStartupTasks(); + _EnqueueStartupTasks(); while (!cancellationToken.IsCancellationRequested) { if (_queuedTasks.Any()) { - (ITask task, Dictionary arguments) = _queuedTasks.Dequeue(); - _runningTask = task; + QueuedTask task = _queuedTasks.Dequeue(); try { - await RunTask(task, arguments); + await _RunTask(task.Task, task.ProgressReporter, task.Arguments, task.CancellationToken); + } + catch (TaskFailedException ex) + { + _logger.LogWarning("The task \"{Task}\" failed: {Message}", + task.Task.Metadata.Name, ex.Message); } catch (Exception e) { - _logger.LogError(e, "An unhandled exception occured while running the task {Task}", task.Name); + _logger.LogError(e, "An unhandled exception occured while running the task {Task}", + task.Task.Metadata.Name); } } else @@ -129,93 +180,116 @@ 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) + /// An optional cancellation token that will be passed to the task. + /// + /// 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(ManagedTask task, + [NotNull] IProgress progress, + Dictionary arguments, + CancellationToken? cancellationToken = null) { - _logger.LogInformation("Task starting: {Task}", task.Name); - - ICollection all = task.GetParameters(); - - ICollection invalids = arguments.Keys - .Where(x => all.Any(y => x != y.Name)) - .ToArray(); - if (invalids.Any()) + using (_logger.BeginScope("Task: {Task}", task.Metadata.Name)) { - string invalidsStr = string.Join(", ", invalids); - throw new ArgumentException($"{invalidsStr} are invalid arguments for the task {task.Name}"); - } - - TaskParameters args = new(all - .Select(x => + await using Owned taskObj = task.Factory.Invoke(); + ICollection all = taskObj.Value.GetParameters(); + + _runningTask = (task.Metadata, taskObj.Value); + ICollection invalids = arguments.Keys + .Where(x => all.All(y => x != y.Name)) + .ToArray(); + if (invalids.Any()) { - object value = arguments - .FirstOrDefault(y => string.Equals(y.Key, x.Name, StringComparison.OrdinalIgnoreCase)) - .Value; - if (value == null && x.IsRequired) - throw new ArgumentException($"The argument {x.Name} is required to run {task.Name}" + - " but it was not specified."); - return x.CreateValue(value ?? x.DefaultValue); - })); + throw new ArgumentException($"{string.Join(", ", invalids)} are " + + $"invalid arguments for the task {task.Metadata.Name}"); + } - using IServiceScope scope = _provider.CreateScope(); - InjectServices(task, x => scope.ServiceProvider.GetRequiredService(x)); - await task.Run(args, _taskToken.Token); - InjectServices(task, _ => null); - _logger.LogInformation("Task finished: {Task}", task.Name); + TaskParameters args = new(all + .Select(x => + { + object value = arguments + .FirstOrDefault(y => string.Equals(y.Key, x.Name, StringComparison.OrdinalIgnoreCase)) + .Value; + if (value == null && x.IsRequired) + throw new ArgumentException($"The argument {x.Name} is required to run " + + $"{task.Metadata.Name} but it was not specified."); + return x.CreateValue(value ?? x.DefaultValue); + })); + + _logger.LogInformation("Task starting: {Task} ({Parameters})", + task.Metadata.Name, args.ToDictionary(x => x.Name, x => x.As())); + + CancellationToken token = cancellationToken != null + ? CancellationTokenSource.CreateLinkedTokenSource(_taskToken.Token, cancellationToken.Value).Token + : _taskToken.Token; + await taskObj.Value.Run(args, progress, token); + + _logger.LogInformation("Task finished: {Task}", task.Metadata.Name); + _runningTask = null; + } } - /// - /// Inject services into the marked properties of the given object. - /// - /// The object to inject - /// The function used to retrieve services. (The function is called immediately) - private static void InjectServices(ITask obj, [InstantHandle] Func retrieve) - { - IEnumerable properties = obj.GetType().GetProperties() - .Where(x => x.GetCustomAttribute() != null) - .Where(x => x.CanWrite); - - foreach (PropertyInfo property in properties) - property.SetValue(obj, retrieve(property.PropertyType)); - } - /// /// Start tasks that are scheduled for start. /// private void QueueScheduledTasks() { - IEnumerable tasksToQueue = _tasks.Where(x => x.scheduledDate <= DateTime.Now) - .Select(x => x.task.Slug); + IEnumerable tasksToQueue = _tasks.Where(x => x.ScheduledDate <= DateTime.Now) + .Select(x => x.Metadata.Slug); foreach (string task in tasksToQueue) { _logger.LogDebug("Queuing task scheduled for running: {Task}", task); - StartTask(task, new Dictionary()); + StartTask(task, new Progress(), new Dictionary()); } } /// /// Queue startup tasks with respect to the priority rules. /// - private void EnqueueStartupTasks() + private void _EnqueueStartupTasks() { - IEnumerable startupTasks = _tasks.Select(x => x.task) - .Where(x => x.RunOnStartup) - .OrderByDescending(x => x.Priority); - foreach (ITask task in startupTasks) - _queuedTasks.Enqueue((task, new Dictionary())); + IEnumerable startupTasks = _tasks + .Where(x => x.Metadata.RunOnStartup) + .OrderByDescending(x => x.Metadata.Priority) + .Select(x => x.Metadata.Slug); + foreach (string task in startupTasks) + StartTask(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); + int index = _tasks.FindIndex(x => x.Metadata.Slug == taskSlug); if (index == -1) throw new ItemNotFoundException($"No task found with the slug {taskSlug}"); - _queuedTasks.Enqueue((_tasks[index].task, arguments)); - _tasks[index] = (_tasks[index].task, GetNextTaskDate(taskSlug)); + _queuedTasks.Enqueue(new QueuedTask + { + Task = _tasks[index], + ProgressReporter = progress, + Arguments = arguments, + CancellationToken = cancellationToken + }); + _tasks[index].ScheduledDate = GetNextTaskDate(taskSlug); + } + + /// + public void StartTask(IProgress progress, + Dictionary arguments = null, + CancellationToken? cancellationToken = null) + where T : ITask + { + TaskMetadataAttribute metadata = typeof(T).GetCustomAttribute(); + if (metadata == null) + throw new ArgumentException($"No metadata found on the given task (type: {typeof(T).Name})."); + StartTask(metadata.Slug, progress, arguments, cancellationToken); } /// @@ -231,15 +305,17 @@ namespace Kyoo.Controllers } /// - public ICollection GetRunningTasks() + public ICollection<(TaskMetadataAttribute, ITask)> GetRunningTasks() { - return new[] {_runningTask}; + return _runningTask == null + ? ArraySegment<(TaskMetadataAttribute, ITask)>.Empty + : new[] { _runningTask.Value }; } /// - public ICollection GetAllTasks() + public ICollection GetAllTasks() { - return _tasks.Select(x => x.task).ToArray(); + return _tasks.Select(x => x.Metadata).ToArray(); } } } \ No newline at end of file diff --git a/Kyoo/Controllers/ThumbnailsManager.cs b/Kyoo/Controllers/ThumbnailsManager.cs index 1e84cef6..389891b3 100644 --- a/Kyoo/Controllers/ThumbnailsManager.cs +++ b/Kyoo/Controllers/ThumbnailsManager.cs @@ -1,157 +1,292 @@ using Kyoo.Models; using System; using System.IO; -using System.Net; using System.Threading.Tasks; using JetBrains.Annotations; using Kyoo.Models.Options; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Kyoo.Controllers { + /// + /// Download images and retrieve the path of those images for a resource. + /// public class ThumbnailsManager : IThumbnailsManager { - private readonly IFileManager _files; + /// + /// The file manager used to download the image if the file is distant + /// + private readonly IFileSystem _files; + /// + /// A logger to report errors. + /// + private readonly ILogger _logger; + /// + /// The options containing the base path of people images and provider logos. + /// private readonly IOptionsMonitor _options; + /// + /// A library manager used to load episode and seasons shows if they are not loaded. + /// + private readonly Lazy _library; - public ThumbnailsManager(IFileManager files, IOptionsMonitor options) + /// + /// Create a new . + /// + /// The file manager to use. + /// A logger to report errors + /// The options to use. + /// A library manager used to load shows if they are not loaded. + public ThumbnailsManager(IFileSystem files, + ILogger logger, + IOptionsMonitor options, + Lazy library) { _files = files; + _logger = logger; _options = options; - Directory.CreateDirectory(_options.CurrentValue.PeoplePath); - Directory.CreateDirectory(_options.CurrentValue.ProviderPath); + _library = library; + + options.OnChange(x => + { + _files.CreateDirectory(x.PeoplePath); + _files.CreateDirectory(x.ProviderPath); + }); } - private static async Task DownloadImage(string url, string localPath, string what) + /// + public Task DownloadImages(T item, bool alwaysDownload = false) + where T : IResource { + if (item == null) + throw new ArgumentNullException(nameof(item)); + return item switch + { + Show show => _Validate(show, alwaysDownload), + Season season => _Validate(season, alwaysDownload), + Episode episode => _Validate(episode, alwaysDownload), + People people => _Validate(people, alwaysDownload), + Provider provider => _Validate(provider, alwaysDownload), + _ => Task.FromResult(false) + }; + } + + /// + /// An helper function to download an image using a . + /// + /// The distant url of the image + /// The local path of the image + /// What is currently downloaded (used for errors) + /// true if an image has been downloaded, false otherwise. + private async Task _DownloadImage(string url, string localPath, string what) + { + if (url == localPath) + return false; + try { - using WebClient client = new(); - await client.DownloadFileTaskAsync(new Uri(url), localPath); + await using Stream reader = await _files.GetReader(url); + await using Stream local = await _files.NewFile(localPath); + await reader.CopyToAsync(local); + return true; } - catch (WebException exception) + catch (Exception ex) { - await Console.Error.WriteLineAsync($"{what} could not be downloaded. Error: {exception.Message}."); + _logger.LogError(ex, "{What} could not be downloaded", what); + return false; } } - public async Task Validate(Show show, bool alwaysDownload) + /// + /// Download images of a specified show. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// true if an image has been downloaded, false otherwise. + private async Task _Validate([NotNull] Show show, bool alwaysDownload) { + bool ret = false; + if (show.Poster != null) { - string posterPath = await GetShowPoster(show); - if (alwaysDownload || !File.Exists(posterPath)) - await DownloadImage(show.Poster, posterPath, $"The poster of {show.Title}"); + string posterPath = await GetPoster(show); + if (alwaysDownload || !await _files.Exists(posterPath)) + ret |= await _DownloadImage(show.Poster, posterPath, $"The poster of {show.Title}"); } if (show.Logo != null) { - string logoPath = await GetShowLogo(show); - if (alwaysDownload || !File.Exists(logoPath)) - await DownloadImage(show.Logo, logoPath, $"The logo of {show.Title}"); + string logoPath = await GetLogo(show); + if (alwaysDownload || !await _files.Exists(logoPath)) + ret |= await _DownloadImage(show.Logo, logoPath, $"The logo of {show.Title}"); } if (show.Backdrop != null) { - string backdropPath = await GetShowBackdrop(show); - if (alwaysDownload || !File.Exists(backdropPath)) - await DownloadImage(show.Backdrop, backdropPath, $"The backdrop of {show.Title}"); + string backdropPath = await GetThumbnail(show); + if (alwaysDownload || !await _files.Exists(backdropPath)) + ret |= await _DownloadImage(show.Backdrop, backdropPath, $"The backdrop of {show.Title}"); } - - foreach (PeopleRole role in show.People) - await Validate(role.People, alwaysDownload); + + return ret; } - public async Task Validate([NotNull] People people, bool alwaysDownload) + /// + /// Download images of a specified person. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// true if an image has been downloaded, false otherwise. + private async Task _Validate([NotNull] People people, bool alwaysDownload) { if (people == null) throw new ArgumentNullException(nameof(people)); if (people.Poster == null) - return; - string localPath = await GetPeoplePoster(people); - if (alwaysDownload || !File.Exists(localPath)) - await DownloadImage(people.Poster, localPath, $"The profile picture of {people.Name}"); + return false; + string localPath = await GetPoster(people); + if (alwaysDownload || !await _files.Exists(localPath)) + return await _DownloadImage(people.Poster, localPath, $"The profile picture of {people.Name}"); + return false; } - public async Task Validate(Season season, bool alwaysDownload) + /// + /// Download images of a specified season. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// true if an image has been downloaded, false otherwise. + private async Task _Validate([NotNull] Season season, bool alwaysDownload) { - if (season?.Show?.Path == null || season.Poster == null) - return; + if (season.Poster == null) + return false; - string localPath = await GetSeasonPoster(season); - if (alwaysDownload || !File.Exists(localPath)) - await DownloadImage(season.Poster, localPath, $"The poster of {season.Show.Title}'s season {season.SeasonNumber}"); + string localPath = await GetPoster(season); + if (alwaysDownload || !await _files.Exists(localPath)) + return await _DownloadImage(season.Poster, localPath, $"The poster of {season.Slug}"); + return false; } - public async Task Validate(Episode episode, bool alwaysDownload) + /// + /// Download images of a specified episode. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// true if an image has been downloaded, false otherwise. + private async Task _Validate([NotNull] Episode episode, bool alwaysDownload) { - if (episode?.Path == null || episode.Thumb == null) - return; + if (episode.Thumb == null) + return false; - string localPath = await GetEpisodeThumb(episode); - if (alwaysDownload || !File.Exists(localPath)) - await DownloadImage(episode.Thumb, localPath, $"The thumbnail of {episode.Slug}"); + string localPath = await _GetEpisodeThumb(episode); + if (alwaysDownload || !await _files.Exists(localPath)) + return await _DownloadImage(episode.Thumb, localPath, $"The thumbnail of {episode.Slug}"); + return false; } - public async Task Validate(Provider provider, bool alwaysDownload) + /// + /// Download images of a specified provider. + /// + /// + /// The item to cache images. + /// + /// + /// true if images should be downloaded even if they already exists locally, false otherwise. + /// + /// true if an image has been downloaded, false otherwise. + private async Task _Validate([NotNull] Provider provider, bool alwaysDownload) { if (provider.Logo == null) - return; + return false; - string localPath = await GetProviderLogo(provider); - if (alwaysDownload || !File.Exists(localPath)) - await DownloadImage(provider.Logo, localPath, $"The logo of {provider.Slug}"); + string localPath = await GetLogo(provider); + if (alwaysDownload || !await _files.Exists(localPath)) + return await _DownloadImage(provider.Logo, localPath, $"The logo of {provider.Slug}"); + return false; } - public Task GetShowBackdrop(Show show) + /// + public Task GetPoster(T item) + where T : IResource { - if (show?.Path == null) - throw new ArgumentNullException(nameof(show)); - return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "backdrop.jpg")); + if (item == null) + throw new ArgumentNullException(nameof(item)); + return item switch + { + Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "poster.jpg")), + Season season => _GetSeasonPoster(season), + People actor => Task.FromResult(_files.Combine(_options.CurrentValue.PeoplePath, $"{actor.Slug}.jpg")), + _ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a poster.") + }; + } + + /// + /// Retrieve the path of a season's poster. + /// + /// The season to retrieve the poster from. + /// The path of the season's poster. + private async Task _GetSeasonPoster(Season season) + { + if (season.Show == null) + await _library.Value.Load(season, x => x.Show); + return _files.Combine(_files.GetExtraDirectory(season.Show), $"season-{season.SeasonNumber}.jpg"); } - public Task GetShowLogo(Show show) + /// + public Task GetThumbnail(T item) + where T : IResource { - if (show?.Path == null) - throw new ArgumentNullException(nameof(show)); - return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "logo.png")); + if (item == null) + throw new ArgumentNullException(nameof(item)); + return item switch + { + Show show => Task.FromResult(_files.Combine(_files.GetExtraDirectory(show), "backdrop.jpg")), + Episode episode => _GetEpisodeThumb(episode), + _ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.") + }; } - public Task GetShowPoster(Show show) + /// + /// Get the path for an episode's thumbnail. + /// + /// The episode to retrieve the thumbnail from + /// The path of the given episode's thumbnail. + private async Task _GetEpisodeThumb(Episode episode) { - if (show?.Path == null) - throw new ArgumentNullException(nameof(show)); - return Task.FromResult(Path.Combine(_files.GetExtraDirectory(show), "poster.jpg")); + if (episode.Show == null) + await _library.Value.Load(episode, x => x.Show); + string dir = _files.Combine(_files.GetExtraDirectory(episode.Show), "Thumbnails"); + await _files.CreateDirectory(dir); + return _files.Combine(dir, $"{Path.GetFileNameWithoutExtension(episode.Path)}.jpg"); } - public Task GetSeasonPoster(Season season) + /// + public Task GetLogo(T item) + where T : IResource { - if (season == null) - throw new ArgumentNullException(nameof(season)); - return Task.FromResult(Path.Combine(_files.GetExtraDirectory(season), $"season-{season.SeasonNumber}.jpg")); - } - - public Task GetEpisodeThumb(Episode episode) - { - string dir = Path.Combine(_files.GetExtraDirectory(episode), "Thumbnails"); - Directory.CreateDirectory(dir); - return Task.FromResult(Path.Combine(dir, $"{Path.GetFileNameWithoutExtension(episode.Path)}.jpg")); - } - - public Task GetPeoplePoster(People people) - { - if (people == null) - throw new ArgumentNullException(nameof(people)); - string peoplePath = _options.CurrentValue.PeoplePath; - string thumbPath = Path.GetFullPath(Path.Combine(peoplePath, $"{people.Slug}.jpg")); - return Task.FromResult(thumbPath.StartsWith(peoplePath) ? thumbPath : null); - } - - public Task GetProviderLogo(Provider provider) - { - if (provider == null) - throw new ArgumentNullException(nameof(provider)); - string providerPath = _options.CurrentValue.ProviderPath; - string thumbPath = Path.GetFullPath(Path.Combine(providerPath, $"{provider.Slug}.{provider.LogoExtension}")); - return Task.FromResult(thumbPath.StartsWith(providerPath) ? thumbPath : null); + if (item == null) + throw new ArgumentNullException(nameof(item)); + return Task.FromResult(item switch + { + Show show => _files.Combine(_files.GetExtraDirectory(show), "logo.png"), + Provider provider => _files.Combine(_options.CurrentValue.ProviderPath, + $"{provider.Slug}.{provider.LogoExtension}"), + _ => throw new NotSupportedException($"The type {typeof(T).Name} does not have a thumbnail.") + }); } } } diff --git a/Kyoo/Controllers/Transcoder.cs b/Kyoo/Controllers/Transcoder.cs index 8f6e006e..47291753 100644 --- a/Kyoo/Controllers/Transcoder.cs +++ b/Kyoo/Controllers/Transcoder.cs @@ -72,24 +72,29 @@ namespace Kyoo.Controllers } } - private readonly IFileManager _files; + private readonly IFileSystem _files; private readonly IOptions _options; + private readonly Lazy _library; - public Transcoder(IFileManager files, IOptions options) + public Transcoder(IFileSystem files, IOptions options, Lazy library) { _files = files; _options = options; + _library = library; if (TranscoderAPI.init() != Marshal.SizeOf()) throw new BadTranscoderException(); } - public Task ExtractInfos(Episode episode, bool reextract) + public async Task ExtractInfos(Episode episode, bool reextract) { - string dir = _files.GetExtraDirectory(episode); + if (episode.Show == null) + await _library.Value.Load(episode, x => x.Show); + + string dir = _files.GetExtraDirectory(episode.Show); if (dir == null) throw new ArgumentException("Invalid path."); - return Task.Factory.StartNew( + return await Task.Factory.StartNew( () => TranscoderAPI.ExtractInfos(episode.Path, dir, reextract), TaskCreationOptions.LongRunning); } diff --git a/Kyoo/CoreModule.cs b/Kyoo/CoreModule.cs index 34e24e75..2f5c7cfb 100644 --- a/Kyoo/CoreModule.cs +++ b/Kyoo/CoreModule.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; +using Autofac; +using Autofac.Core; +using Autofac.Core.Registration; using Kyoo.Controllers; +using Kyoo.Models.Attributes; using Kyoo.Models.Options; using Kyoo.Models.Permissions; using Kyoo.Tasks; @@ -31,12 +34,14 @@ namespace Kyoo /// public ICollection Provides => new[] { - typeof(IFileManager), + typeof(IFileSystem), typeof(ITranscoder), typeof(IThumbnailsManager), - typeof(IProviderManager), + typeof(IMetadataProvider), typeof(ITaskManager), - typeof(ILibraryManager) + typeof(ILibraryManager), + typeof(IIdentifier), + typeof(AProviderComposite) }; /// @@ -77,6 +82,11 @@ namespace Kyoo /// The configuration to use. /// private readonly IConfiguration _configuration; + + /// + /// The configuration manager used to register typed/untyped implementations. + /// + [Injected] public IConfigurationManager ConfigurationManager { private get; set; } /// @@ -88,20 +98,58 @@ namespace Kyoo _configuration = configuration; } + /// + public void Configure(ContainerBuilder builder) + { + builder.RegisterComposite(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + + builder.RegisterComposite(); + builder.Register(x => (AProviderComposite)x.Resolve()); + + builder.RegisterTask(); + builder.RegisterTask(); + builder.RegisterTask(); + builder.RegisterTask(); + builder.RegisterTask(); + + static bool DatabaseIsPresent(IComponentRegistryBuilder x) + => x.IsRegistered(new TypedService(typeof(DatabaseContext))); + + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + builder.RegisterRepository().OnlyIf(DatabaseIsPresent); + + builder.RegisterType().As() + .IfNotRegistered(typeof(IPermissionValidator)); + } + /// public void Configure(IServiceCollection services, ICollection availableTypes) { string publicUrl = _configuration.GetPublicUrl(); services.Configure(_configuration.GetSection(BasicOptions.Path)); - services.AddConfiguration(BasicOptions.Path); services.Configure(_configuration.GetSection(TaskOptions.Path)); - services.AddConfiguration(TaskOptions.Path); services.Configure(_configuration.GetSection(MediaOptions.Path)); - services.AddConfiguration(MediaOptions.Path); - services.AddUntypedConfiguration("database"); - services.AddUntypedConfiguration("logging"); - + services.AddControllers() .AddNewtonsoftJson(x => { @@ -109,41 +157,18 @@ namespace Kyoo x.SerializerSettings.Converters.Add(new PeopleRoleConverter()); }); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddHostedService(x => x.GetService() as TaskManager); - - services.AddScoped(); - - if (ProviderCondition.Has(typeof(DatabaseContext), availableTypes)) - { - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - services.AddRepository(); - } - - services.AddTask(); - - if (services.All(x => x.ServiceType != typeof(IPermissionValidator))) - services.AddSingleton(); } /// public void ConfigureAspNet(IApplicationBuilder app) { + ConfigurationManager.AddTyped(BasicOptions.Path); + ConfigurationManager.AddTyped(TaskOptions.Path); + ConfigurationManager.AddTyped(MediaOptions.Path); + ConfigurationManager.AddUntyped("database"); + ConfigurationManager.AddUntyped("logging"); + FileExtensionContentTypeProvider contentTypeProvider = new(); contentTypeProvider.Mappings[".data"] = "application/octet-stream"; app.UseStaticFiles(new StaticFileOptions diff --git a/Kyoo/Helper.cs b/Kyoo/Helper.cs new file mode 100644 index 00000000..244f6eec --- /dev/null +++ b/Kyoo/Helper.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Kyoo.Models.Attributes; +using Newtonsoft.Json; + +namespace Kyoo +{ + public static class Helper + { + /// + /// Inject services into the marked properties of the given object. + /// + /// The object to inject + /// The function used to retrieve services. (The function is called immediately) + public static void InjectServices(object obj, [InstantHandle] Func retrieve) + { + IEnumerable properties = obj.GetType().GetProperties() + .Where(x => x.GetCustomAttribute() != null) + .Where(x => x.CanWrite); + + foreach (PropertyInfo property in properties) + property.SetValue(obj, retrieve(property.PropertyType)); + } + + /// + /// An helper method to get json content from an http server. This is a temporary thing and will probably be + /// replaced by a call to the function of the same name in the System.Net.Http.Json namespace when .net6 + /// gets released. + /// + /// The http server to use. + /// The url to retrieve + /// The type of object to convert + /// A T representing the json contained at the given url. + public static async Task GetFromJsonAsync(this HttpClient client, string url) + { + HttpResponseMessage ret = await client.GetAsync(url); + ret.EnsureSuccessStatusCode(); + string content = await ret.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(content); + } + } +} \ No newline at end of file diff --git a/Kyoo/Kyoo.csproj b/Kyoo/Kyoo.csproj index 438f3e9b..70fae8ca 100644 --- a/Kyoo/Kyoo.csproj +++ b/Kyoo/Kyoo.csproj @@ -32,8 +32,12 @@ + + + + diff --git a/Kyoo/Models/FileExtensions.cs b/Kyoo/Models/FileExtensions.cs new file mode 100644 index 00000000..5b29fe08 --- /dev/null +++ b/Kyoo/Models/FileExtensions.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; + +namespace Kyoo.Models.Watch +{ + /// + /// A static class allowing one to identify files extensions. + /// + public static class FileExtensions + { + /// + /// The list of known video extensions + /// + public static readonly ImmutableArray VideoExtensions = ImmutableArray.Create( + ".webm", + ".mkv", + ".flv", + ".vob", + ".ogg", + ".ogv", + ".avi", + ".mts", + ".m2ts", + ".ts", + ".mov", + ".qt", + ".asf", + ".mp4", + ".m4p", + ".m4v", + ".mpg", + ".mp2", + ".mpeg", + ".mpe", + ".mpv", + ".m2v", + ".3gp", + ".3g2" + ); + + /// + /// Check if a file represent a video file (only by checking the extension of the file) + /// + /// The path of the file to check + /// true if the file is a video file, false otherwise. + public static bool IsVideo(string filePath) + { + return VideoExtensions.Contains(Path.GetExtension(filePath)); + } + + /// + /// The dictionary of known subtitles extensions and the name of the subtitle codec. + /// + public static readonly ImmutableDictionary SubtitleExtensions = new Dictionary + { + {".ass", "ass"}, + {".str", "subrip"} + }.ToImmutableDictionary(); + + /// + /// Check if a file represent a subtitle file (only by checking the extension of the file) + /// + /// The path of the file to check + /// true if the file is a subtitle file, false otherwise. + public static bool IsSubtitle(string filePath) + { + return SubtitleExtensions.ContainsKey(Path.GetExtension(filePath)); + } + } +} \ No newline at end of file diff --git a/Kyoo/Models/LazyDi.cs b/Kyoo/Models/LazyDi.cs deleted file mode 100644 index 477e1ec4..00000000 --- a/Kyoo/Models/LazyDi.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; - -namespace Kyoo.Models -{ - public class LazyDi : Lazy - { - public LazyDi(IServiceProvider provider) - : base(provider.GetRequiredService) - { } - } -} \ No newline at end of file diff --git a/Kyoo/Models/Options/MediaOptions.cs b/Kyoo/Models/Options/MediaOptions.cs index d53b13d0..4b02e4db 100644 --- a/Kyoo/Models/Options/MediaOptions.cs +++ b/Kyoo/Models/Options/MediaOptions.cs @@ -13,11 +13,11 @@ namespace Kyoo.Models.Options /// /// A regex for episodes /// - public string Regex { get; set; } + public string[] Regex { get; set; } /// /// A regex for subtitles /// - public string SubtitleRegex { get; set; } + public string[] SubtitleRegex { get; set; } } } \ No newline at end of file diff --git a/Kyoo/Program.cs b/Kyoo/Program.cs index 12227266..f1adfb40 100644 --- a/Kyoo/Program.cs +++ b/Kyoo/Program.cs @@ -2,12 +2,14 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading.Tasks; +using Autofac; +using Autofac.Extensions.DependencyInjection; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; + namespace Kyoo { /// @@ -30,6 +32,8 @@ namespace Kyoo if (!File.Exists("./settings.json")) File.Copy(Path.Join(AppDomain.CurrentDomain.BaseDirectory, "settings.json"), "settings.json"); + IWebHostBuilder builder = CreateWebHostBuilder(args); + bool? debug = Environment.GetEnvironmentVariable("ENVIRONMENT")?.ToLowerInvariant() switch { "d" => true, @@ -43,18 +47,21 @@ namespace Kyoo }; if (debug == null && Environment.GetEnvironmentVariable("ENVIRONMENT") != null) - Console.WriteLine($"Invalid ENVIRONMENT variable. Supported values are \"debug\" and \"prod\". Ignoring..."); + { + Console.WriteLine( + $"Invalid ENVIRONMENT variable. Supported values are \"debug\" and \"prod\". Ignoring..."); + } #if DEBUG debug ??= true; #endif - Console.WriteLine($"Running as {Environment.UserName}."); - IWebHostBuilder builder = CreateWebHostBuilder(args); if (debug != null) builder = builder.UseEnvironment(debug == true ? "Development" : "Production"); + try { + Console.WriteLine($"Running as {Environment.UserName}."); await builder.Build().RunAsync(); } catch (Exception ex) @@ -86,6 +93,11 @@ namespace Kyoo IConfiguration configuration = SetupConfig(new ConfigurationBuilder(), args).Build(); return new WebHostBuilder() + .ConfigureServices(x => + { + AutofacServiceProviderFactory factory = new(); + x.Replace(ServiceDescriptor.Singleton>(factory)); + }) .UseContentRoot(AppDomain.CurrentDomain.BaseDirectory) .UseConfiguration(configuration) .ConfigureAppConfiguration(x => SetupConfig(x, args)) @@ -99,12 +111,6 @@ namespace Kyoo .AddDebug() .AddEventSourceLogger(); }) - .UseDefaultServiceProvider((context, options) => - { - options.ValidateScopes = context.HostingEnvironment.IsDevelopment(); - if (context.HostingEnvironment.IsDevelopment()) - StaticWebAssetsLoader.UseStaticWebAssets(context.HostingEnvironment, context.Configuration); - }) .ConfigureServices(x => x.AddRouting()) .UseKestrel(options => { options.AddServerHeader = false; }) .UseIIS() diff --git a/Kyoo/Startup.cs b/Kyoo/Startup.cs index 4d5995ef..59a14f7d 100644 --- a/Kyoo/Startup.cs +++ b/Kyoo/Startup.cs @@ -1,8 +1,9 @@ using System; using System.IO; +using Autofac; +using Autofac.Extras.AttributeMetadata; using Kyoo.Authentication; using Kyoo.Controllers; -using Kyoo.Models; using Kyoo.Models.Options; using Kyoo.Postgresql; using Kyoo.Tasks; @@ -71,19 +72,23 @@ namespace Kyoo services.AddHttpClient(); - services.AddTransient(typeof(Lazy<>), typeof(LazyDi<>)); - - services.AddSingleton(_plugins); - services.AddTask(); _plugins.ConfigureServices(services); } + + public void ConfigureContainer(ContainerBuilder builder) + { + builder.RegisterModule(); + builder.RegisterInstance(_plugins).As().ExternallyOwned(); + builder.RegisterTask(); + _plugins.ConfigureContainer(builder); + } /// /// Configure the asp net host. /// /// The asp net host to configure /// The host environment (is the app in development mode?) - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider provider) { if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); @@ -110,7 +115,9 @@ namespace Kyoo return next(); }); app.UseResponseCompression(); - + + if (_plugins is PluginManager manager) + manager.SetProvider(provider); _plugins.ConfigureAspnet(app); app.UseSpa(spa => diff --git a/Kyoo/Tasks/Crawler.cs b/Kyoo/Tasks/Crawler.cs index 9f1185dd..333710a3 100644 --- a/Kyoo/Tasks/Crawler.cs +++ b/Kyoo/Tasks/Crawler.cs @@ -1,36 +1,60 @@ using System; using Kyoo.Models; -using Microsoft.Extensions.Configuration; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; using Kyoo.Controllers; -using Kyoo.Models.Attributes; -using Kyoo.Models.Exceptions; -using Microsoft.Extensions.DependencyInjection; +using Kyoo.Models.Watch; +using Microsoft.Extensions.Logging; namespace Kyoo.Tasks { + /// + /// A task to add new video files. + /// + [TaskMetadata("scan", "Scan libraries", "Scan your libraries and load data for new shows.", RunOnStartup = true)] 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 bool RunOnStartup => true; - public int Priority => 0; - - [Injected] public IServiceProvider ServiceProvider { private get; set; } - [Injected] public IThumbnailsManager ThumbnailsManager { private get; set; } - [Injected] public IProviderManager MetadataProvider { private get; set; } - [Injected] public ITranscoder Transcoder { private get; set; } - [Injected] public IConfiguration Config { private get; set; } + /// + /// The library manager used to get libraries and providers to use. + /// + private readonly ILibraryManager _libraryManager; + /// + /// The file manager used walk inside directories and check they existences. + /// + private readonly IFileSystem _fileSystem; + /// + /// A task manager used to create sub tasks for each episode to add to the database. + /// + private readonly ITaskManager _taskManager; + /// + /// The logger used to inform the current status to the console. + /// + private readonly ILogger _logger; - private int _parallelTasks; + /// + /// Create a new . + /// + /// The library manager to retrieve existing episodes/library/tracks + /// The file system to glob files + /// The task manager used to start . + /// The logger used print messages. + public Crawler(ILibraryManager libraryManager, + IFileSystem fileSystem, + ITaskManager taskManager, + ILogger logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _taskManager = taskManager; + _logger = logger; + } + + /// public TaskParameters GetParameters() { return new() @@ -39,397 +63,108 @@ 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(); + ICollection tracks = 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, tracks, reporter, cancellationToken); + percent += 100f / libraries.Count; + + if (cancellationToken.IsCancellationRequested) + return; + } + + progress.Report(100); } - private async Task Scan(Library library, IEnumerable episodes, IEnumerable tracks, CancellationToken cancellationToken) + private async Task Scan(Library library, + IEnumerable episodes, + IEnumerable tracks, + 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; + ICollection files = await _fileSystem.ListFiles(path, SearchOption.AllDirectories); - 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; - } + if (cancellationToken.IsCancellationRequested) + return; - List> shows = files - .Where(x => IsVideo(x) && episodes.All(y => y.Path != x)) + // 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(FileExtensions.IsVideo) + .Where(x => episodes.All(y => y.Path != x)) .GroupBy(Path.GetDirectoryName) .ToList(); - - // 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))); - 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) - { - 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 + string[] paths = shows.Select(x => x.First()) + .Concat(shows.SelectMany(x => x.Skip(1))) + .ToArray(); + float percent = 0; + IProgress reporter = new Progress(x => { - 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); + // ReSharper disable once AccessToModifiedClosure + progress.Report((percent + x / paths.Length - 10) / library.Paths.Length); + }); + + foreach (string episodePath in paths) + { + _taskManager.StartTask(reporter, new Dictionary + { + ["path"] = episodePath, + ["library"] = library + }, cancellationToken); + percent += 100f / paths.Length; } - 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 = 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) + + string[] subtitles = files + .Where(FileExtensions.IsSubtitle) + .Where(x => !x.Contains("/Extra/")) + .Where(x => tracks.All(y => y.Path != x)) + .ToArray(); + percent = 0; + reporter = new Progress(x => { - await libraryManager.Load(old, x => x.ExternalIDs); - return old; - } - - if (show.StartAir != null) + // ReSharper disable once AccessToModifiedClosure + progress.Report((90 + (percent + x / subtitles.Length)) / library.Paths.Length); + }); + + foreach (string trackPath in subtitles) { - show.Slug += $"-{show.StartAir.Value.Year}"; - await libraryManager.Create(show); + _taskManager.StartTask(reporter, new Dictionary + { + ["path"] = trackPath + }, cancellationToken); + percent += 100f / subtitles.Length; } - 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 = 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 = 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 = - { - ".webm", - ".mkv", - ".flv", - ".vob", - ".ogg", - ".ogv", - ".avi", - ".mts", - ".m2ts", - ".ts", - ".mov", - ".qt", - ".asf", - ".mp4", - ".m4p", - ".m4v", - ".mpg", - ".mp2", - ".mpeg", - ".mpe", - ".mpv", - ".m2v", - ".3gp", - ".3g2" - }; - - private static bool IsVideo(string filePath) - { - return VideoExtensions.Contains(Path.GetExtension(filePath)); - } - - private static readonly Dictionary SubtitleExtensions = new() - { - {".ass", "ass"}, - {".str", "subrip"} - }; - - private static bool IsSubtitle(string filePath) - { - return SubtitleExtensions.ContainsKey(Path.GetExtension(filePath)); } } } 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..b98989f5 --- /dev/null +++ b/Kyoo/Tasks/Housekeeping.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; +using Kyoo.Controllers; +using Kyoo.Models; +using Microsoft.Extensions.Logging; + +namespace Kyoo.Tasks +{ + /// + /// A task to remove orphaned episode and series. + /// + [TaskMetadata("housekeeping", "Housekeeping", "Remove orphaned episode and series.", RunOnStartup = true)] + public class Housekeeping : ITask + { + /// + /// The library manager used to get libraries or remove deleted episodes. + /// + private readonly ILibraryManager _libraryManager; + /// + /// The file manager used walk inside directories and check they existences. + /// + private readonly IFileSystem _fileSystem; + /// + /// The logger used to inform the user that episodes has been removed. + /// + private readonly ILogger _logger; + + /// + /// Create a new task. + /// + /// The library manager used to get libraries or remove deleted episodes. + /// The file manager used walk inside directories and check they existences. + /// The logger used to inform the user that episodes has been removed. + public Housekeeping(ILibraryManager libraryManager, IFileSystem fileSystem, ILogger logger) + { + _libraryManager = libraryManager; + _fileSystem = fileSystem; + _logger = logger; + } + + /// + public TaskParameters GetParameters() + { + return new(); + } + + /// + 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 _fileSystem.Exists(show.Path)) + continue; + _logger.LogWarning("Show {Name}'s folder has been deleted (was {Path}), removing it from kyoo", + show.Title, show.Path); + await _libraryManager.Delete(show); + } + + foreach (Episode episode in await _libraryManager.GetAll()) + { + progress.Report(count / delCount * 100); + count++; + + if (await _fileSystem.Exists(episode.Path)) + continue; + _logger.LogWarning("Episode {Slug}'s file has been deleted (was {Path}), removing it from kyoo", + episode.Slug, episode.Path); + await _libraryManager.Delete(episode); + } + + progress.Report(100); + } + } +} \ No newline at end of file diff --git a/Kyoo/Tasks/MetadataProviderLoader.cs b/Kyoo/Tasks/MetadataProviderLoader.cs index 899a2657..42342434 100644 --- a/Kyoo/Tasks/MetadataProviderLoader.cs +++ b/Kyoo/Tasks/MetadataProviderLoader.cs @@ -1,46 +1,77 @@ -// 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 MetadataProviderLoader : ITask -// { -// public string Slug => "reload-metdata"; -// public string Name => "Reload Metadata Providers"; -// public string Description => "Add every loaded metadata provider to the database."; -// public string HelpMessage => null; -// public bool RunOnStartup => true; -// public int Priority => 1000; -// -// public async Task Run(IServiceProvider serviceProvider, CancellationToken cancellationToken, string arguments = null) -// { -// using IServiceScope serviceScope = serviceProvider.CreateScope(); -// IProviderRepository providers = serviceScope.ServiceProvider.GetService(); -// IThumbnailsManager thumbnails = serviceScope.ServiceProvider.GetService(); -// IPluginManager pluginManager = serviceScope.ServiceProvider.GetService(); -// -// foreach (IMetadataProvider provider in pluginManager!.GetPlugins()) -// { -// if (string.IsNullOrEmpty(provider.Provider.Slug)) -// throw new ArgumentException($"Empty provider slug (name: {provider.Provider.Name})."); -// await providers!.CreateIfNotExists(provider.Provider); -// await thumbnails!.Validate(provider.Provider); -// } -// } -// -// public Task> GetPossibleParameters() -// { -// return Task.FromResult>(null); -// } -// -// public int? Progress() -// { -// return null; -// } -// } -// } \ No newline at end of file +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; +using Kyoo.Controllers; +using Kyoo.Models.Exceptions; + +namespace Kyoo.Tasks +{ + /// + /// A task that download metadata providers images. + /// + [TaskMetadata("reload-metadata", "Reload Metadata Providers", "Add every loaded metadata provider to the database.", + RunOnStartup = true, Priority = 1000, IsHidden = true)] + public class MetadataProviderLoader : ITask + { + /// + /// The provider repository used to create in-db providers from metadata providers. + /// + private readonly IProviderRepository _providers; + /// + /// The thumbnail manager used to download providers logo. + /// + private readonly IThumbnailsManager _thumbnails; + /// + /// The list of metadata providers to register. + /// + private readonly ICollection _metadataProviders; + + /// + /// Create a new task. + /// + /// + /// The provider repository used to create in-db providers from metadata providers. + /// + /// + /// The thumbnail manager used to download providers logo. + /// + /// + /// The list of metadata providers to register. + /// + public MetadataProviderLoader(IProviderRepository providers, + IThumbnailsManager thumbnails, + ICollection metadataProviders) + { + _providers = providers; + _thumbnails = thumbnails; + _metadataProviders = metadataProviders; + } + + + /// + public TaskParameters GetParameters() + { + return new(); + } + + /// + public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) + { + float percent = 0; + progress.Report(0); + + foreach (IMetadataProvider provider in _metadataProviders) + { + if (string.IsNullOrEmpty(provider.Provider.Slug)) + throw new TaskFailedException($"Empty provider slug (name: {provider.Provider.Name})."); + await _providers.CreateIfNotExists(provider.Provider); + await _thumbnails.DownloadImages(provider.Provider); + percent += 100f / _metadataProviders.Count; + progress.Report(percent); + } + progress.Report(100); + } + } +} \ No newline at end of file diff --git a/Kyoo/Tasks/PluginInitializer.cs b/Kyoo/Tasks/PluginInitializer.cs index 55907649..c9f814a6 100644 --- a/Kyoo/Tasks/PluginInitializer.cs +++ b/Kyoo/Tasks/PluginInitializer.cs @@ -1,60 +1,63 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; using Kyoo.Controllers; -using Kyoo.Models.Attributes; namespace Kyoo.Tasks { /// /// A task run on Kyoo's startup to initialize plugins /// + [TaskMetadata("plugin-init", "Plugin Initializer", "A task to initialize plugins.", + RunOnStartup = true, Priority = int.MaxValue, IsHidden = true)] public class PluginInitializer : ITask { - /// - public string Slug => "plugin-init"; - - /// - public string Name => "PluginInitializer"; - - /// - public string Description => "A task to initialize plugins."; - - /// - public string HelpMessage => null; - - /// - public bool RunOnStartup => true; - - /// - public int Priority => int.MaxValue; - - /// /// The plugin manager used to retrieve plugins to initialize them. /// - [Injected] public IPluginManager PluginManager { private get; set; } + private readonly IPluginManager _pluginManager; /// /// The service provider given to each method. /// - [Injected] public IServiceProvider Provider { private get; set; } - - /// - public Task Run(TaskParameters arguments, CancellationToken cancellationToken) - { - foreach (IPlugin plugin in PluginManager.GetAllPlugins()) - plugin.Initialize(Provider); - return Task.CompletedTask; - } + private readonly IServiceProvider _provider; + /// + /// Create a new task + /// + /// The plugin manager used to retrieve plugins to initialize them. + /// The service provider given to each method. + public PluginInitializer(IPluginManager pluginManager, IServiceProvider provider) + { + _pluginManager = pluginManager; + _provider = provider; + } + + + /// public TaskParameters GetParameters() { return new(); } - - public int? Progress() + + /// + public Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) { - return null; + 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; } } } \ 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..9df8b1f7 --- /dev/null +++ b/Kyoo/Tasks/RegisterEpisode.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.Models.Exceptions; + +namespace Kyoo.Tasks +{ + /// + /// A task to register a new episode + /// + [TaskMetadata("register", "Register episode", "Register a new episode")] + public class RegisterEpisode : ITask + { + /// + /// An identifier to extract metadata from paths. + /// + private readonly IIdentifier _identifier; + /// + /// The library manager used to register the episode. + /// + private readonly ILibraryManager _libraryManager; + /// + /// A metadata provider to retrieve the metadata of the new episode (and related items if they do not exist). + /// + private readonly AProviderComposite _metadataProvider; + /// + /// The thumbnail manager used to download images. + /// + private readonly IThumbnailsManager _thumbnailsManager; + /// + /// The transcoder used to extract subtitles and metadata. + /// + private readonly ITranscoder _transcoder; + + /// + /// Create a new task. + /// + /// + /// An identifier to extract metadata from paths. + /// + /// + /// The library manager used to register the episode. + /// + /// + /// A metadata provider to retrieve the metadata of the new episode (and related items if they do not exist). + /// + /// + /// The thumbnail manager used to download images. + /// + /// + /// The transcoder used to extract subtitles and metadata. + /// + public RegisterEpisode(IIdentifier identifier, + ILibraryManager libraryManager, + AProviderComposite metadataProvider, + IThumbnailsManager thumbnailsManager, + ITranscoder transcoder) + { + _identifier = identifier; + _libraryManager = libraryManager; + _metadataProvider = metadataProvider; + _thumbnailsManager = thumbnailsManager; + _transcoder = transcoder; + } + + /// + 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(); + progress.Report(0); + + if (library != null) + { + if (library.Providers == null) + await _libraryManager.Load(library, x => x.Providers); + _metadataProvider.UseProviders(library.Providers); + } + + try + { + (Collection collection, Show show, Season season, Episode episode) = await _identifier.Identify(path); + progress.Report(15); + + collection = await _RegisterAndFill(collection); + progress.Report(20); + + Show registeredShow = await _RegisterAndFill(show); + if (registeredShow.Path != show.Path) + { + if (show.StartAir.HasValue) + { + show.Slug += $"-{show.StartAir.Value.Year}"; + show = await _libraryManager.Create(show); + } + else + { + throw new TaskFailedException($"Duplicated show found ({show.Slug}) " + + $"at {registeredShow.Path} and {show.Path}"); + } + } + else + show = registeredShow; + + // If they are not already loaded, load external ids to allow metadata providers to use them. + if (show.ExternalIDs == null) + await _libraryManager.Load(show, x => x.ExternalIDs); + progress.Report(50); + + if (season != null) + season.Show = show; + season = await _RegisterAndFill(season); + if (season != null) + season.Title ??= $"Season {season.SeasonNumber}"; + progress.Report(60); + + episode.Show = show; + episode.Season = season; + if (!show.IsMovie) + episode = await _metadataProvider.Get(episode); + progress.Report(70); + episode.Tracks = (await _transcoder.ExtractInfos(episode, false)) + .Where(x => x.Type != StreamType.Attachment) + .ToArray(); + await _thumbnailsManager.DownloadImages(episode); + progress.Report(90); + + await _libraryManager.Create(episode); + progress.Report(95); + await _libraryManager.AddShowLink(show, library, collection); + progress.Report(100); + } + catch (IdentificationFailedException ex) + { + throw new TaskFailedException(ex); + } + catch (DuplicatedItemException ex) + { + throw new TaskFailedException(ex); + } + } + + /// + /// Retrieve the equivalent item if it already exists in the database, + /// if it does not, fill metadata using the metadata provider, download images and register the item to the + /// database. + /// + /// The item to retrieve or fill and register + /// The type of the item + /// The existing or filled item. + private async Task _RegisterAndFill(T item) + where T : class, IResource + { + if (item == null || string.IsNullOrEmpty(item.Slug)) + return null; + + T existing = await _libraryManager.GetOrDefault(item.Slug); + if (existing != null) + return existing; + item = await _metadataProvider.Get(item); + await _thumbnailsManager.DownloadImages(item); + return await _libraryManager.CreateIfNotExists(item); + } + } +} \ No newline at end of file diff --git a/Kyoo/Tasks/RegisterSubtitle.cs b/Kyoo/Tasks/RegisterSubtitle.cs new file mode 100644 index 00000000..6c36ea58 --- /dev/null +++ b/Kyoo/Tasks/RegisterSubtitle.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Kyoo.Common.Models.Attributes; +using Kyoo.Controllers; +using Kyoo.Models; +using Kyoo.Models.Exceptions; + +namespace Kyoo.Tasks +{ + /// + /// A task to register a new episode + /// + [TaskMetadata("register-sub", "Register subtitle", "Register a new subtitle")] + public class RegisterSubtitle : ITask + { + /// + /// An identifier to extract metadata from paths. + /// + private readonly IIdentifier _identifier; + /// + /// The library manager used to register the episode. + /// + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new task. + /// + /// An identifier to extract metadata from paths. + /// The library manager used to register the episode. + public RegisterSubtitle(IIdentifier identifier, ILibraryManager libraryManager) + { + _identifier = identifier; + _libraryManager = libraryManager; + } + + /// + public TaskParameters GetParameters() + { + return new() + { + TaskParameter.CreateRequired("path", "The path of the subtitle file") + }; + } + + /// + public async Task Run(TaskParameters arguments, IProgress progress, CancellationToken cancellationToken) + { + string path = arguments["path"].As(); + + try + { + progress.Report(0); + Track track = await _identifier.IdentifyTrack(path); + progress.Report(25); + + if (track.Episode == null) + throw new TaskFailedException($"No episode identified for the track at {path}"); + if (track.Episode.ID == 0) + { + if (track.Episode.Slug != null) + track.Episode = await _libraryManager.Get(track.Episode.Slug); + else if (track.Episode.Path != null) + { + track.Episode = await _libraryManager.GetOrDefault(x => x.Path.StartsWith(track.Episode.Path)); + if (track.Episode == null) + throw new TaskFailedException($"No episode found for the track at: {path}."); + } + else + throw new TaskFailedException($"No episode identified for the track at {path}"); + } + + progress.Report(50); + await _libraryManager.Create(track); + progress.Report(100); + } + catch (IdentificationFailedException ex) + { + throw new TaskFailedException(ex); + } + } + } +} \ No newline at end of file diff --git a/Kyoo/Views/EpisodeApi.cs b/Kyoo/Views/EpisodeApi.cs index 490d0b34..b7d83e27 100644 --- a/Kyoo/Views/EpisodeApi.cs +++ b/Kyoo/Views/EpisodeApi.cs @@ -21,11 +21,11 @@ namespace Kyoo.Api { private readonly ILibraryManager _libraryManager; private readonly IThumbnailsManager _thumbnails; - private readonly IFileManager _files; + private readonly IFileSystem _files; public EpisodeApi(ILibraryManager libraryManager, IOptions options, - IFileManager files, + IFileSystem files, IThumbnailsManager thumbnails) : base(libraryManager.EpisodeRepository, options.Value.PublicUrl) { @@ -188,13 +188,14 @@ namespace Kyoo.Api } } - [HttpGet("{id:int}/thumb")] + [HttpGet("{id:int}/thumbnail")] + [HttpGet("{id:int}/backdrop")] public async Task GetThumb(int id) { try { Episode episode = await _libraryManager.Get(id); - return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + return _files.FileResult(await _thumbnails.GetThumbnail(episode)); } catch (ItemNotFoundException) { @@ -202,13 +203,14 @@ namespace Kyoo.Api } } - [HttpGet("{slug}/thumb")] + [HttpGet("{slug}/thumbnail")] + [HttpGet("{slug}/backdrop")] public async Task GetThumb(string slug) { try { Episode episode = await _libraryManager.Get(slug); - return _files.FileResult(await _thumbnails.GetEpisodeThumb(episode)); + return _files.FileResult(await _thumbnails.GetThumbnail(episode)); } catch (ItemNotFoundException) { 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/PeopleApi.cs b/Kyoo/Views/PeopleApi.cs index c421fc16..bb757a60 100644 --- a/Kyoo/Views/PeopleApi.cs +++ b/Kyoo/Views/PeopleApi.cs @@ -18,12 +18,12 @@ namespace Kyoo.Api public class PeopleApi : CrudApi { private readonly ILibraryManager _libraryManager; - private readonly IFileManager _files; + private readonly IFileSystem _files; private readonly IThumbnailsManager _thumbs; public PeopleApi(ILibraryManager libraryManager, IOptions options, - IFileManager files, + IFileSystem files, IThumbnailsManager thumbs) : base(libraryManager.PeopleRepository, options.Value.PublicUrl) { @@ -94,7 +94,7 @@ namespace Kyoo.Api People people = await _libraryManager.GetOrDefault(id); if (people == null) return NotFound(); - return _files.FileResult(await _thumbs.GetPeoplePoster(people)); + return _files.FileResult(await _thumbs.GetPoster(people)); } [HttpGet("{slug}/poster")] @@ -103,7 +103,7 @@ namespace Kyoo.Api People people = await _libraryManager.GetOrDefault(slug); if (people == null) return NotFound(); - return _files.FileResult(await _thumbs.GetPeoplePoster(people)); + return _files.FileResult(await _thumbs.GetPoster(people)); } } } \ No newline at end of file diff --git a/Kyoo/Views/ProviderApi.cs b/Kyoo/Views/ProviderApi.cs index eac22675..026b79ef 100644 --- a/Kyoo/Views/ProviderApi.cs +++ b/Kyoo/Views/ProviderApi.cs @@ -17,11 +17,11 @@ namespace Kyoo.Api { private readonly IThumbnailsManager _thumbnails; private readonly ILibraryManager _libraryManager; - private readonly IFileManager _files; + private readonly IFileSystem _files; public ProviderApi(ILibraryManager libraryManager, IOptions options, - IFileManager files, + IFileSystem files, IThumbnailsManager thumbnails) : base(libraryManager.ProviderRepository, options.Value.PublicUrl) { @@ -36,7 +36,7 @@ namespace Kyoo.Api Provider provider = await _libraryManager.GetOrDefault(id); if (provider == null) return NotFound(); - return _files.FileResult(await _thumbnails.GetProviderLogo(provider)); + return _files.FileResult(await _thumbnails.GetLogo(provider)); } [HttpGet("{slug}/logo")] @@ -45,7 +45,7 @@ namespace Kyoo.Api Provider provider = await _libraryManager.GetOrDefault(slug); if (provider == null) return NotFound(); - return _files.FileResult(await _thumbnails.GetProviderLogo(provider)); + return _files.FileResult(await _thumbnails.GetLogo(provider)); } } } \ No newline at end of file diff --git a/Kyoo/Views/SeasonApi.cs b/Kyoo/Views/SeasonApi.cs index a32b0b1c..8d08dd0c 100644 --- a/Kyoo/Views/SeasonApi.cs +++ b/Kyoo/Views/SeasonApi.cs @@ -20,12 +20,12 @@ namespace Kyoo.Api { private readonly ILibraryManager _libraryManager; private readonly IThumbnailsManager _thumbs; - private readonly IFileManager _files; + private readonly IFileSystem _files; public SeasonApi(ILibraryManager libraryManager, IOptions options, IThumbnailsManager thumbs, - IFileManager files) + IFileSystem files) : base(libraryManager.SeasonRepository, options.Value.PublicUrl) { _libraryManager = libraryManager; @@ -144,24 +144,24 @@ namespace Kyoo.Api return ret; } - [HttpGet("{id:int}/thumb")] - public async Task GetThumb(int id) + [HttpGet("{id:int}/poster")] + public async Task GetPoster(int id) { Season season = await _libraryManager.GetOrDefault(id); if (season == null) return NotFound(); await _libraryManager.Load(season, x => x.Show); - return _files.FileResult(await _thumbs.GetSeasonPoster(season)); + return _files.FileResult(await _thumbs.GetPoster(season)); } - [HttpGet("{slug}/thumb")] - public async Task GetThumb(string slug) + [HttpGet("{slug}/poster")] + public async Task GetPoster(string slug) { Season season = await _libraryManager.GetOrDefault(slug); if (season == null) return NotFound(); await _libraryManager.Load(season, x => x.Show); - return _files.FileResult(await _thumbs.GetSeasonPoster(season)); + return _files.FileResult(await _thumbs.GetPoster(season)); } } } \ No newline at end of file diff --git a/Kyoo/Views/ShowApi.cs b/Kyoo/Views/ShowApi.cs index 966aad05..04854849 100644 --- a/Kyoo/Views/ShowApi.cs +++ b/Kyoo/Views/ShowApi.cs @@ -23,11 +23,11 @@ namespace Kyoo.Api public class ShowApi : CrudApi { private readonly ILibraryManager _libraryManager; - private readonly IFileManager _files; + private readonly IFileSystem _files; private readonly IThumbnailsManager _thumbs; public ShowApi(ILibraryManager libraryManager, - IFileManager files, + IFileSystem files, IThumbnailsManager thumbs, IOptions options) : base(libraryManager.ShowRepository, options.Value.PublicUrl) @@ -386,7 +386,7 @@ namespace Kyoo.Api string path = Path.Combine(_files.GetExtraDirectory(show), "Attachments"); return (await _files.ListFiles(path)) .ToDictionary(Path.GetFileNameWithoutExtension, - x => $"{BaseURL}/api/shows/{slug}/fonts/{Path.GetFileName(x)}"); + x => $"{BaseURL}api/shows/{slug}/fonts/{Path.GetFileName(x)}"); } catch (ItemNotFoundException) { @@ -417,7 +417,7 @@ namespace Kyoo.Api try { Show show = await _libraryManager.Get(slug); - return _files.FileResult(await _thumbs.GetShowPoster(show)); + return _files.FileResult(await _thumbs.GetPoster(show)); } catch (ItemNotFoundException) { @@ -431,7 +431,7 @@ namespace Kyoo.Api try { Show show = await _libraryManager.Get(slug); - return _files.FileResult(await _thumbs.GetShowLogo(show)); + return _files.FileResult(await _thumbs.GetLogo(show)); } catch (ItemNotFoundException) { @@ -440,12 +440,13 @@ namespace Kyoo.Api } [HttpGet("{slug}/backdrop")] + [HttpGet("{slug}/thumbnail")] public async Task GetBackdrop(string slug) { try { Show show = await _libraryManager.Get(slug); - return _files.FileResult(await _thumbs.GetShowBackdrop(show)); + return _files.FileResult(await _thumbs.GetThumbnail(show)); } catch (ItemNotFoundException) { diff --git a/Kyoo/Views/SubtitleApi.cs b/Kyoo/Views/SubtitleApi.cs index 7f05eedf..89d036aa 100644 --- a/Kyoo/Views/SubtitleApi.cs +++ b/Kyoo/Views/SubtitleApi.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using Kyoo.Controllers; using Kyoo.Models.Permissions; @@ -13,20 +14,51 @@ namespace Kyoo.Api public class SubtitleApi : ControllerBase { private readonly ILibraryManager _libraryManager; - private readonly IFileManager _files; + private readonly IFileSystem _files; - public SubtitleApi(ILibraryManager libraryManager, IFileManager files) + public SubtitleApi(ILibraryManager libraryManager, IFileSystem files) { _libraryManager = libraryManager; _files = files; } - - [HttpGet("{slug}.{extension}")] + [HttpGet("{id:int}")] [Permission(nameof(SubtitleApi), Kind.Read)] - public async Task GetSubtitle(string slug, string extension) + public async Task GetSubtitle(int id) { - Track subtitle = await _libraryManager.GetOrDefault(Track.EditSlug(slug, StreamType.Subtitle)); + Track subtitle = await _libraryManager.GetOrDefault(id); + return subtitle != null + ? _files.FileResult(subtitle.Path) + : NotFound(); + } + + [HttpGet("{id:int}.{extension}")] + [Permission(nameof(SubtitleApi), Kind.Read)] + public async Task GetSubtitle(int id, string extension) + { + Track subtitle = await _libraryManager.GetOrDefault(id); + if (subtitle == null) + return NotFound(); + if (subtitle.Codec == "subrip" && extension == "vtt") + return new ConvertSubripToVtt(subtitle.Path, _files); + return _files.FileResult(subtitle.Path); + } + + + [HttpGet("{slug}")] + [Permission(nameof(SubtitleApi), Kind.Read)] + public async Task GetSubtitle(string slug) + { + string extension = null; + + if (slug.Count(x => x == '.') == 2) + { + int idx = slug.LastIndexOf('.'); + extension = slug[(idx + 1)..]; + slug = slug[..idx]; + } + + Track subtitle = await _libraryManager.GetOrDefault(Track.BuildSlug(slug, StreamType.Subtitle)); if (subtitle == null) return NotFound(); if (subtitle.Codec == "subrip" && extension == "vtt") @@ -39,9 +71,9 @@ namespace Kyoo.Api public class ConvertSubripToVtt : IActionResult { private readonly string _path; - private readonly IFileManager _files; + private readonly IFileSystem _files; - public ConvertSubripToVtt(string subtitlePath, IFileManager files) + public ConvertSubripToVtt(string subtitlePath, IFileSystem files) { _path = subtitlePath; _files = files; @@ -60,7 +92,7 @@ namespace Kyoo.Api await writer.WriteLineAsync(""); await writer.WriteLineAsync(""); - using StreamReader reader = new(_files.GetReader(_path)); + using StreamReader reader = new(await _files.GetReader(_path)); string line; while ((line = await reader.ReadLineAsync()) != null) { 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) diff --git a/Kyoo/Views/TrackApi.cs b/Kyoo/Views/TrackApi.cs index 0eadaf3b..07df38ce 100644 --- a/Kyoo/Views/TrackApi.cs +++ b/Kyoo/Views/TrackApi.cs @@ -45,8 +45,6 @@ namespace Kyoo.Api { try { - // TODO This won't work with the local repository implementation. - // TODO Implement something like this (a dotnet-ef's QueryCompilationContext): https://stackoverflow.com/questions/62687811/how-can-i-convert-a-custom-function-to-a-sql-expression-for-entity-framework-cor return await _libraryManager.Get(x => x.Tracks.Any(y => y.Slug == slug)); } catch (ItemNotFoundException) diff --git a/Kyoo/Views/VideoApi.cs b/Kyoo/Views/VideoApi.cs index 5c48f7bc..cbc2937b 100644 --- a/Kyoo/Views/VideoApi.cs +++ b/Kyoo/Views/VideoApi.cs @@ -18,12 +18,12 @@ namespace Kyoo.Api private readonly ILibraryManager _libraryManager; private readonly ITranscoder _transcoder; private readonly IOptions _options; - private readonly IFileManager _files; + private readonly IFileSystem _files; public VideoApi(ILibraryManager libraryManager, ITranscoder transcoder, IOptions options, - IFileManager files) + IFileSystem files) { _libraryManager = libraryManager; _transcoder = transcoder; diff --git a/Kyoo/settings.json b/Kyoo/settings.json index 2ff0e24b..66ff8566 100644 --- a/Kyoo/settings.json +++ b/Kyoo/settings.json @@ -44,8 +44,14 @@ }, "media": { - "regex": "(?:\\/(?.*?))?\\/(?.*?)(?: \\(\\d+\\))?\\/\\k(?: \\(\\d+\\))?(?:(?: S(?\\d+)E(?\\d+))| (?\\d+))?.*$", - "subtitleRegex": "^(?.*)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$" + "regex": [ + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? S(?\\d+)E(?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))? (?\\d+)\\..*$", + "^\\/?(?.+)?\\/(?.+?)(?: \\((?\\d+)\\))?\\/\\k(?: \\(\\d+\\))?\\..*$" + ], + "subtitleRegex": [ + "^(?.+)\\.(?\\w{1,3})\\.(?default\\.)?(?forced\\.)?.*$" + ] }, "authentication": { @@ -60,5 +66,9 @@ }, "profilePicturePath": "users/", "clients": [] + }, + + "tvdb": { + "apiKey": "REDACTED" } }