mirror of
				https://github.com/zoriya/Kyoo.git
				synced 2025-10-31 10:37:13 -04:00 
			
		
		
		
	Switch to file scopped namespaces (#349)
This commit is contained in:
		
						commit
						5fedce71a0
					
				| @ -1,2 +1,3 @@ | |||||||
| 7e6e56a366babe17e7891a5897180efbf93c00c5 | 7e6e56a366babe17e7891a5897180efbf93c00c5 | ||||||
| a5638203a6ecb9f372a5a61e1c8fd443bf3a17fe | a5638203a6ecb9f372a5a61e1c8fd443bf3a17fe | ||||||
|  | 18e301f26acd7f2e97eac26c5f48377fa13956f5 | ||||||
|  | |||||||
| @ -16,6 +16,8 @@ dotnet_diagnostic.IDE0055.severity = none | |||||||
| dotnet_diagnostic.IDE0058.severity = none | dotnet_diagnostic.IDE0058.severity = none | ||||||
| dotnet_diagnostic.IDE0130.severity = none | dotnet_diagnostic.IDE0130.severity = none | ||||||
| 
 | 
 | ||||||
|  | # Convert to file-scoped namespace | ||||||
|  | csharp_style_namespace_declarations = file_scoped:warning | ||||||
| # Sort using and Import directives with System.* appearing first | # Sort using and Import directives with System.* appearing first | ||||||
| dotnet_sort_system_directives_first = true | dotnet_sort_system_directives_first = true | ||||||
| csharp_using_directive_placement = outside_namespace:warning | csharp_using_directive_placement = outside_namespace:warning | ||||||
|  | |||||||
| @ -18,64 +18,63 @@ | |||||||
| 
 | 
 | ||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface to interact with the database. Every repository is mapped through here. | ||||||
|  | /// </summary> | ||||||
|  | public interface ILibraryManager | ||||||
| { | { | ||||||
|  | 	IRepository<T> Repository<T>() | ||||||
|  | 		where T : IResource, IQuery; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An interface to interact with the database. Every repository is mapped through here. | 	/// The repository that handle libraries items (a wrapper around shows and collections). | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface ILibraryManager | 	IRepository<ILibraryItem> LibraryItems { get; } | ||||||
| 	{ |  | ||||||
| 		IRepository<T> Repository<T>() |  | ||||||
| 			where T : IResource, IQuery; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle libraries items (a wrapper around shows and collections). | 	/// The repository that handle new items. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<ILibraryItem> LibraryItems { get; } | 	IRepository<INews> News { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle new items. | 	/// The repository that handle watched items. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<INews> News { get; } | 	IWatchStatusRepository WatchStatus { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle watched items. | 	/// The repository that handle collections. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IWatchStatusRepository WatchStatus { get; } | 	IRepository<Collection> Collections { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle collections. | 	/// The repository that handle shows. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Collection> Collections { get; } | 	IRepository<Movie> Movies { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle shows. | 	/// The repository that handle shows. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Movie> Movies { get; } | 	IRepository<Show> Shows { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle shows. | 	/// The repository that handle seasons. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Show> Shows { get; } | 	IRepository<Season> Seasons { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle seasons. | 	/// The repository that handle episodes. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Season> Seasons { get; } | 	IRepository<Episode> Episodes { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle episodes. | 	/// The repository that handle studios. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Episode> Episodes { get; } | 	IRepository<Studio> Studios { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The repository that handle studios. | 	/// The repository that handle users. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		IRepository<Studio> Studios { get; } | 	IRepository<User> Users { get; } | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The repository that handle users. |  | ||||||
| 		/// </summary> |  | ||||||
| 		IRepository<User> Users { get; } |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,29 +19,28 @@ | |||||||
| using Kyoo.Abstractions.Models.Permissions; | using Kyoo.Abstractions.Models.Permissions; | ||||||
| using Microsoft.AspNetCore.Mvc.Filters; | using Microsoft.AspNetCore.Mvc.Filters; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A service to validate permissions. | ||||||
|  | /// </summary> | ||||||
|  | public interface IPermissionValidator | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A service to validate permissions. | 	/// Create an IAuthorizationFilter that will be used to validate permissions. | ||||||
|  | 	/// This can registered with any lifetime. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IPermissionValidator | 	/// <param name="attribute">The permission attribute to validate.</param> | ||||||
| 	{ | 	/// <returns>An authorization filter used to validate the permission.</returns> | ||||||
| 		/// <summary> | 	IFilterMetadata Create(PermissionAttribute attribute); | ||||||
| 		/// Create an IAuthorizationFilter that will be used to validate permissions. |  | ||||||
| 		/// This can registered with any lifetime. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="attribute">The permission attribute to validate.</param> |  | ||||||
| 		/// <returns>An authorization filter used to validate the permission.</returns> |  | ||||||
| 		IFilterMetadata Create(PermissionAttribute attribute); |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create an IAuthorizationFilter that will be used to validate permissions. | 	/// Create an IAuthorizationFilter that will be used to validate permissions. | ||||||
| 		/// This can registered with any lifetime. | 	/// This can registered with any lifetime. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="attribute"> | 	/// <param name="attribute"> | ||||||
| 		/// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>. | 	/// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <returns>An authorization filter used to validate the permission.</returns> | 	/// <returns>An authorization filter used to validate the permission.</returns> | ||||||
| 		IFilterMetadata Create(PartialPermissionAttribute attribute); | 	IFilterMetadata Create(PartialPermissionAttribute attribute); | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,46 +21,45 @@ using System.Collections.Generic; | |||||||
| using Autofac; | using Autofac; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A common interface used to discord plugins | ||||||
|  | /// </summary> | ||||||
|  | /// <remarks> | ||||||
|  | /// You can inject services in the IPlugin constructor. | ||||||
|  | /// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. | ||||||
|  | /// </remarks> | ||||||
|  | public interface IPlugin | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A common interface used to discord plugins | 	/// The name of the plugin | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <remarks> | 	string Name { get; } | ||||||
| 	/// You can inject services in the IPlugin constructor. | 
 | ||||||
| 	/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. | 	/// <summary> | ||||||
| 	/// </remarks> | 	/// An optional configuration step to allow a plugin to change asp net configurations. | ||||||
| 	public interface IPlugin | 	/// </summary> | ||||||
|  | 	/// <seealso cref="SA"/> | ||||||
|  | 	IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A configure method that will be run on plugin's startup. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="builder">The autofac service container to register services.</param> | ||||||
|  | 	void Configure(ContainerBuilder builder) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		// Skipped | ||||||
| 		/// The name of the plugin | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		string Name { get; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// An optional configuration step to allow a plugin to change asp net configurations. | 	/// A configure method that will be run on plugin's startup. | ||||||
| 		/// </summary> | 	/// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise | ||||||
| 		/// <seealso cref="SA"/> | 	/// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>. | ||||||
| 		IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty; | 	/// </summary> | ||||||
| 
 | 	/// <param name="services">A service container to register new services.</param> | ||||||
| 		/// <summary> | 	void Configure(IServiceCollection services) | ||||||
| 		/// A configure method that will be run on plugin's startup. | 	{ | ||||||
| 		/// </summary> | 		// Skipped | ||||||
| 		/// <param name="builder">The autofac service container to register services.</param> |  | ||||||
| 		void Configure(ContainerBuilder builder) |  | ||||||
| 		{ |  | ||||||
| 			// Skipped |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A configure method that will be run on plugin's startup. |  | ||||||
| 		/// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise |  | ||||||
| 		/// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="services">A service container to register new services.</param> |  | ||||||
| 		void Configure(IServiceCollection services) |  | ||||||
| 		{ |  | ||||||
| 			// Skipped |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,51 +20,50 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using Kyoo.Abstractions.Models.Exceptions; | using Kyoo.Abstractions.Models.Exceptions; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A manager to load plugins and retrieve information from them. | ||||||
|  | /// </summary> | ||||||
|  | public interface IPluginManager | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A manager to load plugins and retrieve information from them. | 	/// Get a single plugin that match the type and name given. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IPluginManager | 	/// <param name="name">The name of the plugin</param> | ||||||
| 	{ | 	/// <typeparam name="T">The type of the plugin</typeparam> | ||||||
| 		/// <summary> | 	/// <exception cref="ItemNotFoundException">If no plugins match the query</exception> | ||||||
| 		/// Get a single plugin that match the type and name given. | 	/// <returns>A plugin that match the queries</returns> | ||||||
| 		/// </summary> | 	public T GetPlugin<T>(string name); | ||||||
| 		/// <param name="name">The name of the plugin</param> |  | ||||||
| 		/// <typeparam name="T">The type of the plugin</typeparam> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If no plugins match the query</exception> |  | ||||||
| 		/// <returns>A plugin that match the queries</returns> |  | ||||||
| 		public T GetPlugin<T>(string name); |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get all plugins of the given type. | 	/// Get all plugins of the given type. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <typeparam name="T">The type of plugins to get</typeparam> | 	/// <typeparam name="T">The type of plugins to get</typeparam> | ||||||
| 		/// <returns>A list of plugins matching the given type or an empty list of none match.</returns> | 	/// <returns>A list of plugins matching the given type or an empty list of none match.</returns> | ||||||
| 		public ICollection<T> GetPlugins<T>(); | 	public ICollection<T> GetPlugins<T>(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted. | 	/// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <returns>All plugins currently loaded.</returns> | 	/// <returns>All plugins currently loaded.</returns> | ||||||
| 		public ICollection<IPlugin> GetAllPlugins(); | 	public ICollection<IPlugin> GetAllPlugins(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Load plugins and their dependencies from the plugin directory. | 	/// Load plugins and their dependencies from the plugin directory. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="plugins"> | 	/// <param name="plugins"> | ||||||
| 		/// An initial plugin list to use. | 	/// An initial plugin list to use. | ||||||
| 		/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. | 	/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		public void LoadPlugins(ICollection<IPlugin> plugins); | 	public void LoadPlugins(ICollection<IPlugin> plugins); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Load plugins and their dependencies from the plugin directory. | 	/// Load plugins and their dependencies from the plugin directory. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="plugins"> | 	/// <param name="plugins"> | ||||||
| 		/// An initial plugin list to use. | 	/// An initial plugin list to use. | ||||||
| 		/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. | 	/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		public void LoadPlugins(params Type[] plugins); | 	public void LoadPlugins(params Type[] plugins); | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,249 +23,245 @@ using Kyoo.Abstractions.Models; | |||||||
| using Kyoo.Abstractions.Models.Exceptions; | using Kyoo.Abstractions.Models.Exceptions; | ||||||
| using Kyoo.Abstractions.Models.Utils; | using Kyoo.Abstractions.Models.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A common repository for every resources. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The resource's type that this repository manage.</typeparam> | ||||||
|  | public interface IRepository<T> : IBaseRepository | ||||||
|  | 	where T : IResource, IQuery | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A common repository for every resources. | 	/// The event handler type for all events of this repository. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">The resource's type that this repository manage.</typeparam> | 	/// <param name="resource">The resource created/modified/deleted</param> | ||||||
| 	public interface IRepository<T> : IBaseRepository | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
| 		where T : IResource, IQuery | 	public delegate Task ResourceEventHandler(T resource); | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The event handler type for all events of this repository. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="resource">The resource created/modified/deleted</param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		public delegate Task ResourceEventHandler(T resource); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get a resource from it's ID. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="id">The id of the resource</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T> Get(Guid id, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get a resource from it's slug. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="slug">The slug of the resource</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T> Get(string slug, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get the first resource that match the predicate. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="filter">A predicate to filter the resource.</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param> |  | ||||||
| 		/// <param name="reverse">Reverse the sort.</param> |  | ||||||
| 		/// <param name="afterId">Select the first element after this id if it was in a list.</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T> Get( |  | ||||||
| 			Filter<T> filter, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Sort<T>? sortBy = default, |  | ||||||
| 			bool reverse = false, |  | ||||||
| 			Guid? afterId = default |  | ||||||
| 		); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get a resource from it's ID or null if it is not found. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="id">The id of the resource</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T?> GetOrDefault(Guid id, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get a resource from it's slug or null if it is not found. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="slug">The slug of the resource</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T?> GetOrDefault(string slug, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get the first resource that match the predicate or null if it is not found. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="filter">A predicate to filter the resource.</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param> |  | ||||||
| 		/// <param name="reverse">Reverse the sort.</param> |  | ||||||
| 		/// <param name="afterId">Select the first element after this id if it was in a list.</param> |  | ||||||
| 		/// <returns>The resource found</returns> |  | ||||||
| 		Task<T?> GetOrDefault( |  | ||||||
| 			Filter<T>? filter, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Sort<T>? sortBy = default, |  | ||||||
| 			bool reverse = false, |  | ||||||
| 			Guid? afterId = default |  | ||||||
| 		); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Search for resources with the database. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="query">The query string.</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <returns>A list of resources found</returns> |  | ||||||
| 		Task<ICollection<T>> Search(string query, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get every resources that match all filters |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="filter">A filter predicate</param> |  | ||||||
| 		/// <param name="sort">Sort information about the query (sort by, sort order)</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <param name="limit">How pagination should be done (where to start and how many to return)</param> |  | ||||||
| 		/// <returns>A list of resources that match every filters</returns> |  | ||||||
| 		Task<ICollection<T>> GetAll( |  | ||||||
| 			Filter<T>? filter = null, |  | ||||||
| 			Sort<T>? sort = default, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Pagination? limit = default |  | ||||||
| 		); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get the number of resources that match the filter's predicate. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="filter">A filter predicate</param> |  | ||||||
| 		/// <returns>How many resources matched that filter</returns> |  | ||||||
| 		Task<int> GetCount(Filter<T>? filter = null); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Map a list of ids to a list of items (keep the order). |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="ids">The list of items id.</param> |  | ||||||
| 		/// <param name="include">The related fields to include.</param> |  | ||||||
| 		/// <returns>A list of resources mapped from ids.</returns> |  | ||||||
| 		Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The item to register</param> |  | ||||||
| 		/// <returns>The resource registers and completed by database's information (related items and so on)</returns> |  | ||||||
| 		Task<T> Create(T obj); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new resource if it does not exist already. If it does, the existing value is returned instead. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The object to create</param> |  | ||||||
| 		/// <returns>The newly created item or the existing value if it existed.</returns> |  | ||||||
| 		Task<T> CreateIfNotExists(T obj); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Called when a resource has been created. |  | ||||||
| 		/// </summary> |  | ||||||
| 		static event ResourceEventHandler OnCreated; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Callback that should be called after a resource has been created. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The resource newly created.</param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		protected static Task OnResourceCreated(T obj) => |  | ||||||
| 			OnCreated?.Invoke(obj) ?? Task.CompletedTask; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Edit a resource and replace every property |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="edited">The resource to edit, it's ID can't change.</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> |  | ||||||
| 		/// <returns>The resource edited and completed by database's information (related items and so on)</returns> |  | ||||||
| 		Task<T> Edit(T edited); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Edit only specific properties of a resource |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="id">The id of the resource to edit</param> |  | ||||||
| 		/// <param name="patch"> |  | ||||||
| 		/// A method that will be called when you need to update every properties that you want to |  | ||||||
| 		/// persist. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> |  | ||||||
| 		/// <returns>The resource edited and completed by database's information (related items and so on)</returns> |  | ||||||
| 		Task<T> Patch(Guid id, Func<T, T> patch); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Called when a resource has been edited. |  | ||||||
| 		/// </summary> |  | ||||||
| 		static event ResourceEventHandler OnEdited; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Callback that should be called after a resource has been edited. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The resource newly edited.</param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		protected static Task OnResourceEdited(T obj) => |  | ||||||
| 			OnEdited?.Invoke(obj) ?? Task.CompletedTask; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete a resource by it's ID |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="id">The ID of the resource</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		Task Delete(Guid id); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete a resource by it's slug |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="slug">The slug of the resource</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		Task Delete(string slug); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete a resource |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The resource to delete</param> |  | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		Task Delete(T obj); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete all resources that match the predicate. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		Task DeleteAll(Filter<T> filter); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Called when a resource has been edited. |  | ||||||
| 		/// </summary> |  | ||||||
| 		static event ResourceEventHandler OnDeleted; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Callback that should be called after a resource has been deleted. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="obj">The resource newly deleted.</param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		protected static Task OnResourceDeleted(T obj) => |  | ||||||
| 			OnDeleted?.Invoke(obj) ?? Task.CompletedTask; |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>. | 	/// Get a resource from it's ID. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IBaseRepository | 	/// <param name="id">The id of the resource</param> | ||||||
| 	{ | 	/// <param name="include">The related fields to include.</param> | ||||||
| 		/// <summary> | 	/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> | ||||||
| 		/// The type for witch this repository is responsible or null if non applicable. | 	/// <returns>The resource found</returns> | ||||||
| 		/// </summary> | 	Task<T> Get(Guid id, Include<T>? include = default); | ||||||
| 		Type RepositoryType { get; } |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	public interface IUserRepository : IRepository<User> | 	/// <summary> | ||||||
| 	{ | 	/// Get a resource from it's slug. | ||||||
| 		Task<User?> GetByExternalId(string provider, string id); | 	/// </summary> | ||||||
| 		Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token); | 	/// <param name="slug">The slug of the resource</param> | ||||||
| 		Task<User> DeleteExternalToken(Guid userId, string provider); | 	/// <param name="include">The related fields to include.</param> | ||||||
| 	} | 	/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> | ||||||
|  | 	/// <returns>The resource found</returns> | ||||||
|  | 	Task<T> Get(string slug, Include<T>? include = default); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get the first resource that match the predicate. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="filter">A predicate to filter the resource.</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param> | ||||||
|  | 	/// <param name="reverse">Reverse the sort.</param> | ||||||
|  | 	/// <param name="afterId">Select the first element after this id if it was in a list.</param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item could not be found.</exception> | ||||||
|  | 	/// <returns>The resource found</returns> | ||||||
|  | 	Task<T> Get( | ||||||
|  | 		Filter<T> filter, | ||||||
|  | 		Include<T>? include = default, | ||||||
|  | 		Sort<T>? sortBy = default, | ||||||
|  | 		bool reverse = false, | ||||||
|  | 		Guid? afterId = default | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get a resource from it's ID or null if it is not found. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="id">The id of the resource</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <returns>The resource found</returns> | ||||||
|  | 	Task<T?> GetOrDefault(Guid id, Include<T>? include = default); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get a resource from it's slug or null if it is not found. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="slug">The slug of the resource</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <returns>The resource found</returns> | ||||||
|  | 	Task<T?> GetOrDefault(string slug, Include<T>? include = default); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get the first resource that match the predicate or null if it is not found. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="filter">A predicate to filter the resource.</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param> | ||||||
|  | 	/// <param name="reverse">Reverse the sort.</param> | ||||||
|  | 	/// <param name="afterId">Select the first element after this id if it was in a list.</param> | ||||||
|  | 	/// <returns>The resource found</returns> | ||||||
|  | 	Task<T?> GetOrDefault( | ||||||
|  | 		Filter<T>? filter, | ||||||
|  | 		Include<T>? include = default, | ||||||
|  | 		Sort<T>? sortBy = default, | ||||||
|  | 		bool reverse = false, | ||||||
|  | 		Guid? afterId = default | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Search for resources with the database. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="query">The query string.</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <returns>A list of resources found</returns> | ||||||
|  | 	Task<ICollection<T>> Search(string query, Include<T>? include = default); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get every resources that match all filters | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="filter">A filter predicate</param> | ||||||
|  | 	/// <param name="sort">Sort information about the query (sort by, sort order)</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <param name="limit">How pagination should be done (where to start and how many to return)</param> | ||||||
|  | 	/// <returns>A list of resources that match every filters</returns> | ||||||
|  | 	Task<ICollection<T>> GetAll( | ||||||
|  | 		Filter<T>? filter = null, | ||||||
|  | 		Sort<T>? sort = default, | ||||||
|  | 		Include<T>? include = default, | ||||||
|  | 		Pagination? limit = default | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get the number of resources that match the filter's predicate. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="filter">A filter predicate</param> | ||||||
|  | 	/// <returns>How many resources matched that filter</returns> | ||||||
|  | 	Task<int> GetCount(Filter<T>? filter = null); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Map a list of ids to a list of items (keep the order). | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="ids">The list of items id.</param> | ||||||
|  | 	/// <param name="include">The related fields to include.</param> | ||||||
|  | 	/// <returns>A list of resources mapped from ids.</returns> | ||||||
|  | 	Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new resource. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The item to register</param> | ||||||
|  | 	/// <returns>The resource registers and completed by database's information (related items and so on)</returns> | ||||||
|  | 	Task<T> Create(T obj); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new resource if it does not exist already. If it does, the existing value is returned instead. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The object to create</param> | ||||||
|  | 	/// <returns>The newly created item or the existing value if it existed.</returns> | ||||||
|  | 	Task<T> CreateIfNotExists(T obj); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Called when a resource has been created. | ||||||
|  | 	/// </summary> | ||||||
|  | 	static event ResourceEventHandler OnCreated; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Callback that should be called after a resource has been created. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The resource newly created.</param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	protected static Task OnResourceCreated(T obj) => OnCreated?.Invoke(obj) ?? Task.CompletedTask; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Edit a resource and replace every property | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="edited">The resource to edit, it's ID can't change.</param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
|  | 	/// <returns>The resource edited and completed by database's information (related items and so on)</returns> | ||||||
|  | 	Task<T> Edit(T edited); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Edit only specific properties of a resource | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="id">The id of the resource to edit</param> | ||||||
|  | 	/// <param name="patch"> | ||||||
|  | 	/// A method that will be called when you need to update every properties that you want to | ||||||
|  | 	/// persist. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
|  | 	/// <returns>The resource edited and completed by database's information (related items and so on)</returns> | ||||||
|  | 	Task<T> Patch(Guid id, Func<T, T> patch); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Called when a resource has been edited. | ||||||
|  | 	/// </summary> | ||||||
|  | 	static event ResourceEventHandler OnEdited; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Callback that should be called after a resource has been edited. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The resource newly edited.</param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	protected static Task OnResourceEdited(T obj) => OnEdited?.Invoke(obj) ?? Task.CompletedTask; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete a resource by it's ID | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="id">The ID of the resource</param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	Task Delete(Guid id); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete a resource by it's slug | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="slug">The slug of the resource</param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	Task Delete(string slug); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete a resource | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The resource to delete</param> | ||||||
|  | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	Task Delete(T obj); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete all resources that match the predicate. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	Task DeleteAll(Filter<T> filter); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Called when a resource has been edited. | ||||||
|  | 	/// </summary> | ||||||
|  | 	static event ResourceEventHandler OnDeleted; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Callback that should be called after a resource has been deleted. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="obj">The resource newly deleted.</param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	protected static Task OnResourceDeleted(T obj) => OnDeleted?.Invoke(obj) ?? Task.CompletedTask; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>. | ||||||
|  | /// </summary> | ||||||
|  | public interface IBaseRepository | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The type for witch this repository is responsible or null if non applicable. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Type RepositoryType { get; } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | public interface IUserRepository : IRepository<User> | ||||||
|  | { | ||||||
|  | 	Task<User?> GetByExternalId(string provider, string id); | ||||||
|  | 	Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token); | ||||||
|  | 	Task<User> DeleteExternalToken(Guid userId, string provider); | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,59 +21,58 @@ using System.IO; | |||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Download images and retrieve the path of those images for a resource. | ||||||
|  | /// </summary> | ||||||
|  | public interface IThumbnailsManager | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Download images and retrieve the path of those images for a resource. | 	/// Download images of a specified item. | ||||||
|  | 	/// If no images is available to download, do nothing and silently return. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IThumbnailsManager | 	/// <param name="item"> | ||||||
| 	{ | 	/// The item to cache images. | ||||||
| 		/// <summary> | 	/// </param> | ||||||
| 		/// Download images of a specified item. | 	/// <typeparam name="T">The type of the item</typeparam> | ||||||
| 		/// If no images is available to download, do nothing and silently return. | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
| 		/// </summary> | 	Task DownloadImages<T>(T item) | ||||||
| 		/// <param name="item"> | 		where T : IThumbnails; | ||||||
| 		/// The item to cache images. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <typeparam name="T">The type of the item</typeparam> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		Task DownloadImages<T>(T item) |  | ||||||
| 			where T : IThumbnails; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Retrieve the local path of an image of the given item. | 	/// Retrieve the local path of an image of the given item. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="item">The item to retrieve the poster from.</param> | 	/// <param name="item">The item to retrieve the poster from.</param> | ||||||
| 		/// <param name="image">The ID of the image.</param> | 	/// <param name="image">The ID of the image.</param> | ||||||
| 		/// <param name="quality">The quality of the image</param> | 	/// <param name="quality">The quality of the image</param> | ||||||
| 		/// <typeparam name="T">The type of the item</typeparam> | 	/// <typeparam name="T">The type of the item</typeparam> | ||||||
| 		/// <returns>The path of the image for the given resource or null if it does not exists.</returns> | 	/// <returns>The path of the image for the given resource or null if it does not exists.</returns> | ||||||
| 		string GetImagePath<T>(T item, string image, ImageQuality quality) | 	string GetImagePath<T>(T item, string image, ImageQuality quality) | ||||||
| 			where T : IThumbnails; | 		where T : IThumbnails; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Delete images associated with the item. | 	/// Delete images associated with the item. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="item"> | 	/// <param name="item"> | ||||||
| 		/// The item with cached images. | 	/// The item with cached images. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <typeparam name="T">The type of the item</typeparam> | 	/// <typeparam name="T">The type of the item</typeparam> | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
| 		Task DeleteImages<T>(T item) | 	Task DeleteImages<T>(T item) | ||||||
| 			where T : IThumbnails; | 		where T : IThumbnails; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Set the user's profile picture | 	/// Set the user's profile picture | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="userId">The id of the user. </param> | 	/// <param name="userId">The id of the user. </param> | ||||||
| 		/// <returns>The byte stream of the image. Null if no image exist.</returns> | 	/// <returns>The byte stream of the image. Null if no image exist.</returns> | ||||||
| 		Task<Stream> GetUserImage(Guid userId); | 	Task<Stream> GetUserImage(Guid userId); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Set the user's profile picture | 	/// Set the user's profile picture | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="userId">The id of the user. </param> | 	/// <param name="userId">The id of the user. </param> | ||||||
| 		/// <param name="image">The byte stream of the image. Null to delete the image.</param> | 	/// <param name="image">The byte stream of the image. Null to delete the image.</param> | ||||||
| 		Task SetUserImage(Guid userId, Stream? image); | 	Task SetUserImage(Guid userId, Stream? image); | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,256 +19,252 @@ | |||||||
| using System; | using System; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>. | ||||||
|  | /// It also contains helper methods for creating new <see cref="StartupAction"/>. | ||||||
|  | /// </summary> | ||||||
|  | public static class SA | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>. | 	/// The highest predefined priority existing for <see cref="StartupAction"/>. | ||||||
| 	/// It also contains helper methods for creating new <see cref="StartupAction"/>. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class SA | 	public const int Before = 5000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Items defining routing (see IApplicationBuilder.UseRouting use this priority. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int Routing = 4000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Actions defining new static files router use this priority. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int StaticFiles = 3000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Actions calling IApplicationBuilder.UseAuthentication use this priority. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int Authentication = 2000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Actions calling IApplicationBuilder.UseAuthorization use this priority. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int Authorization = 1000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall). | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int Endpoint = 0; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The lowest predefined priority existing for <see cref="StartupAction"/>. | ||||||
|  | 	/// It should run after all other actions. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public const int After = -1000; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="StartupAction"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="action">The action to run</param> | ||||||
|  | 	/// <param name="priority">The priority of the new action</param> | ||||||
|  | 	/// <returns>A new <see cref="StartupAction"/></returns> | ||||||
|  | 	public static StartupAction New(Action action, int priority) => new(action, priority); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="StartupAction"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="action">The action to run</param> | ||||||
|  | 	/// <param name="priority">The priority of the new action</param> | ||||||
|  | 	/// <typeparam name="T">A dependency that this action will use.</typeparam> | ||||||
|  | 	/// <returns>A new <see cref="StartupAction"/></returns> | ||||||
|  | 	public static StartupAction<T> New<T>(Action<T> action, int priority) | ||||||
|  | 		where T : notnull => new(action, priority); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="StartupAction"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="action">The action to run</param> | ||||||
|  | 	/// <param name="priority">The priority of the new action</param> | ||||||
|  | 	/// <typeparam name="T">A dependency that this action will use.</typeparam> | ||||||
|  | 	/// <typeparam name="T2">A second dependency that this action will use.</typeparam> | ||||||
|  | 	/// <returns>A new <see cref="StartupAction"/></returns> | ||||||
|  | 	public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority) | ||||||
|  | 		where T : notnull | ||||||
|  | 		where T2 : notnull => new(action, priority); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="StartupAction"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="action">The action to run</param> | ||||||
|  | 	/// <param name="priority">The priority of the new action</param> | ||||||
|  | 	/// <typeparam name="T">A dependency that this action will use.</typeparam> | ||||||
|  | 	/// <typeparam name="T2">A second dependency that this action will use.</typeparam> | ||||||
|  | 	/// <typeparam name="T3">A third dependency that this action will use.</typeparam> | ||||||
|  | 	/// <returns>A new <see cref="StartupAction"/></returns> | ||||||
|  | 	public static StartupAction<T, T2, T3> New<T, T2, T3>(Action<T, T2, T3> action, int priority) | ||||||
|  | 		where T : notnull | ||||||
|  | 		where T2 : notnull | ||||||
|  | 		where T3 : notnull => new(action, priority); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A <see cref="IStartupAction"/> with no dependencies. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public class StartupAction : IStartupAction | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// The highest predefined priority existing for <see cref="StartupAction"/>. | 		/// The action to execute at startup. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		public const int Before = 5000; | 		private readonly Action _action; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <inheritdoc /> | ||||||
| 		/// Items defining routing (see IApplicationBuilder.UseRouting use this priority. | 		public int Priority { get; } | ||||||
| 		/// </summary> |  | ||||||
| 		public const int Routing = 4000; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Actions defining new static files router use this priority. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int StaticFiles = 3000; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Actions calling IApplicationBuilder.UseAuthentication use this priority. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int Authentication = 2000; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Actions calling IApplicationBuilder.UseAuthorization use this priority. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int Authorization = 1000; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall). |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int Endpoint = 0; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The lowest predefined priority existing for <see cref="StartupAction"/>. |  | ||||||
| 		/// It should run after all other actions. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int After = -1000; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Create a new <see cref="StartupAction"/>. | 		/// Create a new <see cref="StartupAction"/>. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		/// <param name="action">The action to run</param> | 		/// <param name="action">The action to execute on startup.</param> | ||||||
| 		/// <param name="priority">The priority of the new action</param> | 		/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> | ||||||
| 		/// <returns>A new <see cref="StartupAction"/></returns> | 		public StartupAction(Action action, int priority) | ||||||
| 		public static StartupAction New(Action action, int priority) => new(action, priority); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="StartupAction"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="action">The action to run</param> |  | ||||||
| 		/// <param name="priority">The priority of the new action</param> |  | ||||||
| 		/// <typeparam name="T">A dependency that this action will use.</typeparam> |  | ||||||
| 		/// <returns>A new <see cref="StartupAction"/></returns> |  | ||||||
| 		public static StartupAction<T> New<T>(Action<T> action, int priority) |  | ||||||
| 			where T : notnull => new(action, priority); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="StartupAction"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="action">The action to run</param> |  | ||||||
| 		/// <param name="priority">The priority of the new action</param> |  | ||||||
| 		/// <typeparam name="T">A dependency that this action will use.</typeparam> |  | ||||||
| 		/// <typeparam name="T2">A second dependency that this action will use.</typeparam> |  | ||||||
| 		/// <returns>A new <see cref="StartupAction"/></returns> |  | ||||||
| 		public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority) |  | ||||||
| 			where T : notnull |  | ||||||
| 			where T2 : notnull => new(action, priority); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="StartupAction"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="action">The action to run</param> |  | ||||||
| 		/// <param name="priority">The priority of the new action</param> |  | ||||||
| 		/// <typeparam name="T">A dependency that this action will use.</typeparam> |  | ||||||
| 		/// <typeparam name="T2">A second dependency that this action will use.</typeparam> |  | ||||||
| 		/// <typeparam name="T3">A third dependency that this action will use.</typeparam> |  | ||||||
| 		/// <returns>A new <see cref="StartupAction"/></returns> |  | ||||||
| 		public static StartupAction<T, T2, T3> New<T, T2, T3>( |  | ||||||
| 			Action<T, T2, T3> action, |  | ||||||
| 			int priority |  | ||||||
| 		) |  | ||||||
| 			where T : notnull |  | ||||||
| 			where T2 : notnull |  | ||||||
| 			where T3 : notnull => new(action, priority); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A <see cref="IStartupAction"/> with no dependencies. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public class StartupAction : IStartupAction |  | ||||||
| 		{ | 		{ | ||||||
| 			/// <summary> | 			_action = action; | ||||||
| 			/// The action to execute at startup. | 			Priority = priority; | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly Action _action; |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public int Priority { get; } |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new <see cref="StartupAction"/>. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="action">The action to execute on startup.</param> |  | ||||||
| 			/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> |  | ||||||
| 			public StartupAction(Action action, int priority) |  | ||||||
| 			{ |  | ||||||
| 				_action = action; |  | ||||||
| 				Priority = priority; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public void Run(IServiceProvider provider) |  | ||||||
| 			{ |  | ||||||
| 				_action.Invoke(); |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <inheritdoc /> | ||||||
| 		/// A <see cref="IStartupAction"/> with one dependencies. | 		public void Run(IServiceProvider provider) | ||||||
| 		/// </summary> |  | ||||||
| 		/// <typeparam name="T">The dependency to use.</typeparam> |  | ||||||
| 		public class StartupAction<T> : IStartupAction |  | ||||||
| 			where T : notnull |  | ||||||
| 		{ | 		{ | ||||||
| 			/// <summary> | 			_action.Invoke(); | ||||||
| 			/// The action to execute at startup. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly Action<T> _action; |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public int Priority { get; } |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new <see cref="StartupAction{T}"/>. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="action">The action to execute on startup.</param> |  | ||||||
| 			/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> |  | ||||||
| 			public StartupAction(Action<T> action, int priority) |  | ||||||
| 			{ |  | ||||||
| 				_action = action; |  | ||||||
| 				Priority = priority; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public void Run(IServiceProvider provider) |  | ||||||
| 			{ |  | ||||||
| 				_action.Invoke(provider.GetRequiredService<T>()); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A <see cref="IStartupAction"/> with two dependencies. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <typeparam name="T">The dependency to use.</typeparam> |  | ||||||
| 		/// <typeparam name="T2">The second dependency to use.</typeparam> |  | ||||||
| 		public class StartupAction<T, T2> : IStartupAction |  | ||||||
| 			where T : notnull |  | ||||||
| 			where T2 : notnull |  | ||||||
| 		{ |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The action to execute at startup. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly Action<T, T2> _action; |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public int Priority { get; } |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new <see cref="StartupAction{T, T2}"/>. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="action">The action to execute on startup.</param> |  | ||||||
| 			/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> |  | ||||||
| 			public StartupAction(Action<T, T2> action, int priority) |  | ||||||
| 			{ |  | ||||||
| 				_action = action; |  | ||||||
| 				Priority = priority; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public void Run(IServiceProvider provider) |  | ||||||
| 			{ |  | ||||||
| 				_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>()); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A <see cref="IStartupAction"/> with three dependencies. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <typeparam name="T">The dependency to use.</typeparam> |  | ||||||
| 		/// <typeparam name="T2">The second dependency to use.</typeparam> |  | ||||||
| 		/// <typeparam name="T3">The third dependency to use.</typeparam> |  | ||||||
| 		public class StartupAction<T, T2, T3> : IStartupAction |  | ||||||
| 			where T : notnull |  | ||||||
| 			where T2 : notnull |  | ||||||
| 			where T3 : notnull |  | ||||||
| 		{ |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The action to execute at startup. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly Action<T, T2, T3> _action; |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public int Priority { get; } |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new <see cref="StartupAction{T, T2, T3}"/>. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="action">The action to execute on startup.</param> |  | ||||||
| 			/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> |  | ||||||
| 			public StartupAction(Action<T, T2, T3> action, int priority) |  | ||||||
| 			{ |  | ||||||
| 				_action = action; |  | ||||||
| 				Priority = priority; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public void Run(IServiceProvider provider) |  | ||||||
| 			{ |  | ||||||
| 				_action.Invoke( |  | ||||||
| 					provider.GetRequiredService<T>(), |  | ||||||
| 					provider.GetRequiredService<T2>(), |  | ||||||
| 					provider.GetRequiredService<T3>() |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An action executed on kyoo's startup to initialize the asp-net container. | 	/// A <see cref="IStartupAction"/> with one dependencies. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <remarks> | 	/// <typeparam name="T">The dependency to use.</typeparam> | ||||||
| 	/// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this. | 	public class StartupAction<T> : IStartupAction | ||||||
| 	/// </remarks> | 		where T : notnull | ||||||
| 	public interface IStartupAction |  | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// The priority of this action. The actions will be executed on descending priority order. | 		/// The action to execute at startup. | ||||||
| 		/// If two actions have the same priority, their order is undefined. |  | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		int Priority { get; } | 		private readonly Action<T> _action; | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public int Priority { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Run this action to configure the container, a service provider containing all services can be used. | 		/// Create a new <see cref="StartupAction{T}"/>. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		/// <param name="provider">The service provider containing all services can be used.</param> | 		/// <param name="action">The action to execute on startup.</param> | ||||||
| 		void Run(IServiceProvider provider); | 		/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> | ||||||
|  | 		public StartupAction(Action<T> action, int priority) | ||||||
|  | 		{ | ||||||
|  | 			_action = action; | ||||||
|  | 			Priority = priority; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public void Run(IServiceProvider provider) | ||||||
|  | 		{ | ||||||
|  | 			_action.Invoke(provider.GetRequiredService<T>()); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A <see cref="IStartupAction"/> with two dependencies. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <typeparam name="T">The dependency to use.</typeparam> | ||||||
|  | 	/// <typeparam name="T2">The second dependency to use.</typeparam> | ||||||
|  | 	public class StartupAction<T, T2> : IStartupAction | ||||||
|  | 		where T : notnull | ||||||
|  | 		where T2 : notnull | ||||||
|  | 	{ | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// The action to execute at startup. | ||||||
|  | 		/// </summary> | ||||||
|  | 		private readonly Action<T, T2> _action; | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public int Priority { get; } | ||||||
|  | 
 | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// Create a new <see cref="StartupAction{T, T2}"/>. | ||||||
|  | 		/// </summary> | ||||||
|  | 		/// <param name="action">The action to execute on startup.</param> | ||||||
|  | 		/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> | ||||||
|  | 		public StartupAction(Action<T, T2> action, int priority) | ||||||
|  | 		{ | ||||||
|  | 			_action = action; | ||||||
|  | 			Priority = priority; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public void Run(IServiceProvider provider) | ||||||
|  | 		{ | ||||||
|  | 			_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>()); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A <see cref="IStartupAction"/> with three dependencies. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <typeparam name="T">The dependency to use.</typeparam> | ||||||
|  | 	/// <typeparam name="T2">The second dependency to use.</typeparam> | ||||||
|  | 	/// <typeparam name="T3">The third dependency to use.</typeparam> | ||||||
|  | 	public class StartupAction<T, T2, T3> : IStartupAction | ||||||
|  | 		where T : notnull | ||||||
|  | 		where T2 : notnull | ||||||
|  | 		where T3 : notnull | ||||||
|  | 	{ | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// The action to execute at startup. | ||||||
|  | 		/// </summary> | ||||||
|  | 		private readonly Action<T, T2, T3> _action; | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public int Priority { get; } | ||||||
|  | 
 | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// Create a new <see cref="StartupAction{T, T2, T3}"/>. | ||||||
|  | 		/// </summary> | ||||||
|  | 		/// <param name="action">The action to execute on startup.</param> | ||||||
|  | 		/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param> | ||||||
|  | 		public StartupAction(Action<T, T2, T3> action, int priority) | ||||||
|  | 		{ | ||||||
|  | 			_action = action; | ||||||
|  | 			Priority = priority; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public void Run(IServiceProvider provider) | ||||||
|  | 		{ | ||||||
|  | 			_action.Invoke( | ||||||
|  | 				provider.GetRequiredService<T>(), | ||||||
|  | 				provider.GetRequiredService<T2>(), | ||||||
|  | 				provider.GetRequiredService<T3>() | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An action executed on kyoo's startup to initialize the asp-net container. | ||||||
|  | /// </summary> | ||||||
|  | /// <remarks> | ||||||
|  | /// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this. | ||||||
|  | /// </remarks> | ||||||
|  | public interface IStartupAction | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The priority of this action. The actions will be executed on descending priority order. | ||||||
|  | 	/// If two actions have the same priority, their order is undefined. | ||||||
|  | 	/// </summary> | ||||||
|  | 	int Priority { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Run this action to configure the container, a service provider containing all services can be used. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="provider">The service provider containing all services can be used.</param> | ||||||
|  | 	void Run(IServiceProvider provider); | ||||||
|  | } | ||||||
|  | |||||||
| @ -23,43 +23,42 @@ using System.Security.Claims; | |||||||
| using Kyoo.Abstractions.Models.Exceptions; | using Kyoo.Abstractions.Models.Exceptions; | ||||||
| using Kyoo.Authentication.Models; | using Kyoo.Authentication.Models; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication | namespace Kyoo.Authentication; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Extension methods. | ||||||
|  | /// </summary> | ||||||
|  | public static class Extensions | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Extension methods. | 	/// Get the permissions of an user. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Extensions | 	/// <param name="user">The user</param> | ||||||
|  | 	/// <returns>The list of permissions</returns> | ||||||
|  | 	public static ICollection<string> GetPermissions(this ClaimsPrincipal user) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') | ||||||
| 		/// Get the permissions of an user. | 			?? Array.Empty<string>(); | ||||||
| 		/// </summary> | 	} | ||||||
| 		/// <param name="user">The user</param> |  | ||||||
| 		/// <returns>The list of permissions</returns> |  | ||||||
| 		public static ICollection<string> GetPermissions(this ClaimsPrincipal user) |  | ||||||
| 		{ |  | ||||||
| 			return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') |  | ||||||
| 				?? Array.Empty<string>(); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get the id of the current user or null if unlogged or invalid. | 	/// Get the id of the current user or null if unlogged or invalid. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="user">The user.</param> | 	/// <param name="user">The user.</param> | ||||||
| 		/// <returns>The id of the user or null.</returns> | 	/// <returns>The id of the user or null.</returns> | ||||||
| 		public static Guid? GetId(this ClaimsPrincipal user) | 	public static Guid? GetId(this ClaimsPrincipal user) | ||||||
| 		{ | 	{ | ||||||
| 			Claim? value = user.FindFirst(Claims.Id); | 		Claim? value = user.FindFirst(Claims.Id); | ||||||
| 			if (Guid.TryParse(value?.Value, out Guid id)) | 		if (Guid.TryParse(value?.Value, out Guid id)) | ||||||
| 				return id; | 			return id; | ||||||
| 			return null; | 		return null; | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		public static Guid GetIdOrThrow(this ClaimsPrincipal user) | 	public static Guid GetIdOrThrow(this ClaimsPrincipal user) | ||||||
| 		{ | 	{ | ||||||
| 			Guid? ret = user.GetId(); | 		Guid? ret = user.GetId(); | ||||||
| 			if (ret == null) | 		if (ret == null) | ||||||
| 				throw new UnauthorizedException(); | 			throw new UnauthorizedException(); | ||||||
| 			return ret.Value; | 		return ret.Value; | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,35 +18,34 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Attributes | namespace Kyoo.Abstractions.Models.Attributes; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An attribute to specify on apis to specify it's documentation's name and category. | ||||||
|  | /// If this is applied on a method, the specified method will be exploded from the controller's page and be | ||||||
|  | /// included on the specified tag page. | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] | ||||||
|  | public class ApiDefinitionAttribute : Attribute | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An attribute to specify on apis to specify it's documentation's name and category. | 	/// The public name of this api. | ||||||
| 	/// If this is applied on a method, the specified method will be exploded from the controller's page and be |  | ||||||
| 	/// included on the specified tag page. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] | 	public string Name { get; } | ||||||
| 	public class ApiDefinitionAttribute : Attribute | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The name of the group in witch this API is. You can also specify a custom sort order using the following | ||||||
|  | 	/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for | ||||||
|  | 	/// th alphabetical ordering. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Group { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="ApiDefinitionAttribute"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="name">The name of the api that will be used on the documentation page.</param> | ||||||
|  | 	public ApiDefinitionAttribute(string name) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Name = name; | ||||||
| 		/// The public name of this api. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Name { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The name of the group in witch this API is. You can also specify a custom sort order using the following |  | ||||||
| 		/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for |  | ||||||
| 		/// th alphabetical ordering. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Group { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="ApiDefinitionAttribute"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="name">The name of the api that will be used on the documentation page.</param> |  | ||||||
| 		public ApiDefinitionAttribute(string name) |  | ||||||
| 		{ |  | ||||||
| 			Name = name; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,11 +18,10 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Attributes | namespace Kyoo.Abstractions.Models.Attributes; | ||||||
| { | 
 | ||||||
| 	/// <summary> | /// <summary> | ||||||
| 	/// An attribute to inform that the property is computed automatically and can't be assigned manually. | /// An attribute to inform that the property is computed automatically and can't be assigned manually. | ||||||
| 	/// </summary> | /// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Property)] | [AttributeUsage(AttributeTargets.Property)] | ||||||
| 	public class ComputedAttribute : NotMergeableAttribute { } | public class ComputedAttribute : NotMergeableAttribute { } | ||||||
| } |  | ||||||
|  | |||||||
| @ -18,37 +18,36 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Attributes | namespace Kyoo.Abstractions.Models.Attributes; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The targeted relation can be loaded. | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Property)] | ||||||
|  | public class LoadableRelationAttribute : Attribute | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The targeted relation can be loaded. | 	/// The name of the field containing the related resource's ID. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Property)] | 	public string? RelationID { get; } | ||||||
| 	public class LoadableRelationAttribute : Attribute | 
 | ||||||
|  | 	public string? Sql { get; set; } | ||||||
|  | 
 | ||||||
|  | 	public string? On { get; set; } | ||||||
|  | 
 | ||||||
|  | 	public string? Projected { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="LoadableRelationAttribute"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public LoadableRelationAttribute() { } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="relationID">The name of the RelationID field.</param> | ||||||
|  | 	public LoadableRelationAttribute(string relationID) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		RelationID = relationID; | ||||||
| 		/// The name of the field containing the related resource's ID. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? RelationID { get; } |  | ||||||
| 
 |  | ||||||
| 		public string? Sql { get; set; } |  | ||||||
| 
 |  | ||||||
| 		public string? On { get; set; } |  | ||||||
| 
 |  | ||||||
| 		public string? Projected { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="LoadableRelationAttribute"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public LoadableRelationAttribute() { } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="relationID">The name of the RelationID field.</param> |  | ||||||
| 		public LoadableRelationAttribute(string relationID) |  | ||||||
| 		{ |  | ||||||
| 			RelationID = relationID; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,23 +18,22 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Attributes | namespace Kyoo.Abstractions.Models.Attributes; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Specify that a property can't be merged. | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Property)] | ||||||
|  | public class NotMergeableAttribute : Attribute { } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface with a method called when this object is merged. | ||||||
|  | /// </summary> | ||||||
|  | public interface IOnMerge | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Specify that a property can't be merged. | 	/// This function is called after the object has been merged. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Property)] | 	/// <param name="merged">The object that has been merged with this.</param> | ||||||
| 	public class NotMergeableAttribute : Attribute { } | 	void OnMerge(object merged); | ||||||
| 
 |  | ||||||
| 	/// <summary> |  | ||||||
| 	/// An interface with a method called when this object is merged. |  | ||||||
| 	/// </summary> |  | ||||||
| 	public interface IOnMerge |  | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// This function is called after the object has been merged. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="merged">The object that has been merged with this.</param> |  | ||||||
| 		void OnMerge(object merged); |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,68 +21,67 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Microsoft.AspNetCore.Mvc.Filters; | using Microsoft.AspNetCore.Mvc.Filters; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Permissions | namespace Kyoo.Abstractions.Models.Permissions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Specify one part of a permissions needed for the API (the kind or the type). | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] | ||||||
|  | public class PartialPermissionAttribute : Attribute, IFilterFactory | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Specify one part of a permissions needed for the API (the kind or the type). | 	/// The needed permission type. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] | 	public string? Type { get; } | ||||||
| 	public class PartialPermissionAttribute : Attribute, IFilterFactory | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The needed permission kind. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Kind? Kind { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The group of this permission. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Group Group { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Ask a permission to run an action. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// With this attribute, you can only specify a type or a kind. | ||||||
|  | 	/// To have a valid permission attribute, you must specify the kind and the permission using two attributes. | ||||||
|  | 	/// Those attributes can be dispatched at different places (one on the class, one on the method for example). | ||||||
|  | 	/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will | ||||||
|  | 	/// lead to unspecified behaviors. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="type">The type of the action</param> | ||||||
|  | 	public PartialPermissionAttribute(string type) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Type = type.ToLower(); | ||||||
| 		/// The needed permission type. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Type { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The needed permission kind. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Kind? Kind { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The group of this permission. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Group Group { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Ask a permission to run an action. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// With this attribute, you can only specify a type or a kind. |  | ||||||
| 		/// To have a valid permission attribute, you must specify the kind and the permission using two attributes. |  | ||||||
| 		/// Those attributes can be dispatched at different places (one on the class, one on the method for example). |  | ||||||
| 		/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will |  | ||||||
| 		/// lead to unspecified behaviors. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="type">The type of the action</param> |  | ||||||
| 		public PartialPermissionAttribute(string type) |  | ||||||
| 		{ |  | ||||||
| 			Type = type.ToLower(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Ask a permission to run an action. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// With this attribute, you can only specify a type or a kind. |  | ||||||
| 		/// To have a valid permission attribute, you must specify the kind and the permission using two attributes. |  | ||||||
| 		/// Those attributes can be dispatched at different places (one on the class, one on the method for example). |  | ||||||
| 		/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will |  | ||||||
| 		/// lead to unspecified behaviors. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="permission">The kind of permission needed.</param> |  | ||||||
| 		public PartialPermissionAttribute(Kind permission) |  | ||||||
| 		{ |  | ||||||
| 			Kind = permission; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) |  | ||||||
| 		{ |  | ||||||
| 			return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public bool IsReusable => true; |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Ask a permission to run an action. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// With this attribute, you can only specify a type or a kind. | ||||||
|  | 	/// To have a valid permission attribute, you must specify the kind and the permission using two attributes. | ||||||
|  | 	/// Those attributes can be dispatched at different places (one on the class, one on the method for example). | ||||||
|  | 	/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will | ||||||
|  | 	/// lead to unspecified behaviors. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="permission">The kind of permission needed.</param> | ||||||
|  | 	public PartialPermissionAttribute(Kind permission) | ||||||
|  | 	{ | ||||||
|  | 		Kind = permission; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) | ||||||
|  | 	{ | ||||||
|  | 		return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public bool IsReusable => true; | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,117 +21,116 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Microsoft.AspNetCore.Mvc.Filters; | using Microsoft.AspNetCore.Mvc.Filters; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Permissions | namespace Kyoo.Abstractions.Models.Permissions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The kind of permission needed. | ||||||
|  | /// </summary> | ||||||
|  | public enum Kind | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
|  | 	/// Allow the user to read for this kind of data. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Read, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow the user to write for this kind of data. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Write, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow the user to create this kind of data. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Create, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow the user to delete this kind of data. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Delete, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow the user to play this file. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Play, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The group of the permission. | ||||||
|  | /// </summary> | ||||||
|  | public enum Group | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Default group indicating no value. | ||||||
|  | 	/// </summary> | ||||||
|  | 	None, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow all operations on basic items types. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Overall, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Allow operation on sensitive items like libraries path, configurations and so on. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Admin | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Specify permissions needed for the API. | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] | ||||||
|  | public class PermissionAttribute : Attribute, IFilterFactory | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The needed permission as string. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string Type { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The needed permission kind. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Kind Kind { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The group of this permission. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Group Group { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Ask a permission to run an action. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="type"> | ||||||
|  | 	/// The type of the action | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="permission"> | ||||||
| 	/// The kind of permission needed. | 	/// The kind of permission needed. | ||||||
| 	/// </summary> | 	/// </param> | ||||||
| 	public enum Kind | 	/// <param name="group"> | ||||||
|  | 	/// The group of this permission (allow grouped permission like overall.read | ||||||
|  | 	/// for all read permissions of this group). | ||||||
|  | 	/// </param> | ||||||
|  | 	public PermissionAttribute(string type, Kind permission, Group group = Group.Overall) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Type = type.ToLower(); | ||||||
| 		/// Allow the user to read for this kind of data. | 		Kind = permission; | ||||||
| 		/// </summary> | 		Group = group; | ||||||
| 		Read, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow the user to write for this kind of data. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Write, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow the user to create this kind of data. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Create, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow the user to delete this kind of data. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Delete, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow the user to play this file. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Play, |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	/// <inheritdoc /> | ||||||
| 	/// The group of the permission. | 	public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) | ||||||
| 	/// </summary> |  | ||||||
| 	public enum Group |  | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this); | ||||||
| 		/// Default group indicating no value. |  | ||||||
| 		/// </summary> |  | ||||||
| 		None, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow all operations on basic items types. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Overall, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Allow operation on sensitive items like libraries path, configurations and so on. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Admin |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public bool IsReusable => true; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Specify permissions needed for the API. | 	/// Return this permission attribute as a string. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] | 	/// <returns>The string representation.</returns> | ||||||
| 	public class PermissionAttribute : Attribute, IFilterFactory | 	public string AsPermissionString() | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return Type; | ||||||
| 		/// The needed permission as string. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Type { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The needed permission kind. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Kind Kind { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The group of this permission. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Group Group { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Ask a permission to run an action. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="type"> |  | ||||||
| 		/// The type of the action |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="permission"> |  | ||||||
| 		/// The kind of permission needed. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="group"> |  | ||||||
| 		/// The group of this permission (allow grouped permission like overall.read |  | ||||||
| 		/// for all read permissions of this group). |  | ||||||
| 		/// </param> |  | ||||||
| 		public PermissionAttribute(string type, Kind permission, Group group = Group.Overall) |  | ||||||
| 		{ |  | ||||||
| 			Type = type.ToLower(); |  | ||||||
| 			Kind = permission; |  | ||||||
| 			Group = group; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) |  | ||||||
| 		{ |  | ||||||
| 			return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public bool IsReusable => true; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Return this permission attribute as a string. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <returns>The string representation.</returns> |  | ||||||
| 		public string AsPermissionString() |  | ||||||
| 		{ |  | ||||||
| 			return Type; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,14 +18,13 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Permissions | namespace Kyoo.Abstractions.Models.Permissions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The annotated route can only be accessed by a logged in user. | ||||||
|  | /// </summary> | ||||||
|  | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] | ||||||
|  | public class UserOnlyAttribute : Attribute | ||||||
| { | { | ||||||
| 	/// <summary> | 	// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation. | ||||||
| 	/// The annotated route can only be accessed by a logged in user. |  | ||||||
| 	/// </summary> |  | ||||||
| 	[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] |  | ||||||
| 	public class UserOnlyAttribute : Attribute |  | ||||||
| 	{ |  | ||||||
| 		// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation. |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,35 +19,34 @@ | |||||||
| using System; | using System; | ||||||
| using System.Runtime.Serialization; | using System.Runtime.Serialization; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Exceptions | namespace Kyoo.Abstractions.Models.Exceptions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An exception raised when an item already exists in the database. | ||||||
|  | /// </summary> | ||||||
|  | [Serializable] | ||||||
|  | public class DuplicatedItemException : Exception | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An exception raised when an item already exists in the database. | 	/// The existing object. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Serializable] | 	public object? Existing { get; } | ||||||
| 	public class DuplicatedItemException : Exception | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="DuplicatedItemException"/> with the default message. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="existing">The existing object.</param> | ||||||
|  | 	public DuplicatedItemException(object? existing = null) | ||||||
|  | 		: base("Already exists in the database.") | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Existing = existing; | ||||||
| 		/// The existing object. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public object? Existing { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="DuplicatedItemException"/> with the default message. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="existing">The existing object.</param> |  | ||||||
| 		public DuplicatedItemException(object? existing = null) |  | ||||||
| 			: base("Already exists in the database.") |  | ||||||
| 		{ |  | ||||||
| 			Existing = existing; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The serialization constructor. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="info">Serialization infos</param> |  | ||||||
| 		/// <param name="context">The serialization context</param> |  | ||||||
| 		protected DuplicatedItemException(SerializationInfo info, StreamingContext context) |  | ||||||
| 			: base(info, context) { } |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The serialization constructor. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="info">Serialization infos</param> | ||||||
|  | 	/// <param name="context">The serialization context</param> | ||||||
|  | 	protected DuplicatedItemException(SerializationInfo info, StreamingContext context) | ||||||
|  | 		: base(info, context) { } | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,33 +19,32 @@ | |||||||
| using System; | using System; | ||||||
| using System.Runtime.Serialization; | using System.Runtime.Serialization; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Exceptions | namespace Kyoo.Abstractions.Models.Exceptions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An exception raised when an item could not be found. | ||||||
|  | /// </summary> | ||||||
|  | [Serializable] | ||||||
|  | public class ItemNotFoundException : Exception | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An exception raised when an item could not be found. | 	/// Create a default <see cref="ItemNotFoundException"/> with no message. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Serializable] | 	public ItemNotFoundException() | ||||||
| 	public class ItemNotFoundException : Exception | 		: base("Item not found") { } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a default <see cref="ItemNotFoundException"/> with no message. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public ItemNotFoundException() |  | ||||||
| 			: base("Item not found") { } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="ItemNotFoundException"/> with a message | 	/// Create a new <see cref="ItemNotFoundException"/> with a message | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="message">The message of the exception</param> | 	/// <param name="message">The message of the exception</param> | ||||||
| 		public ItemNotFoundException(string message) | 	public ItemNotFoundException(string message) | ||||||
| 			: base(message) { } | 		: base(message) { } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The serialization constructor | 	/// The serialization constructor | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="info">Serialization infos</param> | 	/// <param name="info">Serialization infos</param> | ||||||
| 		/// <param name="context">The serialization context</param> | 	/// <param name="context">The serialization context</param> | ||||||
| 		protected ItemNotFoundException(SerializationInfo info, StreamingContext context) | 	protected ItemNotFoundException(SerializationInfo info, StreamingContext context) | ||||||
| 			: base(info, context) { } | 		: base(info, context) { } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,18 +19,17 @@ | |||||||
| using System; | using System; | ||||||
| using System.Runtime.Serialization; | using System.Runtime.Serialization; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Exceptions | namespace Kyoo.Abstractions.Models.Exceptions; | ||||||
|  | 
 | ||||||
|  | [Serializable] | ||||||
|  | public class UnauthorizedException : Exception | ||||||
| { | { | ||||||
| 	[Serializable] | 	public UnauthorizedException() | ||||||
| 	public class UnauthorizedException : Exception | 		: base("User not authenticated or token invalid.") { } | ||||||
| 	{ |  | ||||||
| 		public UnauthorizedException() |  | ||||||
| 			: base("User not authenticated or token invalid.") { } |  | ||||||
| 
 | 
 | ||||||
| 		public UnauthorizedException(string message) | 	public UnauthorizedException(string message) | ||||||
| 			: base(message) { } | 		: base(message) { } | ||||||
| 
 | 
 | ||||||
| 		protected UnauthorizedException(SerializationInfo info, StreamingContext context) | 	protected UnauthorizedException(SerializationInfo info, StreamingContext context) | ||||||
| 			: base(info, context) { } | 		: base(info, context) { } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,30 +16,29 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A genre that allow one to specify categories for shows. | ||||||
|  | /// </summary> | ||||||
|  | public enum Genre | ||||||
| { | { | ||||||
| 	/// <summary> | 	Action, | ||||||
| 	/// A genre that allow one to specify categories for shows. | 	Adventure, | ||||||
| 	/// </summary> | 	Animation, | ||||||
| 	public enum Genre | 	Comedy, | ||||||
| 	{ | 	Crime, | ||||||
| 		Action, | 	Documentary, | ||||||
| 		Adventure, | 	Drama, | ||||||
| 		Animation, | 	Family, | ||||||
| 		Comedy, | 	Fantasy, | ||||||
| 		Crime, | 	History, | ||||||
| 		Documentary, | 	Horror, | ||||||
| 		Drama, | 	Music, | ||||||
| 		Family, | 	Mystery, | ||||||
| 		Fantasy, | 	Romance, | ||||||
| 		History, | 	ScienceFiction, | ||||||
| 		Horror, | 	Thriller, | ||||||
| 		Music, | 	War, | ||||||
| 		Mystery, | 	Western, | ||||||
| 		Romance, |  | ||||||
| 		ScienceFiction, |  | ||||||
| 		Thriller, |  | ||||||
| 		War, |  | ||||||
| 		Western, |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,21 +16,20 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// ID and link of an item on an external provider. | ||||||
|  | /// </summary> | ||||||
|  | public class MetadataId | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// ID and link of an item on an external provider. | 	/// The ID of the resource on the external provider. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class MetadataId | 	public string DataId { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The ID of the resource on the external provider. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string DataId { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The URL of the resource on the external provider. | 	/// The URL of the resource on the external provider. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Link { get; set; } | 	public string? Link { get; set; } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,93 +20,86 @@ using System.Collections.Generic; | |||||||
| using System.Linq; | using System.Linq; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A page of resource that contains information about the pagination of resources. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The type of resource contained in this page.</typeparam> | ||||||
|  | public class Page<T> | ||||||
|  | 	where T : IResource | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A page of resource that contains information about the pagination of resources. | 	/// The link of the current page. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">The type of resource contained in this page.</typeparam> | 	public string This { get; } | ||||||
| 	public class Page<T> | 
 | ||||||
| 		where T : IResource | 	/// <summary> | ||||||
|  | 	/// The link of the first page. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string First { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The link of the previous page. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Previous { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The link of the next page. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Next { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The number of items in the current page. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public int Count => Items.Count; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of items in the page. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public ICollection<T> Items { get; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Page{T}"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="items">The list of items in the page.</param> | ||||||
|  | 	/// <param name="this">The link of the current page.</param> | ||||||
|  | 	/// <param name="previous">The link of the previous page.</param> | ||||||
|  | 	/// <param name="next">The link of the next page.</param> | ||||||
|  | 	/// <param name="first">The link of the first page.</param> | ||||||
|  | 	public Page(ICollection<T> items, string @this, string? previous, string? next, string first) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Items = items; | ||||||
| 		/// The link of the current page. | 		This = @this; | ||||||
| 		/// </summary> | 		Previous = previous; | ||||||
| 		public string This { get; } | 		Next = next; | ||||||
|  | 		First = first; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The link of the first page. | 	/// Create a new <see cref="Page{T}"/> and compute the urls. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string First { get; } | 	/// <param name="items">The list of items in the page.</param> | ||||||
| 
 | 	/// <param name="url">The base url of the resources available from this page.</param> | ||||||
| 		/// <summary> | 	/// <param name="query">The list of query strings of the current page</param> | ||||||
| 		/// The link of the previous page. | 	/// <param name="limit">The number of items requested for the current page.</param> | ||||||
| 		/// </summary> | 	public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit) | ||||||
| 		public string? Previous { get; } | 	{ | ||||||
| 
 | 		Items = items; | ||||||
| 		/// <summary> | 		This = url + query.ToQueryString(); | ||||||
| 		/// The link of the next page. | 		if (items.Count > 0 && query.ContainsKey("afterID")) | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Next { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The number of items in the current page. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int Count => Items.Count; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of items in the page. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public ICollection<T> Items { get; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Page{T}"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="items">The list of items in the page.</param> |  | ||||||
| 		/// <param name="this">The link of the current page.</param> |  | ||||||
| 		/// <param name="previous">The link of the previous page.</param> |  | ||||||
| 		/// <param name="next">The link of the next page.</param> |  | ||||||
| 		/// <param name="first">The link of the first page.</param> |  | ||||||
| 		public Page( |  | ||||||
| 			ICollection<T> items, |  | ||||||
| 			string @this, |  | ||||||
| 			string? previous, |  | ||||||
| 			string? next, |  | ||||||
| 			string first |  | ||||||
| 		) |  | ||||||
| 		{ | 		{ | ||||||
| 			Items = items; | 			query["afterID"] = items.First().Id.ToString(); | ||||||
| 			This = @this; | 			query["reverse"] = "true"; | ||||||
| 			Previous = previous; | 			Previous = url + query.ToQueryString(); | ||||||
| 			Next = next; |  | ||||||
| 			First = first; |  | ||||||
| 		} | 		} | ||||||
| 
 | 		query.Remove("reverse"); | ||||||
| 		/// <summary> | 		if (items.Count == limit && limit > 0) | ||||||
| 		/// Create a new <see cref="Page{T}"/> and compute the urls. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="items">The list of items in the page.</param> |  | ||||||
| 		/// <param name="url">The base url of the resources available from this page.</param> |  | ||||||
| 		/// <param name="query">The list of query strings of the current page</param> |  | ||||||
| 		/// <param name="limit">The number of items requested for the current page.</param> |  | ||||||
| 		public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit) |  | ||||||
| 		{ | 		{ | ||||||
| 			Items = items; | 			query["afterID"] = items.Last().Id.ToString(); | ||||||
| 			This = url + query.ToQueryString(); | 			Next = url + query.ToQueryString(); | ||||||
| 			if (items.Count > 0 && query.ContainsKey("afterID")) |  | ||||||
| 			{ |  | ||||||
| 				query["afterID"] = items.First().Id.ToString(); |  | ||||||
| 				query["reverse"] = "true"; |  | ||||||
| 				Previous = url + query.ToQueryString(); |  | ||||||
| 			} |  | ||||||
| 			query.Remove("reverse"); |  | ||||||
| 			if (items.Count == limit && limit > 0) |  | ||||||
| 			{ |  | ||||||
| 				query["afterID"] = items.Last().Id.ToString(); |  | ||||||
| 				Next = url + query.ToQueryString(); |  | ||||||
| 			} |  | ||||||
| 			query.Remove("afterID"); |  | ||||||
| 			First = url + query.ToQueryString(); |  | ||||||
| 		} | 		} | ||||||
|  | 		query.Remove("afterID"); | ||||||
|  | 		First = url + query.ToQueryString(); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,69 +23,68 @@ using System.Text.Json.Serialization; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class representing collections of <see cref="Show"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem | ||||||
| { | { | ||||||
|  | 	public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name)); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class representing collections of <see cref="Show"/>. | 	/// The name of this collection. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem | 	public string Name { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The description of this collection. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Overview { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public DateTime AddedDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Poster { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Thumbnail { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Logo { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of movies contained in this collection. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Movie>? Movies { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of shows contained in this collection. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Show>? Shows { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	public Collection() { } | ||||||
|  | 
 | ||||||
|  | 	[JsonConstructor] | ||||||
|  | 	public Collection(string name) | ||||||
| 	{ | 	{ | ||||||
| 		public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name)); | 		if (name != null) | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The name of this collection. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Name { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The description of this collection. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Overview { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public DateTime AddedDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Poster { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Thumbnail { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Logo { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of movies contained in this collection. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Movie>? Movies { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of shows contained in this collection. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Show>? Shows { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		public Collection() { } |  | ||||||
| 
 |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public Collection(string name) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (name != null) | 			Slug = Utility.ToSlug(name); | ||||||
| 			{ | 			Name = name; | ||||||
| 				Slug = Utility.ToSlug(name); |  | ||||||
| 				Name = name; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,280 +26,274 @@ using EntityFrameworkCore.Projectables; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class to represent a single show's episode. | ||||||
|  | /// </summary> | ||||||
|  | public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews | ||||||
| { | { | ||||||
| 	/// <summary> | 	// Use absolute numbers by default and fallback to season/episodes if it does not exists. | ||||||
| 	/// A class to represent a single show's episode. | 	public static Sort DefaultSort => | ||||||
| 	/// </summary> | 		new Sort<Episode>.Conglomerate( | ||||||
| 	public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews | 			new Sort<Episode>.By(x => x.AbsoluteNumber), | ||||||
|  | 			new Sort<Episode>.By(x => x.SeasonNumber), | ||||||
|  | 			new Sort<Episode>.By(x => x.EpisodeNumber) | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[Computed] | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug | ||||||
| 	{ | 	{ | ||||||
| 		// Use absolute numbers by default and fallback to season/episodes if it does not exists. | 		get | ||||||
| 		public static Sort DefaultSort => |  | ||||||
| 			new Sort<Episode>.Conglomerate( |  | ||||||
| 				new Sort<Episode>.By(x => x.AbsoluteNumber), |  | ||||||
| 				new Sort<Episode>.By(x => x.SeasonNumber), |  | ||||||
| 				new Sort<Episode>.By(x => x.EpisodeNumber) |  | ||||||
| 			); |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[Computed] |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug |  | ||||||
| 		{ | 		{ | ||||||
| 			get | 			if (ShowSlug != null || Show?.Slug != null) | ||||||
| 			{ | 				return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); | ||||||
| 				if (ShowSlug != null || Show?.Slug != null) | 			return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); | ||||||
| 					return GetSlug( | 		} | ||||||
| 						ShowSlug ?? Show!.Slug, | 		private set | ||||||
| 						SeasonNumber, | 		{ | ||||||
| 						EpisodeNumber, | 			Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)"); | ||||||
| 						AbsoluteNumber |  | ||||||
| 					); |  | ||||||
| 				return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); |  | ||||||
| 			} |  | ||||||
| 			private set |  | ||||||
| 			{ |  | ||||||
| 				Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)"); |  | ||||||
| 
 | 
 | ||||||
|  | 			if (match.Success) | ||||||
|  | 			{ | ||||||
|  | 				ShowSlug = match.Groups["show"].Value; | ||||||
|  | 				SeasonNumber = int.Parse(match.Groups["season"].Value); | ||||||
|  | 				EpisodeNumber = int.Parse(match.Groups["episode"].Value); | ||||||
|  | 			} | ||||||
|  | 			else | ||||||
|  | 			{ | ||||||
|  | 				match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)"); | ||||||
| 				if (match.Success) | 				if (match.Success) | ||||||
| 				{ | 				{ | ||||||
| 					ShowSlug = match.Groups["show"].Value; | 					ShowSlug = match.Groups["show"].Value; | ||||||
| 					SeasonNumber = int.Parse(match.Groups["season"].Value); | 					AbsoluteNumber = int.Parse(match.Groups["absolute"].Value); | ||||||
| 					EpisodeNumber = int.Parse(match.Groups["episode"].Value); |  | ||||||
| 				} | 				} | ||||||
| 				else | 				else | ||||||
| 				{ | 					ShowSlug = value; | ||||||
| 					match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)"); | 				SeasonNumber = null; | ||||||
| 					if (match.Success) | 				EpisodeNumber = null; | ||||||
| 					{ |  | ||||||
| 						ShowSlug = match.Groups["show"].Value; |  | ||||||
| 						AbsoluteNumber = int.Parse(match.Groups["absolute"].Value); |  | ||||||
| 					} |  | ||||||
| 					else |  | ||||||
| 						ShowSlug = value; |  | ||||||
| 					SeasonNumber = null; |  | ||||||
| 					EpisodeNumber = null; |  | ||||||
| 				} |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. | 	/// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[JsonIgnore] | 	[JsonIgnore] | ||||||
| 		public string? ShowSlug { private get; set; } | 	public string? ShowSlug { private get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The ID of the Show containing this episode. | 	/// The ID of the Show containing this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public Guid ShowId { get; set; } | 	public Guid ShowId { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The show that contains this episode. | 	/// The show that contains this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[LoadableRelation(nameof(ShowId))] | 	[LoadableRelation(nameof(ShowId))] | ||||||
| 		public Show? Show { get; set; } | 	public Show? Show { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The ID of the Season containing this episode. | 	/// The ID of the Season containing this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public Guid? SeasonId { get; set; } | 	public Guid? SeasonId { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The season that contains this episode. | 	/// The season that contains this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// This can be null if the season is unknown and the episode is only identified | 	/// This can be null if the season is unknown and the episode is only identified | ||||||
| 		/// by it's <see cref="AbsoluteNumber"/>. | 	/// by it's <see cref="AbsoluteNumber"/>. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		[LoadableRelation(nameof(SeasonId))] | 	[LoadableRelation(nameof(SeasonId))] | ||||||
| 		public Season? Season { get; set; } | 	public Season? Season { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The season in witch this episode is in. | 	/// The season in witch this episode is in. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public int? SeasonNumber { get; set; } | 	public int? SeasonNumber { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The number of this episode in it's season. | 	/// The number of this episode in it's season. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public int? EpisodeNumber { get; set; } | 	public int? EpisodeNumber { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. | 	/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public int? AbsoluteNumber { get; set; } | 	public int? AbsoluteNumber { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The path of the video file for this episode. | 	/// The path of the video file for this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string Path { get; set; } | 	public string Path { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The title of this episode. | 	/// The title of this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Name { get; set; } | 	public string? Name { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The overview of this episode. | 	/// The overview of this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Overview { get; set; } | 	public string? Overview { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// How long is this episode? (in minutes) | 	/// How long is this episode? (in minutes) | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public int? Runtime { get; set; } | 	public int? Runtime { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The release date of this episode. It can be null if unknown. | 	/// The release date of this episode. It can be null if unknown. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public DateTime? ReleaseDate { get; set; } | 	public DateTime? ReleaseDate { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public DateTime AddedDate { get; set; } | 	public DateTime AddedDate { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Image? Poster { get; set; } | 	public Image? Poster { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Image? Thumbnail { get; set; } | 	public Image? Thumbnail { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Image? Logo { get; set; } | 	public Image? Logo { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The previous episode that should be seen before viewing this one. | 	/// The previous episode that should be seen before viewing this one. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] | 	[Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] | ||||||
| 		[LoadableRelation( | 	[LoadableRelation( | ||||||
| 			// language=PostgreSQL | 		// language=PostgreSQL | ||||||
| 			Sql = """
 | 		Sql = """
 | ||||||
| 					select | 				select | ||||||
| 						pe.* -- Episode as pe | 					pe.* -- Episode as pe | ||||||
| 					from | 				from | ||||||
| 						episodes as "pe" | 					episodes as "pe" | ||||||
| 					where | 				where | ||||||
| 						pe.show_id = "this".show_id | 					pe.show_id = "this".show_id | ||||||
| 						and (pe.absolute_number < "this".absolute_number | 					and (pe.absolute_number < "this".absolute_number | ||||||
| 							or pe.season_number < "this".season_number | 						or pe.season_number < "this".season_number | ||||||
| 							or (pe.season_number = "this".season_number | 						or (pe.season_number = "this".season_number | ||||||
| 								and e.episode_number < "this".episode_number)) | 							and e.episode_number < "this".episode_number)) | ||||||
| 					order by | 				order by | ||||||
| 						pe.absolute_number desc nulls last, | 					pe.absolute_number desc nulls last, | ||||||
| 						pe.season_number desc, | 					pe.season_number desc, | ||||||
| 						pe.episode_number desc | 					pe.episode_number desc | ||||||
| 					limit 1 | 				limit 1 | ||||||
| 				"""
 | 			"""
 | ||||||
| 		)] | 	)] | ||||||
| 		public Episode? PreviousEpisode { get; set; } | 	public Episode? PreviousEpisode { get; set; } | ||||||
| 
 | 
 | ||||||
| 		private Episode? _PreviousEpisode => | 	private Episode? _PreviousEpisode => | ||||||
| 			Show! | 		Show! | ||||||
| 				.Episodes!.OrderBy(x => x.AbsoluteNumber == null) | 			.Episodes!.OrderBy(x => x.AbsoluteNumber == null) | ||||||
| 				.ThenByDescending(x => x.AbsoluteNumber) | 			.ThenByDescending(x => x.AbsoluteNumber) | ||||||
| 				.ThenByDescending(x => x.SeasonNumber) | 			.ThenByDescending(x => x.SeasonNumber) | ||||||
| 				.ThenByDescending(x => x.EpisodeNumber) | 			.ThenByDescending(x => x.EpisodeNumber) | ||||||
| 				.FirstOrDefault(x => | 			.FirstOrDefault(x => | ||||||
| 					x.AbsoluteNumber < AbsoluteNumber | 				x.AbsoluteNumber < AbsoluteNumber | ||||||
| 					|| x.SeasonNumber < SeasonNumber | 				|| x.SeasonNumber < SeasonNumber | ||||||
| 					|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) | 				|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) | ||||||
| 				); | 			); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The next episode to watch after this one. | 	/// The next episode to watch after this one. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] | 	[Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] | ||||||
| 		[LoadableRelation( | 	[LoadableRelation( | ||||||
| 			// language=PostgreSQL | 		// language=PostgreSQL | ||||||
| 			Sql = """
 | 		Sql = """
 | ||||||
| 					select | 				select | ||||||
| 						ne.* -- Episode as ne | 					ne.* -- Episode as ne | ||||||
| 					from | 				from | ||||||
| 						episodes as "ne" | 					episodes as "ne" | ||||||
| 					where | 				where | ||||||
| 						ne.show_id = "this".show_id | 					ne.show_id = "this".show_id | ||||||
| 						and (ne.absolute_number > "this".absolute_number | 					and (ne.absolute_number > "this".absolute_number | ||||||
| 							or ne.season_number > "this".season_number | 						or ne.season_number > "this".season_number | ||||||
| 							or (ne.season_number = "this".season_number | 						or (ne.season_number = "this".season_number | ||||||
| 								and e.episode_number > "this".episode_number)) | 							and e.episode_number > "this".episode_number)) | ||||||
| 					order by | 				order by | ||||||
| 						ne.absolute_number, | 					ne.absolute_number, | ||||||
| 						ne.season_number, | 					ne.season_number, | ||||||
| 						ne.episode_number | 					ne.episode_number | ||||||
| 					limit 1 | 				limit 1 | ||||||
| 				"""
 | 			"""
 | ||||||
| 		)] | 	)] | ||||||
| 		public Episode? NextEpisode { get; set; } | 	public Episode? NextEpisode { get; set; } | ||||||
| 
 | 
 | ||||||
| 		private Episode? _NextEpisode => | 	private Episode? _NextEpisode => | ||||||
| 			Show! | 		Show! | ||||||
| 				.Episodes!.OrderBy(x => x.AbsoluteNumber) | 			.Episodes!.OrderBy(x => x.AbsoluteNumber) | ||||||
| 				.ThenBy(x => x.SeasonNumber) | 			.ThenBy(x => x.SeasonNumber) | ||||||
| 				.ThenBy(x => x.EpisodeNumber) | 			.ThenBy(x => x.EpisodeNumber) | ||||||
| 				.FirstOrDefault(x => | 			.FirstOrDefault(x => | ||||||
| 					x.AbsoluteNumber > AbsoluteNumber | 				x.AbsoluteNumber > AbsoluteNumber | ||||||
| 					|| x.SeasonNumber > SeasonNumber | 				|| x.SeasonNumber > SeasonNumber | ||||||
| 					|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) | 				|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) | ||||||
| 				); | 			); | ||||||
| 
 | 
 | ||||||
| 		[JsonIgnore] | 	[JsonIgnore] | ||||||
| 		public ICollection<EpisodeWatchStatus>? Watched { get; set; } | 	public ICollection<EpisodeWatchStatus>? Watched { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Metadata of what an user as started/planned to watch. | 	/// Metadata of what an user as started/planned to watch. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] | 	[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] | ||||||
| 		[LoadableRelation( | 	[LoadableRelation( | ||||||
| 			Sql = "episode_watch_status", | 		Sql = "episode_watch_status", | ||||||
| 			On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" | 		On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" | ||||||
| 		)] | 	)] | ||||||
| 		public EpisodeWatchStatus? WatchStatus { get; set; } | 	public EpisodeWatchStatus? WatchStatus { get; set; } | ||||||
| 
 | 
 | ||||||
| 		// There is a global query filter to filter by user so we just need to do single. | 	// There is a global query filter to filter by user so we just need to do single. | ||||||
| 		private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); | 	private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Links to watch this episode. | 	/// Links to watch this episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public VideoLinks Links => | 	public VideoLinks Links => | ||||||
| 			new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; | 		new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get the slug of an episode. | 	/// Get the slug of an episode. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="showSlug">The slug of the show. It can't be null.</param> | 	/// <param name="showSlug">The slug of the show. It can't be null.</param> | ||||||
| 		/// <param name="seasonNumber"> | 	/// <param name="seasonNumber"> | ||||||
| 		/// The season in which the episode is. | 	/// The season in which the episode is. | ||||||
| 		/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. | 	/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <param name="episodeNumber"> | 	/// <param name="episodeNumber"> | ||||||
| 		/// The number of the episode in it's season. | 	/// The number of the episode in it's season. | ||||||
| 		/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. | 	/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <param name="absoluteNumber"> | 	/// <param name="absoluteNumber"> | ||||||
| 		/// The absolute number of this show. | 	/// The absolute number of this show. | ||||||
| 		/// If you don't know it or this is a movie, use null | 	/// If you don't know it or this is a movie, use null | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <returns>The slug corresponding to the given arguments</returns> | 	/// <returns>The slug corresponding to the given arguments</returns> | ||||||
| 		public static string GetSlug( | 	public static string GetSlug( | ||||||
| 			string showSlug, | 		string showSlug, | ||||||
| 			int? seasonNumber, | 		int? seasonNumber, | ||||||
| 			int? episodeNumber, | 		int? episodeNumber, | ||||||
| 			int? absoluteNumber = null | 		int? absoluteNumber = null | ||||||
| 		) | 	) | ||||||
|  | 	{ | ||||||
|  | 		return seasonNumber switch | ||||||
| 		{ | 		{ | ||||||
| 			return seasonNumber switch | 			null when absoluteNumber == null => showSlug, | ||||||
| 			{ | 			null => $"{showSlug}-{absoluteNumber}", | ||||||
| 				null when absoluteNumber == null => showSlug, | 			_ => $"{showSlug}-s{seasonNumber}e{episodeNumber}" | ||||||
| 				null => $"{showSlug}-{absoluteNumber}", | 		}; | ||||||
| 				_ => $"{showSlug}-s{seasonNumber}e{episodeNumber}" |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,16 +18,15 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface applied to resources. | ||||||
|  | /// </summary> | ||||||
|  | public interface IAddedDate | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An interface applied to resources. | 	/// The date at which this resource was added to kyoo. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IAddedDate | 	public DateTime AddedDate { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The date at which this resource was added to kyoo. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime AddedDate { get; set; } |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,16 +18,15 @@ | |||||||
| 
 | 
 | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface applied to resources containing external metadata. | ||||||
|  | /// </summary> | ||||||
|  | public interface IMetadata | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An interface applied to resources containing external metadata. | 	/// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IMetadata | 	public Dictionary<string, MetadataId> ExternalId { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,31 +20,30 @@ using System; | |||||||
| using System.ComponentModel.DataAnnotations; | using System.ComponentModel.DataAnnotations; | ||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface to represent a resource that can be retrieved from the database. | ||||||
|  | /// </summary> | ||||||
|  | public interface IResource : IQuery | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An interface to represent a resource that can be retrieved from the database. | 	/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IResource : IQuery | 	/// <remarks> | ||||||
| 	{ | 	/// You don't need to specify an ID manually when creating a new resource, | ||||||
| 		/// <summary> | 	/// this field is automatically assigned by the <see cref="IRepository{T}"/>. | ||||||
| 		/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. | 	/// </remarks> | ||||||
| 		/// </summary> | 	public Guid Id { get; set; } | ||||||
| 		/// <remarks> |  | ||||||
| 		/// You don't need to specify an ID manually when creating a new resource, |  | ||||||
| 		/// this field is automatically assigned by the <see cref="IRepository{T}"/>. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A human-readable identifier that can be used instead of an ID. | 	/// A human-readable identifier that can be used instead of an ID. | ||||||
| 		/// A slug must be unique for a type of resource but it can be changed. | 	/// A slug must be unique for a type of resource but it can be changed. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// There is no setter for a slug since it can be computed from other fields. | 	/// There is no setter for a slug since it can be computed from other fields. | ||||||
| 		/// For example, a season slug is {ShowSlug}-s{SeasonNumber}. | 	/// For example, a season slug is {ShowSlug}-s{SeasonNumber}. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		[MaxLength(256)] | 	[MaxLength(256)] | ||||||
| 		public string Slug { get; } | 	public string Slug { get; } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,105 +23,101 @@ using System.Globalization; | |||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An interface representing items that contains images (like posters, thumbnails, logo, banners...) | ||||||
|  | /// </summary> | ||||||
|  | public interface IThumbnails | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An interface representing items that contains images (like posters, thumbnails, logo, banners...) | 	/// A poster is a 2/3 format image with the cover of the resource. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface IThumbnails | 	public Image? Poster { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A poster is a 2/3 format image with the cover of the resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Image? Poster { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually |  | ||||||
| 		/// is not an official image. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Image? Thumbnail { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A logo is a small image representing the resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Image? Logo { get; set; } |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	[TypeConverter(typeof(ImageConvertor))] |  | ||||||
| 	[SqlFirstColumn(nameof(Source))] |  | ||||||
| 	public class Image |  | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The original image from another server. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Source { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A hash to display as placeholder while the image is loading. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[MaxLength(32)] |  | ||||||
| 		public string Blurhash { get; set; } |  | ||||||
| 
 |  | ||||||
| 		public Image() { } |  | ||||||
| 
 |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public Image(string source, string? blurhash = null) |  | ||||||
| 		{ |  | ||||||
| 			Source = source; |  | ||||||
| 			Blurhash = blurhash ?? "000000"; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public class ImageConvertor : TypeConverter |  | ||||||
| 		{ |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) |  | ||||||
| 			{ |  | ||||||
| 				if (sourceType == typeof(string)) |  | ||||||
| 					return true; |  | ||||||
| 				return base.CanConvertFrom(context, sourceType); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public override object ConvertFrom( |  | ||||||
| 				ITypeDescriptorContext? context, |  | ||||||
| 				CultureInfo? culture, |  | ||||||
| 				object value |  | ||||||
| 			) |  | ||||||
| 			{ |  | ||||||
| 				if (value is not string source) |  | ||||||
| 					return base.ConvertFrom(context, culture, value)!; |  | ||||||
| 				return new Image(source); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public override bool CanConvertTo( |  | ||||||
| 				ITypeDescriptorContext? context, |  | ||||||
| 				Type? destinationType |  | ||||||
| 			) |  | ||||||
| 			{ |  | ||||||
| 				return false; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The quality of an image | 	/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually | ||||||
|  | 	/// is not an official image. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public enum ImageQuality | 	public Image? Thumbnail { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A logo is a small image representing the resource. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Image? Logo { get; set; } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | [TypeConverter(typeof(ImageConvertor))] | ||||||
|  | [SqlFirstColumn(nameof(Source))] | ||||||
|  | public class Image | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The original image from another server. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string Source { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A hash to display as placeholder while the image is loading. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[MaxLength(32)] | ||||||
|  | 	public string Blurhash { get; set; } | ||||||
|  | 
 | ||||||
|  | 	public Image() { } | ||||||
|  | 
 | ||||||
|  | 	[JsonConstructor] | ||||||
|  | 	public Image(string source, string? blurhash = null) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Source = source; | ||||||
| 		/// Small | 		Blurhash = blurhash ?? "000000"; | ||||||
| 		/// </summary> | 	} | ||||||
| 		Low, |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	public class ImageConvertor : TypeConverter | ||||||
| 		/// Medium | 	{ | ||||||
| 		/// </summary> | 		/// <inheritdoc /> | ||||||
| 		Medium, | 		public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) | ||||||
|  | 		{ | ||||||
|  | 			if (sourceType == typeof(string)) | ||||||
|  | 				return true; | ||||||
|  | 			return base.CanConvertFrom(context, sourceType); | ||||||
|  | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <inheritdoc /> | ||||||
| 		/// Large | 		public override object ConvertFrom( | ||||||
| 		/// </summary> | 			ITypeDescriptorContext? context, | ||||||
| 		High, | 			CultureInfo? culture, | ||||||
|  | 			object value | ||||||
|  | 		) | ||||||
|  | 		{ | ||||||
|  | 			if (value is not string source) | ||||||
|  | 				return base.ConvertFrom(context, culture, value)!; | ||||||
|  | 			return new Image(source); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) | ||||||
|  | 		{ | ||||||
|  | 			return false; | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The quality of an image | ||||||
|  | /// </summary> | ||||||
|  | public enum ImageQuality | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Small | ||||||
|  | 	/// </summary> | ||||||
|  | 	Low, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Medium | ||||||
|  | 	/// </summary> | ||||||
|  | 	Medium, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Large | ||||||
|  | 	/// </summary> | ||||||
|  | 	High, | ||||||
|  | } | ||||||
|  | |||||||
| @ -27,163 +27,162 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A series or a movie. | ||||||
|  | /// </summary> | ||||||
|  | public class Movie | ||||||
|  | 	: IQuery, | ||||||
|  | 		IResource, | ||||||
|  | 		IMetadata, | ||||||
|  | 		IThumbnails, | ||||||
|  | 		IAddedDate, | ||||||
|  | 		ILibraryItem, | ||||||
|  | 		INews, | ||||||
|  | 		IWatchlist | ||||||
| { | { | ||||||
|  | 	public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A series or a movie. | 	/// The title of this show. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Movie | 	public string Name { get; set; } | ||||||
| 		: IQuery, | 
 | ||||||
| 			IResource, | 	/// <summary> | ||||||
| 			IMetadata, | 	/// A catchphrase for this movie. | ||||||
| 			IThumbnails, | 	/// </summary> | ||||||
| 			IAddedDate, | 	public string? Tagline { get; set; } | ||||||
| 			ILibraryItem, | 
 | ||||||
| 			INews, | 	/// <summary> | ||||||
| 			IWatchlist | 	/// The list of alternative titles of this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string[] Aliases { get; set; } = Array.Empty<string>(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The path of the movie video file. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string Path { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The summary of this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Overview { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A list of tags that match this movie. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string[] Tags { get; set; } = Array.Empty<string>(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of genres (themes) this show has. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Genre[] Genres { get; set; } = Array.Empty<Genre>(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Is this show airing, not aired yet or finished? | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Status Status { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// How well this item is rated? (from 0 to 100). | ||||||
|  | 	/// </summary> | ||||||
|  | 	public int Rating { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// How long is this movie? (in minutes) | ||||||
|  | 	/// </summary> | ||||||
|  | 	public int? Runtime { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The date this movie aired. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public DateTime? AirDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public DateTime AddedDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Poster { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Thumbnail { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Logo { get; set; } | ||||||
|  | 
 | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	[Column("air_date")] | ||||||
|  | 	public DateTime? StartAir => AirDate; | ||||||
|  | 
 | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	[Column("air_date")] | ||||||
|  | 	public DateTime? EndAir => AirDate; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A video of a few minutes that tease the content. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Trailer { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The ID of the Studio that made this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public Guid? StudioId { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The Studio that made this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[LoadableRelation(nameof(StudioId))] | ||||||
|  | 	public Studio? Studio { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of collections that contains this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Collection>? Collections { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Links to watch this movie. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public VideoLinks Links => | ||||||
|  | 		new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", }; | ||||||
|  | 
 | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<MovieWatchStatus>? Watched { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Metadata of what an user as started/planned to watch. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] | ||||||
|  | 	[LoadableRelation( | ||||||
|  | 		Sql = "movie_watch_status", | ||||||
|  | 		On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]" | ||||||
|  | 	)] | ||||||
|  | 	public MovieWatchStatus? WatchStatus { get; set; } | ||||||
|  | 
 | ||||||
|  | 	// There is a global query filter to filter by user so we just need to do single. | ||||||
|  | 	private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); | ||||||
|  | 
 | ||||||
|  | 	public Movie() { } | ||||||
|  | 
 | ||||||
|  | 	[JsonConstructor] | ||||||
|  | 	public Movie(string name) | ||||||
| 	{ | 	{ | ||||||
| 		public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name); | 		if (name != null) | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The title of this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Name { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A catchphrase for this movie. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Tagline { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of alternative titles of this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string[] Aliases { get; set; } = Array.Empty<string>(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The path of the movie video file. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Path { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The summary of this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Overview { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A list of tags that match this movie. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string[] Tags { get; set; } = Array.Empty<string>(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of genres (themes) this show has. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Genre[] Genres { get; set; } = Array.Empty<Genre>(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Is this show airing, not aired yet or finished? |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Status Status { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// How well this item is rated? (from 0 to 100). |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int Rating { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// How long is this movie? (in minutes) |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int? Runtime { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The date this movie aired. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? AirDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public DateTime AddedDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Poster { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Thumbnail { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Logo { get; set; } |  | ||||||
| 
 |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		[Column("air_date")] |  | ||||||
| 		public DateTime? StartAir => AirDate; |  | ||||||
| 
 |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		[Column("air_date")] |  | ||||||
| 		public DateTime? EndAir => AirDate; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A video of a few minutes that tease the content. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Trailer { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The ID of the Studio that made this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public Guid? StudioId { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The Studio that made this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[LoadableRelation(nameof(StudioId))] |  | ||||||
| 		public Studio? Studio { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of collections that contains this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Collection>? Collections { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Links to watch this movie. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public VideoLinks Links => |  | ||||||
| 			new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", }; |  | ||||||
| 
 |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<MovieWatchStatus>? Watched { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Metadata of what an user as started/planned to watch. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] |  | ||||||
| 		[LoadableRelation( |  | ||||||
| 			Sql = "movie_watch_status", |  | ||||||
| 			On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]" |  | ||||||
| 		)] |  | ||||||
| 		public MovieWatchStatus? WatchStatus { get; set; } |  | ||||||
| 
 |  | ||||||
| 		// There is a global query filter to filter by user so we just need to do single. |  | ||||||
| 		private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); |  | ||||||
| 
 |  | ||||||
| 		public Movie() { } |  | ||||||
| 
 |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public Movie(string name) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (name != null) | 			Slug = Utility.ToSlug(name); | ||||||
| 			{ | 			Name = name; | ||||||
| 				Slug = Utility.ToSlug(name); |  | ||||||
| 				Name = name; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,124 +26,123 @@ using EntityFrameworkCore.Projectables; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A season of a <see cref="Show"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate | ||||||
| { | { | ||||||
| 	/// <summary> | 	public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber); | ||||||
| 	/// A season of a <see cref="Show"/>. | 
 | ||||||
| 	/// </summary> | 	/// <inheritdoc /> | ||||||
| 	public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[Computed] | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug | ||||||
| 	{ | 	{ | ||||||
| 		public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber); | 		get | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[Computed] |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug |  | ||||||
| 		{ | 		{ | ||||||
| 			get | 			if (ShowSlug == null && Show == null) | ||||||
| 			{ | 				return $"{ShowId}-s{SeasonNumber}"; | ||||||
| 				if (ShowSlug == null && Show == null) | 			return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; | ||||||
| 					return $"{ShowId}-s{SeasonNumber}"; |  | ||||||
| 				return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; |  | ||||||
| 			} |  | ||||||
| 			private set |  | ||||||
| 			{ |  | ||||||
| 				Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)"); |  | ||||||
| 
 |  | ||||||
| 				if (!match.Success) |  | ||||||
| 					throw new ArgumentException( |  | ||||||
| 						"Invalid season slug. Format: {showSlug}-s{seasonNumber}" |  | ||||||
| 					); |  | ||||||
| 				ShowSlug = match.Groups["show"].Value; |  | ||||||
| 				SeasonNumber = int.Parse(match.Groups["season"].Value); |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
|  | 		private set | ||||||
|  | 		{ | ||||||
|  | 			Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)"); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 			if (!match.Success) | ||||||
| 		/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. | 				throw new ArgumentException( | ||||||
| 		/// </summary> | 					"Invalid season slug. Format: {showSlug}-s{seasonNumber}" | ||||||
| 		[JsonIgnore] | 				); | ||||||
| 		public string? ShowSlug { private get; set; } | 			ShowSlug = match.Groups["show"].Value; | ||||||
| 
 | 			SeasonNumber = int.Parse(match.Groups["season"].Value); | ||||||
| 		/// <summary> | 		} | ||||||
| 		/// The ID of the Show containing this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Guid ShowId { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The show that contains this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[LoadableRelation(nameof(ShowId))] |  | ||||||
| 		public Show? Show { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The number of this season. This can be set to 0 to indicate specials. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int SeasonNumber { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The title of this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Name { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A quick overview of this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string? Overview { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The starting air date of this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? StartDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public DateTime AddedDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The ending date of this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? EndDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Poster { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Thumbnail { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Image? Logo { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of episodes that this season contains. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Episode>? Episodes { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The number of episodes in this season. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] |  | ||||||
| 		[NotMapped] |  | ||||||
| 		[LoadableRelation( |  | ||||||
| 			// language=PostgreSQL |  | ||||||
| 			Projected = """
 |  | ||||||
| 					( |  | ||||||
| 						select |  | ||||||
| 							count(*)::int |  | ||||||
| 						from |  | ||||||
| 							episodes as e |  | ||||||
| 						where |  | ||||||
| 							e.season_id = id) as episodes_count |  | ||||||
| 				"""
 |  | ||||||
| 		)] |  | ||||||
| 		public int EpisodesCount { get; set; } |  | ||||||
| 
 |  | ||||||
| 		private int _EpisodesCount => Episodes!.Count; |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public string? ShowSlug { private get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The ID of the Show containing this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Guid ShowId { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The show that contains this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[LoadableRelation(nameof(ShowId))] | ||||||
|  | 	public Show? Show { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The number of this season. This can be set to 0 to indicate specials. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public int SeasonNumber { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The title of this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Name { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A quick overview of this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Overview { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The starting air date of this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public DateTime? StartDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public DateTime AddedDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The ending date of this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public DateTime? EndDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Poster { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Thumbnail { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Image? Logo { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of episodes that this season contains. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Episode>? Episodes { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The number of episodes in this season. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] | ||||||
|  | 	[NotMapped] | ||||||
|  | 	[LoadableRelation( | ||||||
|  | 		// language=PostgreSQL | ||||||
|  | 		Projected = """
 | ||||||
|  | 				( | ||||||
|  | 					select | ||||||
|  | 						count(*)::int | ||||||
|  | 					from | ||||||
|  | 						episodes as e | ||||||
|  | 					where | ||||||
|  | 						e.season_id = id) as episodes_count | ||||||
|  | 			"""
 | ||||||
|  | 	)] | ||||||
|  | 	public int EpisodesCount { get; set; } | ||||||
|  | 
 | ||||||
|  | 	private int _EpisodesCount => Episodes!.Count; | ||||||
| } | } | ||||||
|  | |||||||
| @ -27,254 +27,253 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A series or a movie. | ||||||
|  | /// </summary> | ||||||
|  | public class Show | ||||||
|  | 	: IQuery, | ||||||
|  | 		IResource, | ||||||
|  | 		IMetadata, | ||||||
|  | 		IOnMerge, | ||||||
|  | 		IThumbnails, | ||||||
|  | 		IAddedDate, | ||||||
|  | 		ILibraryItem, | ||||||
|  | 		IWatchlist | ||||||
| { | { | ||||||
|  | 	public static Sort DefaultSort => new Sort<Show>.By(x => x.Name); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A series or a movie. | 	/// The title of this show. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Show | 	public string Name { get; set; } | ||||||
| 		: IQuery, |  | ||||||
| 			IResource, |  | ||||||
| 			IMetadata, |  | ||||||
| 			IOnMerge, |  | ||||||
| 			IThumbnails, |  | ||||||
| 			IAddedDate, |  | ||||||
| 			ILibraryItem, |  | ||||||
| 			IWatchlist |  | ||||||
| 	{ |  | ||||||
| 		public static Sort DefaultSort => new Sort<Show>.By(x => x.Name); |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <summary> | ||||||
| 		public Guid Id { get; set; } | 	/// A catchphrase for this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Tagline { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <summary> | ||||||
| 		[MaxLength(256)] | 	/// The list of alternative titles of this show. | ||||||
| 		public string Slug { get; set; } | 	/// </summary> | ||||||
|  | 	public List<string> Aliases { get; set; } = new(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The title of this show. | 	/// The summary of this show. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string Name { get; set; } | 	public string? Overview { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A catchphrase for this show. | 	/// A list of tags that match this movie. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Tagline { get; set; } | 	public List<string> Tags { get; set; } = new(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The list of alternative titles of this show. | 	/// The list of genres (themes) this show has. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public List<string> Aliases { get; set; } = new(); | 	public List<Genre> Genres { get; set; } = new(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The summary of this show. | 	/// Is this show airing, not aired yet or finished? | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Overview { get; set; } | 	public Status Status { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A list of tags that match this movie. | 	/// How well this item is rated? (from 0 to 100). | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public List<string> Tags { get; set; } = new(); | 	public int Rating { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The list of genres (themes) this show has. | 	/// The date this show started airing. It can be null if this is unknown. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public List<Genre> Genres { get; set; } = new(); | 	public DateTime? StartAir { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Is this show airing, not aired yet or finished? | 	/// The date this show finished airing. | ||||||
| 		/// </summary> | 	/// It can also be null if this is unknown. | ||||||
| 		public Status Status { get; set; } | 	/// </summary> | ||||||
|  | 	public DateTime? EndAir { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// How well this item is rated? (from 0 to 100). | 	public DateTime AddedDate { get; set; } | ||||||
| 		/// </summary> |  | ||||||
| 		public int Rating { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// The date this show started airing. It can be null if this is unknown. | 	public Image? Poster { get; set; } | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? StartAir { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// The date this show finished airing. | 	public Image? Thumbnail { get; set; } | ||||||
| 		/// It can also be null if this is unknown. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? EndAir { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public DateTime AddedDate { get; set; } | 	public Image? Logo { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <summary> | ||||||
| 		public Image? Poster { get; set; } | 	/// A video of a few minutes that tease the content. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string? Trailer { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	[JsonIgnore] | ||||||
| 		public Image? Thumbnail { get; set; } | 	[Column("start_air")] | ||||||
|  | 	public DateTime? AirDate => StartAir; | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Image? Logo { get; set; } | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A video of a few minutes that tease the content. | 	/// The ID of the Studio that made this show. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Trailer { get; set; } | 	public Guid? StudioId { get; set; } | ||||||
| 
 | 
 | ||||||
| 		[JsonIgnore] | 	/// <summary> | ||||||
| 		[Column("start_air")] | 	/// The Studio that made this show. | ||||||
| 		public DateTime? AirDate => StartAir; | 	/// </summary> | ||||||
|  | 	[LoadableRelation(nameof(StudioId))] | ||||||
|  | 	public Studio? Studio { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <summary> | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | 	/// The different seasons in this show. If this is a movie, this list is always null or empty. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Season>? Seasons { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The ID of the Studio that made this show. | 	/// The list of episodes in this show. | ||||||
| 		/// </summary> | 	/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). | ||||||
| 		public Guid? StudioId { get; set; } | 	/// Having an episode is necessary to store metadata and tracks. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Episode>? Episodes { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The Studio that made this show. | 	/// The list of collections that contains this show. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[LoadableRelation(nameof(StudioId))] | 	[JsonIgnore] | ||||||
| 		public Studio? Studio { get; set; } | 	public ICollection<Collection>? Collections { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The different seasons in this show. If this is a movie, this list is always null or empty. | 	/// The first episode of this show. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[JsonIgnore] | 	[Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)] | ||||||
| 		public ICollection<Season>? Seasons { get; set; } | 	[LoadableRelation( | ||||||
| 
 | 		// language=PostgreSQL | ||||||
| 		/// <summary> | 		Sql = """
 | ||||||
| 		/// The list of episodes in this show. | 				select | ||||||
| 		/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). | 					fe.* -- Episode as fe | ||||||
| 		/// Having an episode is necessary to store metadata and tracks. | 				from ( | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Episode>? Episodes { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of collections that contains this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Collection>? Collections { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The first episode of this show. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)] |  | ||||||
| 		[LoadableRelation( |  | ||||||
| 			// language=PostgreSQL |  | ||||||
| 			Sql = """
 |  | ||||||
| 					select | 					select | ||||||
| 						fe.* -- Episode as fe | 						e.*, | ||||||
| 					from ( | 						row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number | ||||||
| 						select | 					from | ||||||
| 							e.*, | 						episodes as e) as "fe" | ||||||
| 							row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number | 				where | ||||||
| 						from | 					fe.number <= 1 | ||||||
| 							episodes as e) as "fe" | 			""",
 | ||||||
|  | 		On = "show_id = \"this\".id" | ||||||
|  | 	)] | ||||||
|  | 	public Episode? FirstEpisode { get; set; } | ||||||
|  | 
 | ||||||
|  | 	private Episode? _FirstEpisode => | ||||||
|  | 		Episodes! | ||||||
|  | 			.OrderBy(x => x.AbsoluteNumber) | ||||||
|  | 			.ThenBy(x => x.SeasonNumber) | ||||||
|  | 			.ThenBy(x => x.EpisodeNumber) | ||||||
|  | 			.FirstOrDefault(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The number of episodes in this show. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] | ||||||
|  | 	[NotMapped] | ||||||
|  | 	[LoadableRelation( | ||||||
|  | 		// language=PostgreSQL | ||||||
|  | 		Projected = """
 | ||||||
|  | 				( | ||||||
|  | 					select | ||||||
|  | 						count(*)::int | ||||||
|  | 					from | ||||||
|  | 						episodes as e | ||||||
| 					where | 					where | ||||||
| 						fe.number <= 1 | 						e.show_id = "this".id) as episodes_count | ||||||
| 				""",
 | 			"""
 | ||||||
| 			On = "show_id = \"this\".id" | 	)] | ||||||
| 		)] | 	public int EpisodesCount { get; set; } | ||||||
| 		public Episode? FirstEpisode { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		private Episode? _FirstEpisode => | 	private int _EpisodesCount => Episodes!.Count; | ||||||
| 			Episodes! |  | ||||||
| 				.OrderBy(x => x.AbsoluteNumber) |  | ||||||
| 				.ThenBy(x => x.SeasonNumber) |  | ||||||
| 				.ThenBy(x => x.EpisodeNumber) |  | ||||||
| 				.FirstOrDefault(); |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	[JsonIgnore] | ||||||
| 		/// The number of episodes in this show. | 	public ICollection<ShowWatchStatus>? Watched { get; set; } | ||||||
| 		/// </summary> |  | ||||||
| 		[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] |  | ||||||
| 		[NotMapped] |  | ||||||
| 		[LoadableRelation( |  | ||||||
| 			// language=PostgreSQL |  | ||||||
| 			Projected = """
 |  | ||||||
| 					( |  | ||||||
| 						select |  | ||||||
| 							count(*)::int |  | ||||||
| 						from |  | ||||||
| 							episodes as e |  | ||||||
| 						where |  | ||||||
| 							e.show_id = "this".id) as episodes_count |  | ||||||
| 				"""
 |  | ||||||
| 		)] |  | ||||||
| 		public int EpisodesCount { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		private int _EpisodesCount => Episodes!.Count; | 	/// <summary> | ||||||
|  | 	/// Metadata of what an user as started/planned to watch. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] | ||||||
|  | 	[LoadableRelation( | ||||||
|  | 		Sql = "show_watch_status", | ||||||
|  | 		On = "show_id = \"this\".id and \"relation\".user_id = [current_user]" | ||||||
|  | 	)] | ||||||
|  | 	public ShowWatchStatus? WatchStatus { get; set; } | ||||||
| 
 | 
 | ||||||
| 		[JsonIgnore] | 	// There is a global query filter to filter by user so we just need to do single. | ||||||
| 		public ICollection<ShowWatchStatus>? Watched { get; set; } | 	private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Metadata of what an user as started/planned to watch. | 	public void OnMerge(object merged) | ||||||
| 		/// </summary> | 	{ | ||||||
| 		[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] | 		if (Seasons != null) | ||||||
| 		[LoadableRelation( |  | ||||||
| 			Sql = "show_watch_status", |  | ||||||
| 			On = "show_id = \"this\".id and \"relation\".user_id = [current_user]" |  | ||||||
| 		)] |  | ||||||
| 		public ShowWatchStatus? WatchStatus { get; set; } |  | ||||||
| 
 |  | ||||||
| 		// There is a global query filter to filter by user so we just need to do single. |  | ||||||
| 		private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void OnMerge(object merged) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (Seasons != null) | 			foreach (Season season in Seasons) | ||||||
| 			{ | 				season.Show = this; | ||||||
| 				foreach (Season season in Seasons) |  | ||||||
| 					season.Show = this; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (Episodes != null) |  | ||||||
| 			{ |  | ||||||
| 				foreach (Episode episode in Episodes) |  | ||||||
| 					episode.Show = this; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		public Show() { } | 		if (Episodes != null) | ||||||
| 
 |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public Show(string name) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (name != null) | 			foreach (Episode episode in Episodes) | ||||||
| 			{ | 				episode.Show = this; | ||||||
| 				Slug = Utility.ToSlug(name); |  | ||||||
| 				Name = name; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	public Show() { } | ||||||
| 	/// The enum containing show's status. | 
 | ||||||
| 	/// </summary> | 	[JsonConstructor] | ||||||
| 	public enum Status | 	public Show(string name) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		if (name != null) | ||||||
| 		/// The status of the show is not known. | 		{ | ||||||
| 		/// </summary> | 			Slug = Utility.ToSlug(name); | ||||||
| 		Unknown, | 			Name = name; | ||||||
| 
 | 		} | ||||||
| 		/// <summary> |  | ||||||
| 		/// The show has finished airing. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Finished, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The show is still actively airing. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Airing, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// This show has not aired yet but has been announced. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Planned |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The enum containing show's status. | ||||||
|  | /// </summary> | ||||||
|  | public enum Status | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The status of the show is not known. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Unknown, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The show has finished airing. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Finished, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The show is still actively airing. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Airing, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// This show has not aired yet but has been announced. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Planned | ||||||
|  | } | ||||||
|  | |||||||
| @ -23,59 +23,58 @@ using System.Text.Json.Serialization; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A studio that make shows. | ||||||
|  | /// </summary> | ||||||
|  | public class Studio : IQuery, IResource, IMetadata | ||||||
| { | { | ||||||
|  | 	public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A studio that make shows. | 	/// The name of this studio. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Studio : IQuery, IResource, IMetadata | 	public string Name { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of shows that are made by this studio. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Show>? Shows { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of movies that are made by this studio. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public ICollection<Movie>? Movies { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new, empty, <see cref="Studio"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Studio() { } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="name">The name of the studio.</param> | ||||||
|  | 	[JsonConstructor] | ||||||
|  | 	public Studio(string name) | ||||||
| 	{ | 	{ | ||||||
| 		public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name); | 		if (name != null) | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The name of this studio. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Name { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of shows that are made by this studio. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Show>? Shows { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of movies that are made by this studio. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public ICollection<Movie>? Movies { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new, empty, <see cref="Studio"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Studio() { } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="name">The name of the studio.</param> |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public Studio(string name) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (name != null) | 			Slug = Utility.ToSlug(name); | ||||||
| 			{ | 			Name = name; | ||||||
| 				Slug = Utility.ToSlug(name); |  | ||||||
| 				Name = name; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,95 +23,94 @@ using System.Text.Json.Serialization; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A single user of the app. | ||||||
|  | /// </summary> | ||||||
|  | public class User : IQuery, IResource, IAddedDate | ||||||
| { | { | ||||||
|  | 	public static Sort DefaultSort => new Sort<User>.By(x => x.Username); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Guid Id { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	[MaxLength(256)] | ||||||
|  | 	public string Slug { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A single user of the app. | 	/// A username displayed to the user. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class User : IQuery, IResource, IAddedDate | 	public string Username { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user email address. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string Email { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user password (hashed, it can't be read like that). The hashing format is implementation defined. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public string? Password { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Does the user can sign-in with a password or only via oidc? | ||||||
|  | 	/// </summary> | ||||||
|  | 	public bool HasPassword => Password != null; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of permissions of the user. The format of this is implementation dependent. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string[] Permissions { get; set; } = Array.Empty<string>(); | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public DateTime AddedDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// User settings | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Dictionary<string, string> Settings { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// User accounts on other services. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Dictionary<string, ExternalToken> ExternalId { get; set; } = new(); | ||||||
|  | 
 | ||||||
|  | 	public User() { } | ||||||
|  | 
 | ||||||
|  | 	[JsonConstructor] | ||||||
|  | 	public User(string username) | ||||||
| 	{ | 	{ | ||||||
| 		public static Sort DefaultSort => new Sort<User>.By(x => x.Username); | 		if (username != null) | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid Id { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		[MaxLength(256)] |  | ||||||
| 		public string Slug { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A username displayed to the user. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Username { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user email address. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Email { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user password (hashed, it can't be read like that). The hashing format is implementation defined. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonIgnore] |  | ||||||
| 		public string? Password { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Does the user can sign-in with a password or only via oidc? |  | ||||||
| 		/// </summary> |  | ||||||
| 		public bool HasPassword => Password != null; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The list of permissions of the user. The format of this is implementation dependent. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string[] Permissions { get; set; } = Array.Empty<string>(); |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public DateTime AddedDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// User settings |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Dictionary<string, string> Settings { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// User accounts on other services. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Dictionary<string, ExternalToken> ExternalId { get; set; } = new(); |  | ||||||
| 
 |  | ||||||
| 		public User() { } |  | ||||||
| 
 |  | ||||||
| 		[JsonConstructor] |  | ||||||
| 		public User(string username) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (username != null) | 			Slug = Utility.ToSlug(username); | ||||||
| 			{ | 			Username = username; | ||||||
| 				Slug = Utility.ToSlug(username); |  | ||||||
| 				Username = username; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | } | ||||||
| 	public class ExternalToken | 
 | ||||||
| 	{ | public class ExternalToken | ||||||
| 		/// <summary> | { | ||||||
| 		/// The id of this user on the external service. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// The id of this user on the external service. | ||||||
| 		public string Id { get; set; } | 	/// </summary> | ||||||
| 
 | 	public string Id { get; set; } | ||||||
| 		/// <summary> | 
 | ||||||
| 		/// The username on the external service. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// The username on the external service. | ||||||
| 		public string Username { get; set; } | 	/// </summary> | ||||||
| 
 | 	public string Username { get; set; } | ||||||
| 		/// <summary> | 
 | ||||||
| 		/// The link to the user profile on this website. Null if it does not exist. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// The link to the user profile on this website. Null if it does not exist. | ||||||
| 		public string? ProfileUrl { get; set; } | 	/// </summary> | ||||||
| 
 | 	public string? ProfileUrl { get; set; } | ||||||
| 		/// <summary> | 
 | ||||||
| 		/// A jwt token used to interact with the service. | 	/// <summary> | ||||||
| 		/// Do not forget to refresh it when using it if necessary. | 	/// A jwt token used to interact with the service. | ||||||
| 		/// </summary> | 	/// Do not forget to refresh it when using it if necessary. | ||||||
| 		public JwtToken Token { get; set; } | 	/// </summary> | ||||||
| 	} | 	public JwtToken Token { get; set; } | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,214 +20,213 @@ using System; | |||||||
| using System.Text.Json.Serialization; | using System.Text.Json.Serialization; | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Has the user started watching, is it planned? | ||||||
|  | /// </summary> | ||||||
|  | public enum WatchStatus | ||||||
| { | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user has already watched this. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Completed, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user started watching this but has not finished. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Watching, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user does not plan to continue watching. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Droped, | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user has not started watching this but plans to. | ||||||
|  | 	/// </summary> | ||||||
|  | 	Planned, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Metadata of what an user as started/planned to watch. | ||||||
|  | /// </summary> | ||||||
|  | [SqlFirstColumn(nameof(UserId))] | ||||||
|  | public class MovieWatchStatus : IAddedDate | ||||||
|  | { | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The ID of the user that started watching this episode. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Guid UserId { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user that started watching this episode. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public User User { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The ID of the movie started. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Guid MovieId { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The <see cref="Movie"/> started. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[JsonIgnore] | ||||||
|  | 	public Movie Movie { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public DateTime AddedDate { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The date at which this item was played. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public DateTime? PlayedDate { get; set; } | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Has the user started watching, is it planned? | 	/// Has the user started watching, is it planned? | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public enum WatchStatus | 	public WatchStatus Status { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user has already watched this. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Completed, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user started watching this but has not finished. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Watching, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user does not plan to continue watching. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Droped, |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user has not started watching this but plans to. |  | ||||||
| 		/// </summary> |  | ||||||
| 		Planned, |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Metadata of what an user as started/planned to watch. | 	/// Where the player has stopped watching the movie (in seconds). | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[SqlFirstColumn(nameof(UserId))] | 	/// <remarks> | ||||||
| 	public class MovieWatchStatus : IAddedDate | 	/// Null if the status is not Watching. | ||||||
| 	{ | 	/// </remarks> | ||||||
| 		/// <summary> | 	public int? WatchedTime { get; set; } | ||||||
| 		/// The ID of the user that started watching this episode. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Guid UserId { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The user that started watching this episode. | 	/// Where the player has stopped watching the movie (in percentage between 0 and 100). | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[JsonIgnore] | 	/// <remarks> | ||||||
| 		public User User { get; set; } | 	/// Null if the status is not Watching. | ||||||
| 
 | 	/// </remarks> | ||||||
| 		/// <summary> | 	public int? WatchedPercent { get; set; } | ||||||
| 		/// The ID of the movie started. | } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public Guid MovieId { get; set; } | [SqlFirstColumn(nameof(UserId))] | ||||||
| 
 | public class EpisodeWatchStatus : IAddedDate | ||||||
| 		/// <summary> | { | ||||||
| 		/// The <see cref="Movie"/> started. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// The ID of the user that started watching this episode. | ||||||
| 		[JsonIgnore] | 	/// </summary> | ||||||
| 		public Movie Movie { get; set; } | 	public Guid UserId { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <summary> | ||||||
| 		public DateTime AddedDate { get; set; } | 	/// The user that started watching this episode. | ||||||
| 
 | 	/// </summary> | ||||||
| 		/// <summary> | 	[JsonIgnore] | ||||||
| 		/// The date at which this item was played. | 	public User User { get; set; } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public DateTime? PlayedDate { get; set; } | 	/// <summary> | ||||||
| 
 | 	/// The ID of the episode started. | ||||||
| 		/// <summary> | 	/// </summary> | ||||||
| 		/// Has the user started watching, is it planned? | 	public Guid? EpisodeId { get; set; } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public WatchStatus Status { get; set; } | 	/// <summary> | ||||||
| 
 | 	/// The <see cref="Episode"/> started. | ||||||
| 		/// <summary> | 	/// </summary> | ||||||
| 		/// Where the player has stopped watching the movie (in seconds). | 	[JsonIgnore] | ||||||
| 		/// </summary> | 	public Episode Episode { get; set; } | ||||||
| 		/// <remarks> | 
 | ||||||
| 		/// Null if the status is not Watching. | 	/// <inheritdoc/> | ||||||
| 		/// </remarks> | 	public DateTime AddedDate { get; set; } | ||||||
| 		public int? WatchedTime { get; set; } | 
 | ||||||
| 
 | 	/// <summary> | ||||||
| 		/// <summary> | 	/// The date at which this item was played. | ||||||
| 		/// Where the player has stopped watching the movie (in percentage between 0 and 100). | 	/// </summary> | ||||||
| 		/// </summary> | 	public DateTime? PlayedDate { get; set; } | ||||||
| 		/// <remarks> | 
 | ||||||
| 		/// Null if the status is not Watching. | 	/// <summary> | ||||||
| 		/// </remarks> | 	/// Has the user started watching, is it planned? | ||||||
| 		public int? WatchedPercent { get; set; } | 	/// </summary> | ||||||
| 	} | 	public WatchStatus Status { get; set; } | ||||||
| 
 | 
 | ||||||
| 	[SqlFirstColumn(nameof(UserId))] | 	/// <summary> | ||||||
| 	public class EpisodeWatchStatus : IAddedDate | 	/// Where the player has stopped watching the episode (in seconds). | ||||||
| 	{ | 	/// </summary> | ||||||
| 		/// <summary> | 	/// <remarks> | ||||||
| 		/// The ID of the user that started watching this episode. | 	/// Null if the status is not Watching. | ||||||
| 		/// </summary> | 	/// </remarks> | ||||||
| 		public Guid UserId { get; set; } | 	public int? WatchedTime { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The user that started watching this episode. | 	/// Where the player has stopped watching the episode (in percentage between 0 and 100). | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[JsonIgnore] | 	/// <remarks> | ||||||
| 		public User User { get; set; } | 	/// Null if the status is not Watching or if the next episode is not started. | ||||||
| 
 | 	/// </remarks> | ||||||
| 		/// <summary> | 	public int? WatchedPercent { get; set; } | ||||||
| 		/// The ID of the episode started. | } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public Guid? EpisodeId { get; set; } | [SqlFirstColumn(nameof(UserId))] | ||||||
| 
 | public class ShowWatchStatus : IAddedDate | ||||||
| 		/// <summary> | { | ||||||
| 		/// The <see cref="Episode"/> started. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// The ID of the user that started watching this episode. | ||||||
| 		[JsonIgnore] | 	/// </summary> | ||||||
| 		public Episode Episode { get; set; } | 	public Guid UserId { get; set; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <summary> | ||||||
| 		public DateTime AddedDate { get; set; } | 	/// The user that started watching this episode. | ||||||
| 
 | 	/// </summary> | ||||||
| 		/// <summary> | 	[JsonIgnore] | ||||||
| 		/// The date at which this item was played. | 	public User User { get; set; } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public DateTime? PlayedDate { get; set; } | 	/// <summary> | ||||||
| 
 | 	/// The ID of the show started. | ||||||
| 		/// <summary> | 	/// </summary> | ||||||
| 		/// Has the user started watching, is it planned? | 	public Guid ShowId { get; set; } | ||||||
| 		/// </summary> | 
 | ||||||
| 		public WatchStatus Status { get; set; } | 	/// <summary> | ||||||
| 
 | 	/// The <see cref="Show"/> started. | ||||||
| 		/// <summary> | 	/// </summary> | ||||||
| 		/// Where the player has stopped watching the episode (in seconds). | 	[JsonIgnore] | ||||||
| 		/// </summary> | 	public Show Show { get; set; } | ||||||
| 		/// <remarks> | 
 | ||||||
| 		/// Null if the status is not Watching. | 	/// <inheritdoc/> | ||||||
| 		/// </remarks> | 	public DateTime AddedDate { get; set; } | ||||||
| 		public int? WatchedTime { get; set; } | 
 | ||||||
| 
 | 	/// <summary> | ||||||
| 		/// <summary> | 	/// The date at which this item was played. | ||||||
| 		/// Where the player has stopped watching the episode (in percentage between 0 and 100). | 	/// </summary> | ||||||
| 		/// </summary> | 	public DateTime? PlayedDate { get; set; } | ||||||
| 		/// <remarks> | 
 | ||||||
| 		/// Null if the status is not Watching or if the next episode is not started. | 	/// <summary> | ||||||
| 		/// </remarks> | 	/// Has the user started watching, is it planned? | ||||||
| 		public int? WatchedPercent { get; set; } | 	/// </summary> | ||||||
| 	} | 	public WatchStatus Status { get; set; } | ||||||
| 
 | 
 | ||||||
| 	[SqlFirstColumn(nameof(UserId))] | 	/// <summary> | ||||||
| 	public class ShowWatchStatus : IAddedDate | 	/// The number of episodes the user has not seen. | ||||||
| 	{ | 	/// </summary> | ||||||
| 		/// <summary> | 	public int UnseenEpisodesCount { get; set; } | ||||||
| 		/// The ID of the user that started watching this episode. | 
 | ||||||
| 		/// </summary> | 	/// <summary> | ||||||
| 		public Guid UserId { get; set; } | 	/// The ID of the episode started. | ||||||
| 
 | 	/// </summary> | ||||||
| 		/// <summary> | 	public Guid? NextEpisodeId { get; set; } | ||||||
| 		/// The user that started watching this episode. | 
 | ||||||
| 		/// </summary> | 	/// <summary> | ||||||
| 		[JsonIgnore] | 	/// The next <see cref="Episode"/> to watch. | ||||||
| 		public User User { get; set; } | 	/// </summary> | ||||||
| 
 | 	public Episode? NextEpisode { get; set; } | ||||||
| 		/// <summary> | 
 | ||||||
| 		/// The ID of the show started. | 	/// <summary> | ||||||
| 		/// </summary> | 	/// Where the player has stopped watching the episode (in seconds). | ||||||
| 		public Guid ShowId { get; set; } | 	/// </summary> | ||||||
| 
 | 	/// <remarks> | ||||||
| 		/// <summary> | 	/// Null if the status is not Watching or if the next episode is not started. | ||||||
| 		/// The <see cref="Show"/> started. | 	/// </remarks> | ||||||
| 		/// </summary> | 	public int? WatchedTime { get; set; } | ||||||
| 		[JsonIgnore] | 
 | ||||||
| 		public Show Show { get; set; } | 	/// <summary> | ||||||
| 
 | 	/// Where the player has stopped watching the episode (in percentage between 0 and 100). | ||||||
| 		/// <inheritdoc/> | 	/// </summary> | ||||||
| 		public DateTime AddedDate { get; set; } | 	/// <remarks> | ||||||
| 
 | 	/// Null if the status is not Watching or if the next episode is not started. | ||||||
| 		/// <summary> | 	/// </remarks> | ||||||
| 		/// The date at which this item was played. | 	public int? WatchedPercent { get; set; } | ||||||
| 		/// </summary> |  | ||||||
| 		public DateTime? PlayedDate { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Has the user started watching, is it planned? |  | ||||||
| 		/// </summary> |  | ||||||
| 		public WatchStatus Status { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The number of episodes the user has not seen. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int UnseenEpisodesCount { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The ID of the episode started. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Guid? NextEpisodeId { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The next <see cref="Episode"/> to watch. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Episode? NextEpisode { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Where the player has stopped watching the episode (in seconds). |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Null if the status is not Watching or if the next episode is not started. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		public int? WatchedTime { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Where the player has stopped watching the episode (in percentage between 0 and 100). |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Null if the status is not Watching or if the next episode is not started. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		public int? WatchedPercent { get; set; } |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,37 +18,36 @@ | |||||||
| 
 | 
 | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Results of a search request. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The search item's type.</typeparam> | ||||||
|  | public class SearchPage<T> : Page<T> | ||||||
|  | 	where T : IResource | ||||||
| { | { | ||||||
| 	/// <summary> | 	public SearchPage( | ||||||
| 	/// Results of a search request. | 		SearchResult result, | ||||||
| 	/// </summary> | 		string @this, | ||||||
| 	/// <typeparam name="T">The search item's type.</typeparam> | 		string? previous, | ||||||
| 	public class SearchPage<T> : Page<T> | 		string? next, | ||||||
| 		where T : IResource | 		string first | ||||||
|  | 	) | ||||||
|  | 		: base(result.Items, @this, previous, next, first) | ||||||
| 	{ | 	{ | ||||||
| 		public SearchPage( | 		Query = result.Query; | ||||||
| 			SearchResult result, | 	} | ||||||
| 			string @this, |  | ||||||
| 			string? previous, |  | ||||||
| 			string? next, |  | ||||||
| 			string first |  | ||||||
| 		) |  | ||||||
| 			: base(result.Items, @this, previous, next, first) |  | ||||||
| 		{ |  | ||||||
| 			Query = result.Query; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The query of the search request. | 	/// The query of the search request. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string? Query { get; init; } | 	public string? Query { get; init; } | ||||||
| 
 | 
 | ||||||
| 		public class SearchResult | 	public class SearchResult | ||||||
| 		{ | 	{ | ||||||
| 			public string? Query { get; set; } | 		public string? Query { get; set; } | ||||||
| 
 | 
 | ||||||
| 			public ICollection<T> Items { get; set; } | 		public ICollection<T> Items { get; set; } | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,41 +16,40 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication.Models | namespace Kyoo.Authentication.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// List of well known claims of kyoo | ||||||
|  | /// </summary> | ||||||
|  | public static class Claims | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// List of well known claims of kyoo | 	/// The id of the user | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Claims | 	public static string Id => "id"; | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The id of the user |  | ||||||
| 		/// </summary> |  | ||||||
| 		public static string Id => "id"; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The name of the user | 	/// The name of the user | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public static string Name => "name"; | 	public static string Name => "name"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The email of the user. | 	/// The email of the user. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public static string Email => "email"; | 	public static string Email => "email"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The list of permissions that the user has. | 	/// The list of permissions that the user has. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public static string Permissions => "permissions"; | 	public static string Permissions => "permissions"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The type of the token (either "access" or "refresh"). | 	/// The type of the token (either "access" or "refresh"). | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public static string Type => "type"; | 	public static string Type => "type"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. | 	/// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public static string Guid => "guid"; | 	public static string Guid => "guid"; | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,43 +18,42 @@ | |||||||
| 
 | 
 | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Utils | namespace Kyoo.Abstractions.Models.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class containing constant numbers. | ||||||
|  | /// </summary> | ||||||
|  | public static class Constants | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class containing constant numbers. | 	/// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route | ||||||
|  | 	/// that won't be included on the swagger. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Constants | 	public const int AlternativeRoute = 1; | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route |  | ||||||
| 		/// that won't be included on the swagger. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const int AlternativeRoute = 1; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users. | 	/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string UsersGroup = "0:Users"; | 	public const string UsersGroup = "0:Users"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo. | 	/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string ResourcesGroup = "1:Resources"; | 	public const string ResourcesGroup = "1:Resources"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A group name for <see cref="ApiDefinitionAttribute"/>. | 	/// A group name for <see cref="ApiDefinitionAttribute"/>. | ||||||
| 		/// It should be used for sub resources of kyoo that help define the main resources. | 	/// It should be used for sub resources of kyoo that help define the main resources. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string MetadataGroup = "2:Metadata"; | 	public const string MetadataGroup = "2:Metadata"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback. | 	/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string WatchGroup = "3:Watch"; | 	public const string WatchGroup = "3:Watch"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins. | 	/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string AdminGroup = "4:Admin"; | 	public const string AdminGroup = "4:Admin"; | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,225 +24,222 @@ using System.Linq; | |||||||
| using System.Linq.Expressions; | using System.Linq.Expressions; | ||||||
| using System.Reflection; | using System.Reflection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Utils | namespace Kyoo.Abstractions.Models.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else | ||||||
|  | /// on the application. | ||||||
|  | /// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>. | ||||||
|  | /// </summary> | ||||||
|  | [TypeConverter(typeof(IdentifierConvertor))] | ||||||
|  | public class Identifier | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else | 	/// The ID of the resource or null if the slug is specified. | ||||||
| 	/// on the application. |  | ||||||
| 	/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[TypeConverter(typeof(IdentifierConvertor))] | 	private readonly Guid? _id; | ||||||
| 	public class Identifier | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The slug of the resource or null if the id is specified. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly string? _slug; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Identifier"/> for the given id. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="id">The id of the resource.</param> | ||||||
|  | 	public Identifier(Guid id) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_id = id; | ||||||
| 		/// The ID of the resource or null if the slug is specified. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly Guid? _id; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The slug of the resource or null if the id is specified. | 	/// Create a new <see cref="Identifier"/> for the given slug. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		private readonly string? _slug; | 	/// <param name="slug">The slug of the resource.</param> | ||||||
|  | 	public Identifier(string slug) | ||||||
|  | 	{ | ||||||
|  | 		_slug = slug; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="Identifier"/> for the given id. | 	/// Pattern match out of the identifier to a resource. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="id">The id of the resource.</param> | 	/// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param> | ||||||
| 		public Identifier(Guid id) | 	/// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param> | ||||||
|  | 	/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam> | ||||||
|  | 	/// <returns> | ||||||
|  | 	/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern. | ||||||
|  | 	/// </returns> | ||||||
|  | 	/// <example> | ||||||
|  | 	/// Example usage: | ||||||
|  | 	/// <code lang="csharp"> | ||||||
|  | 	/// T ret = await identifier.Match( | ||||||
|  | 	///      id => _repository.GetOrDefault(id), | ||||||
|  | 	///      slug => _repository.GetOrDefault(slug) | ||||||
|  | 	/// ); | ||||||
|  | 	/// </code> | ||||||
|  | 	/// </example> | ||||||
|  | 	public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc) | ||||||
|  | 	{ | ||||||
|  | 		return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details). | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param> | ||||||
|  | 	/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param> | ||||||
|  | 	/// <typeparam name="T">The type to match against this identifier.</typeparam> | ||||||
|  | 	/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns> | ||||||
|  | 	/// <example> | ||||||
|  | 	/// <code lang="csharp"> | ||||||
|  | 	/// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug) | ||||||
|  | 	/// </code> | ||||||
|  | 	/// </example> | ||||||
|  | 	public Filter<T> Matcher<T>( | ||||||
|  | 		Expression<Func<T, Guid>> idGetter, | ||||||
|  | 		Expression<Func<T, string>> slugGetter | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); | ||||||
|  | 		BinaryExpression equal = Expression.Equal( | ||||||
|  | 			_id.HasValue ? idGetter.Body : slugGetter.Body, | ||||||
|  | 			self | ||||||
|  | 		); | ||||||
|  | 		ICollection<ParameterExpression> parameters = _id.HasValue | ||||||
|  | 			? idGetter.Parameters | ||||||
|  | 			: slugGetter.Parameters; | ||||||
|  | 		Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters); | ||||||
|  | 		return new Filter<T>.Lambda(lambda); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A matcher overload for nullable IDs. See | ||||||
|  | 	/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/> | ||||||
|  | 	/// for more details. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param> | ||||||
|  | 	/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param> | ||||||
|  | 	/// <typeparam name="T">The type to match against this identifier.</typeparam> | ||||||
|  | 	/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns> | ||||||
|  | 	public Filter<T> Matcher<T>( | ||||||
|  | 		Expression<Func<T, Guid?>> idGetter, | ||||||
|  | 		Expression<Func<T, string>> slugGetter | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); | ||||||
|  | 		BinaryExpression equal = Expression.Equal( | ||||||
|  | 			_id.HasValue ? idGetter.Body : slugGetter.Body, | ||||||
|  | 			self | ||||||
|  | 		); | ||||||
|  | 		ICollection<ParameterExpression> parameters = _id.HasValue | ||||||
|  | 			? idGetter.Parameters | ||||||
|  | 			: slugGetter.Parameters; | ||||||
|  | 		Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters); | ||||||
|  | 		return new Filter<T>.Lambda(lambda); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Return true if this <see cref="Identifier"/> match a resource. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="resource">The resource to match</param> | ||||||
|  | 	/// <returns> | ||||||
|  | 	/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise. | ||||||
|  | 	/// </returns> | ||||||
|  | 	public bool IsSame(IResource resource) | ||||||
|  | 	{ | ||||||
|  | 		return Match(id => resource.Id == id, slug => resource.Slug == slug); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Return a filter to get this <see cref="Identifier"/> match a given resource. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <typeparam name="T">The type of resource to match against.</typeparam> | ||||||
|  | 	/// <returns> | ||||||
|  | 	/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise. | ||||||
|  | 	/// </returns> | ||||||
|  | 	public Filter<T> IsSame<T>() | ||||||
|  | 		where T : IResource | ||||||
|  | 	{ | ||||||
|  | 		return _id.HasValue ? new Filter<T>.Eq("Id", _id.Value) : new Filter<T>.Eq("Slug", _slug!); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public bool Is(Guid uid) | ||||||
|  | 	{ | ||||||
|  | 		return _id.HasValue && _id.Value == uid; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public bool Is(string slug) | ||||||
|  | 	{ | ||||||
|  | 		return !_id.HasValue && _slug == slug; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private Expression<Func<T, bool>> _IsSameExpression<T>() | ||||||
|  | 		where T : IResource | ||||||
|  | 	{ | ||||||
|  | 		return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="listGetter">An expression to retrieve the list to check.</param> | ||||||
|  | 	/// <typeparam name="T">The type that contain the list to check.</typeparam> | ||||||
|  | 	/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam> | ||||||
|  | 	/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns> | ||||||
|  | 	public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter) | ||||||
|  | 		where T2 : IResource | ||||||
|  | 	{ | ||||||
|  | 		MethodInfo method = typeof(Enumerable) | ||||||
|  | 			.GetMethods() | ||||||
|  | 			.Where(x => x.Name == nameof(Enumerable.Any)) | ||||||
|  | 			.FirstOrDefault(x => x.GetParameters().Length == 2)! | ||||||
|  | 			.MakeGenericMethod(typeof(T2)); | ||||||
|  | 		MethodCallExpression call = Expression.Call( | ||||||
|  | 			null, | ||||||
|  | 			method, | ||||||
|  | 			listGetter.Body, | ||||||
|  | 			_IsSameExpression<T2>() | ||||||
|  | 		); | ||||||
|  | 		Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>( | ||||||
|  | 			call, | ||||||
|  | 			listGetter.Parameters | ||||||
|  | 		); | ||||||
|  | 		return new Filter<T>.Lambda(lambda); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public override string ToString() | ||||||
|  | 	{ | ||||||
|  | 		return _id.HasValue ? _id.Value.ToString() : _slug!; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public class IdentifierConvertor : TypeConverter | ||||||
|  | 	{ | ||||||
|  | 		/// <inheritdoc /> | ||||||
|  | 		public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) | ||||||
| 		{ | 		{ | ||||||
| 			_id = id; | 			if (sourceType == typeof(Guid) || sourceType == typeof(string)) | ||||||
| 		} | 				return true; | ||||||
| 
 | 			return base.CanConvertFrom(context, sourceType); | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Identifier"/> for the given slug. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="slug">The slug of the resource.</param> |  | ||||||
| 		public Identifier(string slug) |  | ||||||
| 		{ |  | ||||||
| 			_slug = slug; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Pattern match out of the identifier to a resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam> |  | ||||||
| 		/// <returns> |  | ||||||
| 		/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern. |  | ||||||
| 		/// </returns> |  | ||||||
| 		/// <example> |  | ||||||
| 		/// Example usage: |  | ||||||
| 		/// <code lang="csharp"> |  | ||||||
| 		/// T ret = await identifier.Match( |  | ||||||
| 		///      id => _repository.GetOrDefault(id), |  | ||||||
| 		///      slug => _repository.GetOrDefault(slug) |  | ||||||
| 		/// ); |  | ||||||
| 		/// </code> |  | ||||||
| 		/// </example> |  | ||||||
| 		public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc) |  | ||||||
| 		{ |  | ||||||
| 			return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details). |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <typeparam name="T">The type to match against this identifier.</typeparam> |  | ||||||
| 		/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns> |  | ||||||
| 		/// <example> |  | ||||||
| 		/// <code lang="csharp"> |  | ||||||
| 		/// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug) |  | ||||||
| 		/// </code> |  | ||||||
| 		/// </example> |  | ||||||
| 		public Filter<T> Matcher<T>( |  | ||||||
| 			Expression<Func<T, Guid>> idGetter, |  | ||||||
| 			Expression<Func<T, string>> slugGetter |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); |  | ||||||
| 			BinaryExpression equal = Expression.Equal( |  | ||||||
| 				_id.HasValue ? idGetter.Body : slugGetter.Body, |  | ||||||
| 				self |  | ||||||
| 			); |  | ||||||
| 			ICollection<ParameterExpression> parameters = _id.HasValue |  | ||||||
| 				? idGetter.Parameters |  | ||||||
| 				: slugGetter.Parameters; |  | ||||||
| 			Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters); |  | ||||||
| 			return new Filter<T>.Lambda(lambda); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A matcher overload for nullable IDs. See |  | ||||||
| 		/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/> |  | ||||||
| 		/// for more details. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param> |  | ||||||
| 		/// <typeparam name="T">The type to match against this identifier.</typeparam> |  | ||||||
| 		/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns> |  | ||||||
| 		public Filter<T> Matcher<T>( |  | ||||||
| 			Expression<Func<T, Guid?>> idGetter, |  | ||||||
| 			Expression<Func<T, string>> slugGetter |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); |  | ||||||
| 			BinaryExpression equal = Expression.Equal( |  | ||||||
| 				_id.HasValue ? idGetter.Body : slugGetter.Body, |  | ||||||
| 				self |  | ||||||
| 			); |  | ||||||
| 			ICollection<ParameterExpression> parameters = _id.HasValue |  | ||||||
| 				? idGetter.Parameters |  | ||||||
| 				: slugGetter.Parameters; |  | ||||||
| 			Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters); |  | ||||||
| 			return new Filter<T>.Lambda(lambda); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Return true if this <see cref="Identifier"/> match a resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="resource">The resource to match</param> |  | ||||||
| 		/// <returns> |  | ||||||
| 		/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise. |  | ||||||
| 		/// </returns> |  | ||||||
| 		public bool IsSame(IResource resource) |  | ||||||
| 		{ |  | ||||||
| 			return Match(id => resource.Id == id, slug => resource.Slug == slug); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Return a filter to get this <see cref="Identifier"/> match a given resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <typeparam name="T">The type of resource to match against.</typeparam> |  | ||||||
| 		/// <returns> |  | ||||||
| 		/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise. |  | ||||||
| 		/// </returns> |  | ||||||
| 		public Filter<T> IsSame<T>() |  | ||||||
| 			where T : IResource |  | ||||||
| 		{ |  | ||||||
| 			return _id.HasValue |  | ||||||
| 				? new Filter<T>.Eq("Id", _id.Value) |  | ||||||
| 				: new Filter<T>.Eq("Slug", _slug!); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public bool Is(Guid uid) |  | ||||||
| 		{ |  | ||||||
| 			return _id.HasValue && _id.Value == uid; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public bool Is(string slug) |  | ||||||
| 		{ |  | ||||||
| 			return !_id.HasValue && _slug == slug; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		private Expression<Func<T, bool>> _IsSameExpression<T>() |  | ||||||
| 			where T : IResource |  | ||||||
| 		{ |  | ||||||
| 			return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="listGetter">An expression to retrieve the list to check.</param> |  | ||||||
| 		/// <typeparam name="T">The type that contain the list to check.</typeparam> |  | ||||||
| 		/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam> |  | ||||||
| 		/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns> |  | ||||||
| 		public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter) |  | ||||||
| 			where T2 : IResource |  | ||||||
| 		{ |  | ||||||
| 			MethodInfo method = typeof(Enumerable) |  | ||||||
| 				.GetMethods() |  | ||||||
| 				.Where(x => x.Name == nameof(Enumerable.Any)) |  | ||||||
| 				.FirstOrDefault(x => x.GetParameters().Length == 2)! |  | ||||||
| 				.MakeGenericMethod(typeof(T2)); |  | ||||||
| 			MethodCallExpression call = Expression.Call( |  | ||||||
| 				null, |  | ||||||
| 				method, |  | ||||||
| 				listGetter.Body, |  | ||||||
| 				_IsSameExpression<T2>() |  | ||||||
| 			); |  | ||||||
| 			Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>( |  | ||||||
| 				call, |  | ||||||
| 				listGetter.Parameters |  | ||||||
| 			); |  | ||||||
| 			return new Filter<T>.Lambda(lambda); |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		/// <inheritdoc /> | ||||||
| 		public override string ToString() | 		public override object ConvertFrom( | ||||||
|  | 			ITypeDescriptorContext? context, | ||||||
|  | 			CultureInfo? culture, | ||||||
|  | 			object value | ||||||
|  | 		) | ||||||
| 		{ | 		{ | ||||||
| 			return _id.HasValue ? _id.Value.ToString() : _slug!; | 			if (value is Guid id) | ||||||
| 		} | 				return new Identifier(id); | ||||||
| 
 | 			if (value is not string slug) | ||||||
| 		/// <summary> | 				return base.ConvertFrom(context, culture, value)!; | ||||||
| 		/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>. | 			return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug); | ||||||
| 		/// </summary> |  | ||||||
| 		public class IdentifierConvertor : TypeConverter |  | ||||||
| 		{ |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) |  | ||||||
| 			{ |  | ||||||
| 				if (sourceType == typeof(Guid) || sourceType == typeof(string)) |  | ||||||
| 					return true; |  | ||||||
| 				return base.CanConvertFrom(context, sourceType); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public override object ConvertFrom( |  | ||||||
| 				ITypeDescriptorContext? context, |  | ||||||
| 				CultureInfo? culture, |  | ||||||
| 				object value |  | ||||||
| 			) |  | ||||||
| 			{ |  | ||||||
| 				if (value is Guid id) |  | ||||||
| 					return new Identifier(id); |  | ||||||
| 				if (value is not string slug) |  | ||||||
| 					return base.ConvertFrom(context, culture, value)!; |  | ||||||
| 				return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug); |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -18,56 +18,55 @@ | |||||||
| 
 | 
 | ||||||
| using System; | using System; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about the pagination. How many items should be displayed and where to start. | ||||||
|  | /// </summary> | ||||||
|  | public class Pagination | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about the pagination. How many items should be displayed and where to start. | 	/// The count of items to return. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Pagination | 	public int Limit { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Where to start? Using the given sort. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Guid? AfterID { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Should the previous page be returned instead of the next? | ||||||
|  | 	/// </summary> | ||||||
|  | 	public bool Reverse { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Pagination"/> with default values. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public Pagination() | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Limit = 50; | ||||||
| 		/// The count of items to return. | 		AfterID = null; | ||||||
| 		/// </summary> | 		Reverse = false; | ||||||
| 		public int Limit { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Where to start? Using the given sort. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Guid? AfterID { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Should the previous page be returned instead of the next? |  | ||||||
| 		/// </summary> |  | ||||||
| 		public bool Reverse { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Pagination"/> with default values. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public Pagination() |  | ||||||
| 		{ |  | ||||||
| 			Limit = 50; |  | ||||||
| 			AfterID = null; |  | ||||||
| 			Reverse = false; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Pagination"/> instance. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="count">Set the <see cref="Limit"/> value</param> |  | ||||||
| 		/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param> |  | ||||||
| 		/// <param name="reverse">Should the previous page be returned instead of the next?</param> |  | ||||||
| 		public Pagination(int count, Guid? afterID = null, bool reverse = false) |  | ||||||
| 		{ |  | ||||||
| 			Limit = count; |  | ||||||
| 			AfterID = afterID; |  | ||||||
| 			Reverse = reverse; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Implicitly create a new pagination from a limit number. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="limit">Set the <see cref="Limit"/> value</param> |  | ||||||
| 		/// <returns>A new <see cref="Pagination"/> instance</returns> |  | ||||||
| 		public static implicit operator Pagination(int limit) => new(limit); |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Pagination"/> instance. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="count">Set the <see cref="Limit"/> value</param> | ||||||
|  | 	/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param> | ||||||
|  | 	/// <param name="reverse">Should the previous page be returned instead of the next?</param> | ||||||
|  | 	public Pagination(int count, Guid? afterID = null, bool reverse = false) | ||||||
|  | 	{ | ||||||
|  | 		Limit = count; | ||||||
|  | 		AfterID = afterID; | ||||||
|  | 		Reverse = reverse; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Implicitly create a new pagination from a limit number. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="limit">Set the <see cref="Limit"/> value</param> | ||||||
|  | 	/// <returns>A new <see cref="Pagination"/> instance</returns> | ||||||
|  | 	public static implicit operator Pagination(int limit) => new(limit); | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,42 +19,38 @@ | |||||||
| using System; | using System; | ||||||
| using System.Linq; | using System.Linq; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models.Utils | namespace Kyoo.Abstractions.Models.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The list of errors that where made in the request. | ||||||
|  | /// </summary> | ||||||
|  | public class RequestError | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The list of errors that where made in the request. | 	/// The list of errors that where made in the request. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class RequestError | 	/// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example> | ||||||
|  | 	public string[] Errors { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="RequestError"/> with one error. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="error">The error to specify in the response.</param> | ||||||
|  | 	public RequestError(string error) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		if (error == null) | ||||||
| 		/// The list of errors that where made in the request. | 			throw new ArgumentNullException(nameof(error)); | ||||||
| 		/// </summary> | 		Errors = new[] { error }; | ||||||
| 		/// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example> | 	} | ||||||
| 		public string[] Errors { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="RequestError"/> with one error. | 	/// Create a new <see cref="RequestError"/> with multiple errors. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="error">The error to specify in the response.</param> | 	/// <param name="errors">The errors to specify in the response.</param> | ||||||
| 		public RequestError(string error) | 	public RequestError(string[] errors) | ||||||
| 		{ | 	{ | ||||||
| 			if (error == null) | 		if (errors == null || !errors.Any()) | ||||||
| 				throw new ArgumentNullException(nameof(error)); | 			throw new ArgumentException("Errors must be non null and not empty", nameof(errors)); | ||||||
| 			Errors = new[] { error }; | 		Errors = errors; | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="RequestError"/> with multiple errors. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="errors">The errors to specify in the response.</param> |  | ||||||
| 		public RequestError(string[] errors) |  | ||||||
| 		{ |  | ||||||
| 			if (errors == null || !errors.Any()) |  | ||||||
| 				throw new ArgumentException( |  | ||||||
| 					"Errors must be non null and not empty", |  | ||||||
| 					nameof(errors) |  | ||||||
| 				); |  | ||||||
| 			Errors = errors; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,21 +16,20 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about the pagination. How many items should be displayed and where to start. | ||||||
|  | /// </summary> | ||||||
|  | public class SearchPagination | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about the pagination. How many items should be displayed and where to start. | 	/// The count of items to return. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class SearchPagination | 	public int Limit { get; set; } = 50; | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The count of items to return. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public int Limit { get; set; } = 50; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Where to start? How many items to skip? | 	/// Where to start? How many items to skip? | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public int? Skip { get; set; } | 	public int? Skip { get; set; } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,111 +25,109 @@ using Kyoo.Abstractions.Models; | |||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Controllers | namespace Kyoo.Abstractions.Controllers; | ||||||
| { |  | ||||||
| 	public record Sort; |  | ||||||
| 
 | 
 | ||||||
|  | public record Sort; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about how a query should be sorted. What factor should decide the sort and in which order. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">For witch type this sort applies</typeparam> | ||||||
|  | public record Sort<T> : Sort | ||||||
|  | 	where T : IQuery | ||||||
|  | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about how a query should be sorted. What factor should decide the sort and in which order. | 	/// Sort by a specific key | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">For witch type this sort applies</typeparam> | 	/// <param name="Key">The sort keys. This members will be used to sort the results.</param> | ||||||
| 	public record Sort<T> : Sort | 	/// <param name="Desendant"> | ||||||
| 		where T : IQuery | 	/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. | ||||||
|  | 	/// </param> | ||||||
|  | 	public record By(string Key, bool Desendant = false) : Sort<T> | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Sort by a specific key | 		/// Sort by a specific key | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		/// <param name="Key">The sort keys. This members will be used to sort the results.</param> | 		/// <param name="key">The sort keys. This members will be used to sort the results.</param> | ||||||
| 		/// <param name="Desendant"> | 		/// <param name="desendant"> | ||||||
| 		/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. | 		/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. | ||||||
| 		/// </param> | 		/// </param> | ||||||
| 		public record By(string Key, bool Desendant = false) : Sort<T> | 		public By(Expression<Func<T, object?>> key, bool desendant = false) | ||||||
|  | 			: this(Utility.GetPropertyName(key), desendant) { } | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Sort by multiple keys. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="List">The list of keys to sort by.</param> | ||||||
|  | 	public record Conglomerate(params Sort<T>[] List) : Sort<T>; | ||||||
|  | 
 | ||||||
|  | 	/// <summary>Sort randomly items</summary> | ||||||
|  | 	public record Random(uint Seed) : Sort<T> | ||||||
|  | 	{ | ||||||
|  | 		public Random() | ||||||
|  | 			: this(0) | ||||||
| 		{ | 		{ | ||||||
| 			/// <summary> | 			uint seed = BitConverter.ToUInt32( | ||||||
| 			/// Sort by a specific key | 				BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)), | ||||||
| 			/// </summary> | 				0 | ||||||
| 			/// <param name="key">The sort keys. This members will be used to sort the results.</param> | 			); | ||||||
| 			/// <param name="desendant"> | 			Seed = seed; | ||||||
| 			/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. |  | ||||||
| 			/// </param> |  | ||||||
| 			public By(Expression<Func<T, object?>> key, bool desendant = false) |  | ||||||
| 				: this(Utility.GetPropertyName(key), desendant) { } |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Sort by multiple keys. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="List">The list of keys to sort by.</param> |  | ||||||
| 		public record Conglomerate(params Sort<T>[] List) : Sort<T>; |  | ||||||
| 
 |  | ||||||
| 		/// <summary>Sort randomly items</summary> |  | ||||||
| 		public record Random(uint Seed) : Sort<T> |  | ||||||
| 		{ |  | ||||||
| 			public Random() |  | ||||||
| 				: this(0) |  | ||||||
| 			{ |  | ||||||
| 				uint seed = BitConverter.ToUInt32( |  | ||||||
| 					BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)), |  | ||||||
| 					0 |  | ||||||
| 				); |  | ||||||
| 				Seed = seed; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary>The default sort method for the given type.</summary> |  | ||||||
| 		public record Default : Sort<T> |  | ||||||
| 		{ |  | ||||||
| 			public void Deconstruct(out Sort<T> value) |  | ||||||
| 			{ |  | ||||||
| 				value = (Sort<T>)T.DefaultSort; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive). |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param> |  | ||||||
| 		/// <param name="seed">The random seed.</param> |  | ||||||
| 		/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception> |  | ||||||
| 		/// <returns>A <see cref="Sort{T}"/> for the given string</returns> |  | ||||||
| 		public static Sort<T> From(string? sortBy, uint seed) |  | ||||||
| 		{ |  | ||||||
| 			if (string.IsNullOrEmpty(sortBy) || sortBy == "default") |  | ||||||
| 				return new Default(); |  | ||||||
| 			if (sortBy == "random") |  | ||||||
| 				return new Random(seed); |  | ||||||
| 			if (sortBy.Contains(',')) |  | ||||||
| 				return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray()); |  | ||||||
| 
 |  | ||||||
| 			if (sortBy.StartsWith("random:")) |  | ||||||
| 				return new Random(uint.Parse(sortBy["random:".Length..])); |  | ||||||
| 
 |  | ||||||
| 			string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy; |  | ||||||
| 			string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null; |  | ||||||
| 			bool desendant = order switch |  | ||||||
| 			{ |  | ||||||
| 				"desc" => true, |  | ||||||
| 				"asc" => false, |  | ||||||
| 				null => false, |  | ||||||
| 				_ |  | ||||||
| 					=> throw new ValidationException( |  | ||||||
| 						$"The sort order, if set, should be :asc or :desc but it was :{order}." |  | ||||||
| 					) |  | ||||||
| 			}; |  | ||||||
| 
 |  | ||||||
| 			Type[] types = |  | ||||||
| 				typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; |  | ||||||
| 			PropertyInfo? property = types |  | ||||||
| 				.Select(x => |  | ||||||
| 					x.GetProperty( |  | ||||||
| 						key, |  | ||||||
| 						BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance |  | ||||||
| 					) |  | ||||||
| 				) |  | ||||||
| 				.FirstOrDefault(x => x != null); |  | ||||||
| 			if (property == null) |  | ||||||
| 				throw new ValidationException("The given sort key is not valid."); |  | ||||||
| 			return new By(property.Name, desendant); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary>The default sort method for the given type.</summary> | ||||||
|  | 	public record Default : Sort<T> | ||||||
|  | 	{ | ||||||
|  | 		public void Deconstruct(out Sort<T> value) | ||||||
|  | 		{ | ||||||
|  | 			value = (Sort<T>)T.DefaultSort; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive). | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param> | ||||||
|  | 	/// <param name="seed">The random seed.</param> | ||||||
|  | 	/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception> | ||||||
|  | 	/// <returns>A <see cref="Sort{T}"/> for the given string</returns> | ||||||
|  | 	public static Sort<T> From(string? sortBy, uint seed) | ||||||
|  | 	{ | ||||||
|  | 		if (string.IsNullOrEmpty(sortBy) || sortBy == "default") | ||||||
|  | 			return new Default(); | ||||||
|  | 		if (sortBy == "random") | ||||||
|  | 			return new Random(seed); | ||||||
|  | 		if (sortBy.Contains(',')) | ||||||
|  | 			return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray()); | ||||||
|  | 
 | ||||||
|  | 		if (sortBy.StartsWith("random:")) | ||||||
|  | 			return new Random(uint.Parse(sortBy["random:".Length..])); | ||||||
|  | 
 | ||||||
|  | 		string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy; | ||||||
|  | 		string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null; | ||||||
|  | 		bool desendant = order switch | ||||||
|  | 		{ | ||||||
|  | 			"desc" => true, | ||||||
|  | 			"asc" => false, | ||||||
|  | 			null => false, | ||||||
|  | 			_ | ||||||
|  | 				=> throw new ValidationException( | ||||||
|  | 					$"The sort order, if set, should be :asc or :desc but it was :{order}." | ||||||
|  | 				) | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 		Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; | ||||||
|  | 		PropertyInfo? property = types | ||||||
|  | 			.Select(x => | ||||||
|  | 				x.GetProperty( | ||||||
|  | 					key, | ||||||
|  | 					BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance | ||||||
|  | 				) | ||||||
|  | 			) | ||||||
|  | 			.FirstOrDefault(x => x != null); | ||||||
|  | 		if (property == null) | ||||||
|  | 			throw new ValidationException("The given sort key is not valid."); | ||||||
|  | 		return new By(property.Name, desendant); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,21 +16,20 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions.Models | namespace Kyoo.Abstractions.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The links to see a movie or an episode. | ||||||
|  | /// </summary> | ||||||
|  | public class VideoLinks | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The links to see a movie or an episode. | 	/// The direct link to the unprocessed video (pristine quality). | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class VideoLinks | 	public string Direct { get; set; } | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The direct link to the unprocessed video (pristine quality). |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Direct { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The link to an HLS master playlist containing all qualities available for this video. | 	/// The link to an HLS master playlist containing all qualities available for this video. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string Hls { get; set; } | 	public string Hls { get; set; } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,56 +21,55 @@ using Autofac.Builder; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Abstractions | namespace Kyoo.Abstractions; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A static class with helper functions to setup external modules | ||||||
|  | /// </summary> | ||||||
|  | public static class Module | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A static class with helper functions to setup external modules | 	/// Register a new repository to the container. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Module | 	/// <param name="builder">The container</param> | ||||||
|  | 	/// <typeparam name="T">The type of the repository.</typeparam> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/> | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <returns>The initial container.</returns> | ||||||
|  | 	public static IRegistrationBuilder< | ||||||
|  | 		T, | ||||||
|  | 		ConcreteReflectionActivatorData, | ||||||
|  | 		SingleRegistrationStyle | ||||||
|  | 	> RegisterRepository<T>(this ContainerBuilder builder) | ||||||
|  | 		where T : IBaseRepository | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return builder | ||||||
| 		/// Register a new repository to the container. | 			.RegisterType<T>() | ||||||
| 		/// </summary> | 			.AsSelf() | ||||||
| 		/// <param name="builder">The container</param> | 			.As<IBaseRepository>() | ||||||
| 		/// <typeparam name="T">The type of the repository.</typeparam> | 			.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!) | ||||||
| 		/// <remarks> | 			.InstancePerLifetimeScope(); | ||||||
| 		/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/> | 	} | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <returns>The initial container.</returns> |  | ||||||
| 		public static IRegistrationBuilder< |  | ||||||
| 			T, |  | ||||||
| 			ConcreteReflectionActivatorData, |  | ||||||
| 			SingleRegistrationStyle |  | ||||||
| 		> RegisterRepository<T>(this ContainerBuilder builder) |  | ||||||
| 			where T : IBaseRepository |  | ||||||
| 		{ |  | ||||||
| 			return builder |  | ||||||
| 				.RegisterType<T>() |  | ||||||
| 				.AsSelf() |  | ||||||
| 				.As<IBaseRepository>() |  | ||||||
| 				.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!) |  | ||||||
| 				.InstancePerLifetimeScope(); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Register a new repository with a custom mapping to the container. | 	/// Register a new repository with a custom mapping to the container. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="builder">The container</param> | 	/// <param name="builder">The container</param> | ||||||
| 		/// <typeparam name="T">The custom mapping you have for your repository.</typeparam> | 	/// <typeparam name="T">The custom mapping you have for your repository.</typeparam> | ||||||
| 		/// <typeparam name="T2">The type of the repository.</typeparam> | 	/// <typeparam name="T2">The type of the repository.</typeparam> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/> | 	/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/> | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <returns>The initial container.</returns> | 	/// <returns>The initial container.</returns> | ||||||
| 		public static IRegistrationBuilder< | 	public static IRegistrationBuilder< | ||||||
| 			T2, | 		T2, | ||||||
| 			ConcreteReflectionActivatorData, | 		ConcreteReflectionActivatorData, | ||||||
| 			SingleRegistrationStyle | 		SingleRegistrationStyle | ||||||
| 		> RegisterRepository<T, T2>(this ContainerBuilder builder) | 	> RegisterRepository<T, T2>(this ContainerBuilder builder) | ||||||
| 			where T : notnull | 		where T : notnull | ||||||
| 			where T2 : IBaseRepository, T | 		where T2 : IBaseRepository, T | ||||||
| 		{ | 	{ | ||||||
| 			return builder.RegisterRepository<T2>().AsSelf().As<T>(); | 		return builder.RegisterRepository<T2>().AsSelf().As<T>(); | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,53 +19,52 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Utils | namespace Kyoo.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A set of extensions class for enumerable. | ||||||
|  | /// </summary> | ||||||
|  | public static class EnumerableExtensions | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A set of extensions class for enumerable. | 	/// If the enumerable is empty, execute an action. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class EnumerableExtensions | 	/// <param name="self">The enumerable to check</param> | ||||||
|  | 	/// <param name="action">The action to execute is the list is empty</param> | ||||||
|  | 	/// <typeparam name="T">The type of items inside the list</typeparam> | ||||||
|  | 	/// <returns>The iterator proxied, there is no dual iterations.</returns> | ||||||
|  | 	public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		static IEnumerable<T> Generator(IEnumerable<T> self, Action action) | ||||||
| 		/// If the enumerable is empty, execute an action. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="self">The enumerable to check</param> |  | ||||||
| 		/// <param name="action">The action to execute is the list is empty</param> |  | ||||||
| 		/// <typeparam name="T">The type of items inside the list</typeparam> |  | ||||||
| 		/// <returns>The iterator proxied, there is no dual iterations.</returns> |  | ||||||
| 		public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action) |  | ||||||
| 		{ | 		{ | ||||||
| 			static IEnumerable<T> Generator(IEnumerable<T> self, Action action) | 			using IEnumerator<T> enumerator = self.GetEnumerator(); | ||||||
|  | 
 | ||||||
|  | 			if (!enumerator.MoveNext()) | ||||||
| 			{ | 			{ | ||||||
| 				using IEnumerator<T> enumerator = self.GetEnumerator(); | 				action(); | ||||||
| 
 | 				yield break; | ||||||
| 				if (!enumerator.MoveNext()) |  | ||||||
| 				{ |  | ||||||
| 					action(); |  | ||||||
| 					yield break; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				do |  | ||||||
| 				{ |  | ||||||
| 					yield return enumerator.Current; |  | ||||||
| 				} while (enumerator.MoveNext()); |  | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			return Generator(self, action); | 			do | ||||||
|  | 			{ | ||||||
|  | 				yield return enumerator.Current; | ||||||
|  | 			} while (enumerator.MoveNext()); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		return Generator(self, action); | ||||||
| 		/// A foreach used as a function with a little specificity: the list can be null. | 	} | ||||||
| 		/// </summary> | 
 | ||||||
| 		/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param> | 	/// <summary> | ||||||
| 		/// <param name="action">The action to execute for each arguments</param> | 	/// A foreach used as a function with a little specificity: the list can be null. | ||||||
| 		/// <typeparam name="T">The type of items in the list</typeparam> | 	/// </summary> | ||||||
| 		public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action) | 	/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param> | ||||||
| 		{ | 	/// <param name="action">The action to execute for each arguments</param> | ||||||
| 			if (self == null) | 	/// <typeparam name="T">The type of items in the list</typeparam> | ||||||
| 				return; | 	public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action) | ||||||
| 			foreach (T i in self) | 	{ | ||||||
| 				action(i); | 		if (self == null) | ||||||
| 		} | 			return; | ||||||
|  | 		foreach (T i in self) | ||||||
|  | 			action(i); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -22,113 +22,112 @@ using System.Linq; | |||||||
| using System.Reflection; | using System.Reflection; | ||||||
| using Kyoo.Abstractions.Models.Attributes; | using Kyoo.Abstractions.Models.Attributes; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Utils | namespace Kyoo.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class containing helper methods to merge objects. | ||||||
|  | /// </summary> | ||||||
|  | public static class Merger | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class containing helper methods to merge objects. | 	/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Merger | 	/// <param name="first">The first dictionary to merge</param> | ||||||
|  | 	/// <param name="second">The second dictionary to merge</param> | ||||||
|  | 	/// <param name="hasChanged"> | ||||||
|  | 	/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <typeparam name="T">The type of the keys in dictionaries</typeparam> | ||||||
|  | 	/// <typeparam name="T2">The type of values in the dictionaries</typeparam> | ||||||
|  | 	/// <returns> | ||||||
|  | 	/// A dictionary with the missing elements of <paramref name="second"/> | ||||||
|  | 	/// set to those of <paramref name="first"/>. | ||||||
|  | 	/// </returns> | ||||||
|  | 	public static IDictionary<T, T2>? CompleteDictionaries<T, T2>( | ||||||
|  | 		IDictionary<T, T2>? first, | ||||||
|  | 		IDictionary<T, T2>? second, | ||||||
|  | 		out bool hasChanged | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		if (first == null) | ||||||
| 		/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="first">The first dictionary to merge</param> |  | ||||||
| 		/// <param name="second">The second dictionary to merge</param> |  | ||||||
| 		/// <param name="hasChanged"> |  | ||||||
| 		/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <typeparam name="T">The type of the keys in dictionaries</typeparam> |  | ||||||
| 		/// <typeparam name="T2">The type of values in the dictionaries</typeparam> |  | ||||||
| 		/// <returns> |  | ||||||
| 		/// A dictionary with the missing elements of <paramref name="second"/> |  | ||||||
| 		/// set to those of <paramref name="first"/>. |  | ||||||
| 		/// </returns> |  | ||||||
| 		public static IDictionary<T, T2>? CompleteDictionaries<T, T2>( |  | ||||||
| 			IDictionary<T, T2>? first, |  | ||||||
| 			IDictionary<T, T2>? second, |  | ||||||
| 			out bool hasChanged |  | ||||||
| 		) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (first == null) | 			hasChanged = true; | ||||||
| 			{ |  | ||||||
| 				hasChanged = true; |  | ||||||
| 				return second; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			hasChanged = false; |  | ||||||
| 			if (second == null) |  | ||||||
| 				return first; |  | ||||||
| 			hasChanged = second.Any(x => |  | ||||||
| 				!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false |  | ||||||
| 			); |  | ||||||
| 			foreach ((T key, T2 value) in first) |  | ||||||
| 				second.TryAdd(key, value); |  | ||||||
| 			return second; | 			return second; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		hasChanged = false; | ||||||
| 		/// Set every non-default values of seconds to the corresponding property of second. | 		if (second == null) | ||||||
| 		/// Dictionaries are handled like anonymous objects with a property per key/pair value |  | ||||||
| 		/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/> |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <example> |  | ||||||
| 		/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"} |  | ||||||
| 		/// </example> |  | ||||||
| 		/// <param name="first"> |  | ||||||
| 		/// The object to complete |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="second"> |  | ||||||
| 		/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="where"> |  | ||||||
| 		/// Filter fields that will be merged |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <typeparam name="T">Fields of T will be completed</typeparam> |  | ||||||
| 		/// <returns><paramref name="first"/></returns> |  | ||||||
| 		public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null) |  | ||||||
| 		{ |  | ||||||
| 			if (second == null) |  | ||||||
| 				return first; |  | ||||||
| 
 |  | ||||||
| 			Type type = typeof(T); |  | ||||||
| 			IEnumerable<PropertyInfo> properties = type.GetProperties() |  | ||||||
| 				.Where(x => |  | ||||||
| 					x is { CanRead: true, CanWrite: true } |  | ||||||
| 					&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null |  | ||||||
| 				); |  | ||||||
| 
 |  | ||||||
| 			if (where != null) |  | ||||||
| 				properties = properties.Where(where); |  | ||||||
| 
 |  | ||||||
| 			foreach (PropertyInfo property in properties) |  | ||||||
| 			{ |  | ||||||
| 				object? value = property.GetValue(second); |  | ||||||
| 
 |  | ||||||
| 				if (value?.Equals(property.GetValue(first)) == true) |  | ||||||
| 					continue; |  | ||||||
| 
 |  | ||||||
| 				if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>))) |  | ||||||
| 				{ |  | ||||||
| 					Type[] dictionaryTypes = Utility |  | ||||||
| 						.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))! |  | ||||||
| 						.GenericTypeArguments; |  | ||||||
| 					object?[] parameters = { property.GetValue(first), value, false }; |  | ||||||
| 					object newDictionary = Utility.RunGenericMethod<object>( |  | ||||||
| 						typeof(Merger), |  | ||||||
| 						nameof(CompleteDictionaries), |  | ||||||
| 						dictionaryTypes, |  | ||||||
| 						parameters |  | ||||||
| 					)!; |  | ||||||
| 					if ((bool)parameters[2]!) |  | ||||||
| 						property.SetValue(first, newDictionary); |  | ||||||
| 				} |  | ||||||
| 				else |  | ||||||
| 					property.SetValue(first, value); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (first is IOnMerge merge) |  | ||||||
| 				merge.OnMerge(second); |  | ||||||
| 			return first; | 			return first; | ||||||
|  | 		hasChanged = second.Any(x => | ||||||
|  | 			!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false | ||||||
|  | 		); | ||||||
|  | 		foreach ((T key, T2 value) in first) | ||||||
|  | 			second.TryAdd(key, value); | ||||||
|  | 		return second; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Set every non-default values of seconds to the corresponding property of second. | ||||||
|  | 	/// Dictionaries are handled like anonymous objects with a property per key/pair value | ||||||
|  | 	/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/> | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <example> | ||||||
|  | 	/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"} | ||||||
|  | 	/// </example> | ||||||
|  | 	/// <param name="first"> | ||||||
|  | 	/// The object to complete | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="second"> | ||||||
|  | 	/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="where"> | ||||||
|  | 	/// Filter fields that will be merged | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <typeparam name="T">Fields of T will be completed</typeparam> | ||||||
|  | 	/// <returns><paramref name="first"/></returns> | ||||||
|  | 	public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null) | ||||||
|  | 	{ | ||||||
|  | 		if (second == null) | ||||||
|  | 			return first; | ||||||
|  | 
 | ||||||
|  | 		Type type = typeof(T); | ||||||
|  | 		IEnumerable<PropertyInfo> properties = type.GetProperties() | ||||||
|  | 			.Where(x => | ||||||
|  | 				x is { CanRead: true, CanWrite: true } | ||||||
|  | 				&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		if (where != null) | ||||||
|  | 			properties = properties.Where(where); | ||||||
|  | 
 | ||||||
|  | 		foreach (PropertyInfo property in properties) | ||||||
|  | 		{ | ||||||
|  | 			object? value = property.GetValue(second); | ||||||
|  | 
 | ||||||
|  | 			if (value?.Equals(property.GetValue(first)) == true) | ||||||
|  | 				continue; | ||||||
|  | 
 | ||||||
|  | 			if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>))) | ||||||
|  | 			{ | ||||||
|  | 				Type[] dictionaryTypes = Utility | ||||||
|  | 					.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))! | ||||||
|  | 					.GenericTypeArguments; | ||||||
|  | 				object?[] parameters = { property.GetValue(first), value, false }; | ||||||
|  | 				object newDictionary = Utility.RunGenericMethod<object>( | ||||||
|  | 					typeof(Merger), | ||||||
|  | 					nameof(CompleteDictionaries), | ||||||
|  | 					dictionaryTypes, | ||||||
|  | 					parameters | ||||||
|  | 				)!; | ||||||
|  | 				if ((bool)parameters[2]!) | ||||||
|  | 					property.SetValue(first, newDictionary); | ||||||
|  | 			} | ||||||
|  | 			else | ||||||
|  | 				property.SetValue(first, value); | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		if (first is IOnMerge merge) | ||||||
|  | 			merge.OnMerge(second); | ||||||
|  | 		return first; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,341 +25,338 @@ using System.Reflection; | |||||||
| using System.Text; | using System.Text; | ||||||
| using System.Text.RegularExpressions; | using System.Text.RegularExpressions; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Utils | namespace Kyoo.Utils; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A set of utility functions that can be used everywhere. | ||||||
|  | /// </summary> | ||||||
|  | public static class Utility | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A set of utility functions that can be used everywhere. | 	/// Convert a string to snake case. Stollen from | ||||||
|  | 	/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Utility | 	/// <param name="name">The string to convert.</param> | ||||||
|  | 	/// <returns>The string in snake case</returns> | ||||||
|  | 	public static string ToSnakeCase(this string name) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); | ||||||
| 		/// Convert a string to snake case. Stollen from | 		UnicodeCategory? previousCategory = default; | ||||||
| 		/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs | 
 | ||||||
| 		/// </summary> | 		for (int currentIndex = 0; currentIndex < name.Length; currentIndex++) | ||||||
| 		/// <param name="name">The string to convert.</param> |  | ||||||
| 		/// <returns>The string in snake case</returns> |  | ||||||
| 		public static string ToSnakeCase(this string name) |  | ||||||
| 		{ | 		{ | ||||||
| 			StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); | 			char currentChar = name[currentIndex]; | ||||||
| 			UnicodeCategory? previousCategory = default; | 			if (currentChar == '_') | ||||||
| 
 |  | ||||||
| 			for (int currentIndex = 0; currentIndex < name.Length; currentIndex++) |  | ||||||
| 			{ | 			{ | ||||||
| 				char currentChar = name[currentIndex]; | 				builder.Append('_'); | ||||||
| 				if (currentChar == '_') | 				previousCategory = null; | ||||||
| 				{ | 				continue; | ||||||
| 					builder.Append('_'); | 			} | ||||||
| 					previousCategory = null; |  | ||||||
| 					continue; |  | ||||||
| 				} |  | ||||||
| 
 | 
 | ||||||
| 				UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); | 			UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); | ||||||
| 				switch (currentCategory) | 			switch (currentCategory) | ||||||
| 				{ | 			{ | ||||||
| 					case UnicodeCategory.UppercaseLetter: | 				case UnicodeCategory.UppercaseLetter: | ||||||
| 					case UnicodeCategory.TitlecaseLetter: | 				case UnicodeCategory.TitlecaseLetter: | ||||||
| 						if ( | 					if ( | ||||||
| 							previousCategory == UnicodeCategory.SpaceSeparator | 						previousCategory == UnicodeCategory.SpaceSeparator | ||||||
| 							|| previousCategory == UnicodeCategory.LowercaseLetter | 						|| previousCategory == UnicodeCategory.LowercaseLetter | ||||||
| 							|| ( | 						|| ( | ||||||
| 								previousCategory != UnicodeCategory.DecimalDigitNumber | 							previousCategory != UnicodeCategory.DecimalDigitNumber | ||||||
| 								&& previousCategory != null | 							&& previousCategory != null | ||||||
| 								&& currentIndex > 0 | 							&& currentIndex > 0 | ||||||
| 								&& currentIndex + 1 < name.Length | 							&& currentIndex + 1 < name.Length | ||||||
| 								&& char.IsLower(name[currentIndex + 1]) | 							&& char.IsLower(name[currentIndex + 1]) | ||||||
| 							) |  | ||||||
| 						) | 						) | ||||||
| 						{ | 					) | ||||||
| 							builder.Append('_'); | 					{ | ||||||
| 						} | 						builder.Append('_'); | ||||||
|  | 					} | ||||||
| 
 | 
 | ||||||
| 						currentChar = char.ToLowerInvariant(currentChar); | 					currentChar = char.ToLowerInvariant(currentChar); | ||||||
| 						break; | 					break; | ||||||
| 
 | 
 | ||||||
| 					case UnicodeCategory.LowercaseLetter: | 				case UnicodeCategory.LowercaseLetter: | ||||||
| 					case UnicodeCategory.DecimalDigitNumber: | 				case UnicodeCategory.DecimalDigitNumber: | ||||||
| 						if (previousCategory == UnicodeCategory.SpaceSeparator) | 					if (previousCategory == UnicodeCategory.SpaceSeparator) | ||||||
| 						{ | 					{ | ||||||
| 							builder.Append('_'); | 						builder.Append('_'); | ||||||
| 						} | 					} | ||||||
| 						break; | 					break; | ||||||
| 
 | 
 | ||||||
| 					default: | 				default: | ||||||
| 						if (previousCategory != null) | 					if (previousCategory != null) | ||||||
| 						{ | 					{ | ||||||
| 							previousCategory = UnicodeCategory.SpaceSeparator; | 						previousCategory = UnicodeCategory.SpaceSeparator; | ||||||
| 						} | 					} | ||||||
| 						continue; | 					continue; | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				builder.Append(currentChar); |  | ||||||
| 				previousCategory = currentCategory; |  | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			return builder.ToString(); | 			builder.Append(currentChar); | ||||||
|  | 			previousCategory = currentCategory; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		return builder.ToString(); | ||||||
| 		/// Is the lambda expression a member (like x => x.Body). | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="ex">The expression that should be checked</param> |  | ||||||
| 		/// <returns>True if the expression is a member, false otherwise</returns> |  | ||||||
| 		public static bool IsPropertyExpression(LambdaExpression ex) |  | ||||||
| 		{ |  | ||||||
| 			return ex.Body is MemberExpression |  | ||||||
| 				|| ( |  | ||||||
| 					ex.Body.NodeType == ExpressionType.Convert |  | ||||||
| 					&& ((UnaryExpression)ex.Body).Operand is MemberExpression |  | ||||||
| 				); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) | 	/// Is the lambda expression a member (like x => x.Body). | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="ex">The expression</param> | 	/// <param name="ex">The expression that should be checked</param> | ||||||
| 		/// <returns>The name of the expression</returns> | 	/// <returns>True if the expression is a member, false otherwise</returns> | ||||||
| 		/// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception> | 	public static bool IsPropertyExpression(LambdaExpression ex) | ||||||
| 		public static string GetPropertyName(LambdaExpression ex) | 	{ | ||||||
| 		{ | 		return ex.Body is MemberExpression | ||||||
| 			if (!IsPropertyExpression(ex)) | 			|| ( | ||||||
| 				throw new ArgumentException($"{ex} is not a property expression."); |  | ||||||
| 			MemberExpression? member = |  | ||||||
| 				ex.Body.NodeType == ExpressionType.Convert | 				ex.Body.NodeType == ExpressionType.Convert | ||||||
| 					? ((UnaryExpression)ex.Body).Operand as MemberExpression | 				&& ((UnaryExpression)ex.Body).Operand is MemberExpression | ||||||
| 					: ex.Body as MemberExpression; |  | ||||||
| 			return member!.Member.Name; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Slugify a string (Replace spaces by -, Uniformize accents) |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="str">The string to slugify</param> |  | ||||||
| 		/// <returns>The slug version of the given string</returns> |  | ||||||
| 		public static string ToSlug(string str) |  | ||||||
| 		{ |  | ||||||
| 			str = str.ToLowerInvariant(); |  | ||||||
| 
 |  | ||||||
| 			string normalizedString = str.Normalize(NormalizationForm.FormD); |  | ||||||
| 			StringBuilder stringBuilder = new(); |  | ||||||
| 			foreach (char c in normalizedString) |  | ||||||
| 			{ |  | ||||||
| 				UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); |  | ||||||
| 				if (unicodeCategory != UnicodeCategory.NonSpacingMark) |  | ||||||
| 					stringBuilder.Append(c); |  | ||||||
| 			} |  | ||||||
| 			str = stringBuilder.ToString().Normalize(NormalizationForm.FormC); |  | ||||||
| 
 |  | ||||||
| 			str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled); |  | ||||||
| 			str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled); |  | ||||||
| 			str = str.Trim('-', '_'); |  | ||||||
| 			str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled); |  | ||||||
| 			return str; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned) |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="self">The starting type</param> |  | ||||||
| 		/// <returns>A list of types</returns> |  | ||||||
| 		public static IEnumerable<Type> GetInheritanceTree(this Type self) |  | ||||||
| 		{ |  | ||||||
| 			for (Type? type = self; type != null; type = type.BaseType) |  | ||||||
| 				yield return type; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="type">The type to check</param> |  | ||||||
| 		/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param> |  | ||||||
| 		/// <returns>True if obj inherit from genericType. False otherwise</returns> |  | ||||||
| 		public static bool IsOfGenericType(Type type, Type genericType) |  | ||||||
| 		{ |  | ||||||
| 			if (!genericType.IsGenericType) |  | ||||||
| 				throw new ArgumentException($"{nameof(genericType)} is not a generic type."); |  | ||||||
| 
 |  | ||||||
| 			IEnumerable<Type> types = genericType.IsInterface |  | ||||||
| 				? type.GetInterfaces() |  | ||||||
| 				: type.GetInheritanceTree(); |  | ||||||
| 			return types |  | ||||||
| 				.Prepend(type) |  | ||||||
| 				.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get the generic definition of <paramref name="genericType"/>. |  | ||||||
| 		/// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string> |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="type">The type to check</param> |  | ||||||
| 		/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param> |  | ||||||
| 		/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns> |  | ||||||
| 		/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception> |  | ||||||
| 		public static Type? GetGenericDefinition(Type type, Type genericType) |  | ||||||
| 		{ |  | ||||||
| 			if (!genericType.IsGenericType) |  | ||||||
| 				throw new ArgumentException($"{nameof(genericType)} is not a generic type."); |  | ||||||
| 
 |  | ||||||
| 			IEnumerable<Type> types = genericType.IsInterface |  | ||||||
| 				? type.GetInterfaces() |  | ||||||
| 				: type.GetInheritanceTree(); |  | ||||||
| 			return types |  | ||||||
| 				.Prepend(type) |  | ||||||
| 				.FirstOrDefault(x => |  | ||||||
| 					x.IsGenericType && x.GetGenericTypeDefinition() == genericType |  | ||||||
| 				); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Retrieve a method from an <see cref="Type"/> with the given name and respect the |  | ||||||
| 		/// amount of parameters and generic parameters. This works for polymorphic methods. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="type"> |  | ||||||
| 		/// The type owning the method. For non static methods, this is the <c>this</c>. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="flag"> |  | ||||||
| 		/// The binding flags of the method. This allow you to specify public/private and so on. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="name"> |  | ||||||
| 		/// The name of the method. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="generics"> |  | ||||||
| 		/// The list of generic parameters. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="args"> |  | ||||||
| 		/// The list of parameters. |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <exception cref="ArgumentException">No method match the given constraints.</exception> |  | ||||||
| 		/// <returns>The method handle of the matching method.</returns> |  | ||||||
| 		public static MethodInfo GetMethod( |  | ||||||
| 			Type type, |  | ||||||
| 			BindingFlags flag, |  | ||||||
| 			string name, |  | ||||||
| 			Type[] generics, |  | ||||||
| 			object?[] 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 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 => |  | ||||||
| 				// { |  | ||||||
| 				// 	int i = 0; |  | ||||||
| 				// 	return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++])); |  | ||||||
| 				// }) |  | ||||||
| 				// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified.")) |  | ||||||
| 
 |  | ||||||
| 				// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type. |  | ||||||
| 				// .Where(x => |  | ||||||
| 				// { |  | ||||||
| 				// 	int i = 0; |  | ||||||
| 				// 	return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++])); |  | ||||||
| 				// }) |  | ||||||
| 				// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types.")) |  | ||||||
| 				.Take(2) |  | ||||||
| 				.ToArray(); |  | ||||||
| 
 |  | ||||||
| 			if (methods.Length == 1) |  | ||||||
| 				return methods[0]; |  | ||||||
| 			throw new ArgumentException( |  | ||||||
| 				$"Multiple methods named {name} match the generics and parameters constraints." |  | ||||||
| 			); | 			); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Run a generic static method for a runtime <see cref="Type"/>. | 	/// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <example> | 	/// <param name="ex">The expression</param> | ||||||
| 		/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, | 	/// <returns>The name of the expression</returns> | ||||||
| 		/// you could do: | 	/// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception> | ||||||
| 		/// <code lang="C#"> | 	public static string GetPropertyName(LambdaExpression ex) | ||||||
| 		/// Utility.RunGenericMethod<object>( | 	{ | ||||||
| 		///     typeof(Utility), | 		if (!IsPropertyExpression(ex)) | ||||||
| 		///     nameof(MergeLists), | 			throw new ArgumentException($"{ex} is not a property expression."); | ||||||
| 		///     enumerableType, | 		MemberExpression? member = | ||||||
| 		///     oldValue, newValue, equalityComparer) | 			ex.Body.NodeType == ExpressionType.Convert | ||||||
| 		/// </code> | 				? ((UnaryExpression)ex.Body).Operand as MemberExpression | ||||||
| 		/// </example> | 				: ex.Body as MemberExpression; | ||||||
| 		/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param> | 		return member!.Member.Name; | ||||||
| 		/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param> | 	} | ||||||
| 		/// <param name="type">The generic type to run the method with.</param> |  | ||||||
| 		/// <param name="args">The list of arguments of the method</param> |  | ||||||
| 		/// <typeparam name="T"> |  | ||||||
| 		/// The return type of the method. You can put <see cref="object"/> for an unknown one. |  | ||||||
| 		/// </typeparam> |  | ||||||
| 		/// <exception cref="ArgumentException">No method match the given constraints.</exception> |  | ||||||
| 		/// <returns>The return of the method you wanted to run.</returns> |  | ||||||
| 		/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/> |  | ||||||
| 		public static T? RunGenericMethod<T>( |  | ||||||
| 			Type owner, |  | ||||||
| 			string methodName, |  | ||||||
| 			Type type, |  | ||||||
| 			params object[] args |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return RunGenericMethod<T>(owner, methodName, new[] { type }, args); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Run a generic static method for a multiple runtime <see cref="Type"/>. | 	/// Slugify a string (Replace spaces by -, Uniformize accents) | ||||||
| 		/// If your generic method only needs one type, see | 	/// </summary> | ||||||
| 		/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/> | 	/// <param name="str">The string to slugify</param> | ||||||
| 		/// </summary> | 	/// <returns>The slug version of the given string</returns> | ||||||
| 		/// <example> | 	public static string ToSlug(string str) | ||||||
| 		/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, | 	{ | ||||||
| 		/// you could do: | 		str = str.ToLowerInvariant(); | ||||||
| 		/// <code> | 
 | ||||||
| 		/// Utility.RunGenericMethod<object>( | 		string normalizedString = str.Normalize(NormalizationForm.FormD); | ||||||
| 		///     typeof(Utility), | 		StringBuilder stringBuilder = new(); | ||||||
| 		///     nameof(MergeLists), | 		foreach (char c in normalizedString) | ||||||
| 		///     enumerableType, |  | ||||||
| 		///     oldValue, newValue, equalityComparer) |  | ||||||
| 		/// </code> |  | ||||||
| 		/// </example> |  | ||||||
| 		/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param> |  | ||||||
| 		/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param> |  | ||||||
| 		/// <param name="types">The list of generic types to run the method with.</param> |  | ||||||
| 		/// <param name="args">The list of arguments of the method</param> |  | ||||||
| 		/// <typeparam name="T"> |  | ||||||
| 		/// The return type of the method. You can put <see cref="object"/> for an unknown one. |  | ||||||
| 		/// </typeparam> |  | ||||||
| 		/// <exception cref="ArgumentException">No method match the given constraints.</exception> |  | ||||||
| 		/// <returns>The return of the method you wanted to run.</returns> |  | ||||||
| 		/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/> |  | ||||||
| 		public static T? RunGenericMethod<T>( |  | ||||||
| 			Type owner, |  | ||||||
| 			string methodName, |  | ||||||
| 			Type[] types, |  | ||||||
| 			params object?[] args |  | ||||||
| 		) |  | ||||||
| 		{ | 		{ | ||||||
| 			if (types.Length < 1) | 			UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); | ||||||
|  | 			if (unicodeCategory != UnicodeCategory.NonSpacingMark) | ||||||
|  | 				stringBuilder.Append(c); | ||||||
|  | 		} | ||||||
|  | 		str = stringBuilder.ToString().Normalize(NormalizationForm.FormC); | ||||||
|  | 
 | ||||||
|  | 		str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled); | ||||||
|  | 		str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled); | ||||||
|  | 		str = str.Trim('-', '_'); | ||||||
|  | 		str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled); | ||||||
|  | 		return str; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned) | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="self">The starting type</param> | ||||||
|  | 	/// <returns>A list of types</returns> | ||||||
|  | 	public static IEnumerable<Type> GetInheritanceTree(this Type self) | ||||||
|  | 	{ | ||||||
|  | 		for (Type? type = self; type != null; type = type.BaseType) | ||||||
|  | 			yield return type; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="type">The type to check</param> | ||||||
|  | 	/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param> | ||||||
|  | 	/// <returns>True if obj inherit from genericType. False otherwise</returns> | ||||||
|  | 	public static bool IsOfGenericType(Type type, Type genericType) | ||||||
|  | 	{ | ||||||
|  | 		if (!genericType.IsGenericType) | ||||||
|  | 			throw new ArgumentException($"{nameof(genericType)} is not a generic type."); | ||||||
|  | 
 | ||||||
|  | 		IEnumerable<Type> types = genericType.IsInterface | ||||||
|  | 			? type.GetInterfaces() | ||||||
|  | 			: type.GetInheritanceTree(); | ||||||
|  | 		return types | ||||||
|  | 			.Prepend(type) | ||||||
|  | 			.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get the generic definition of <paramref name="genericType"/>. | ||||||
|  | 	/// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string> | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="type">The type to check</param> | ||||||
|  | 	/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param> | ||||||
|  | 	/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns> | ||||||
|  | 	/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception> | ||||||
|  | 	public static Type? GetGenericDefinition(Type type, Type genericType) | ||||||
|  | 	{ | ||||||
|  | 		if (!genericType.IsGenericType) | ||||||
|  | 			throw new ArgumentException($"{nameof(genericType)} is not a generic type."); | ||||||
|  | 
 | ||||||
|  | 		IEnumerable<Type> types = genericType.IsInterface | ||||||
|  | 			? type.GetInterfaces() | ||||||
|  | 			: type.GetInheritanceTree(); | ||||||
|  | 		return types | ||||||
|  | 			.Prepend(type) | ||||||
|  | 			.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Retrieve a method from an <see cref="Type"/> with the given name and respect the | ||||||
|  | 	/// amount of parameters and generic parameters. This works for polymorphic methods. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="type"> | ||||||
|  | 	/// The type owning the method. For non static methods, this is the <c>this</c>. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="flag"> | ||||||
|  | 	/// The binding flags of the method. This allow you to specify public/private and so on. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="name"> | ||||||
|  | 	/// The name of the method. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="generics"> | ||||||
|  | 	/// The list of generic parameters. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="args"> | ||||||
|  | 	/// The list of parameters. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <exception cref="ArgumentException">No method match the given constraints.</exception> | ||||||
|  | 	/// <returns>The method handle of the matching method.</returns> | ||||||
|  | 	public static MethodInfo GetMethod( | ||||||
|  | 		Type type, | ||||||
|  | 		BindingFlags flag, | ||||||
|  | 		string name, | ||||||
|  | 		Type[] generics, | ||||||
|  | 		object?[] 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 ArgumentException( | 				throw new ArgumentException( | ||||||
| 					$"The {nameof(types)} array is empty. At least one type is needed." | 					$"A method named {name} with " | ||||||
|  | 						+ $"{args.Length} arguments and {generics.Length} generic " | ||||||
|  | 						+ $"types could not be found on {type.Name}." | ||||||
| 				); | 				); | ||||||
| 			MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args); | 			}) | ||||||
| 			return (T?)method.MakeGenericMethod(types).Invoke(null, args); | 			// TODO this won't work but I don't know why. | ||||||
| 		} | 			// .Where(x => | ||||||
|  | 			// { | ||||||
|  | 			// 	int i = 0; | ||||||
|  | 			// 	return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++])); | ||||||
|  | 			// }) | ||||||
|  | 			// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified.")) | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 			// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type. | ||||||
| 		/// Convert a dictionary to a query string. | 			// .Where(x => | ||||||
| 		/// </summary> | 			// { | ||||||
| 		/// <param name="query">The list of query parameters.</param> | 			// 	int i = 0; | ||||||
| 		/// <returns>A valid query string with all items in the dictionary.</returns> | 			// 	return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++])); | ||||||
| 		public static string ToQueryString(this Dictionary<string, string> query) | 			// }) | ||||||
| 		{ | 			// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types.")) | ||||||
| 			if (!query.Any()) | 			.Take(2) | ||||||
| 				return string.Empty; | 			.ToArray(); | ||||||
| 			return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}")); | 
 | ||||||
| 		} | 		if (methods.Length == 1) | ||||||
|  | 			return methods[0]; | ||||||
|  | 		throw new ArgumentException( | ||||||
|  | 			$"Multiple methods named {name} match the generics and parameters constraints." | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Run a generic static method for a runtime <see cref="Type"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <example> | ||||||
|  | 	/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, | ||||||
|  | 	/// you could do: | ||||||
|  | 	/// <code lang="C#"> | ||||||
|  | 	/// Utility.RunGenericMethod<object>( | ||||||
|  | 	///     typeof(Utility), | ||||||
|  | 	///     nameof(MergeLists), | ||||||
|  | 	///     enumerableType, | ||||||
|  | 	///     oldValue, newValue, equalityComparer) | ||||||
|  | 	/// </code> | ||||||
|  | 	/// </example> | ||||||
|  | 	/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param> | ||||||
|  | 	/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param> | ||||||
|  | 	/// <param name="type">The generic type to run the method with.</param> | ||||||
|  | 	/// <param name="args">The list of arguments of the method</param> | ||||||
|  | 	/// <typeparam name="T"> | ||||||
|  | 	/// The return type of the method. You can put <see cref="object"/> for an unknown one. | ||||||
|  | 	/// </typeparam> | ||||||
|  | 	/// <exception cref="ArgumentException">No method match the given constraints.</exception> | ||||||
|  | 	/// <returns>The return of the method you wanted to run.</returns> | ||||||
|  | 	/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/> | ||||||
|  | 	public static T? RunGenericMethod<T>( | ||||||
|  | 		Type owner, | ||||||
|  | 		string methodName, | ||||||
|  | 		Type type, | ||||||
|  | 		params object[] args | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return RunGenericMethod<T>(owner, methodName, new[] { type }, args); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Run a generic static method for a multiple runtime <see cref="Type"/>. | ||||||
|  | 	/// If your generic method only needs one type, see | ||||||
|  | 	/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/> | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <example> | ||||||
|  | 	/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, | ||||||
|  | 	/// you could do: | ||||||
|  | 	/// <code> | ||||||
|  | 	/// Utility.RunGenericMethod<object>( | ||||||
|  | 	///     typeof(Utility), | ||||||
|  | 	///     nameof(MergeLists), | ||||||
|  | 	///     enumerableType, | ||||||
|  | 	///     oldValue, newValue, equalityComparer) | ||||||
|  | 	/// </code> | ||||||
|  | 	/// </example> | ||||||
|  | 	/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param> | ||||||
|  | 	/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param> | ||||||
|  | 	/// <param name="types">The list of generic types to run the method with.</param> | ||||||
|  | 	/// <param name="args">The list of arguments of the method</param> | ||||||
|  | 	/// <typeparam name="T"> | ||||||
|  | 	/// The return type of the method. You can put <see cref="object"/> for an unknown one. | ||||||
|  | 	/// </typeparam> | ||||||
|  | 	/// <exception cref="ArgumentException">No method match the given constraints.</exception> | ||||||
|  | 	/// <returns>The return of the method you wanted to run.</returns> | ||||||
|  | 	/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/> | ||||||
|  | 	public static T? RunGenericMethod<T>( | ||||||
|  | 		Type owner, | ||||||
|  | 		string methodName, | ||||||
|  | 		Type[] types, | ||||||
|  | 		params object?[] args | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		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); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Convert a dictionary to a query string. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="query">The list of query parameters.</param> | ||||||
|  | 	/// <returns>A valid query string with all items in the dictionary.</returns> | ||||||
|  | 	public static string ToQueryString(this Dictionary<string, string> query) | ||||||
|  | 	{ | ||||||
|  | 		if (!query.Any()) | ||||||
|  | 			return string.Empty; | ||||||
|  | 		return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}")); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -32,158 +32,151 @@ using Microsoft.Extensions.Logging; | |||||||
| using Microsoft.Extensions.Primitives; | using Microsoft.Extensions.Primitives; | ||||||
| using Microsoft.IdentityModel.Tokens; | using Microsoft.IdentityModel.Tokens; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication | namespace Kyoo.Authentication; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A module that enable OpenID authentication for Kyoo. | ||||||
|  | /// </summary> | ||||||
|  | /// <remarks> | ||||||
|  | /// Create a new authentication module instance and use the given configuration. | ||||||
|  | /// </remarks> | ||||||
|  | public class AuthenticationModule( | ||||||
|  | 	IConfiguration configuration, | ||||||
|  | 	ILogger<AuthenticationModule> logger | ||||||
|  | ) : IPlugin | ||||||
| { | { | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public string Name => "Authentication"; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A module that enable OpenID authentication for Kyoo. | 	/// The configuration to use. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <remarks> | 	private readonly IConfiguration _configuration = configuration; | ||||||
| 	/// Create a new authentication module instance and use the given configuration. | 
 | ||||||
| 	/// </remarks> | 	/// <inheritdoc /> | ||||||
| 	public class AuthenticationModule( | 	public void Configure(ContainerBuilder builder) | ||||||
| 		IConfiguration configuration, |  | ||||||
| 		ILogger<AuthenticationModule> logger |  | ||||||
| 	) : IPlugin |  | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance(); | ||||||
| 		public string Name => "Authentication"; | 		builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// The configuration to use. | 	public void Configure(IServiceCollection services) | ||||||
| 		/// </summary> | 	{ | ||||||
| 		private readonly IConfiguration _configuration = configuration; | 		string secret = _configuration.GetValue( | ||||||
| 
 | 			"AUTHENTICATION_SECRET", | ||||||
| 		/// <inheritdoc /> | 			AuthenticationOption.DefaultSecret | ||||||
| 		public void Configure(ContainerBuilder builder) | 		)!; | ||||||
| 		{ | 		PermissionOption options = | ||||||
| 			builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance(); | 			new() | ||||||
| 			builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance(); | 			{ | ||||||
| 		} | 				Default = _configuration | ||||||
| 
 | 					.GetValue("UNLOGGED_PERMISSIONS", "")! | ||||||
| 		/// <inheritdoc /> | 					.Split(',') | ||||||
| 		public void Configure(IServiceCollection services) | 					.Where(x => x.Length > 0) | ||||||
| 		{ | 					.ToArray(), | ||||||
| 			string secret = _configuration.GetValue( | 				NewUser = _configuration | ||||||
| 				"AUTHENTICATION_SECRET", | 					.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! | ||||||
| 				AuthenticationOption.DefaultSecret | 					.Split(','), | ||||||
| 			)!; | 				RequireVerification = _configuration.GetValue("REQUIRE_ACCOUNT_VERIFICATION", true), | ||||||
| 			PermissionOption options = | 				PublicUrl = | ||||||
| 				new() | 					_configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901", | ||||||
| 				{ | 				ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','), | ||||||
| 					Default = _configuration | 				OIDC = _configuration | ||||||
| 						.GetValue("UNLOGGED_PERMISSIONS", "")! | 					.AsEnumerable() | ||||||
| 						.Split(',') | 					.Where((pair) => pair.Key.StartsWith("OIDC_")) | ||||||
| 						.Where(x => x.Length > 0) | 					.Aggregate( | ||||||
| 						.ToArray(), | 						new Dictionary<string, OidcProvider>(), | ||||||
| 					NewUser = _configuration | 						(acc, val) => | ||||||
| 						.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! | 						{ | ||||||
| 						.Split(','), | 							if (val.Value is null) | ||||||
| 					RequireVerification = _configuration.GetValue( | 								return acc; | ||||||
| 						"REQUIRE_ACCOUNT_VERIFICATION", | 							if (val.Key.Split("_") is not ["OIDC", string provider, string key]) | ||||||
| 						true |  | ||||||
| 					), |  | ||||||
| 					PublicUrl = |  | ||||||
| 						_configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901", |  | ||||||
| 					ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','), |  | ||||||
| 					OIDC = _configuration |  | ||||||
| 						.AsEnumerable() |  | ||||||
| 						.Where((pair) => pair.Key.StartsWith("OIDC_")) |  | ||||||
| 						.Aggregate( |  | ||||||
| 							new Dictionary<string, OidcProvider>(), |  | ||||||
| 							(acc, val) => |  | ||||||
| 							{ | 							{ | ||||||
| 								if (val.Value is null) | 								logger.LogError("Invalid oidc config value: {Key}", val.Key); | ||||||
| 									return acc; |  | ||||||
| 								if (val.Key.Split("_") is not ["OIDC", string provider, string key]) |  | ||||||
| 								{ |  | ||||||
| 									logger.LogError("Invalid oidc config value: {Key}", val.Key); |  | ||||||
| 									return acc; |  | ||||||
| 								} |  | ||||||
| 								provider = provider.ToLowerInvariant(); |  | ||||||
| 								key = key.ToLowerInvariant(); |  | ||||||
| 
 |  | ||||||
| 								if (!acc.ContainsKey(provider)) |  | ||||||
| 									acc.Add(provider, new(provider)); |  | ||||||
| 								switch (key) |  | ||||||
| 								{ |  | ||||||
| 									case "clientid": |  | ||||||
| 										acc[provider].ClientId = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "secret": |  | ||||||
| 										acc[provider].Secret = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "scope": |  | ||||||
| 										acc[provider].Scope = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "authorization": |  | ||||||
| 										acc[provider].AuthorizationUrl = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "token": |  | ||||||
| 										acc[provider].TokenUrl = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "userinfo": |  | ||||||
| 									case "profile": |  | ||||||
| 										acc[provider].ProfileUrl = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "name": |  | ||||||
| 										acc[provider].DisplayName = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									case "logo": |  | ||||||
| 										acc[provider].LogoUrl = val.Value; |  | ||||||
| 										break; |  | ||||||
| 									default: |  | ||||||
| 										logger.LogError("Invalid oidc config value: {Key}", key); |  | ||||||
| 										return acc; |  | ||||||
| 								} |  | ||||||
| 								return acc; | 								return acc; | ||||||
| 							} | 							} | ||||||
| 						), | 							provider = provider.ToLowerInvariant(); | ||||||
| 				}; | 							key = key.ToLowerInvariant(); | ||||||
| 			services.AddSingleton(options); |  | ||||||
| 			services.AddSingleton( |  | ||||||
| 				new AuthenticationOption() { Secret = secret, Permissions = options, } |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			services | 							if (!acc.ContainsKey(provider)) | ||||||
| 				.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | 								acc.Add(provider, new(provider)); | ||||||
| 				.AddJwtBearer(options => | 							switch (key) | ||||||
| 				{ |  | ||||||
| 					options.Events = new() |  | ||||||
| 					{ |  | ||||||
| 						OnMessageReceived = (ctx) => |  | ||||||
| 						{ |  | ||||||
| 							string prefix = "Bearer "; |  | ||||||
| 							if ( |  | ||||||
| 								ctx.Request.Headers.TryGetValue( |  | ||||||
| 									"Authorization", |  | ||||||
| 									out StringValues val |  | ||||||
| 								) |  | ||||||
| 								&& val.ToString() is string auth |  | ||||||
| 								&& auth.StartsWith(prefix) |  | ||||||
| 							) |  | ||||||
| 							{ | 							{ | ||||||
| 								ctx.Token ??= auth[prefix.Length..]; | 								case "clientid": | ||||||
|  | 									acc[provider].ClientId = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "secret": | ||||||
|  | 									acc[provider].Secret = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "scope": | ||||||
|  | 									acc[provider].Scope = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "authorization": | ||||||
|  | 									acc[provider].AuthorizationUrl = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "token": | ||||||
|  | 									acc[provider].TokenUrl = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "userinfo": | ||||||
|  | 								case "profile": | ||||||
|  | 									acc[provider].ProfileUrl = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "name": | ||||||
|  | 									acc[provider].DisplayName = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								case "logo": | ||||||
|  | 									acc[provider].LogoUrl = val.Value; | ||||||
|  | 									break; | ||||||
|  | 								default: | ||||||
|  | 									logger.LogError("Invalid oidc config value: {Key}", key); | ||||||
|  | 									return acc; | ||||||
| 							} | 							} | ||||||
| 							ctx.Token ??= ctx.Request.Cookies["X-Bearer"]; | 							return acc; | ||||||
| 							return Task.CompletedTask; |  | ||||||
| 						} | 						} | ||||||
| 					}; | 					), | ||||||
| 					options.TokenValidationParameters = new TokenValidationParameters |  | ||||||
| 					{ |  | ||||||
| 						ValidateIssuer = false, |  | ||||||
| 						ValidateAudience = false, |  | ||||||
| 						ValidateLifetime = true, |  | ||||||
| 						ValidateIssuerSigningKey = true, |  | ||||||
| 						IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)) |  | ||||||
| 					}; |  | ||||||
| 				}); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IEnumerable<IStartupAction> ConfigureSteps => |  | ||||||
| 			new IStartupAction[] |  | ||||||
| 			{ |  | ||||||
| 				SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication), |  | ||||||
| 			}; | 			}; | ||||||
|  | 		services.AddSingleton(options); | ||||||
|  | 		services.AddSingleton( | ||||||
|  | 			new AuthenticationOption() { Secret = secret, Permissions = options, } | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		services | ||||||
|  | 			.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | ||||||
|  | 			.AddJwtBearer(options => | ||||||
|  | 			{ | ||||||
|  | 				options.Events = new() | ||||||
|  | 				{ | ||||||
|  | 					OnMessageReceived = (ctx) => | ||||||
|  | 					{ | ||||||
|  | 						string prefix = "Bearer "; | ||||||
|  | 						if ( | ||||||
|  | 							ctx.Request.Headers.TryGetValue("Authorization", out StringValues val) | ||||||
|  | 							&& val.ToString() is string auth | ||||||
|  | 							&& auth.StartsWith(prefix) | ||||||
|  | 						) | ||||||
|  | 						{ | ||||||
|  | 							ctx.Token ??= auth[prefix.Length..]; | ||||||
|  | 						} | ||||||
|  | 						ctx.Token ??= ctx.Request.Cookies["X-Bearer"]; | ||||||
|  | 						return Task.CompletedTask; | ||||||
|  | 					} | ||||||
|  | 				}; | ||||||
|  | 				options.TokenValidationParameters = new TokenValidationParameters | ||||||
|  | 				{ | ||||||
|  | 					ValidateIssuer = false, | ||||||
|  | 					ValidateAudience = false, | ||||||
|  | 					ValidateLifetime = true, | ||||||
|  | 					ValidateIssuerSigningKey = true, | ||||||
|  | 					IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)) | ||||||
|  | 				}; | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IEnumerable<IStartupAction> ConfigureSteps => | ||||||
|  | 		new IStartupAction[] | ||||||
|  | 		{ | ||||||
|  | 			SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication), | ||||||
|  | 		}; | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,34 +21,33 @@ using System.Threading.Tasks; | |||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| using Microsoft.IdentityModel.Tokens; | using Microsoft.IdentityModel.Tokens; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication | namespace Kyoo.Authentication; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The service that controls jwt creation and validation. | ||||||
|  | /// </summary> | ||||||
|  | public interface ITokenController | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The service that controls jwt creation and validation. | 	/// Create a new access token for the given user. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public interface ITokenController | 	/// <param name="user">The user to create a token for.</param> | ||||||
| 	{ | 	/// <param name="expireIn">When this token will expire.</param> | ||||||
| 		/// <summary> | 	/// <returns>A new, valid access token.</returns> | ||||||
| 		/// Create a new access token for the given user. | 	string CreateAccessToken(User user, out TimeSpan expireIn); | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="user">The user to create a token for.</param> |  | ||||||
| 		/// <param name="expireIn">When this token will expire.</param> |  | ||||||
| 		/// <returns>A new, valid access token.</returns> |  | ||||||
| 		string CreateAccessToken(User user, out TimeSpan expireIn); |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new refresh token for the given user. | 	/// Create a new refresh token for the given user. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="user">The user to create a token for.</param> | 	/// <param name="user">The user to create a token for.</param> | ||||||
| 		/// <returns>A new, valid refresh token.</returns> | 	/// <returns>A new, valid refresh token.</returns> | ||||||
| 		Task<string> CreateRefreshToken(User user); | 	Task<string> CreateRefreshToken(User user); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. | 	/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="refreshToken">The refresh token to validate.</param> | 	/// <param name="refreshToken">The refresh token to validate.</param> | ||||||
| 		/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception> | 	/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception> | ||||||
| 		/// <returns>The id of the token's user.</returns> | 	/// <returns>The id of the token's user.</returns> | ||||||
| 		Guid GetRefreshTokenUserID(string refreshToken); | 	Guid GetRefreshTokenUserID(string refreshToken); | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -32,267 +32,253 @@ using Microsoft.AspNetCore.Mvc; | |||||||
| using Microsoft.AspNetCore.Mvc.Filters; | using Microsoft.AspNetCore.Mvc.Filters; | ||||||
| using Microsoft.Extensions.Primitives; | using Microsoft.Extensions.Primitives; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication | namespace Kyoo.Authentication; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A permission validator to validate permission with user Permission array | ||||||
|  | /// or the default array from the configurations if the user is not logged. | ||||||
|  | /// </summary> | ||||||
|  | public class PermissionValidator : IPermissionValidator | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A permission validator to validate permission with user Permission array | 	/// The permissions options to retrieve default permissions. | ||||||
| 	/// or the default array from the configurations if the user is not logged. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class PermissionValidator : IPermissionValidator | 	private readonly PermissionOption _options; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new factory with the given options. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="options">The option containing default values.</param> | ||||||
|  | 	public PermissionValidator(PermissionOption options) | ||||||
| 	{ | 	{ | ||||||
|  | 		_options = options; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IFilterMetadata Create(PermissionAttribute attribute) | ||||||
|  | 	{ | ||||||
|  | 		return new PermissionValidatorFilter( | ||||||
|  | 			attribute.Type, | ||||||
|  | 			attribute.Kind, | ||||||
|  | 			attribute.Group, | ||||||
|  | 			_options | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IFilterMetadata Create(PartialPermissionAttribute attribute) | ||||||
|  | 	{ | ||||||
|  | 		return new PermissionValidatorFilter( | ||||||
|  | 			((object?)attribute.Type ?? attribute.Kind)!, | ||||||
|  | 			attribute.Group, | ||||||
|  | 			_options | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The authorization filter used by <see cref="PermissionValidator"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private class PermissionValidatorFilter : IAsyncAuthorizationFilter | ||||||
|  | 	{ | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// The permission to validate. | ||||||
|  | 		/// </summary> | ||||||
|  | 		private readonly string? _permission; | ||||||
|  | 
 | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// The kind of permission needed. | ||||||
|  | 		/// </summary> | ||||||
|  | 		private readonly Kind? _kind; | ||||||
|  | 
 | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// The group of he permission. | ||||||
|  | 		/// </summary> | ||||||
|  | 		private Group _group; | ||||||
|  | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// The permissions options to retrieve default permissions. | 		/// The permissions options to retrieve default permissions. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		private readonly PermissionOption _options; | 		private readonly PermissionOption _options; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Create a new factory with the given options. | 		/// Create a new permission validator with the given options. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
|  | 		/// <param name="permission">The permission to validate.</param> | ||||||
|  | 		/// <param name="kind">The kind of permission needed.</param> | ||||||
|  | 		/// <param name="group">The group of the permission.</param> | ||||||
| 		/// <param name="options">The option containing default values.</param> | 		/// <param name="options">The option containing default values.</param> | ||||||
| 		public PermissionValidator(PermissionOption options) | 		public PermissionValidatorFilter( | ||||||
|  | 			string permission, | ||||||
|  | 			Kind kind, | ||||||
|  | 			Group group, | ||||||
|  | 			PermissionOption options | ||||||
|  | 		) | ||||||
| 		{ | 		{ | ||||||
|  | 			_permission = permission; | ||||||
|  | 			_kind = kind; | ||||||
|  | 			_group = group; | ||||||
|  | 			_options = options; | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// Create a new permission validator with the given options. | ||||||
|  | 		/// </summary> | ||||||
|  | 		/// <param name="partialInfo">The partial permission to validate.</param> | ||||||
|  | 		/// <param name="group">The group of the permission.</param> | ||||||
|  | 		/// <param name="options">The option containing default values.</param> | ||||||
|  | 		public PermissionValidatorFilter(object partialInfo, Group? group, PermissionOption options) | ||||||
|  | 		{ | ||||||
|  | 			switch (partialInfo) | ||||||
|  | 			{ | ||||||
|  | 				case Kind kind: | ||||||
|  | 					_kind = kind; | ||||||
|  | 					break; | ||||||
|  | 				case string perm: | ||||||
|  | 					_permission = perm; | ||||||
|  | 					break; | ||||||
|  | 				default: | ||||||
|  | 					throw new ArgumentException( | ||||||
|  | 						$"{nameof(partialInfo)} can only be a permission string or a kind." | ||||||
|  | 					); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			if (group is not null and not Group.None) | ||||||
|  | 				_group = group.Value; | ||||||
| 			_options = options; | 			_options = options; | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		/// <inheritdoc /> | ||||||
| 		public IFilterMetadata Create(PermissionAttribute attribute) | 		public async Task OnAuthorizationAsync(AuthorizationFilterContext context) | ||||||
| 		{ | 		{ | ||||||
| 			return new PermissionValidatorFilter( | 			string? permission = _permission; | ||||||
| 				attribute.Type, | 			Kind? kind = _kind; | ||||||
| 				attribute.Kind, |  | ||||||
| 				attribute.Group, |  | ||||||
| 				_options |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 			if (permission == null || kind == null) | ||||||
| 		public IFilterMetadata Create(PartialPermissionAttribute attribute) |  | ||||||
| 		{ |  | ||||||
| 			return new PermissionValidatorFilter( |  | ||||||
| 				((object?)attribute.Type ?? attribute.Kind)!, |  | ||||||
| 				attribute.Group, |  | ||||||
| 				_options |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The authorization filter used by <see cref="PermissionValidator"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		private class PermissionValidatorFilter : IAsyncAuthorizationFilter |  | ||||||
| 		{ |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The permission to validate. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly string? _permission; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The kind of permission needed. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly Kind? _kind; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The group of he permission. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private Group _group; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The permissions options to retrieve default permissions. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly PermissionOption _options; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new permission validator with the given options. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="permission">The permission to validate.</param> |  | ||||||
| 			/// <param name="kind">The kind of permission needed.</param> |  | ||||||
| 			/// <param name="group">The group of the permission.</param> |  | ||||||
| 			/// <param name="options">The option containing default values.</param> |  | ||||||
| 			public PermissionValidatorFilter( |  | ||||||
| 				string permission, |  | ||||||
| 				Kind kind, |  | ||||||
| 				Group group, |  | ||||||
| 				PermissionOption options |  | ||||||
| 			) |  | ||||||
| 			{ | 			{ | ||||||
| 				_permission = permission; | 				if (context.HttpContext.Items["PermissionGroup"] is Group group and not Group.None) | ||||||
| 				_kind = kind; | 					_group = group; | ||||||
| 				_group = group; | 				else if (_group == Group.None) | ||||||
| 				_options = options; | 					_group = Group.Overall; | ||||||
| 			} | 				else | ||||||
|  | 					context.HttpContext.Items["PermissionGroup"] = _group; | ||||||
| 
 | 
 | ||||||
| 			/// <summary> | 				switch (context.HttpContext.Items["PermissionType"]) | ||||||
| 			/// Create a new permission validator with the given options. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="partialInfo">The partial permission to validate.</param> |  | ||||||
| 			/// <param name="group">The group of the permission.</param> |  | ||||||
| 			/// <param name="options">The option containing default values.</param> |  | ||||||
| 			public PermissionValidatorFilter( |  | ||||||
| 				object partialInfo, |  | ||||||
| 				Group? group, |  | ||||||
| 				PermissionOption options |  | ||||||
| 			) |  | ||||||
| 			{ |  | ||||||
| 				switch (partialInfo) |  | ||||||
| 				{ | 				{ | ||||||
| 					case Kind kind: |  | ||||||
| 						_kind = kind; |  | ||||||
| 						break; |  | ||||||
| 					case string perm: | 					case string perm: | ||||||
| 						_permission = perm; | 						permission = perm; | ||||||
| 						break; | 						break; | ||||||
|  | 					case Kind kin: | ||||||
|  | 						kind = kin; | ||||||
|  | 						break; | ||||||
|  | 					case null when kind != null: | ||||||
|  | 						context.HttpContext.Items["PermissionType"] = kind; | ||||||
|  | 						return; | ||||||
|  | 					case null when permission != null: | ||||||
|  | 						context.HttpContext.Items["PermissionType"] = permission; | ||||||
|  | 						return; | ||||||
| 					default: | 					default: | ||||||
| 						throw new ArgumentException( | 						throw new ArgumentException( | ||||||
| 							$"{nameof(partialInfo)} can only be a permission string or a kind." | 							"Multiple non-matching partial permission attribute " | ||||||
|  | 								+ "are not supported." | ||||||
| 						); | 						); | ||||||
| 				} | 				} | ||||||
| 
 |  | ||||||
| 				if (group is not null and not Group.None) |  | ||||||
| 					_group = group.Value; |  | ||||||
| 				_options = options; |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			/// <inheritdoc /> |  | ||||||
| 			public async Task OnAuthorizationAsync(AuthorizationFilterContext context) |  | ||||||
| 			{ |  | ||||||
| 				string? permission = _permission; |  | ||||||
| 				Kind? kind = _kind; |  | ||||||
| 
 |  | ||||||
| 				if (permission == null || kind == null) | 				if (permission == null || kind == null) | ||||||
| 				{ | 				{ | ||||||
| 					if ( | 					throw new ArgumentException( | ||||||
| 						context.HttpContext.Items["PermissionGroup"] | 						"The permission type or kind is still missing after two partial " | ||||||
| 						is Group group | 							+ "permission attributes, this is unsupported." | ||||||
| 							and not Group.None | 					); | ||||||
| 					) |  | ||||||
| 						_group = group; |  | ||||||
| 					else if (_group == Group.None) |  | ||||||
| 						_group = Group.Overall; |  | ||||||
| 					else |  | ||||||
| 						context.HttpContext.Items["PermissionGroup"] = _group; |  | ||||||
| 
 |  | ||||||
| 					switch (context.HttpContext.Items["PermissionType"]) |  | ||||||
| 					{ |  | ||||||
| 						case string perm: |  | ||||||
| 							permission = perm; |  | ||||||
| 							break; |  | ||||||
| 						case Kind kin: |  | ||||||
| 							kind = kin; |  | ||||||
| 							break; |  | ||||||
| 						case null when kind != null: |  | ||||||
| 							context.HttpContext.Items["PermissionType"] = kind; |  | ||||||
| 							return; |  | ||||||
| 						case null when permission != null: |  | ||||||
| 							context.HttpContext.Items["PermissionType"] = permission; |  | ||||||
| 							return; |  | ||||||
| 						default: |  | ||||||
| 							throw new ArgumentException( |  | ||||||
| 								"Multiple non-matching partial permission attribute " |  | ||||||
| 									+ "are not supported." |  | ||||||
| 							); |  | ||||||
| 					} |  | ||||||
| 					if (permission == null || kind == null) |  | ||||||
| 					{ |  | ||||||
| 						throw new ArgumentException( |  | ||||||
| 							"The permission type or kind is still missing after two partial " |  | ||||||
| 								+ "permission attributes, this is unsupported." |  | ||||||
| 						); |  | ||||||
| 					} |  | ||||||
| 				} | 				} | ||||||
|  | 			} | ||||||
| 
 | 
 | ||||||
| 				string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}"; | 			string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}"; | ||||||
| 				string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; | 			string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; | ||||||
| 				AuthenticateResult res = _ApiKeyCheck(context); | 			AuthenticateResult res = _ApiKeyCheck(context); | ||||||
| 				if (res.None) | 			if (res.None) | ||||||
| 					res = await _JwtCheck(context); | 				res = await _JwtCheck(context); | ||||||
| 
 | 
 | ||||||
| 				if (res.Succeeded) | 			if (res.Succeeded) | ||||||
| 				{ | 			{ | ||||||
| 					ICollection<string> permissions = res.Principal.GetPermissions(); | 				ICollection<string> permissions = res.Principal.GetPermissions(); | ||||||
| 					if (permissions.All(x => x != permStr && x != overallStr)) | 				if (permissions.All(x => x != permStr && x != overallStr)) | ||||||
| 						context.Result = _ErrorResult( |  | ||||||
| 							$"Missing permission {permStr} or {overallStr}", |  | ||||||
| 							StatusCodes.Status403Forbidden |  | ||||||
| 						); |  | ||||||
| 				} |  | ||||||
| 				else if (res.None) |  | ||||||
| 				{ |  | ||||||
| 					ICollection<string> permissions = _options.Default ?? Array.Empty<string>(); |  | ||||||
| 					if (permissions.All(x => x != permStr && x != overallStr)) |  | ||||||
| 					{ |  | ||||||
| 						context.Result = _ErrorResult( |  | ||||||
| 							$"Unlogged user does not have permission {permStr} or {overallStr}", |  | ||||||
| 							StatusCodes.Status401Unauthorized |  | ||||||
| 						); |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 				else if (res.Failure != null) |  | ||||||
| 					context.Result = _ErrorResult( | 					context.Result = _ErrorResult( | ||||||
| 						res.Failure.Message, | 						$"Missing permission {permStr} or {overallStr}", | ||||||
| 						StatusCodes.Status403Forbidden | 						StatusCodes.Status403Forbidden | ||||||
| 					); | 					); | ||||||
| 				else | 			} | ||||||
|  | 			else if (res.None) | ||||||
|  | 			{ | ||||||
|  | 				ICollection<string> permissions = _options.Default ?? Array.Empty<string>(); | ||||||
|  | 				if (permissions.All(x => x != permStr && x != overallStr)) | ||||||
|  | 				{ | ||||||
| 					context.Result = _ErrorResult( | 					context.Result = _ErrorResult( | ||||||
| 						"Authentication panic", | 						$"Unlogged user does not have permission {permStr} or {overallStr}", | ||||||
| 						StatusCodes.Status500InternalServerError | 						StatusCodes.Status401Unauthorized | ||||||
| 					); | 					); | ||||||
|  | 				} | ||||||
| 			} | 			} | ||||||
| 
 | 			else if (res.Failure != null) | ||||||
| 			private AuthenticateResult _ApiKeyCheck(ActionContext context) | 				context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden); | ||||||
| 			{ | 			else | ||||||
| 				if ( | 				context.Result = _ErrorResult( | ||||||
| 					!context.HttpContext.Request.Headers.TryGetValue( | 					"Authentication panic", | ||||||
| 						"X-API-Key", | 					StatusCodes.Status500InternalServerError | ||||||
| 						out StringValues apiKey |  | ||||||
| 					) |  | ||||||
| 				) |  | ||||||
| 					return AuthenticateResult.NoResult(); |  | ||||||
| 				if (!_options.ApiKeys.Contains<string>(apiKey!)) |  | ||||||
| 					return AuthenticateResult.Fail("Invalid API-Key."); |  | ||||||
| 				return AuthenticateResult.Success( |  | ||||||
| 					new AuthenticationTicket( |  | ||||||
| 						new ClaimsPrincipal( |  | ||||||
| 							new[] |  | ||||||
| 							{ |  | ||||||
| 								new ClaimsIdentity( |  | ||||||
| 									new[] |  | ||||||
| 									{ |  | ||||||
| 										// TODO: Make permission configurable, for now every APIKEY as all permissions. |  | ||||||
| 										new Claim( |  | ||||||
| 											Claims.Permissions, |  | ||||||
| 											string.Join(',', PermissionOption.Admin) |  | ||||||
| 										) |  | ||||||
| 									} |  | ||||||
| 								) |  | ||||||
| 							} |  | ||||||
| 						), |  | ||||||
| 						"apikey" |  | ||||||
| 					) |  | ||||||
| 				); | 				); | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			private async Task<AuthenticateResult> _JwtCheck(ActionContext context) |  | ||||||
| 			{ |  | ||||||
| 				AuthenticateResult ret = await context.HttpContext.AuthenticateAsync( |  | ||||||
| 					JwtBearerDefaults.AuthenticationScheme |  | ||||||
| 				); |  | ||||||
| 				// Change the failure message to make the API nice to use. |  | ||||||
| 				if (ret.Failure != null) |  | ||||||
| 					return AuthenticateResult.Fail( |  | ||||||
| 						"Invalid JWT token. The token may have expired." |  | ||||||
| 					); |  | ||||||
| 				return ret; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		private AuthenticateResult _ApiKeyCheck(ActionContext context) | ||||||
| 		/// Create a new action result with the given error message and error code. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="error">The error message.</param> |  | ||||||
| 		/// <param name="code">The status code of the error.</param> |  | ||||||
| 		/// <returns>The resulting error action.</returns> |  | ||||||
| 		private static IActionResult _ErrorResult(string error, int code) |  | ||||||
| 		{ | 		{ | ||||||
| 			return new ObjectResult(new RequestError(error)) { StatusCode = code }; | 			if ( | ||||||
|  | 				!context.HttpContext.Request.Headers.TryGetValue( | ||||||
|  | 					"X-API-Key", | ||||||
|  | 					out StringValues apiKey | ||||||
|  | 				) | ||||||
|  | 			) | ||||||
|  | 				return AuthenticateResult.NoResult(); | ||||||
|  | 			if (!_options.ApiKeys.Contains<string>(apiKey!)) | ||||||
|  | 				return AuthenticateResult.Fail("Invalid API-Key."); | ||||||
|  | 			return AuthenticateResult.Success( | ||||||
|  | 				new AuthenticationTicket( | ||||||
|  | 					new ClaimsPrincipal( | ||||||
|  | 						new[] | ||||||
|  | 						{ | ||||||
|  | 							new ClaimsIdentity( | ||||||
|  | 								new[] | ||||||
|  | 								{ | ||||||
|  | 									// TODO: Make permission configurable, for now every APIKEY as all permissions. | ||||||
|  | 									new Claim( | ||||||
|  | 										Claims.Permissions, | ||||||
|  | 										string.Join(',', PermissionOption.Admin) | ||||||
|  | 									) | ||||||
|  | 								} | ||||||
|  | 							) | ||||||
|  | 						} | ||||||
|  | 					), | ||||||
|  | 					"apikey" | ||||||
|  | 				) | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		private async Task<AuthenticateResult> _JwtCheck(ActionContext context) | ||||||
|  | 		{ | ||||||
|  | 			AuthenticateResult ret = await context.HttpContext.AuthenticateAsync( | ||||||
|  | 				JwtBearerDefaults.AuthenticationScheme | ||||||
|  | 			); | ||||||
|  | 			// Change the failure message to make the API nice to use. | ||||||
|  | 			if (ret.Failure != null) | ||||||
|  | 				return AuthenticateResult.Fail("Invalid JWT token. The token may have expired."); | ||||||
|  | 			return ret; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new action result with the given error message and error code. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="error">The error message.</param> | ||||||
|  | 	/// <param name="code">The status code of the error.</param> | ||||||
|  | 	/// <returns>The resulting error action.</returns> | ||||||
|  | 	private static IActionResult _ErrorResult(string error, int code) | ||||||
|  | 	{ | ||||||
|  | 		return new ObjectResult(new RequestError(error)) { StatusCode = code }; | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -27,109 +27,108 @@ using Kyoo.Abstractions.Models; | |||||||
| using Kyoo.Authentication.Models; | using Kyoo.Authentication.Models; | ||||||
| using Microsoft.IdentityModel.Tokens; | using Microsoft.IdentityModel.Tokens; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication | namespace Kyoo.Authentication; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The service that controls jwt creation and validation. | ||||||
|  | /// </summary> | ||||||
|  | public class TokenController : ITokenController | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The service that controls jwt creation and validation. | 	/// The options that this controller will use. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class TokenController : ITokenController | 	private readonly AuthenticationOption _options; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="TokenController"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="options">The options that this controller will use.</param> | ||||||
|  | 	public TokenController(AuthenticationOption options) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_options = options; | ||||||
| 		/// The options that this controller will use. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly AuthenticationOption _options; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Create a new <see cref="TokenController"/>. | 	public string CreateAccessToken(User user, out TimeSpan expireIn) | ||||||
| 		/// </summary> | 	{ | ||||||
| 		/// <param name="options">The options that this controller will use.</param> | 		expireIn = new TimeSpan(1, 0, 0); | ||||||
| 		public TokenController(AuthenticationOption options) |  | ||||||
| 		{ |  | ||||||
| 			_options = options; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | ||||||
| 		public string CreateAccessToken(User user, out TimeSpan expireIn) | 		SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); | ||||||
| 		{ | 		string permissions = | ||||||
| 			expireIn = new TimeSpan(1, 0, 0); | 			user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; | ||||||
|  | 		List<Claim> claims = | ||||||
|  | 			new() | ||||||
|  | 			{ | ||||||
|  | 				new Claim(Claims.Id, user.Id.ToString()), | ||||||
|  | 				new Claim(Claims.Name, user.Username), | ||||||
|  | 				new Claim(Claims.Permissions, permissions), | ||||||
|  | 				new Claim(Claims.Type, "access") | ||||||
|  | 			}; | ||||||
|  | 		if (user.Email != null) | ||||||
|  | 			claims.Add(new Claim(Claims.Email, user.Email)); | ||||||
|  | 		JwtSecurityToken token = | ||||||
|  | 			new( | ||||||
|  | 				signingCredentials: credential, | ||||||
|  | 				claims: claims, | ||||||
|  | 				expires: DateTime.UtcNow.Add(expireIn) | ||||||
|  | 			); | ||||||
|  | 		return new JwtSecurityTokenHandler().WriteToken(token); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 			SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | 	/// <inheritdoc /> | ||||||
| 			SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); | 	public Task<string> CreateRefreshToken(User user) | ||||||
| 			string permissions = | 	{ | ||||||
| 				user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; | 		SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | ||||||
| 			List<Claim> claims = | 		SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); | ||||||
| 				new() | 		JwtSecurityToken token = | ||||||
|  | 			new( | ||||||
|  | 				signingCredentials: credential, | ||||||
|  | 				claims: new[] | ||||||
| 				{ | 				{ | ||||||
| 					new Claim(Claims.Id, user.Id.ToString()), | 					new Claim(Claims.Id, user.Id.ToString()), | ||||||
| 					new Claim(Claims.Name, user.Username), | 					new Claim(Claims.Guid, Guid.NewGuid().ToString()), | ||||||
| 					new Claim(Claims.Permissions, permissions), | 					new Claim(Claims.Type, "refresh") | ||||||
| 					new Claim(Claims.Type, "access") | 				}, | ||||||
| 				}; | 				expires: DateTime.UtcNow.AddYears(1) | ||||||
| 			if (user.Email != null) | 			); | ||||||
| 				claims.Add(new Claim(Claims.Email, user.Email)); | 		// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user. | ||||||
| 			JwtSecurityToken token = | 		return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token)); | ||||||
| 				new( | 	} | ||||||
| 					signingCredentials: credential, |  | ||||||
| 					claims: claims, |  | ||||||
| 					expires: DateTime.UtcNow.Add(expireIn) |  | ||||||
| 				); |  | ||||||
| 			return new JwtSecurityTokenHandler().WriteToken(token); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public Task<string> CreateRefreshToken(User user) | 	public Guid GetRefreshTokenUserID(string refreshToken) | ||||||
|  | 	{ | ||||||
|  | 		SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | ||||||
|  | 		JwtSecurityTokenHandler tokenHandler = new(); | ||||||
|  | 		ClaimsPrincipal principal; | ||||||
|  | 		try | ||||||
| 		{ | 		{ | ||||||
| 			SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | 			principal = tokenHandler.ValidateToken( | ||||||
| 			SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); | 				refreshToken, | ||||||
| 			JwtSecurityToken token = | 				new TokenValidationParameters | ||||||
| 				new( | 				{ | ||||||
| 					signingCredentials: credential, | 					ValidateIssuer = false, | ||||||
| 					claims: new[] | 					ValidateAudience = false, | ||||||
| 					{ | 					ValidateIssuerSigningKey = true, | ||||||
| 						new Claim(Claims.Id, user.Id.ToString()), | 					ValidateLifetime = true, | ||||||
| 						new Claim(Claims.Guid, Guid.NewGuid().ToString()), | 					IssuerSigningKey = key | ||||||
| 						new Claim(Claims.Type, "refresh") | 				}, | ||||||
| 					}, | 				out SecurityToken _ | ||||||
| 					expires: DateTime.UtcNow.AddYears(1) | 			); | ||||||
| 				); |  | ||||||
| 			// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user. |  | ||||||
| 			return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token)); |  | ||||||
| 		} | 		} | ||||||
| 
 | 		catch (Exception) | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Guid GetRefreshTokenUserID(string refreshToken) |  | ||||||
| 		{ | 		{ | ||||||
| 			SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); | 			throw new SecurityTokenException("Invalid refresh token"); | ||||||
| 			JwtSecurityTokenHandler tokenHandler = new(); |  | ||||||
| 			ClaimsPrincipal principal; |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				principal = tokenHandler.ValidateToken( |  | ||||||
| 					refreshToken, |  | ||||||
| 					new TokenValidationParameters |  | ||||||
| 					{ |  | ||||||
| 						ValidateIssuer = false, |  | ||||||
| 						ValidateAudience = false, |  | ||||||
| 						ValidateIssuerSigningKey = true, |  | ||||||
| 						ValidateLifetime = true, |  | ||||||
| 						IssuerSigningKey = key |  | ||||||
| 					}, |  | ||||||
| 					out SecurityToken _ |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			catch (Exception) |  | ||||||
| 			{ |  | ||||||
| 				throw new SecurityTokenException("Invalid refresh token"); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh") |  | ||||||
| 				throw new SecurityTokenException( |  | ||||||
| 					"Invalid token type. The token should be a refresh token." |  | ||||||
| 				); |  | ||||||
| 			Claim identifier = principal.Claims.First(x => x.Type == Claims.Id); |  | ||||||
| 			if (Guid.TryParse(identifier.Value, out Guid id)) |  | ||||||
| 				return id; |  | ||||||
| 			throw new SecurityTokenException("Token not associated to any user."); |  | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh") | ||||||
|  | 			throw new SecurityTokenException( | ||||||
|  | 				"Invalid token type. The token should be a refresh token." | ||||||
|  | 			); | ||||||
|  | 		Claim identifier = principal.Claims.First(x => x.Type == Claims.Id); | ||||||
|  | 		if (Guid.TryParse(identifier.Value, out Guid id)) | ||||||
|  | 			return id; | ||||||
|  | 		throw new SecurityTokenException("Token not associated to any user."); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,32 +16,31 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication.Models.DTO | namespace Kyoo.Authentication.Models.DTO; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A model only used on login requests. | ||||||
|  | /// </summary> | ||||||
|  | public class LoginRequest | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A model only used on login requests. | 	/// The user's username. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class LoginRequest | 	public string Username { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user's password. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public string Password { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Initializes a new instance of the <see cref="LoginRequest"/> class. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="username">The user's username.</param> | ||||||
|  | 	/// <param name="password">The user's password.</param> | ||||||
|  | 	public LoginRequest(string username, string password) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Username = username; | ||||||
| 		/// The user's username. | 		Password = password; | ||||||
| 		/// </summary> |  | ||||||
| 		public string Username { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The user's password. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public string Password { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Initializes a new instance of the <see cref="LoginRequest"/> class. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="username">The user's username.</param> |  | ||||||
| 		/// <param name="password">The user's password.</param> |  | ||||||
| 		public LoginRequest(string username, string password) |  | ||||||
| 		{ |  | ||||||
| 			Username = username; |  | ||||||
| 			Password = password; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -21,57 +21,56 @@ using Kyoo.Abstractions.Models; | |||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| using BCryptNet = BCrypt.Net.BCrypt; | using BCryptNet = BCrypt.Net.BCrypt; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication.Models.DTO | namespace Kyoo.Authentication.Models.DTO; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A model only used on register requests. | ||||||
|  | /// </summary> | ||||||
|  | public class RegisterRequest | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A model only used on register requests. | 	/// The user email address | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class RegisterRequest | 	[EmailAddress(ErrorMessage = "The email must be a valid email address")] | ||||||
|  | 	public string Email { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user's username. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[MinLength(4, ErrorMessage = "The username must have at least {1} characters")] | ||||||
|  | 	public string Username { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The user's password. | ||||||
|  | 	/// </summary> | ||||||
|  | 	[MinLength(4, ErrorMessage = "The password must have at least {1} characters")] | ||||||
|  | 	public string Password { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Initializes a new instance of the <see cref="RegisterRequest"/> class. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="email">The user email address.</param> | ||||||
|  | 	/// <param name="username">The user's username.</param> | ||||||
|  | 	/// <param name="password">The user's password.</param> | ||||||
|  | 	public RegisterRequest(string email, string username, string password) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Email = email; | ||||||
| 		/// The user email address | 		Username = username; | ||||||
| 		/// </summary> | 		Password = password; | ||||||
| 		[EmailAddress(ErrorMessage = "The email must be a valid email address")] | 	} | ||||||
| 		public string Email { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The user's username. | 	/// Convert this register request to a new <see cref="User"/> class. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[MinLength(4, ErrorMessage = "The username must have at least {1} characters")] | 	/// <returns>A user representing this request.</returns> | ||||||
| 		public string Username { get; set; } | 	public User ToUser() | ||||||
| 
 | 	{ | ||||||
| 		/// <summary> | 		return new User | ||||||
| 		/// The user's password. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[MinLength(4, ErrorMessage = "The password must have at least {1} characters")] |  | ||||||
| 		public string Password { get; set; } |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Initializes a new instance of the <see cref="RegisterRequest"/> class. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="email">The user email address.</param> |  | ||||||
| 		/// <param name="username">The user's username.</param> |  | ||||||
| 		/// <param name="password">The user's password.</param> |  | ||||||
| 		public RegisterRequest(string email, string username, string password) |  | ||||||
| 		{ | 		{ | ||||||
| 			Email = email; | 			Slug = Utility.ToSlug(Username), | ||||||
| 			Username = username; | 			Username = Username, | ||||||
| 			Password = password; | 			Password = BCryptNet.HashPassword(Password), | ||||||
| 		} | 			Email = Email, | ||||||
| 
 | 		}; | ||||||
| 		/// <summary> |  | ||||||
| 		/// Convert this register request to a new <see cref="User"/> class. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <returns>A user representing this request.</returns> |  | ||||||
| 		public User ToUser() |  | ||||||
| 		{ |  | ||||||
| 			return new User |  | ||||||
| 			{ |  | ||||||
| 				Slug = Utility.ToSlug(Username), |  | ||||||
| 				Username = Username, |  | ||||||
| 				Password = BCryptNet.HashPassword(Password), |  | ||||||
| 				Email = Email, |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -16,31 +16,30 @@ | |||||||
| // You should have received a copy of the GNU General Public License | // You should have received a copy of the GNU General Public License | ||||||
| // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | // along with Kyoo. If not, see <https://www.gnu.org/licenses/>. | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication.Models | namespace Kyoo.Authentication.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The main authentication options. | ||||||
|  | /// </summary> | ||||||
|  | public class AuthenticationOption | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The main authentication options. | 	/// The path to get this option from the root configuration. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class AuthenticationOption | 	public const string Path = "authentication"; | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The path to get this option from the root configuration. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public const string Path = "authentication"; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The default jwt secret. | 	/// The default jwt secret. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?"; | 	public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?"; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The secret used to encrypt the jwt. | 	/// The secret used to encrypt the jwt. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public string Secret { get; set; } = DefaultSecret; | 	public string Secret { get; set; } = DefaultSecret; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Options for permissions | 	/// Options for permissions | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		public PermissionOption Permissions { get; set; } = new(); | 	public PermissionOption Permissions { get; set; } = new(); | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -35,464 +35,458 @@ using Microsoft.IdentityModel.Tokens; | |||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| using BCryptNet = BCrypt.Net.BCrypt; | using BCryptNet = BCrypt.Net.BCrypt; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Authentication.Views | namespace Kyoo.Authentication.Views; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Sign in, Sign up or refresh tokens. | ||||||
|  | /// </summary> | ||||||
|  | [ApiController] | ||||||
|  | [Route("auth")] | ||||||
|  | [ApiDefinition("Authentication", Group = UsersGroup)] | ||||||
|  | public class AuthApi( | ||||||
|  | 	IUserRepository users, | ||||||
|  | 	OidcController oidc, | ||||||
|  | 	ITokenController tokenController, | ||||||
|  | 	IThumbnailsManager thumbs, | ||||||
|  | 	PermissionOption options | ||||||
|  | ) : ControllerBase | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Sign in, Sign up or refresh tokens. | 	/// Create a new Forbidden result from an object. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[ApiController] | 	/// <param name="value">The json value to output on the response.</param> | ||||||
| 	[Route("auth")] | 	/// <returns>A new forbidden result with the given json object.</returns> | ||||||
| 	[ApiDefinition("Authentication", Group = UsersGroup)] | 	public static ObjectResult Forbid(object value) | ||||||
| 	public class AuthApi( |  | ||||||
| 		IUserRepository users, |  | ||||||
| 		OidcController oidc, |  | ||||||
| 		ITokenController tokenController, |  | ||||||
| 		IThumbnailsManager thumbs, |  | ||||||
| 		PermissionOption options |  | ||||||
| 	) : ControllerBase |  | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; | ||||||
| 		/// Create a new Forbidden result from an object. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="value">The json value to output on the response.</param> |  | ||||||
| 		/// <returns>A new forbidden result with the given json object.</returns> |  | ||||||
| 		public static ObjectResult Forbid(object value) |  | ||||||
| 		{ |  | ||||||
| 			return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams) | 	private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams) | ||||||
|  | 	{ | ||||||
|  | 		char querySep = baseUrl.Contains('?') ? '&' : '?'; | ||||||
|  | 		foreach ((string key, string? val) in queryParams) | ||||||
| 		{ | 		{ | ||||||
| 			char querySep = baseUrl.Contains('?') ? '&' : '?'; | 			if (val is null) | ||||||
| 			foreach ((string key, string? val) in queryParams) | 				continue; | ||||||
| 			{ | 			baseUrl += $"{querySep}{key}={val}"; | ||||||
| 				if (val is null) | 			querySep = '&'; | ||||||
| 					continue; |  | ||||||
| 				baseUrl += $"{querySep}{key}={val}"; |  | ||||||
| 				querySep = '&'; |  | ||||||
| 			} |  | ||||||
| 			return baseUrl; |  | ||||||
| 		} | 		} | ||||||
|  | 		return baseUrl; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Oauth Login. | 	/// Oauth Login. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Login via a registered oauth provider. | 	/// Login via a registered oauth provider. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="provider">The provider code.</param> | 	/// <param name="provider">The provider code.</param> | ||||||
| 		/// <param name="redirectUrl"> | 	/// <param name="redirectUrl"> | ||||||
| 		/// A url where you will be redirected with the query params provider, code and error. It can be a deep link. | 	/// A url where you will be redirected with the query params provider, code and error. It can be a deep link. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <returns>A redirect to the provider's login page.</returns> | 	/// <returns>A redirect to the provider's login page.</returns> | ||||||
| 		/// <response code="404">The provider is not register with this instance of kyoo.</response> | 	/// <response code="404">The provider is not register with this instance of kyoo.</response> | ||||||
| 		[HttpGet("login/{provider}")] | 	[HttpGet("login/{provider}")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status302Found)] | 	[ProducesResponseType(StatusCodes.Status302Found)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] | 	[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] | ||||||
| 		public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl) | 	public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl) | ||||||
|  | 	{ | ||||||
|  | 		if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) | ||||||
| 		{ | 		{ | ||||||
| 			if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) | 			return NotFound( | ||||||
| 			{ | 				new RequestError( | ||||||
| 				return NotFound( | 					$"Invalid provider. {provider} is not registered no this instance of kyoo." | ||||||
| 					new RequestError( |  | ||||||
| 						$"Invalid provider. {provider} is not registered no this instance of kyoo." |  | ||||||
| 					) |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			OidcProvider prov = options.OIDC[provider]; |  | ||||||
| 			return Redirect( |  | ||||||
| 				_BuildUrl( |  | ||||||
| 					prov.AuthorizationUrl, |  | ||||||
| 					new() |  | ||||||
| 					{ |  | ||||||
| 						["response_type"] = "code", |  | ||||||
| 						["client_id"] = prov.ClientId, |  | ||||||
| 						["redirect_uri"] = |  | ||||||
| 							$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", |  | ||||||
| 						["scope"] = prov.Scope, |  | ||||||
| 						["state"] = redirectUrl, |  | ||||||
| 					} |  | ||||||
| 				) | 				) | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
| 
 | 		OidcProvider prov = options.OIDC[provider]; | ||||||
| 		/// <summary> | 		return Redirect( | ||||||
| 		/// Oauth Code Redirect. | 			_BuildUrl( | ||||||
| 		/// </summary> | 				prov.AuthorizationUrl, | ||||||
| 		/// <remarks> | 				new() | ||||||
| 		/// This route is not meant to be called manually, the user should be redirected automatically here |  | ||||||
| 		/// after a successful login on the /login/{provider} page. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <returns>A redirect to the provider's login page.</returns> |  | ||||||
| 		/// <response code="403">The provider gave an error.</response> |  | ||||||
| 		[HttpGet("logged/{provider}")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status302Found)] |  | ||||||
| 		public ActionResult OauthCodeRedirect( |  | ||||||
| 			string provider, |  | ||||||
| 			string code, |  | ||||||
| 			string state, |  | ||||||
| 			string? error |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return Redirect( |  | ||||||
| 				_BuildUrl( |  | ||||||
| 					state, |  | ||||||
| 					new() |  | ||||||
| 					{ |  | ||||||
| 						["provider"] = provider, |  | ||||||
| 						["code"] = code, |  | ||||||
| 						["error"] = error, |  | ||||||
| 					} |  | ||||||
| 				) |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Oauth callback |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// This route should be manually called by the page that got redirected to after a call to /login/{provider}. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <returns>A jwt token</returns> |  | ||||||
| 		/// <response code="400">Bad provider or code</response> |  | ||||||
| 		[HttpPost("callback/{provider}")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code) |  | ||||||
| 		{ |  | ||||||
| 			if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) |  | ||||||
| 			{ |  | ||||||
| 				return NotFound( |  | ||||||
| 					new RequestError( |  | ||||||
| 						$"Invalid provider. {provider} is not registered no this instance of kyoo." |  | ||||||
| 					) |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			if (code == null) |  | ||||||
| 				return BadRequest(new RequestError("Invalid code.")); |  | ||||||
| 
 |  | ||||||
| 			Guid? userId = User.GetId(); |  | ||||||
| 			User user = userId.HasValue |  | ||||||
| 				? await oidc.LinkAccountOrLogin(userId.Value, provider, code) |  | ||||||
| 				: await oidc.LoginViaCode(provider, code); |  | ||||||
| 			return new JwtToken( |  | ||||||
| 				tokenController.CreateAccessToken(user, out TimeSpan expireIn), |  | ||||||
| 				await tokenController.CreateRefreshToken(user), |  | ||||||
| 				expireIn |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Unlink account |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Unlink your account from an external account. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="provider">The provider code.</param> |  | ||||||
| 		/// <returns>Your updated user account</returns> |  | ||||||
| 		[HttpDelete("login/{provider}")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		public Task<User> UnlinkAccount(string provider) |  | ||||||
| 		{ |  | ||||||
| 			Guid id = User.GetIdOrThrow(); |  | ||||||
| 			return users.DeleteExternalToken(id, provider); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Login. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Login as a user and retrieve an access and a refresh token. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="request">The body of the request.</param> |  | ||||||
| 		/// <returns>A new access and a refresh token.</returns> |  | ||||||
| 		/// <response code="403">The user and password does not match.</response> |  | ||||||
| 		[HttpPost("login")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request) |  | ||||||
| 		{ |  | ||||||
| 			User? user = await users.GetOrDefault( |  | ||||||
| 				new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username) |  | ||||||
| 			); |  | ||||||
| 			if (user == null || !BCryptNet.Verify(request.Password, user.Password)) |  | ||||||
| 				return Forbid(new RequestError("The user and password does not match.")); |  | ||||||
| 
 |  | ||||||
| 			return new JwtToken( |  | ||||||
| 				tokenController.CreateAccessToken(user, out TimeSpan expireIn), |  | ||||||
| 				await tokenController.CreateRefreshToken(user), |  | ||||||
| 				expireIn |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Register. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Register a new user and get a new access/refresh token for this new user. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="request">The body of the request.</param> |  | ||||||
| 		/// <returns>A new access and a refresh token.</returns> |  | ||||||
| 		/// <response code="400">The request is invalid.</response> |  | ||||||
| 		/// <response code="409">A user already exists with this username or email address.</response> |  | ||||||
| 		[HttpPost("register")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request) |  | ||||||
| 		{ |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				User user = await users.Create(request.ToUser()); |  | ||||||
| 				return new JwtToken( |  | ||||||
| 					tokenController.CreateAccessToken(user, out TimeSpan expireIn), |  | ||||||
| 					await tokenController.CreateRefreshToken(user), |  | ||||||
| 					expireIn |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			catch (DuplicatedItemException) |  | ||||||
| 			{ |  | ||||||
| 				return Conflict(new RequestError("A user already exists with this username.")); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Refresh a token. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Refresh an access token using the given refresh token. A new access and refresh token are generated. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="token">A valid refresh token.</param> |  | ||||||
| 		/// <returns>A new access and refresh token.</returns> |  | ||||||
| 		/// <response code="403">The given refresh token is invalid.</response> |  | ||||||
| 		[HttpGet("refresh")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token) |  | ||||||
| 		{ |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				Guid userId = tokenController.GetRefreshTokenUserID(token); |  | ||||||
| 				User user = await users.Get(userId); |  | ||||||
| 				return new JwtToken( |  | ||||||
| 					tokenController.CreateAccessToken(user, out TimeSpan expireIn), |  | ||||||
| 					await tokenController.CreateRefreshToken(user), |  | ||||||
| 					expireIn |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			catch (ItemNotFoundException) |  | ||||||
| 			{ |  | ||||||
| 				return Forbid(new RequestError("Invalid refresh token.")); |  | ||||||
| 			} |  | ||||||
| 			catch (SecurityTokenException ex) |  | ||||||
| 			{ |  | ||||||
| 				return Forbid(new RequestError(ex.Message)); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Reset your password |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Change your password. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="request">The old and new password</param> |  | ||||||
| 		/// <returns>Your account info.</returns> |  | ||||||
| 		/// <response code="403">The old password is invalid.</response> |  | ||||||
| 		[HttpPost("password-reset")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request) |  | ||||||
| 		{ |  | ||||||
| 			User user = await users.Get(User.GetIdOrThrow()); |  | ||||||
| 			if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password)) |  | ||||||
| 				return Forbid(new RequestError("The old password is invalid.")); |  | ||||||
| 			return await users.Patch( |  | ||||||
| 				user.Id, |  | ||||||
| 				(user) => |  | ||||||
| 				{ | 				{ | ||||||
| 					user.Password = BCryptNet.HashPassword(request.NewPassword); | 					["response_type"] = "code", | ||||||
| 					return user; | 					["client_id"] = prov.ClientId, | ||||||
|  | 					["redirect_uri"] = | ||||||
|  | 						$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", | ||||||
|  | 					["scope"] = prov.Scope, | ||||||
|  | 					["state"] = redirectUrl, | ||||||
| 				} | 				} | ||||||
|  | 			) | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Oauth Code Redirect. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// This route is not meant to be called manually, the user should be redirected automatically here | ||||||
|  | 	/// after a successful login on the /login/{provider} page. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <returns>A redirect to the provider's login page.</returns> | ||||||
|  | 	/// <response code="403">The provider gave an error.</response> | ||||||
|  | 	[HttpGet("logged/{provider}")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status302Found)] | ||||||
|  | 	public ActionResult OauthCodeRedirect(string provider, string code, string state, string? error) | ||||||
|  | 	{ | ||||||
|  | 		return Redirect( | ||||||
|  | 			_BuildUrl( | ||||||
|  | 				state, | ||||||
|  | 				new() | ||||||
|  | 				{ | ||||||
|  | 					["provider"] = provider, | ||||||
|  | 					["code"] = code, | ||||||
|  | 					["error"] = error, | ||||||
|  | 				} | ||||||
|  | 			) | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Oauth callback | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// This route should be manually called by the page that got redirected to after a call to /login/{provider}. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <returns>A jwt token</returns> | ||||||
|  | 	/// <response code="400">Bad provider or code</response> | ||||||
|  | 	[HttpPost("callback/{provider}")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code) | ||||||
|  | 	{ | ||||||
|  | 		if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) | ||||||
|  | 		{ | ||||||
|  | 			return NotFound( | ||||||
|  | 				new RequestError( | ||||||
|  | 					$"Invalid provider. {provider} is not registered no this instance of kyoo." | ||||||
|  | 				) | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|  | 		if (code == null) | ||||||
|  | 			return BadRequest(new RequestError("Invalid code.")); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		Guid? userId = User.GetId(); | ||||||
| 		/// Get authenticated user. | 		User user = userId.HasValue | ||||||
| 		/// </summary> | 			? await oidc.LinkAccountOrLogin(userId.Value, provider, code) | ||||||
| 		/// <remarks> | 			: await oidc.LoginViaCode(provider, code); | ||||||
| 		/// Get information about the currently authenticated user. This can also be used to ensure that you are | 		return new JwtToken( | ||||||
| 		/// logged in. | 			tokenController.CreateAccessToken(user, out TimeSpan expireIn), | ||||||
| 		/// </remarks> | 			await tokenController.CreateRefreshToken(user), | ||||||
| 		/// <returns>The currently authenticated user.</returns> | 			expireIn | ||||||
| 		/// <response code="401">The user is not authenticated.</response> | 		); | ||||||
| 		/// <response code="403">The given access token is invalid.</response> | 	} | ||||||
| 		[HttpGet("me")] | 
 | ||||||
| 		[UserOnly] | 	/// <summary> | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	/// Unlink account | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | 	/// </summary> | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | 	/// <remarks> | ||||||
| 		public async Task<ActionResult<User>> GetMe() | 	/// Unlink your account from an external account. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="provider">The provider code.</param> | ||||||
|  | 	/// <returns>Your updated user account</returns> | ||||||
|  | 	[HttpDelete("login/{provider}")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	public Task<User> UnlinkAccount(string provider) | ||||||
|  | 	{ | ||||||
|  | 		Guid id = User.GetIdOrThrow(); | ||||||
|  | 		return users.DeleteExternalToken(id, provider); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Login. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Login as a user and retrieve an access and a refresh token. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="request">The body of the request.</param> | ||||||
|  | 	/// <returns>A new access and a refresh token.</returns> | ||||||
|  | 	/// <response code="403">The user and password does not match.</response> | ||||||
|  | 	[HttpPost("login")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request) | ||||||
|  | 	{ | ||||||
|  | 		User? user = await users.GetOrDefault( | ||||||
|  | 			new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username) | ||||||
|  | 		); | ||||||
|  | 		if (user == null || !BCryptNet.Verify(request.Password, user.Password)) | ||||||
|  | 			return Forbid(new RequestError("The user and password does not match.")); | ||||||
|  | 
 | ||||||
|  | 		return new JwtToken( | ||||||
|  | 			tokenController.CreateAccessToken(user, out TimeSpan expireIn), | ||||||
|  | 			await tokenController.CreateRefreshToken(user), | ||||||
|  | 			expireIn | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Register. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Register a new user and get a new access/refresh token for this new user. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="request">The body of the request.</param> | ||||||
|  | 	/// <returns>A new access and a refresh token.</returns> | ||||||
|  | 	/// <response code="400">The request is invalid.</response> | ||||||
|  | 	/// <response code="409">A user already exists with this username or email address.</response> | ||||||
|  | 	[HttpPost("register")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
| 		{ | 		{ | ||||||
| 			try | 			User user = await users.Create(request.ToUser()); | ||||||
| 			{ | 			return new JwtToken( | ||||||
| 				return await users.Get(User.GetIdOrThrow()); | 				tokenController.CreateAccessToken(user, out TimeSpan expireIn), | ||||||
| 			} | 				await tokenController.CreateRefreshToken(user), | ||||||
| 			catch (ItemNotFoundException) | 				expireIn | ||||||
| 			{ | 			); | ||||||
| 				return Forbid(new RequestError("Invalid token")); |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 
 | 		catch (DuplicatedItemException) | ||||||
| 		/// <summary> |  | ||||||
| 		/// Edit self |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Edit information about the currently authenticated user. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="user">The new data for the current user.</param> |  | ||||||
| 		/// <returns>The currently authenticated user after modifications.</returns> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpPut("me")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<User>> EditMe(User user) |  | ||||||
| 		{ | 		{ | ||||||
| 			try | 			return Conflict(new RequestError("A user already exists with this username.")); | ||||||
| 			{ |  | ||||||
| 				user.Id = User.GetIdOrThrow(); |  | ||||||
| 				return await users.Edit(user); |  | ||||||
| 			} |  | ||||||
| 			catch (ItemNotFoundException) |  | ||||||
| 			{ |  | ||||||
| 				return Forbid(new RequestError("Invalid token")); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Patch self |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Edit only provided informations about the currently authenticated user. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="patch">The new data for the current user.</param> |  | ||||||
| 		/// <returns>The currently authenticated user after modifications.</returns> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpPatch("me")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch) |  | ||||||
| 		{ |  | ||||||
| 			Guid userId = User.GetIdOrThrow(); |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				if (patch.Id.HasValue && patch.Id != userId) |  | ||||||
| 					throw new ArgumentException("Can't edit your user id."); |  | ||||||
| 				if (patch.ContainsKey(nameof(Abstractions.Models.User.Password))) |  | ||||||
| 					throw new ArgumentException( |  | ||||||
| 						"Can't edit your password via a PATCH. Use /auth/password-reset" |  | ||||||
| 					); |  | ||||||
| 				return await users.Patch(userId, patch.Apply); |  | ||||||
| 			} |  | ||||||
| 			catch (ItemNotFoundException) |  | ||||||
| 			{ |  | ||||||
| 				return Forbid(new RequestError("Invalid token")); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete account |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Delete the current account. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpDelete("me")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<User>> DeleteMe() |  | ||||||
| 		{ |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				await users.Delete(User.GetIdOrThrow()); |  | ||||||
| 				return NoContent(); |  | ||||||
| 			} |  | ||||||
| 			catch (ItemNotFoundException) |  | ||||||
| 			{ |  | ||||||
| 				return Forbid(new RequestError("Invalid token")); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get profile picture |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Get your profile picture |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpGet("me/logo")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult> GetProfilePicture() |  | ||||||
| 		{ |  | ||||||
| 			Stream img = await thumbs.GetUserImage(User.GetIdOrThrow()); |  | ||||||
| 			// Allow clients to cache the image for 6 month. |  | ||||||
| 			Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); |  | ||||||
| 			return File(img, "image/webp", true); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Set profile picture |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Set your profile picture |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpPost("me/logo")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult> SetProfilePicture(IFormFile picture) |  | ||||||
| 		{ |  | ||||||
| 			if (picture == null || picture.Length == 0) |  | ||||||
| 				return BadRequest(); |  | ||||||
| 			await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); |  | ||||||
| 			return NoContent(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete profile picture |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Delete your profile picture |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <response code="401">The user is not authenticated.</response> |  | ||||||
| 		/// <response code="403">The given access token is invalid.</response> |  | ||||||
| 		[HttpDelete("me/logo")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult> DeleteProfilePicture() |  | ||||||
| 		{ |  | ||||||
| 			await thumbs.SetUserImage(User.GetIdOrThrow(), null); |  | ||||||
| 			return NoContent(); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Refresh a token. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Refresh an access token using the given refresh token. A new access and refresh token are generated. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="token">A valid refresh token.</param> | ||||||
|  | 	/// <returns>A new access and refresh token.</returns> | ||||||
|  | 	/// <response code="403">The given refresh token is invalid.</response> | ||||||
|  | 	[HttpGet("refresh")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			Guid userId = tokenController.GetRefreshTokenUserID(token); | ||||||
|  | 			User user = await users.Get(userId); | ||||||
|  | 			return new JwtToken( | ||||||
|  | 				tokenController.CreateAccessToken(user, out TimeSpan expireIn), | ||||||
|  | 				await tokenController.CreateRefreshToken(user), | ||||||
|  | 				expireIn | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
|  | 		catch (ItemNotFoundException) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError("Invalid refresh token.")); | ||||||
|  | 		} | ||||||
|  | 		catch (SecurityTokenException ex) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError(ex.Message)); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Reset your password | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Change your password. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="request">The old and new password</param> | ||||||
|  | 	/// <returns>Your account info.</returns> | ||||||
|  | 	/// <response code="403">The old password is invalid.</response> | ||||||
|  | 	[HttpPost("password-reset")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request) | ||||||
|  | 	{ | ||||||
|  | 		User user = await users.Get(User.GetIdOrThrow()); | ||||||
|  | 		if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password)) | ||||||
|  | 			return Forbid(new RequestError("The old password is invalid.")); | ||||||
|  | 		return await users.Patch( | ||||||
|  | 			user.Id, | ||||||
|  | 			(user) => | ||||||
|  | 			{ | ||||||
|  | 				user.Password = BCryptNet.HashPassword(request.NewPassword); | ||||||
|  | 				return user; | ||||||
|  | 			} | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get authenticated user. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Get information about the currently authenticated user. This can also be used to ensure that you are | ||||||
|  | 	/// logged in. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <returns>The currently authenticated user.</returns> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpGet("me")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<User>> GetMe() | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			return await users.Get(User.GetIdOrThrow()); | ||||||
|  | 		} | ||||||
|  | 		catch (ItemNotFoundException) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError("Invalid token")); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Edit self | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Edit information about the currently authenticated user. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="user">The new data for the current user.</param> | ||||||
|  | 	/// <returns>The currently authenticated user after modifications.</returns> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpPut("me")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<User>> EditMe(User user) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			user.Id = User.GetIdOrThrow(); | ||||||
|  | 			return await users.Edit(user); | ||||||
|  | 		} | ||||||
|  | 		catch (ItemNotFoundException) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError("Invalid token")); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Patch self | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Edit only provided informations about the currently authenticated user. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="patch">The new data for the current user.</param> | ||||||
|  | 	/// <returns>The currently authenticated user after modifications.</returns> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpPatch("me")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch) | ||||||
|  | 	{ | ||||||
|  | 		Guid userId = User.GetIdOrThrow(); | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			if (patch.Id.HasValue && patch.Id != userId) | ||||||
|  | 				throw new ArgumentException("Can't edit your user id."); | ||||||
|  | 			if (patch.ContainsKey(nameof(Abstractions.Models.User.Password))) | ||||||
|  | 				throw new ArgumentException( | ||||||
|  | 					"Can't edit your password via a PATCH. Use /auth/password-reset" | ||||||
|  | 				); | ||||||
|  | 			return await users.Patch(userId, patch.Apply); | ||||||
|  | 		} | ||||||
|  | 		catch (ItemNotFoundException) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError("Invalid token")); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete account | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Delete the current account. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpDelete("me")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<User>> DeleteMe() | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			await users.Delete(User.GetIdOrThrow()); | ||||||
|  | 			return NoContent(); | ||||||
|  | 		} | ||||||
|  | 		catch (ItemNotFoundException) | ||||||
|  | 		{ | ||||||
|  | 			return Forbid(new RequestError("Invalid token")); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get profile picture | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Get your profile picture | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpGet("me/logo")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult> GetProfilePicture() | ||||||
|  | 	{ | ||||||
|  | 		Stream img = await thumbs.GetUserImage(User.GetIdOrThrow()); | ||||||
|  | 		// Allow clients to cache the image for 6 month. | ||||||
|  | 		Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); | ||||||
|  | 		return File(img, "image/webp", true); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Set profile picture | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Set your profile picture | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpPost("me/logo")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult> SetProfilePicture(IFormFile picture) | ||||||
|  | 	{ | ||||||
|  | 		if (picture == null || picture.Length == 0) | ||||||
|  | 			return BadRequest(); | ||||||
|  | 		await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); | ||||||
|  | 		return NoContent(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete profile picture | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Delete your profile picture | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <response code="401">The user is not authenticated.</response> | ||||||
|  | 	/// <response code="403">The given access token is invalid.</response> | ||||||
|  | 	[HttpDelete("me/logo")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult> DeleteProfilePicture() | ||||||
|  | 	{ | ||||||
|  | 		await thumbs.SetUserImage(User.GetIdOrThrow(), null); | ||||||
|  | 		return NoContent(); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,23 +20,22 @@ using Kyoo.Abstractions.Models.Utils; | |||||||
| using Microsoft.AspNetCore.Http; | using Microsoft.AspNetCore.Http; | ||||||
| using Microsoft.AspNetCore.Routing; | using Microsoft.AspNetCore.Routing; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The route constraint that goes with the <see cref="Identifier"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class IdentifierRouteConstraint : IRouteConstraint | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <inheritdoc /> | ||||||
| 	/// The route constraint that goes with the <see cref="Identifier"/>. | 	public bool Match( | ||||||
| 	/// </summary> | 		HttpContext? httpContext, | ||||||
| 	public class IdentifierRouteConstraint : IRouteConstraint | 		IRouter? route, | ||||||
|  | 		string routeKey, | ||||||
|  | 		RouteValueDictionary values, | ||||||
|  | 		RouteDirection routeDirection | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		return values.ContainsKey(routeKey); | ||||||
| 		public bool Match( |  | ||||||
| 			HttpContext? httpContext, |  | ||||||
| 			IRouter? route, |  | ||||||
| 			string routeKey, |  | ||||||
| 			RouteValueDictionary values, |  | ||||||
| 			RouteDirection routeDirection |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return values.ContainsKey(routeKey); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -20,87 +20,86 @@ using System.Linq; | |||||||
| using Kyoo.Abstractions.Controllers; | using Kyoo.Abstractions.Controllers; | ||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An class to interact with the database. Every repository is mapped through here. | ||||||
|  | /// </summary> | ||||||
|  | public class LibraryManager : ILibraryManager | ||||||
| { | { | ||||||
| 	/// <summary> | 	private readonly IBaseRepository[] _repositories; | ||||||
| 	/// An class to interact with the database. Every repository is mapped through here. | 
 | ||||||
| 	/// </summary> | 	public LibraryManager( | ||||||
| 	public class LibraryManager : ILibraryManager | 		IRepository<ILibraryItem> libraryItemRepository, | ||||||
|  | 		IRepository<INews> newsRepository, | ||||||
|  | 		IWatchStatusRepository watchStatusRepository, | ||||||
|  | 		IRepository<Collection> collectionRepository, | ||||||
|  | 		IRepository<Movie> movieRepository, | ||||||
|  | 		IRepository<Show> showRepository, | ||||||
|  | 		IRepository<Season> seasonRepository, | ||||||
|  | 		IRepository<Episode> episodeRepository, | ||||||
|  | 		IRepository<Studio> studioRepository, | ||||||
|  | 		IRepository<User> userRepository | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		private readonly IBaseRepository[] _repositories; | 		LibraryItems = libraryItemRepository; | ||||||
|  | 		News = newsRepository; | ||||||
|  | 		WatchStatus = watchStatusRepository; | ||||||
|  | 		Collections = collectionRepository; | ||||||
|  | 		Movies = movieRepository; | ||||||
|  | 		Shows = showRepository; | ||||||
|  | 		Seasons = seasonRepository; | ||||||
|  | 		Episodes = episodeRepository; | ||||||
|  | 		Studios = studioRepository; | ||||||
|  | 		Users = userRepository; | ||||||
| 
 | 
 | ||||||
| 		public LibraryManager( | 		_repositories = new IBaseRepository[] | ||||||
| 			IRepository<ILibraryItem> libraryItemRepository, |  | ||||||
| 			IRepository<INews> newsRepository, |  | ||||||
| 			IWatchStatusRepository watchStatusRepository, |  | ||||||
| 			IRepository<Collection> collectionRepository, |  | ||||||
| 			IRepository<Movie> movieRepository, |  | ||||||
| 			IRepository<Show> showRepository, |  | ||||||
| 			IRepository<Season> seasonRepository, |  | ||||||
| 			IRepository<Episode> episodeRepository, |  | ||||||
| 			IRepository<Studio> studioRepository, |  | ||||||
| 			IRepository<User> userRepository |  | ||||||
| 		) |  | ||||||
| 		{ | 		{ | ||||||
| 			LibraryItems = libraryItemRepository; | 			LibraryItems, | ||||||
| 			News = newsRepository; | 			News, | ||||||
| 			WatchStatus = watchStatusRepository; | 			Collections, | ||||||
| 			Collections = collectionRepository; | 			Movies, | ||||||
| 			Movies = movieRepository; | 			Shows, | ||||||
| 			Shows = showRepository; | 			Seasons, | ||||||
| 			Seasons = seasonRepository; | 			Episodes, | ||||||
| 			Episodes = episodeRepository; | 			Studios, | ||||||
| 			Studios = studioRepository; | 			Users | ||||||
| 			Users = userRepository; | 		}; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 			_repositories = new IBaseRepository[] | 	/// <inheritdoc /> | ||||||
| 			{ | 	public IRepository<ILibraryItem> LibraryItems { get; } | ||||||
| 				LibraryItems, |  | ||||||
| 				News, |  | ||||||
| 				Collections, |  | ||||||
| 				Movies, |  | ||||||
| 				Shows, |  | ||||||
| 				Seasons, |  | ||||||
| 				Episodes, |  | ||||||
| 				Studios, |  | ||||||
| 				Users |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<ILibraryItem> LibraryItems { get; } | 	public IRepository<INews> News { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<INews> News { get; } | 	public IWatchStatusRepository WatchStatus { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IWatchStatusRepository WatchStatus { get; } | 	public IRepository<Collection> Collections { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Collection> Collections { get; } | 	public IRepository<Movie> Movies { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Movie> Movies { get; } | 	public IRepository<Show> Shows { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Show> Shows { get; } | 	public IRepository<Season> Seasons { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Season> Seasons { get; } | 	public IRepository<Episode> Episodes { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Episode> Episodes { get; } | 	public IRepository<Studio> Studios { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public IRepository<Studio> Studios { get; } | 	public IRepository<User> Users { get; } | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	public IRepository<T> Repository<T>() | ||||||
| 		public IRepository<User> Users { get; } | 		where T : IResource, IQuery | ||||||
| 
 | 	{ | ||||||
| 		public IRepository<T> Repository<T>() | 		return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T)); | ||||||
| 			where T : IResource, IQuery |  | ||||||
| 		{ |  | ||||||
| 			return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T)); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,78 +26,77 @@ using Kyoo.Abstractions.Models.Utils; | |||||||
| using Kyoo.Postgresql; | using Kyoo.Postgresql; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle collections | ||||||
|  | /// </summary> | ||||||
|  | public class CollectionRepository : LocalRepository<Collection> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle collections | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class CollectionRepository : LocalRepository<Collection> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="CollectionRepository"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="database">The database handle to use</param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to store images.</param> | ||||||
|  | 	public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs) | ||||||
|  | 		: base(database, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_database = database; | ||||||
| 		/// The database handle | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Create a new <see cref="CollectionRepository"/>. | 	public override async Task<ICollection<Collection>> Search( | ||||||
| 		/// </summary> | 		string query, | ||||||
| 		/// <param name="database">The database handle to use</param> | 		Include<Collection>? include = default | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to store images.</param> | 	) | ||||||
| 		public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs) | 	{ | ||||||
| 			: base(database, thumbs) | 		return await AddIncludes(_database.Collections, include) | ||||||
| 		{ | 			.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) | ||||||
| 			_database = database; | 			.Take(20) | ||||||
| 		} | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public override async Task<ICollection<Collection>> Search( | 	public override async Task<Collection> Create(Collection obj) | ||||||
| 			string query, | 	{ | ||||||
| 			Include<Collection>? include = default | 		await base.Create(obj); | ||||||
| 		) | 		_database.Entry(obj).State = EntityState.Added; | ||||||
| 		{ | 		await _database.SaveChangesAsync(() => Get(obj.Slug)); | ||||||
| 			return await AddIncludes(_database.Collections, include) | 		await IRepository<Collection>.OnResourceCreated(obj); | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) | 		return obj; | ||||||
| 				.Take(20) | 	} | ||||||
| 				.ToListAsync(); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public override async Task<Collection> Create(Collection obj) | 	protected override async Task Validate(Collection resource) | ||||||
| 		{ | 	{ | ||||||
| 			await base.Create(obj); | 		await base.Validate(resource); | ||||||
| 			_database.Entry(obj).State = EntityState.Added; |  | ||||||
| 			await _database.SaveChangesAsync(() => Get(obj.Slug)); |  | ||||||
| 			await IRepository<Collection>.OnResourceCreated(obj); |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		if (string.IsNullOrEmpty(resource.Name)) | ||||||
| 		protected override async Task Validate(Collection resource) | 			throw new ArgumentException("The collection's name must be set and not empty"); | ||||||
| 		{ | 	} | ||||||
| 			await base.Validate(resource); |  | ||||||
| 
 | 
 | ||||||
| 			if (string.IsNullOrEmpty(resource.Name)) | 	public async Task AddMovie(Guid id, Guid movieId) | ||||||
| 				throw new ArgumentException("The collection's name must be set and not empty"); | 	{ | ||||||
| 		} | 		_database.AddLinks<Collection, Movie>(id, movieId); | ||||||
|  | 		await _database.SaveChangesAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		public async Task AddMovie(Guid id, Guid movieId) | 	public async Task AddShow(Guid id, Guid showId) | ||||||
| 		{ | 	{ | ||||||
| 			_database.AddLinks<Collection, Movie>(id, movieId); | 		_database.AddLinks<Collection, Show>(id, showId); | ||||||
| 			await _database.SaveChangesAsync(); | 		await _database.SaveChangesAsync(); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		public async Task AddShow(Guid id, Guid showId) | 	/// <inheritdoc /> | ||||||
| 		{ | 	public override async Task Delete(Collection obj) | ||||||
| 			_database.AddLinks<Collection, Show>(id, showId); | 	{ | ||||||
| 			await _database.SaveChangesAsync(); | 		_database.Entry(obj).State = EntityState.Deleted; | ||||||
| 		} | 		await _database.SaveChangesAsync(); | ||||||
| 
 | 		await base.Delete(obj); | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task Delete(Collection obj) |  | ||||||
| 		{ |  | ||||||
| 			_database.Entry(obj).State = EntityState.Deleted; |  | ||||||
| 			await _database.SaveChangesAsync(); |  | ||||||
| 			await base.Delete(obj); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -27,130 +27,128 @@ using Kyoo.Postgresql; | |||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle episodes. | ||||||
|  | /// </summary> | ||||||
|  | public class EpisodeRepository : LocalRepository<Episode> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle episodes. | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class EpisodeRepository : LocalRepository<Episode> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	private readonly IRepository<Show> _shows; | ||||||
|  | 
 | ||||||
|  | 	static EpisodeRepository() | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		// Edit episode slugs when the show's slug changes. | ||||||
| 		/// The database handle | 		IRepository<Show>.OnEdited += async (show) => | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 |  | ||||||
| 		private readonly IRepository<Show> _shows; |  | ||||||
| 
 |  | ||||||
| 		static EpisodeRepository() |  | ||||||
| 		{ | 		{ | ||||||
| 			// Edit episode slugs when the show's slug changes. | 			await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); | ||||||
| 			IRepository<Show>.OnEdited += async (show) => | 			DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||||||
| 			{ | 			List<Episode> episodes = await database | ||||||
| 				await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); | 				.Episodes.AsTracking() | ||||||
| 				DatabaseContext database = | 				.Where(x => x.ShowId == show.Id) | ||||||
| 					scope.ServiceProvider.GetRequiredService<DatabaseContext>(); |  | ||||||
| 				List<Episode> episodes = await database |  | ||||||
| 					.Episodes.AsTracking() |  | ||||||
| 					.Where(x => x.ShowId == show.Id) |  | ||||||
| 					.ToListAsync(); |  | ||||||
| 				foreach (Episode ep in episodes) |  | ||||||
| 				{ |  | ||||||
| 					ep.ShowSlug = show.Slug; |  | ||||||
| 					await database.SaveChangesAsync(); |  | ||||||
| 					await IRepository<Episode>.OnResourceEdited(ep); |  | ||||||
| 				} |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="EpisodeRepository"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="database">The database handle to use.</param> |  | ||||||
| 		/// <param name="shows">A show repository</param> |  | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to store images.</param> |  | ||||||
| 		public EpisodeRepository( |  | ||||||
| 			DatabaseContext database, |  | ||||||
| 			IRepository<Show> shows, |  | ||||||
| 			IThumbnailsManager thumbs |  | ||||||
| 		) |  | ||||||
| 			: base(database, thumbs) |  | ||||||
| 		{ |  | ||||||
| 			_database = database; |  | ||||||
| 			_shows = shows; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task<ICollection<Episode>> Search( |  | ||||||
| 			string query, |  | ||||||
| 			Include<Episode>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return await AddIncludes(_database.Episodes, include) |  | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) |  | ||||||
| 				.Take(20) |  | ||||||
| 				.ToListAsync(); | 				.ToListAsync(); | ||||||
| 		} | 			foreach (Episode ep in episodes) | ||||||
|  | 			{ | ||||||
|  | 				ep.ShowSlug = show.Slug; | ||||||
|  | 				await database.SaveChangesAsync(); | ||||||
|  | 				await IRepository<Episode>.OnResourceEdited(ep); | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		protected override Task<Episode?> GetDuplicated(Episode item) | 	/// <summary> | ||||||
| 		{ | 	/// Create a new <see cref="EpisodeRepository"/>. | ||||||
| 			if (item is { SeasonNumber: not null, EpisodeNumber: not null }) | 	/// </summary> | ||||||
| 				return _database.Episodes.FirstOrDefaultAsync(x => | 	/// <param name="database">The database handle to use.</param> | ||||||
| 					x.ShowId == item.ShowId | 	/// <param name="shows">A show repository</param> | ||||||
| 					&& x.SeasonNumber == item.SeasonNumber | 	/// <param name="thumbs">The thumbnail manager used to store images.</param> | ||||||
| 					&& x.EpisodeNumber == item.EpisodeNumber | 	public EpisodeRepository( | ||||||
| 				); | 		DatabaseContext database, | ||||||
|  | 		IRepository<Show> shows, | ||||||
|  | 		IThumbnailsManager thumbs | ||||||
|  | 	) | ||||||
|  | 		: base(database, thumbs) | ||||||
|  | 	{ | ||||||
|  | 		_database = database; | ||||||
|  | 		_shows = shows; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public override async Task<ICollection<Episode>> Search( | ||||||
|  | 		string query, | ||||||
|  | 		Include<Episode>? include = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return await AddIncludes(_database.Episodes, include) | ||||||
|  | 			.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) | ||||||
|  | 			.Take(20) | ||||||
|  | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	protected override Task<Episode?> GetDuplicated(Episode item) | ||||||
|  | 	{ | ||||||
|  | 		if (item is { SeasonNumber: not null, EpisodeNumber: not null }) | ||||||
| 			return _database.Episodes.FirstOrDefaultAsync(x => | 			return _database.Episodes.FirstOrDefaultAsync(x => | ||||||
| 				x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber | 				x.ShowId == item.ShowId | ||||||
|  | 				&& x.SeasonNumber == item.SeasonNumber | ||||||
|  | 				&& x.EpisodeNumber == item.EpisodeNumber | ||||||
|  | 			); | ||||||
|  | 		return _database.Episodes.FirstOrDefaultAsync(x => | ||||||
|  | 			x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public override async Task<Episode> Create(Episode obj) | ||||||
|  | 	{ | ||||||
|  | 		obj.ShowSlug = | ||||||
|  | 			obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug; | ||||||
|  | 		await base.Create(obj); | ||||||
|  | 		_database.Entry(obj).State = EntityState.Added; | ||||||
|  | 		await _database.SaveChangesAsync(() => GetDuplicated(obj)); | ||||||
|  | 		await IRepository<Episode>.OnResourceCreated(obj); | ||||||
|  | 		return obj; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override async Task Validate(Episode resource) | ||||||
|  | 	{ | ||||||
|  | 		await base.Validate(resource); | ||||||
|  | 		if (resource.ShowId == Guid.Empty) | ||||||
|  | 		{ | ||||||
|  | 			if (resource.Show == null) | ||||||
|  | 			{ | ||||||
|  | 				throw new ArgumentException( | ||||||
|  | 					$"Can't store an episode not related " | ||||||
|  | 						+ $"to any show (showID: {resource.ShowId})." | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
|  | 			resource.ShowId = resource.Show.Id; | ||||||
|  | 		} | ||||||
|  | 		if (resource.SeasonId == null && resource.SeasonNumber != null) | ||||||
|  | 		{ | ||||||
|  | 			resource.Season = await _database.Seasons.FirstOrDefaultAsync(x => | ||||||
|  | 				x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber | ||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public override async Task<Episode> Create(Episode obj) | 	public override async Task Delete(Episode obj) | ||||||
| 		{ | 	{ | ||||||
| 			obj.ShowSlug = | 		int epCount = await _database | ||||||
| 				obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug; | 			.Episodes.Where(x => x.ShowId == obj.ShowId) | ||||||
| 			await base.Create(obj); | 			.Take(2) | ||||||
| 			_database.Entry(obj).State = EntityState.Added; | 			.CountAsync(); | ||||||
| 			await _database.SaveChangesAsync(() => GetDuplicated(obj)); | 		_database.Entry(obj).State = EntityState.Deleted; | ||||||
| 			await IRepository<Episode>.OnResourceCreated(obj); | 		await _database.SaveChangesAsync(); | ||||||
| 			return obj; | 		await base.Delete(obj); | ||||||
| 		} | 		if (epCount == 1) | ||||||
| 
 | 			await _shows.Delete(obj.ShowId); | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override async Task Validate(Episode resource) |  | ||||||
| 		{ |  | ||||||
| 			await base.Validate(resource); |  | ||||||
| 			if (resource.ShowId == Guid.Empty) |  | ||||||
| 			{ |  | ||||||
| 				if (resource.Show == null) |  | ||||||
| 				{ |  | ||||||
| 					throw new ArgumentException( |  | ||||||
| 						$"Can't store an episode not related " |  | ||||||
| 							+ $"to any show (showID: {resource.ShowId})." |  | ||||||
| 					); |  | ||||||
| 				} |  | ||||||
| 				resource.ShowId = resource.Show.Id; |  | ||||||
| 			} |  | ||||||
| 			if (resource.SeasonId == null && resource.SeasonNumber != null) |  | ||||||
| 			{ |  | ||||||
| 				resource.Season = await _database.Seasons.FirstOrDefaultAsync(x => |  | ||||||
| 					x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task Delete(Episode obj) |  | ||||||
| 		{ |  | ||||||
| 			int epCount = await _database |  | ||||||
| 				.Episodes.Where(x => x.ShowId == obj.ShowId) |  | ||||||
| 				.Take(2) |  | ||||||
| 				.CountAsync(); |  | ||||||
| 			_database.Entry(obj).State = EntityState.Deleted; |  | ||||||
| 			await _database.SaveChangesAsync(); |  | ||||||
| 			await base.Delete(obj); |  | ||||||
| 			if (epCount == 1) |  | ||||||
| 				await _shows.Delete(obj.ShowId); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,103 +25,102 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| using Kyoo.Abstractions.Models.Utils; | using Kyoo.Abstractions.Models.Utils; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle library items. | ||||||
|  | /// </summary> | ||||||
|  | public class LibraryItemRepository : DapperRepository<ILibraryItem> | ||||||
| { | { | ||||||
| 	/// <summary> | 	// language=PostgreSQL | ||||||
| 	/// A local repository to handle library items. | 	protected override FormattableString Sql => | ||||||
| 	/// </summary> | 		$"""
 | ||||||
| 	public class LibraryItemRepository : DapperRepository<ILibraryItem> | 			select | ||||||
|  | 				s.*, -- Show as s | ||||||
|  | 				m.*, | ||||||
|  | 				c.* | ||||||
|  | 				/* includes */ | ||||||
|  | 			from | ||||||
|  | 				shows as s | ||||||
|  | 				full outer join ( | ||||||
|  | 				select | ||||||
|  | 					* -- Movie | ||||||
|  | 				from | ||||||
|  | 					movies) as m on false | ||||||
|  | 				full outer join( | ||||||
|  | 					select | ||||||
|  | 						c.* -- Collection as c | ||||||
|  | 					from | ||||||
|  | 						collections as c | ||||||
|  | 					left join link_collection_show as ls on ls.collection_id = c.id | ||||||
|  | 					left join link_collection_movie as lm on lm.collection_id = c.id | ||||||
|  | 					group by c.id | ||||||
|  | 					having count(*) > 1 | ||||||
|  | 				) as c on false | ||||||
|  | 			""";
 | ||||||
|  | 
 | ||||||
|  | 	protected override Dictionary<string, Type> Config => | ||||||
|  | 		new() | ||||||
|  | 		{ | ||||||
|  | 			{ "s", typeof(Show) }, | ||||||
|  | 			{ "m", typeof(Movie) }, | ||||||
|  | 			{ "c", typeof(Collection) } | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 	protected override ILibraryItem Mapper(List<object?> items) | ||||||
|  | 	{ | ||||||
|  | 		if (items[0] is Show show && show.Id != Guid.Empty) | ||||||
|  | 			return show; | ||||||
|  | 		if (items[1] is Movie movie && movie.Id != Guid.Empty) | ||||||
|  | 			return movie; | ||||||
|  | 		if (items[2] is Collection collection && collection.Id != Guid.Empty) | ||||||
|  | 			return collection; | ||||||
|  | 		throw new InvalidDataException(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public LibraryItemRepository(DbConnection database, SqlVariableContext context) | ||||||
|  | 		: base(database, context) { } | ||||||
|  | 
 | ||||||
|  | 	public async Task<ICollection<ILibraryItem>> GetAllOfCollection( | ||||||
|  | 		Guid collectionId, | ||||||
|  | 		Filter<ILibraryItem>? filter = default, | ||||||
|  | 		Sort<ILibraryItem>? sort = default, | ||||||
|  | 		Include<ILibraryItem>? include = default, | ||||||
|  | 		Pagination? limit = default | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		// language=PostgreSQL | 		// language=PostgreSQL | ||||||
| 		protected override FormattableString Sql => | 		FormattableString sql = $"""
 | ||||||
| 			$"""
 | 			select | ||||||
|  | 				s.*, | ||||||
|  | 				m.* | ||||||
|  | 				/* includes */ | ||||||
|  | 			from ( | ||||||
| 				select | 				select | ||||||
| 					s.*, -- Show as s | 					* -- Show | ||||||
| 					m.*, |  | ||||||
| 					c.* |  | ||||||
| 					/* includes */ |  | ||||||
| 				from | 				from | ||||||
| 					shows as s | 					shows | ||||||
| 					full outer join ( | 				inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId} | ||||||
| 					select | 			) as s | ||||||
| 						* -- Movie | 			full outer join ( | ||||||
| 					from |  | ||||||
| 						movies) as m on false |  | ||||||
| 					full outer join( |  | ||||||
| 						select |  | ||||||
| 							c.* -- Collection as c |  | ||||||
| 						from |  | ||||||
| 							collections as c |  | ||||||
| 						left join link_collection_show as ls on ls.collection_id = c.id |  | ||||||
| 						left join link_collection_movie as lm on lm.collection_id = c.id |  | ||||||
| 						group by c.id |  | ||||||
| 						having count(*) > 1 |  | ||||||
| 					) as c on false |  | ||||||
| 				""";
 |  | ||||||
| 
 |  | ||||||
| 		protected override Dictionary<string, Type> Config => |  | ||||||
| 			new() |  | ||||||
| 			{ |  | ||||||
| 				{ "s", typeof(Show) }, |  | ||||||
| 				{ "m", typeof(Movie) }, |  | ||||||
| 				{ "c", typeof(Collection) } |  | ||||||
| 			}; |  | ||||||
| 
 |  | ||||||
| 		protected override ILibraryItem Mapper(List<object?> items) |  | ||||||
| 		{ |  | ||||||
| 			if (items[0] is Show show && show.Id != Guid.Empty) |  | ||||||
| 				return show; |  | ||||||
| 			if (items[1] is Movie movie && movie.Id != Guid.Empty) |  | ||||||
| 				return movie; |  | ||||||
| 			if (items[2] is Collection collection && collection.Id != Guid.Empty) |  | ||||||
| 				return collection; |  | ||||||
| 			throw new InvalidDataException(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public LibraryItemRepository(DbConnection database, SqlVariableContext context) |  | ||||||
| 			: base(database, context) { } |  | ||||||
| 
 |  | ||||||
| 		public async Task<ICollection<ILibraryItem>> GetAllOfCollection( |  | ||||||
| 			Guid collectionId, |  | ||||||
| 			Filter<ILibraryItem>? filter = default, |  | ||||||
| 			Sort<ILibraryItem>? sort = default, |  | ||||||
| 			Include<ILibraryItem>? include = default, |  | ||||||
| 			Pagination? limit = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			// language=PostgreSQL |  | ||||||
| 			FormattableString sql = $"""
 |  | ||||||
| 				select | 				select | ||||||
| 					s.*, | 					* -- Movie | ||||||
| 					m.* | 				from | ||||||
| 					/* includes */ | 					movies | ||||||
| 				from ( | 				inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId} | ||||||
| 					select | 			) as m on false | ||||||
| 						* -- Show | 			""";
 | ||||||
| 					from |  | ||||||
| 						shows |  | ||||||
| 					inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId} |  | ||||||
| 				) as s |  | ||||||
| 				full outer join ( |  | ||||||
| 					select |  | ||||||
| 						* -- Movie |  | ||||||
| 					from |  | ||||||
| 						movies |  | ||||||
| 					inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId} |  | ||||||
| 				) as m on false |  | ||||||
| 				""";
 |  | ||||||
| 
 | 
 | ||||||
| 			return await Database.Query<ILibraryItem>( | 		return await Database.Query<ILibraryItem>( | ||||||
| 				sql, | 			sql, | ||||||
| 				new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, | 			new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, | ||||||
| 				Mapper, | 			Mapper, | ||||||
| 				(id) => Get(id), | 			(id) => Get(id), | ||||||
| 				Context, | 			Context, | ||||||
| 				include, | 			include, | ||||||
| 				filter, | 			filter, | ||||||
| 				sort ?? new Sort<ILibraryItem>.Default(), | 			sort ?? new Sort<ILibraryItem>.Default(), | ||||||
| 				limit ?? new() | 			limit ?? new() | ||||||
| 			); | 		); | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -32,468 +32,456 @@ using Kyoo.Postgresql; | |||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A base class to create repositories using Entity Framework. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The type of this repository</typeparam> | ||||||
|  | public abstract class LocalRepository<T> : IRepository<T> | ||||||
|  | 	where T : class, IResource, IQuery | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A base class to create repositories using Entity Framework. | 	/// The Entity Framework's Database handle. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">The type of this repository</typeparam> | 	protected DbContext Database { get; } | ||||||
| 	public abstract class LocalRepository<T> : IRepository<T> | 
 | ||||||
| 		where T : class, IResource, IQuery | 	/// <summary> | ||||||
|  | 	/// The thumbnail manager used to store images. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly IThumbnailsManager _thumbs; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to store images.</param> | ||||||
|  | 	protected LocalRepository(DbContext database, IThumbnailsManager thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Database = database; | ||||||
| 		/// The Entity Framework's Database handle. | 		_thumbs = thumbs; | ||||||
| 		/// </summary> | 	} | ||||||
| 		protected DbContext Database { get; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc/> | ||||||
| 		/// The thumbnail manager used to store images. | 	public Type RepositoryType => typeof(T); | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly IThumbnailsManager _thumbs; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle. | 	/// Sort the given query. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param> | 	/// <param name="query">The query to sort.</param> | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to store images.</param> | 	/// <param name="sortBy">How to sort the query.</param> | ||||||
| 		protected LocalRepository(DbContext database, IThumbnailsManager thumbs) | 	/// <returns>The newly sorted query.</returns> | ||||||
|  | 	protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy) | ||||||
|  | 	{ | ||||||
|  | 		sortBy ??= new Sort<T>.Default(); | ||||||
|  | 
 | ||||||
|  | 		IOrderedQueryable<T> _SortBy( | ||||||
|  | 			IQueryable<T> qr, | ||||||
|  | 			Expression<Func<T, object>> sort, | ||||||
|  | 			bool desc, | ||||||
|  | 			bool then | ||||||
|  | 		) | ||||||
| 		{ | 		{ | ||||||
| 			Database = database; | 			if (then && qr is IOrderedQueryable<T> qro) | ||||||
| 			_thumbs = thumbs; | 			{ | ||||||
|  | 				return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); | ||||||
|  | 			} | ||||||
|  | 			return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 		IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then) | ||||||
| 		public Type RepositoryType => typeof(T); |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Sort the given query. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="query">The query to sort.</param> |  | ||||||
| 		/// <param name="sortBy">How to sort the query.</param> |  | ||||||
| 		/// <returns>The newly sorted query.</returns> |  | ||||||
| 		protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy) |  | ||||||
| 		{ | 		{ | ||||||
| 			sortBy ??= new Sort<T>.Default(); | 			switch (sortBy) | ||||||
| 
 |  | ||||||
| 			IOrderedQueryable<T> _SortBy( |  | ||||||
| 				IQueryable<T> qr, |  | ||||||
| 				Expression<Func<T, object>> sort, |  | ||||||
| 				bool desc, |  | ||||||
| 				bool then |  | ||||||
| 			) |  | ||||||
| 			{ | 			{ | ||||||
| 				if (then && qr is IOrderedQueryable<T> qro) | 				case Sort<T>.Default(var value): | ||||||
| 				{ | 					return _Sort(query, value, then); | ||||||
| 					return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); | 				case Sort<T>.By(var key, var desc): | ||||||
| 				} | 					return _SortBy(query, x => EF.Property<T>(x, key), desc, then); | ||||||
| 				return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); | 				case Sort<T>.Random(var seed): | ||||||
|  | 					// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function | ||||||
|  | 					return _SortBy( | ||||||
|  | 						query, | ||||||
|  | 						x => DatabaseContext.MD5(seed + x.Id.ToString()), | ||||||
|  | 						false, | ||||||
|  | 						then | ||||||
|  | 					); | ||||||
|  | 				case Sort<T>.Conglomerate(var sorts): | ||||||
|  | 					IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false); | ||||||
|  | 					foreach (Sort<T> sort in sorts.Skip(1)) | ||||||
|  | 						nQuery = _Sort(nQuery, sort, true); | ||||||
|  | 					return nQuery; | ||||||
|  | 				default: | ||||||
|  | 					// The language should not require me to do this... | ||||||
|  | 					throw new SwitchExpressionException(); | ||||||
| 			} | 			} | ||||||
| 
 |  | ||||||
| 			IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then) |  | ||||||
| 			{ |  | ||||||
| 				switch (sortBy) |  | ||||||
| 				{ |  | ||||||
| 					case Sort<T>.Default(var value): |  | ||||||
| 						return _Sort(query, value, then); |  | ||||||
| 					case Sort<T>.By(var key, var desc): |  | ||||||
| 						return _SortBy(query, x => EF.Property<T>(x, key), desc, then); |  | ||||||
| 					case Sort<T>.Random(var seed): |  | ||||||
| 						// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function |  | ||||||
| 						return _SortBy( |  | ||||||
| 							query, |  | ||||||
| 							x => DatabaseContext.MD5(seed + x.Id.ToString()), |  | ||||||
| 							false, |  | ||||||
| 							then |  | ||||||
| 						); |  | ||||||
| 					case Sort<T>.Conglomerate(var sorts): |  | ||||||
| 						IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false); |  | ||||||
| 						foreach (Sort<T> sort in sorts.Skip(1)) |  | ||||||
| 							nQuery = _Sort(nQuery, sort, true); |  | ||||||
| 						return nQuery; |  | ||||||
| 					default: |  | ||||||
| 						// The language should not require me to do this... |  | ||||||
| 						throw new SwitchExpressionException(); |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 			return _Sort(query, sortBy, false).ThenBy(x => x.Id); |  | ||||||
| 		} | 		} | ||||||
|  | 		return _Sort(query, sortBy, false).ThenBy(x => x.Id); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include) | 	protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include) | ||||||
| 		{ | 	{ | ||||||
| 			if (include == null) | 		if (include == null) | ||||||
| 				return query; |  | ||||||
| 			foreach (string field in include.Fields) |  | ||||||
| 				query = query.Include(field); |  | ||||||
| 			return query; | 			return query; | ||||||
| 		} | 		foreach (string field in include.Fields) | ||||||
|  | 			query = query.Include(field); | ||||||
|  | 		return query; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get a resource from it's ID and make the <see cref="Database"/> instance track it. | 	/// Get a resource from it's ID and make the <see cref="Database"/> instance track it. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="id">The ID of the resource</param> | 	/// <param name="id">The ID of the resource</param> | ||||||
| 		/// <exception cref="ItemNotFoundException">If the item is not found</exception> | 	/// <exception cref="ItemNotFoundException">If the item is not found</exception> | ||||||
| 		/// <returns>The tracked resource with the given ID</returns> | 	/// <returns>The tracked resource with the given ID</returns> | ||||||
| 		protected virtual async Task<T> GetWithTracking(Guid id) | 	protected virtual async Task<T> GetWithTracking(Guid id) | ||||||
|  | 	{ | ||||||
|  | 		T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id); | ||||||
|  | 		if (ret == null) | ||||||
|  | 			throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); | ||||||
|  | 		return ret; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task<T> Get(Guid id, Include<T>? include = default) | ||||||
|  | 	{ | ||||||
|  | 		T? ret = await GetOrDefault(id, include); | ||||||
|  | 		if (ret == null) | ||||||
|  | 			throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); | ||||||
|  | 		return ret; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task<T> Get(string slug, Include<T>? include = default) | ||||||
|  | 	{ | ||||||
|  | 		T? ret = await GetOrDefault(slug, include); | ||||||
|  | 		if (ret == null) | ||||||
|  | 			throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}"); | ||||||
|  | 		return ret; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task<T> Get( | ||||||
|  | 		Filter<T> filter, | ||||||
|  | 		Include<T>? include = default, | ||||||
|  | 		Sort<T>? sortBy = default, | ||||||
|  | 		bool reverse = false, | ||||||
|  | 		Guid? afterId = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); | ||||||
|  | 		if (ret == null) | ||||||
|  | 			throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); | ||||||
|  | 		return ret; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	protected virtual Task<T?> GetDuplicated(T item) | ||||||
|  | 	{ | ||||||
|  | 		return GetOrDefault(item.Slug); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default) | ||||||
|  | 	{ | ||||||
|  | 		return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default) | ||||||
|  | 	{ | ||||||
|  | 		if (slug == "random") | ||||||
| 		{ | 		{ | ||||||
| 			T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id); | 			return AddIncludes(Database.Set<T>(), include) | ||||||
| 			if (ret == null) | 				.OrderBy(x => EF.Functions.Random()) | ||||||
| 				throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); | 				.FirstOrDefaultAsync(); | ||||||
| 			return ret; |  | ||||||
| 		} | 		} | ||||||
|  | 		return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <inheritdoc /> | ||||||
| 		public virtual async Task<T> Get(Guid id, Include<T>? include = default) | 	public virtual async Task<T?> GetOrDefault( | ||||||
| 		{ | 		Filter<T>? filter, | ||||||
| 			T? ret = await GetOrDefault(id, include); | 		Include<T>? include = default, | ||||||
| 			if (ret == null) | 		Sort<T>? sortBy = default, | ||||||
| 				throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); | 		bool reverse = false, | ||||||
| 			return ret; | 		Guid? afterId = default | ||||||
| 		} | 	) | ||||||
|  | 	{ | ||||||
|  | 		IQueryable<T> query = await ApplyFilters( | ||||||
|  | 			Database.Set<T>(), | ||||||
|  | 			filter, | ||||||
|  | 			sortBy, | ||||||
|  | 			new Pagination(1, afterId, reverse), | ||||||
|  | 			include | ||||||
|  | 		); | ||||||
|  | 		return await query.FirstOrDefaultAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <inheritdoc/> | ||||||
| 		public virtual async Task<T> Get(string slug, Include<T>? include = default) | 	public virtual async Task<ICollection<T>> FromIds( | ||||||
| 		{ | 		IList<Guid> ids, | ||||||
| 			T? ret = await GetOrDefault(slug, include); | 		Include<T>? include = default | ||||||
| 			if (ret == null) | 	) | ||||||
| 				throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}"); | 	{ | ||||||
| 			return ret; | 		return ( | ||||||
| 		} | 			await AddIncludes(Database.Set<T>(), include) | ||||||
| 
 | 				.Where(x => ids.Contains(x.Id)) | ||||||
| 		/// <inheritdoc/> | 				.ToListAsync() | ||||||
| 		public virtual async Task<T> Get( |  | ||||||
| 			Filter<T> filter, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Sort<T>? sortBy = default, |  | ||||||
| 			bool reverse = false, |  | ||||||
| 			Guid? afterId = default |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			.OrderBy(x => ids.IndexOf(x.Id)) | ||||||
| 			T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); | 			.ToList(); | ||||||
| 			if (ret == null) | 	} | ||||||
| 				throw new ItemNotFoundException( |  | ||||||
| 					$"No {typeof(T).Name} found with the given predicate." |  | ||||||
| 				); |  | ||||||
| 			return ret; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		protected virtual Task<T?> GetDuplicated(T item) | 	/// <inheritdoc/> | ||||||
| 		{ | 	public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default); | ||||||
| 			return GetOrDefault(item.Slug); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc/> | ||||||
| 		public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default) | 	public virtual async Task<ICollection<T>> GetAll( | ||||||
| 		{ | 		Filter<T>? filter = null, | ||||||
| 			return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id); | 		Sort<T>? sort = default, | ||||||
| 		} | 		Include<T>? include = default, | ||||||
|  | 		Pagination? limit = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		IQueryable<T> query = await ApplyFilters(Database.Set<T>(), filter, sort, limit, include); | ||||||
|  | 		return await query.ToListAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <summary> | ||||||
| 		public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default) | 	/// Apply filters to a query to ease sort, pagination and where queries for resources of this repository | ||||||
| 		{ | 	/// </summary> | ||||||
| 			if (slug == "random") | 	/// <param name="query">The base query to filter.</param> | ||||||
| 			{ | 	/// <param name="filter">An expression to filter based on arbitrary conditions</param> | ||||||
| 				return AddIncludes(Database.Set<T>(), include) | 	/// <param name="sort">The sort settings (sort order and sort by)</param> | ||||||
| 					.OrderBy(x => EF.Functions.Random()) | 	/// <param name="limit">Pagination information (where to start and how many to get)</param> | ||||||
| 					.FirstOrDefaultAsync(); | 	/// <param name="include">Related fields to also load with this query.</param> | ||||||
| 			} | 	/// <returns>The filtered query</returns> | ||||||
| 			return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug); | 	protected async Task<IQueryable<T>> ApplyFilters( | ||||||
| 		} | 		IQueryable<T> query, | ||||||
|  | 		Filter<T>? filter = null, | ||||||
|  | 		Sort<T>? sort = default, | ||||||
|  | 		Pagination? limit = default, | ||||||
|  | 		Include<T>? include = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		query = AddIncludes(query, include); | ||||||
|  | 		query = Sort(query, sort); | ||||||
|  | 		limit ??= new(); | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		if (limit.AfterID != null) | ||||||
| 		public virtual async Task<T?> GetOrDefault( |  | ||||||
| 			Filter<T>? filter, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Sort<T>? sortBy = default, |  | ||||||
| 			bool reverse = false, |  | ||||||
| 			Guid? afterId = default |  | ||||||
| 		) |  | ||||||
| 		{ | 		{ | ||||||
| 			IQueryable<T> query = await ApplyFilters( | 			T reference = await Get(limit.AfterID.Value); | ||||||
| 				Database.Set<T>(), | 			Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate( | ||||||
| 				filter, |  | ||||||
| 				sortBy, |  | ||||||
| 				new Pagination(1, afterId, reverse), |  | ||||||
| 				include |  | ||||||
| 			); |  | ||||||
| 			return await query.FirstOrDefaultAsync(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual async Task<ICollection<T>> FromIds( |  | ||||||
| 			IList<Guid> ids, |  | ||||||
| 			Include<T>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return ( |  | ||||||
| 				await AddIncludes(Database.Set<T>(), include) |  | ||||||
| 					.Where(x => ids.Contains(x.Id)) |  | ||||||
| 					.ToListAsync() |  | ||||||
| 			) |  | ||||||
| 				.OrderBy(x => ids.IndexOf(x.Id)) |  | ||||||
| 				.ToList(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default); |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual async Task<ICollection<T>> GetAll( |  | ||||||
| 			Filter<T>? filter = null, |  | ||||||
| 			Sort<T>? sort = default, |  | ||||||
| 			Include<T>? include = default, |  | ||||||
| 			Pagination? limit = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			IQueryable<T> query = await ApplyFilters( |  | ||||||
| 				Database.Set<T>(), |  | ||||||
| 				filter, |  | ||||||
| 				sort, | 				sort, | ||||||
| 				limit, | 				reference, | ||||||
| 				include | 				!limit.Reverse | ||||||
| 			); | 			); | ||||||
| 			return await query.ToListAsync(); | 			filter = Filter.And(filter, keysetFilter); | ||||||
| 		} | 		} | ||||||
|  | 		if (filter != null) | ||||||
|  | 			query = query.Where(filter.ToEfLambda()); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if (limit.Reverse) | ||||||
| 		/// Apply filters to a query to ease sort, pagination and where queries for resources of this repository | 			query = query.Reverse(); | ||||||
| 		/// </summary> | 		if (limit.Limit > 0) | ||||||
| 		/// <param name="query">The base query to filter.</param> | 			query = query.Take(limit.Limit); | ||||||
| 		/// <param name="filter">An expression to filter based on arbitrary conditions</param> | 		if (limit.Reverse) | ||||||
| 		/// <param name="sort">The sort settings (sort order and sort by)</param> | 			query = query.Reverse(); | ||||||
| 		/// <param name="limit">Pagination information (where to start and how many to get)</param> |  | ||||||
| 		/// <param name="include">Related fields to also load with this query.</param> |  | ||||||
| 		/// <returns>The filtered query</returns> |  | ||||||
| 		protected async Task<IQueryable<T>> ApplyFilters( |  | ||||||
| 			IQueryable<T> query, |  | ||||||
| 			Filter<T>? filter = null, |  | ||||||
| 			Sort<T>? sort = default, |  | ||||||
| 			Pagination? limit = default, |  | ||||||
| 			Include<T>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			query = AddIncludes(query, include); |  | ||||||
| 			query = Sort(query, sort); |  | ||||||
| 			limit ??= new(); |  | ||||||
| 
 | 
 | ||||||
| 			if (limit.AfterID != null) | 		return query; | ||||||
| 			{ | 	} | ||||||
| 				T reference = await Get(limit.AfterID.Value); |  | ||||||
| 				Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate( |  | ||||||
| 					sort, |  | ||||||
| 					reference, |  | ||||||
| 					!limit.Reverse |  | ||||||
| 				); |  | ||||||
| 				filter = Filter.And(filter, keysetFilter); |  | ||||||
| 			} |  | ||||||
| 			if (filter != null) |  | ||||||
| 				query = query.Where(filter.ToEfLambda()); |  | ||||||
| 
 | 
 | ||||||
| 			if (limit.Reverse) | 	/// <inheritdoc/> | ||||||
| 				query = query.Reverse(); | 	public virtual Task<int> GetCount(Filter<T>? filter = null) | ||||||
| 			if (limit.Limit > 0) | 	{ | ||||||
| 				query = query.Take(limit.Limit); | 		IQueryable<T> query = Database.Set<T>(); | ||||||
| 			if (limit.Reverse) | 		if (filter != null) | ||||||
| 				query = query.Reverse(); | 			query = query.Where(filter.ToEfLambda()); | ||||||
|  | 		return query.CountAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 			return query; | 	/// <inheritdoc/> | ||||||
| 		} | 	public virtual async Task<T> Create(T obj) | ||||||
| 
 | 	{ | ||||||
| 		/// <inheritdoc/> | 		await Validate(obj); | ||||||
| 		public virtual Task<int> GetCount(Filter<T>? filter = null) | 		if (obj is IThumbnails thumbs) | ||||||
| 		{ |  | ||||||
| 			IQueryable<T> query = Database.Set<T>(); |  | ||||||
| 			if (filter != null) |  | ||||||
| 				query = query.Where(filter.ToEfLambda()); |  | ||||||
| 			return query.CountAsync(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual async Task<T> Create(T obj) |  | ||||||
| 		{ |  | ||||||
| 			await Validate(obj); |  | ||||||
| 			if (obj is IThumbnails thumbs) |  | ||||||
| 			{ |  | ||||||
| 				try |  | ||||||
| 				{ |  | ||||||
| 					await _thumbs.DownloadImages(thumbs); |  | ||||||
| 				} |  | ||||||
| 				catch (DuplicatedItemException e) when (e.Existing is null) |  | ||||||
| 				{ |  | ||||||
| 					throw new DuplicatedItemException(await GetDuplicated(obj)); |  | ||||||
| 				} |  | ||||||
| 				if (thumbs.Poster != null) |  | ||||||
| 					Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State = |  | ||||||
| 						EntityState.Added; |  | ||||||
| 				if (thumbs.Thumbnail != null) |  | ||||||
| 					Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State = |  | ||||||
| 						EntityState.Added; |  | ||||||
| 				if (thumbs.Logo != null) |  | ||||||
| 					Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State = |  | ||||||
| 						EntityState.Added; |  | ||||||
| 			} |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual async Task<T> CreateIfNotExists(T obj) |  | ||||||
| 		{ | 		{ | ||||||
| 			try | 			try | ||||||
| 			{ | 			{ | ||||||
| 				T? old = await GetOrDefault(obj.Slug); | 				await _thumbs.DownloadImages(thumbs); | ||||||
| 				if (old != null) |  | ||||||
| 					return old; |  | ||||||
| 
 |  | ||||||
| 				return await Create(obj); |  | ||||||
| 			} | 			} | ||||||
| 			catch (DuplicatedItemException) | 			catch (DuplicatedItemException e) when (e.Existing is null) | ||||||
| 			{ | 			{ | ||||||
| 				return await Get(obj.Slug); | 				throw new DuplicatedItemException(await GetDuplicated(obj)); | ||||||
| 			} | 			} | ||||||
|  | 			if (thumbs.Poster != null) | ||||||
|  | 				Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State = | ||||||
|  | 					EntityState.Added; | ||||||
|  | 			if (thumbs.Thumbnail != null) | ||||||
|  | 				Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State = | ||||||
|  | 					EntityState.Added; | ||||||
|  | 			if (thumbs.Logo != null) | ||||||
|  | 				Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State = | ||||||
|  | 					EntityState.Added; | ||||||
| 		} | 		} | ||||||
|  | 		return obj; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <inheritdoc/> | ||||||
| 		public virtual async Task<T> Edit(T edited) | 	public virtual async Task<T> CreateIfNotExists(T obj) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
| 		{ | 		{ | ||||||
| 			bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; | 			T? old = await GetOrDefault(obj.Slug); | ||||||
| 			Database.ChangeTracker.LazyLoadingEnabled = false; | 			if (old != null) | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				T old = await GetWithTracking(edited.Id); |  | ||||||
| 
 |  | ||||||
| 				Merger.Complete( |  | ||||||
| 					old, |  | ||||||
| 					edited, |  | ||||||
| 					x => x.GetCustomAttribute<LoadableRelationAttribute>() == null |  | ||||||
| 				); |  | ||||||
| 				await EditRelations(old, edited); |  | ||||||
| 				await Database.SaveChangesAsync(); |  | ||||||
| 				await IRepository<T>.OnResourceEdited(old); |  | ||||||
| 				return old; | 				return old; | ||||||
| 			} |  | ||||||
| 			finally |  | ||||||
| 			{ |  | ||||||
| 				Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; |  | ||||||
| 				Database.ChangeTracker.Clear(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 			return await Create(obj); | ||||||
| 		public virtual async Task<T> Patch(Guid id, Func<T, T> patch) | 		} | ||||||
|  | 		catch (DuplicatedItemException) | ||||||
|  | 		{ | ||||||
|  | 			return await Get(obj.Slug); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task<T> Edit(T edited) | ||||||
|  | 	{ | ||||||
|  | 		bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; | ||||||
|  | 		Database.ChangeTracker.LazyLoadingEnabled = false; | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			T old = await GetWithTracking(edited.Id); | ||||||
|  | 
 | ||||||
|  | 			Merger.Complete( | ||||||
|  | 				old, | ||||||
|  | 				edited, | ||||||
|  | 				x => x.GetCustomAttribute<LoadableRelationAttribute>() == null | ||||||
|  | 			); | ||||||
|  | 			await EditRelations(old, edited); | ||||||
|  | 			await Database.SaveChangesAsync(); | ||||||
|  | 			await IRepository<T>.OnResourceEdited(old); | ||||||
|  | 			return old; | ||||||
|  | 		} | ||||||
|  | 		finally | ||||||
|  | 		{ | ||||||
|  | 			Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; | ||||||
|  | 			Database.ChangeTracker.Clear(); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task<T> Patch(Guid id, Func<T, T> patch) | ||||||
|  | 	{ | ||||||
|  | 		bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; | ||||||
|  | 		Database.ChangeTracker.LazyLoadingEnabled = false; | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			T resource = await GetWithTracking(id); | ||||||
|  | 
 | ||||||
|  | 			resource = patch(resource); | ||||||
|  | 
 | ||||||
|  | 			await Database.SaveChangesAsync(); | ||||||
|  | 			await IRepository<T>.OnResourceEdited(resource); | ||||||
|  | 			return resource; | ||||||
|  | 		} | ||||||
|  | 		finally | ||||||
|  | 		{ | ||||||
|  | 			Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; | ||||||
|  | 			Database.ChangeTracker.Clear(); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// An overridable method to edit relation of a resource. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="resource"> | ||||||
|  | 	/// The non edited resource | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="changed"> | ||||||
|  | 	/// The new version of <paramref name="resource"/>. | ||||||
|  | 	/// This item will be saved on the database and replace <paramref name="resource"/> | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	protected virtual Task EditRelations(T resource, T changed) | ||||||
|  | 	{ | ||||||
|  | 		if (resource is IThumbnails thumbs && changed is IThumbnails chng) | ||||||
|  | 		{ | ||||||
|  | 			Database.Entry(thumbs).Reference(x => x.Poster).IsModified = | ||||||
|  | 				thumbs.Poster != chng.Poster; | ||||||
|  | 			Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified = | ||||||
|  | 				thumbs.Thumbnail != chng.Thumbnail; | ||||||
|  | 			Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo; | ||||||
|  | 		} | ||||||
|  | 		return Validate(resource); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A method called just before saving a new resource to the database. | ||||||
|  | 	/// It is also called on the default implementation of <see cref="EditRelations"/> | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="resource">The resource that will be saved</param> | ||||||
|  | 	/// <exception cref="ArgumentException"> | ||||||
|  | 	/// You can throw this if the resource is illegal and should not be saved. | ||||||
|  | 	/// </exception> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	protected virtual Task Validate(T resource) | ||||||
|  | 	{ | ||||||
|  | 		if ( | ||||||
|  | 			typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>() | ||||||
|  | 			!= null | ||||||
|  | 		) | ||||||
|  | 			return Task.CompletedTask; | ||||||
|  | 		if (string.IsNullOrEmpty(resource.Slug)) | ||||||
|  | 			throw new ArgumentException("Resource can't have null as a slug."); | ||||||
|  | 		if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random") | ||||||
| 		{ | 		{ | ||||||
| 			bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; |  | ||||||
| 			Database.ChangeTracker.LazyLoadingEnabled = false; |  | ||||||
| 			try | 			try | ||||||
| 			{ | 			{ | ||||||
| 				T resource = await GetWithTracking(id); | 				MethodInfo? setter = typeof(T).GetProperty(nameof(resource.Slug))!.GetSetMethod(); | ||||||
| 
 | 				if (setter != null) | ||||||
| 				resource = patch(resource); | 					setter.Invoke(resource, new object[] { resource.Slug + '!' }); | ||||||
| 
 | 				else | ||||||
| 				await Database.SaveChangesAsync(); |  | ||||||
| 				await IRepository<T>.OnResourceEdited(resource); |  | ||||||
| 				return resource; |  | ||||||
| 			} |  | ||||||
| 			finally |  | ||||||
| 			{ |  | ||||||
| 				Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; |  | ||||||
| 				Database.ChangeTracker.Clear(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// An overridable method to edit relation of a resource. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="resource"> |  | ||||||
| 		/// The non edited resource |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <param name="changed"> |  | ||||||
| 		/// The new version of <paramref name="resource"/>. |  | ||||||
| 		/// This item will be saved on the database and replace <paramref name="resource"/> |  | ||||||
| 		/// </param> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		protected virtual Task EditRelations(T resource, T changed) |  | ||||||
| 		{ |  | ||||||
| 			if (resource is IThumbnails thumbs && changed is IThumbnails chng) |  | ||||||
| 			{ |  | ||||||
| 				Database.Entry(thumbs).Reference(x => x.Poster).IsModified = |  | ||||||
| 					thumbs.Poster != chng.Poster; |  | ||||||
| 				Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified = |  | ||||||
| 					thumbs.Thumbnail != chng.Thumbnail; |  | ||||||
| 				Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo; |  | ||||||
| 			} |  | ||||||
| 			return Validate(resource); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A method called just before saving a new resource to the database. |  | ||||||
| 		/// It is also called on the default implementation of <see cref="EditRelations"/> |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="resource">The resource that will be saved</param> |  | ||||||
| 		/// <exception cref="ArgumentException"> |  | ||||||
| 		/// You can throw this if the resource is illegal and should not be saved. |  | ||||||
| 		/// </exception> |  | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> |  | ||||||
| 		protected virtual Task Validate(T resource) |  | ||||||
| 		{ |  | ||||||
| 			if ( |  | ||||||
| 				typeof(T) |  | ||||||
| 					.GetProperty(nameof(resource.Slug))! |  | ||||||
| 					.GetCustomAttribute<ComputedAttribute>() != null |  | ||||||
| 			) |  | ||||||
| 				return Task.CompletedTask; |  | ||||||
| 			if (string.IsNullOrEmpty(resource.Slug)) |  | ||||||
| 				throw new ArgumentException("Resource can't have null as a slug."); |  | ||||||
| 			if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random") |  | ||||||
| 			{ |  | ||||||
| 				try |  | ||||||
| 				{ |  | ||||||
| 					MethodInfo? setter = typeof(T) |  | ||||||
| 						.GetProperty(nameof(resource.Slug))! |  | ||||||
| 						.GetSetMethod(); |  | ||||||
| 					if (setter != null) |  | ||||||
| 						setter.Invoke(resource, new object[] { resource.Slug + '!' }); |  | ||||||
| 					else |  | ||||||
| 						throw new ArgumentException( |  | ||||||
| 							"Resources slug can't be number only or the literal \"random\"." |  | ||||||
| 						); |  | ||||||
| 				} |  | ||||||
| 				catch |  | ||||||
| 				{ |  | ||||||
| 					throw new ArgumentException( | 					throw new ArgumentException( | ||||||
| 						"Resources slug can't be number only or the literal \"random\"." | 						"Resources slug can't be number only or the literal \"random\"." | ||||||
| 					); | 					); | ||||||
| 				} |  | ||||||
| 			} | 			} | ||||||
| 			return Task.CompletedTask; | 			catch | ||||||
|  | 			{ | ||||||
|  | 				throw new ArgumentException( | ||||||
|  | 					"Resources slug can't be number only or the literal \"random\"." | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
|  | 		return Task.CompletedTask; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <inheritdoc/> | ||||||
| 		public virtual async Task Delete(Guid id) | 	public virtual async Task Delete(Guid id) | ||||||
| 		{ | 	{ | ||||||
| 			T resource = await Get(id); | 		T resource = await Get(id); | ||||||
|  | 		await Delete(resource); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual async Task Delete(string slug) | ||||||
|  | 	{ | ||||||
|  | 		T resource = await Get(slug); | ||||||
|  | 		await Delete(resource); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public virtual Task Delete(T obj) | ||||||
|  | 	{ | ||||||
|  | 		IRepository<T>.OnResourceDeleted(obj); | ||||||
|  | 		if (obj is IThumbnails thumbs) | ||||||
|  | 			return _thumbs.DeleteImages(thumbs); | ||||||
|  | 		return Task.CompletedTask; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public async Task DeleteAll(Filter<T> filter) | ||||||
|  | 	{ | ||||||
|  | 		foreach (T resource in await GetAll(filter)) | ||||||
| 			await Delete(resource); | 			await Delete(resource); | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual async Task Delete(string slug) |  | ||||||
| 		{ |  | ||||||
| 			T resource = await Get(slug); |  | ||||||
| 			await Delete(resource); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public virtual Task Delete(T obj) |  | ||||||
| 		{ |  | ||||||
| 			IRepository<T>.OnResourceDeleted(obj); |  | ||||||
| 			if (obj is IThumbnails thumbs) |  | ||||||
| 				return _thumbs.DeleteImages(thumbs); |  | ||||||
| 			return Task.CompletedTask; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public async Task DeleteAll(Filter<T> filter) |  | ||||||
| 		{ |  | ||||||
| 			foreach (T resource in await GetAll(filter)) |  | ||||||
| 				await Delete(resource); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,85 +25,84 @@ using Kyoo.Abstractions.Models.Utils; | |||||||
| using Kyoo.Postgresql; | using Kyoo.Postgresql; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle shows | ||||||
|  | /// </summary> | ||||||
|  | public class MovieRepository : LocalRepository<Movie> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle shows | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class MovieRepository : LocalRepository<Movie> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A studio repository to handle creation/validation of related studios. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly IRepository<Studio> _studios; | ||||||
|  | 
 | ||||||
|  | 	public MovieRepository( | ||||||
|  | 		DatabaseContext database, | ||||||
|  | 		IRepository<Studio> studios, | ||||||
|  | 		IThumbnailsManager thumbs | ||||||
|  | 	) | ||||||
|  | 		: base(database, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_database = database; | ||||||
| 		/// The database handle | 		_studios = studios; | ||||||
| 		/// </summary> | 	} | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// A studio repository to handle creation/validation of related studios. | 	public override async Task<ICollection<Movie>> Search( | ||||||
| 		/// </summary> | 		string query, | ||||||
| 		private readonly IRepository<Studio> _studios; | 		Include<Movie>? include = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return await AddIncludes(_database.Movies, include) | ||||||
|  | 			.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) | ||||||
|  | 			.Take(20) | ||||||
|  | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		public MovieRepository( | 	/// <inheritdoc /> | ||||||
| 			DatabaseContext database, | 	public override async Task<Movie> Create(Movie obj) | ||||||
| 			IRepository<Studio> studios, | 	{ | ||||||
| 			IThumbnailsManager thumbs | 		await base.Create(obj); | ||||||
| 		) | 		_database.Entry(obj).State = EntityState.Added; | ||||||
| 			: base(database, thumbs) | 		await _database.SaveChangesAsync(() => Get(obj.Slug)); | ||||||
|  | 		await IRepository<Movie>.OnResourceCreated(obj); | ||||||
|  | 		return obj; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override async Task Validate(Movie resource) | ||||||
|  | 	{ | ||||||
|  | 		await base.Validate(resource); | ||||||
|  | 		if (resource.Studio != null) | ||||||
| 		{ | 		{ | ||||||
| 			_database = database; | 			resource.Studio = await _studios.CreateIfNotExists(resource.Studio); | ||||||
| 			_studios = studios; | 			resource.StudioId = resource.Studio.Id; | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task<ICollection<Movie>> Search( |  | ||||||
| 			string query, |  | ||||||
| 			Include<Movie>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return await AddIncludes(_database.Movies, include) |  | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) |  | ||||||
| 				.Take(20) |  | ||||||
| 				.ToListAsync(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task<Movie> Create(Movie obj) |  | ||||||
| 		{ |  | ||||||
| 			await base.Create(obj); |  | ||||||
| 			_database.Entry(obj).State = EntityState.Added; |  | ||||||
| 			await _database.SaveChangesAsync(() => Get(obj.Slug)); |  | ||||||
| 			await IRepository<Movie>.OnResourceCreated(obj); |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override async Task Validate(Movie resource) |  | ||||||
| 		{ |  | ||||||
| 			await base.Validate(resource); |  | ||||||
| 			if (resource.Studio != null) |  | ||||||
| 			{ |  | ||||||
| 				resource.Studio = await _studios.CreateIfNotExists(resource.Studio); |  | ||||||
| 				resource.StudioId = resource.Studio.Id; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override async Task EditRelations(Movie resource, Movie changed) |  | ||||||
| 		{ |  | ||||||
| 			await Validate(changed); |  | ||||||
| 
 |  | ||||||
| 			if (changed.Studio != null || changed.StudioId == null) |  | ||||||
| 			{ |  | ||||||
| 				await Database.Entry(resource).Reference(x => x.Studio).LoadAsync(); |  | ||||||
| 				resource.Studio = changed.Studio; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task Delete(Movie obj) |  | ||||||
| 		{ |  | ||||||
| 			_database.Remove(obj); |  | ||||||
| 			await _database.SaveChangesAsync(); |  | ||||||
| 			await base.Delete(obj); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override async Task EditRelations(Movie resource, Movie changed) | ||||||
|  | 	{ | ||||||
|  | 		await Validate(changed); | ||||||
|  | 
 | ||||||
|  | 		if (changed.Studio != null || changed.StudioId == null) | ||||||
|  | 		{ | ||||||
|  | 			await Database.Entry(resource).Reference(x => x.Studio).LoadAsync(); | ||||||
|  | 			resource.Studio = changed.Studio; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public override async Task Delete(Movie obj) | ||||||
|  | 	{ | ||||||
|  | 		_database.Remove(obj); | ||||||
|  | 		await _database.SaveChangesAsync(); | ||||||
|  | 		await base.Delete(obj); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -22,43 +22,42 @@ using System.Data.Common; | |||||||
| using System.IO; | using System.IO; | ||||||
| using Kyoo.Abstractions.Models; | using Kyoo.Abstractions.Models; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle shows | ||||||
|  | /// </summary> | ||||||
|  | public class NewsRepository : DapperRepository<INews> | ||||||
| { | { | ||||||
| 	/// <summary> | 	// language=PostgreSQL | ||||||
| 	/// A local repository to handle shows | 	protected override FormattableString Sql => | ||||||
| 	/// </summary> | 		$"""
 | ||||||
| 	public class NewsRepository : DapperRepository<INews> | 			select | ||||||
| 	{ | 				e.*, -- Episode as e | ||||||
| 		// language=PostgreSQL | 				m.* | ||||||
| 		protected override FormattableString Sql => | 				/* includes */ | ||||||
| 			$"""
 | 			from | ||||||
|  | 				episodes as e | ||||||
|  | 			full outer join ( | ||||||
| 				select | 				select | ||||||
| 					e.*, -- Episode as e | 					* -- Movie | ||||||
| 					m.* |  | ||||||
| 					/* includes */ |  | ||||||
| 				from | 				from | ||||||
| 					episodes as e | 					movies | ||||||
| 				full outer join ( | 			) as m on false | ||||||
| 					select | 			""";
 | ||||||
| 						* -- Movie |  | ||||||
| 					from |  | ||||||
| 						movies |  | ||||||
| 				) as m on false |  | ||||||
| 				""";
 |  | ||||||
| 
 | 
 | ||||||
| 		protected override Dictionary<string, Type> Config => | 	protected override Dictionary<string, Type> Config => | ||||||
| 			new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; | 		new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; | ||||||
| 
 | 
 | ||||||
| 		protected override INews Mapper(List<object?> items) | 	protected override INews Mapper(List<object?> items) | ||||||
| 		{ | 	{ | ||||||
| 			if (items[0] is Episode episode && episode.Id != Guid.Empty) | 		if (items[0] is Episode episode && episode.Id != Guid.Empty) | ||||||
| 				return episode; | 			return episode; | ||||||
| 			if (items[1] is Movie movie && movie.Id != Guid.Empty) | 		if (items[1] is Movie movie && movie.Id != Guid.Empty) | ||||||
| 				return movie; | 			return movie; | ||||||
| 			throw new InvalidDataException(); | 		throw new InvalidDataException(); | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public NewsRepository(DbConnection database, SqlVariableContext context) |  | ||||||
| 			: base(database, context) { } |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	public NewsRepository(DbConnection database, SqlVariableContext context) | ||||||
|  | 		: base(database, context) { } | ||||||
| } | } | ||||||
|  | |||||||
| @ -29,105 +29,103 @@ using Kyoo.Postgresql; | |||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle seasons. | ||||||
|  | /// </summary> | ||||||
|  | public class SeasonRepository : LocalRepository<Season> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle seasons. | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class SeasonRepository : LocalRepository<Season> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	static SeasonRepository() | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		// Edit seasons slugs when the show's slug changes. | ||||||
| 		/// The database handle | 		IRepository<Show>.OnEdited += async (show) => | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 |  | ||||||
| 		static SeasonRepository() |  | ||||||
| 		{ | 		{ | ||||||
| 			// Edit seasons slugs when the show's slug changes. | 			await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); | ||||||
| 			IRepository<Show>.OnEdited += async (show) => | 			DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); | ||||||
| 			{ | 			List<Season> seasons = await database | ||||||
| 				await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); | 				.Seasons.AsTracking() | ||||||
| 				DatabaseContext database = | 				.Where(x => x.ShowId == show.Id) | ||||||
| 					scope.ServiceProvider.GetRequiredService<DatabaseContext>(); |  | ||||||
| 				List<Season> seasons = await database |  | ||||||
| 					.Seasons.AsTracking() |  | ||||||
| 					.Where(x => x.ShowId == show.Id) |  | ||||||
| 					.ToListAsync(); |  | ||||||
| 				foreach (Season season in seasons) |  | ||||||
| 				{ |  | ||||||
| 					season.ShowSlug = show.Slug; |  | ||||||
| 					await database.SaveChangesAsync(); |  | ||||||
| 					await IRepository<Season>.OnResourceEdited(season); |  | ||||||
| 				} |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="SeasonRepository"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="database">The database handle that will be used</param> |  | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to store images.</param> |  | ||||||
| 		public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs) |  | ||||||
| 			: base(database, thumbs) |  | ||||||
| 		{ |  | ||||||
| 			_database = database; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		protected override Task<Season?> GetDuplicated(Season item) |  | ||||||
| 		{ |  | ||||||
| 			return _database.Seasons.FirstOrDefaultAsync(x => |  | ||||||
| 				x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public override async Task<ICollection<Season>> Search( |  | ||||||
| 			string query, |  | ||||||
| 			Include<Season>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return await AddIncludes(_database.Seasons, include) |  | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) |  | ||||||
| 				.Take(20) |  | ||||||
| 				.ToListAsync(); | 				.ToListAsync(); | ||||||
| 		} | 			foreach (Season season in seasons) | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public override async Task<Season> Create(Season obj) |  | ||||||
| 		{ |  | ||||||
| 			await base.Create(obj); |  | ||||||
| 			obj.ShowSlug = |  | ||||||
| 				(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug |  | ||||||
| 				?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}"); |  | ||||||
| 			_database.Entry(obj).State = EntityState.Added; |  | ||||||
| 			await _database.SaveChangesAsync(() => GetDuplicated(obj)); |  | ||||||
| 			await IRepository<Season>.OnResourceCreated(obj); |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		protected override async Task Validate(Season resource) |  | ||||||
| 		{ |  | ||||||
| 			await base.Validate(resource); |  | ||||||
| 			if (resource.ShowId == Guid.Empty) |  | ||||||
| 			{ | 			{ | ||||||
| 				if (resource.Show == null) | 				season.ShowSlug = show.Slug; | ||||||
| 				{ | 				await database.SaveChangesAsync(); | ||||||
| 					throw new ValidationException( | 				await IRepository<Season>.OnResourceEdited(season); | ||||||
| 						$"Can't store a season not related to any show " |  | ||||||
| 							+ $"(showID: {resource.ShowId})." |  | ||||||
| 					); |  | ||||||
| 				} |  | ||||||
| 				resource.ShowId = resource.Show.Id; |  | ||||||
| 			} | 			} | ||||||
| 		} | 		}; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc/> | 	/// <summary> | ||||||
| 		public override async Task Delete(Season obj) | 	/// Create a new <see cref="SeasonRepository"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="database">The database handle that will be used</param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to store images.</param> | ||||||
|  | 	public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs) | ||||||
|  | 		: base(database, thumbs) | ||||||
|  | 	{ | ||||||
|  | 		_database = database; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	protected override Task<Season?> GetDuplicated(Season item) | ||||||
|  | 	{ | ||||||
|  | 		return _database.Seasons.FirstOrDefaultAsync(x => | ||||||
|  | 			x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public override async Task<ICollection<Season>> Search( | ||||||
|  | 		string query, | ||||||
|  | 		Include<Season>? include = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return await AddIncludes(_database.Seasons, include) | ||||||
|  | 			.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) | ||||||
|  | 			.Take(20) | ||||||
|  | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public override async Task<Season> Create(Season obj) | ||||||
|  | 	{ | ||||||
|  | 		await base.Create(obj); | ||||||
|  | 		obj.ShowSlug = | ||||||
|  | 			(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug | ||||||
|  | 			?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}"); | ||||||
|  | 		_database.Entry(obj).State = EntityState.Added; | ||||||
|  | 		await _database.SaveChangesAsync(() => GetDuplicated(obj)); | ||||||
|  | 		await IRepository<Season>.OnResourceCreated(obj); | ||||||
|  | 		return obj; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	protected override async Task Validate(Season resource) | ||||||
|  | 	{ | ||||||
|  | 		await base.Validate(resource); | ||||||
|  | 		if (resource.ShowId == Guid.Empty) | ||||||
| 		{ | 		{ | ||||||
| 			_database.Remove(obj); | 			if (resource.Show == null) | ||||||
| 			await _database.SaveChangesAsync(); | 			{ | ||||||
| 			await base.Delete(obj); | 				throw new ValidationException( | ||||||
|  | 					$"Can't store a season not related to any show " | ||||||
|  | 						+ $"(showID: {resource.ShowId})." | ||||||
|  | 				); | ||||||
|  | 			} | ||||||
|  | 			resource.ShowId = resource.Show.Id; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public override async Task Delete(Season obj) | ||||||
|  | 	{ | ||||||
|  | 		_database.Remove(obj); | ||||||
|  | 		await _database.SaveChangesAsync(); | ||||||
|  | 		await base.Delete(obj); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,85 +26,84 @@ using Kyoo.Postgresql; | |||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle shows | ||||||
|  | /// </summary> | ||||||
|  | public class ShowRepository : LocalRepository<Show> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle shows | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class ShowRepository : LocalRepository<Show> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A studio repository to handle creation/validation of related studios. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly IRepository<Studio> _studios; | ||||||
|  | 
 | ||||||
|  | 	public ShowRepository( | ||||||
|  | 		DatabaseContext database, | ||||||
|  | 		IRepository<Studio> studios, | ||||||
|  | 		IThumbnailsManager thumbs | ||||||
|  | 	) | ||||||
|  | 		: base(database, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_database = database; | ||||||
| 		/// The database handle | 		_studios = studios; | ||||||
| 		/// </summary> | 	} | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// A studio repository to handle creation/validation of related studios. | 	public override async Task<ICollection<Show>> Search( | ||||||
| 		/// </summary> | 		string query, | ||||||
| 		private readonly IRepository<Studio> _studios; | 		Include<Show>? include = default | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return await AddIncludes(_database.Shows, include) | ||||||
|  | 			.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) | ||||||
|  | 			.Take(20) | ||||||
|  | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		public ShowRepository( | 	/// <inheritdoc /> | ||||||
| 			DatabaseContext database, | 	public override async Task<Show> Create(Show obj) | ||||||
| 			IRepository<Studio> studios, | 	{ | ||||||
| 			IThumbnailsManager thumbs | 		await base.Create(obj); | ||||||
| 		) | 		_database.Entry(obj).State = EntityState.Added; | ||||||
| 			: base(database, thumbs) | 		await _database.SaveChangesAsync(() => Get(obj.Slug)); | ||||||
|  | 		await IRepository<Show>.OnResourceCreated(obj); | ||||||
|  | 		return obj; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override async Task Validate(Show resource) | ||||||
|  | 	{ | ||||||
|  | 		await base.Validate(resource); | ||||||
|  | 		if (resource.Studio != null) | ||||||
| 		{ | 		{ | ||||||
| 			_database = database; | 			resource.Studio = await _studios.CreateIfNotExists(resource.Studio); | ||||||
| 			_studios = studios; | 			resource.StudioId = resource.Studio.Id; | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task<ICollection<Show>> Search( |  | ||||||
| 			string query, |  | ||||||
| 			Include<Show>? include = default |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return await AddIncludes(_database.Shows, include) |  | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) |  | ||||||
| 				.Take(20) |  | ||||||
| 				.ToListAsync(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task<Show> Create(Show obj) |  | ||||||
| 		{ |  | ||||||
| 			await base.Create(obj); |  | ||||||
| 			_database.Entry(obj).State = EntityState.Added; |  | ||||||
| 			await _database.SaveChangesAsync(() => Get(obj.Slug)); |  | ||||||
| 			await IRepository<Show>.OnResourceCreated(obj); |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override async Task Validate(Show resource) |  | ||||||
| 		{ |  | ||||||
| 			await base.Validate(resource); |  | ||||||
| 			if (resource.Studio != null) |  | ||||||
| 			{ |  | ||||||
| 				resource.Studio = await _studios.CreateIfNotExists(resource.Studio); |  | ||||||
| 				resource.StudioId = resource.Studio.Id; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override async Task EditRelations(Show resource, Show changed) |  | ||||||
| 		{ |  | ||||||
| 			await Validate(changed); |  | ||||||
| 
 |  | ||||||
| 			if (changed.Studio != null || changed.StudioId == null) |  | ||||||
| 			{ |  | ||||||
| 				await Database.Entry(resource).Reference(x => x.Studio).LoadAsync(); |  | ||||||
| 				resource.Studio = changed.Studio; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task Delete(Show obj) |  | ||||||
| 		{ |  | ||||||
| 			_database.Remove(obj); |  | ||||||
| 			await _database.SaveChangesAsync(); |  | ||||||
| 			await base.Delete(obj); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override async Task EditRelations(Show resource, Show changed) | ||||||
|  | 	{ | ||||||
|  | 		await Validate(changed); | ||||||
|  | 
 | ||||||
|  | 		if (changed.Studio != null || changed.StudioId == null) | ||||||
|  | 		{ | ||||||
|  | 			await Database.Entry(resource).Reference(x => x.Studio).LoadAsync(); | ||||||
|  | 			resource.Studio = changed.Studio; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public override async Task Delete(Show obj) | ||||||
|  | 	{ | ||||||
|  | 		_database.Remove(obj); | ||||||
|  | 		await _database.SaveChangesAsync(); | ||||||
|  | 		await base.Delete(obj); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,57 +26,56 @@ using Kyoo.Postgresql; | |||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A local repository to handle studios | ||||||
|  | /// </summary> | ||||||
|  | public class StudioRepository : LocalRepository<Studio> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A local repository to handle studios | 	/// The database handle | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class StudioRepository : LocalRepository<Studio> | 	private readonly DatabaseContext _database; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="StudioRepository"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="database">The database handle</param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to store images.</param> | ||||||
|  | 	public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs) | ||||||
|  | 		: base(database, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_database = database; | ||||||
| 		/// The database handle | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly DatabaseContext _database; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Create a new <see cref="StudioRepository"/>. | 	public override async Task<ICollection<Studio>> Search( | ||||||
| 		/// </summary> | 		string query, | ||||||
| 		/// <param name="database">The database handle</param> | 		Include<Studio>? include = default | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to store images.</param> | 	) | ||||||
| 		public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs) | 	{ | ||||||
| 			: base(database, thumbs) | 		return await AddIncludes(_database.Studios, include) | ||||||
| 		{ | 			.Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) | ||||||
| 			_database = database; | 			.Take(20) | ||||||
| 		} | 			.ToListAsync(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public override async Task<ICollection<Studio>> Search( | 	public override async Task<Studio> Create(Studio obj) | ||||||
| 			string query, | 	{ | ||||||
| 			Include<Studio>? include = default | 		await base.Create(obj); | ||||||
| 		) | 		_database.Entry(obj).State = EntityState.Added; | ||||||
| 		{ | 		await _database.SaveChangesAsync(() => Get(obj.Slug)); | ||||||
| 			return await AddIncludes(_database.Studios, include) | 		await IRepository<Studio>.OnResourceCreated(obj); | ||||||
| 				.Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) | 		return obj; | ||||||
| 				.Take(20) | 	} | ||||||
| 				.ToListAsync(); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public override async Task<Studio> Create(Studio obj) | 	public override async Task Delete(Studio obj) | ||||||
| 		{ | 	{ | ||||||
| 			await base.Create(obj); | 		_database.Entry(obj).State = EntityState.Deleted; | ||||||
| 			_database.Entry(obj).State = EntityState.Added; | 		await _database.SaveChangesAsync(); | ||||||
| 			await _database.SaveChangesAsync(() => Get(obj.Slug)); | 		await base.Delete(obj); | ||||||
| 			await IRepository<Studio>.OnResourceCreated(obj); |  | ||||||
| 			return obj; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public override async Task Delete(Studio obj) |  | ||||||
| 		{ |  | ||||||
| 			_database.Entry(obj).State = EntityState.Deleted; |  | ||||||
| 			await _database.SaveChangesAsync(); |  | ||||||
| 			await base.Delete(obj); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -31,238 +31,236 @@ using Kyoo.Abstractions.Models.Exceptions; | |||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| using SkiaSharp; | using SkiaSharp; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Controllers | namespace Kyoo.Core.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Download images and retrieve the path of those images for a resource. | ||||||
|  | /// </summary> | ||||||
|  | public class ThumbnailsManager( | ||||||
|  | 	IHttpClientFactory clientFactory, | ||||||
|  | 	ILogger<ThumbnailsManager> logger, | ||||||
|  | 	Lazy<IRepository<User>> users | ||||||
|  | ) : IThumbnailsManager | ||||||
| { | { | ||||||
| 	/// <summary> | 	private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = new(); | ||||||
| 	/// Download images and retrieve the path of those images for a resource. | 
 | ||||||
| 	/// </summary> | 	private static async Task _WriteTo(SKBitmap bitmap, string path, int quality) | ||||||
| 	public class ThumbnailsManager( |  | ||||||
| 		IHttpClientFactory clientFactory, |  | ||||||
| 		ILogger<ThumbnailsManager> logger, |  | ||||||
| 		Lazy<IRepository<User>> users |  | ||||||
| 	) : IThumbnailsManager |  | ||||||
| 	{ | 	{ | ||||||
| 		private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = | 		SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); | ||||||
| 			new(); | 		await using Stream reader = data.AsStream(); | ||||||
|  | 		await using Stream file = File.Create(path); | ||||||
|  | 		await reader.CopyToAsync(file); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		private static async Task _WriteTo(SKBitmap bitmap, string path, int quality) | 	private async Task _DownloadImage(Image? image, string localPath, string what) | ||||||
|  | 	{ | ||||||
|  | 		if (image == null) | ||||||
|  | 			return; | ||||||
|  | 		try | ||||||
| 		{ | 		{ | ||||||
| 			SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); | 			logger.LogInformation("Downloading image {What}", what); | ||||||
| 			await using Stream reader = data.AsStream(); |  | ||||||
| 			await using Stream file = File.Create(path); |  | ||||||
| 			await reader.CopyToAsync(file); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		private async Task _DownloadImage(Image? image, string localPath, string what) | 			HttpClient client = clientFactory.CreateClient(); | ||||||
| 		{ | 			HttpResponseMessage response = await client.GetAsync(image.Source); | ||||||
| 			if (image == null) | 			response.EnsureSuccessStatusCode(); | ||||||
| 				return; | 			await using Stream reader = await response.Content.ReadAsStreamAsync(); | ||||||
| 			try | 			using SKCodec codec = SKCodec.Create(reader); | ||||||
|  | 			if (codec == null) | ||||||
| 			{ | 			{ | ||||||
| 				logger.LogInformation("Downloading image {What}", what); | 				logger.LogError("Unsupported codec for {What}", what); | ||||||
| 
 |  | ||||||
| 				HttpClient client = clientFactory.CreateClient(); |  | ||||||
| 				HttpResponseMessage response = await client.GetAsync(image.Source); |  | ||||||
| 				response.EnsureSuccessStatusCode(); |  | ||||||
| 				await using Stream reader = await response.Content.ReadAsStreamAsync(); |  | ||||||
| 				using SKCodec codec = SKCodec.Create(reader); |  | ||||||
| 				if (codec == null) |  | ||||||
| 				{ |  | ||||||
| 					logger.LogError("Unsupported codec for {What}", what); |  | ||||||
| 					return; |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				SKImageInfo info = codec.Info; |  | ||||||
| 				info.ColorType = SKColorType.Rgba8888; |  | ||||||
| 				using SKBitmap original = SKBitmap.Decode(codec, info); |  | ||||||
| 
 |  | ||||||
| 				using SKBitmap high = original.Resize( |  | ||||||
| 					new SKSizeI(original.Width, original.Height), |  | ||||||
| 					SKFilterQuality.High |  | ||||||
| 				); |  | ||||||
| 				await _WriteTo( |  | ||||||
| 					original, |  | ||||||
| 					$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp", |  | ||||||
| 					90 |  | ||||||
| 				); |  | ||||||
| 
 |  | ||||||
| 				using SKBitmap medium = high.Resize( |  | ||||||
| 					new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)), |  | ||||||
| 					SKFilterQuality.Medium |  | ||||||
| 				); |  | ||||||
| 				await _WriteTo( |  | ||||||
| 					medium, |  | ||||||
| 					$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp", |  | ||||||
| 					75 |  | ||||||
| 				); |  | ||||||
| 
 |  | ||||||
| 				using SKBitmap low = medium.Resize( |  | ||||||
| 					new SKSizeI(original.Width / 2, original.Height / 2), |  | ||||||
| 					SKFilterQuality.Low |  | ||||||
| 				); |  | ||||||
| 				await _WriteTo( |  | ||||||
| 					low, |  | ||||||
| 					$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp", |  | ||||||
| 					50 |  | ||||||
| 				); |  | ||||||
| 
 |  | ||||||
| 				image.Blurhash = Blurhasher.Encode(low, 4, 3); |  | ||||||
| 			} |  | ||||||
| 			catch (Exception ex) |  | ||||||
| 			{ |  | ||||||
| 				logger.LogError(ex, "{What} could not be downloaded", what); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public async Task DownloadImages<T>(T item) |  | ||||||
| 			where T : IThumbnails |  | ||||||
| 		{ |  | ||||||
| 			string name = item is IResource res ? res.Slug : "???"; |  | ||||||
| 
 |  | ||||||
| 			string posterPath = |  | ||||||
| 				$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp"; |  | ||||||
| 			bool duplicated = false; |  | ||||||
| 			TaskCompletionSource<object>? sync = null; |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				lock (_downloading) |  | ||||||
| 				{ |  | ||||||
| 					if (_downloading.ContainsKey(posterPath)) |  | ||||||
| 					{ |  | ||||||
| 						duplicated = true; |  | ||||||
| 						sync = _downloading.GetValueOrDefault(posterPath); |  | ||||||
| 					} |  | ||||||
| 					else |  | ||||||
| 					{ |  | ||||||
| 						sync = new(); |  | ||||||
| 						_downloading.Add(posterPath, sync); |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 				if (duplicated) |  | ||||||
| 				{ |  | ||||||
| 					object? dup = sync != null ? await sync.Task : null; |  | ||||||
| 					if (dup != null) |  | ||||||
| 						throw new DuplicatedItemException(dup); |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				await _DownloadImage( |  | ||||||
| 					item.Poster, |  | ||||||
| 					_GetBaseImagePath(item, "poster"), |  | ||||||
| 					$"The poster of {name}" |  | ||||||
| 				); |  | ||||||
| 				await _DownloadImage( |  | ||||||
| 					item.Thumbnail, |  | ||||||
| 					_GetBaseImagePath(item, "thumbnail"), |  | ||||||
| 					$"The poster of {name}" |  | ||||||
| 				); |  | ||||||
| 				await _DownloadImage( |  | ||||||
| 					item.Logo, |  | ||||||
| 					_GetBaseImagePath(item, "logo"), |  | ||||||
| 					$"The poster of {name}" |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 			finally |  | ||||||
| 			{ |  | ||||||
| 				if (!duplicated) |  | ||||||
| 				{ |  | ||||||
| 					lock (_downloading) |  | ||||||
| 					{ |  | ||||||
| 						_downloading.Remove(posterPath); |  | ||||||
| 						sync!.SetResult(item); |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		private static string _GetBaseImagePath<T>(T item, string image) |  | ||||||
| 		{ |  | ||||||
| 			string directory = item switch |  | ||||||
| 			{ |  | ||||||
| 				IResource res |  | ||||||
| 					=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug), |  | ||||||
| 				_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant()) |  | ||||||
| 			}; |  | ||||||
| 			Directory.CreateDirectory(directory); |  | ||||||
| 			return Path.Combine(directory, image); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public string GetImagePath<T>(T item, string image, ImageQuality quality) |  | ||||||
| 			where T : IThumbnails |  | ||||||
| 		{ |  | ||||||
| 			return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp"; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public Task DeleteImages<T>(T item) |  | ||||||
| 			where T : IThumbnails |  | ||||||
| 		{ |  | ||||||
| 			IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" } |  | ||||||
| 				.SelectMany(x => _GetBaseImagePath(item, x)) |  | ||||||
| 				.SelectMany(x => |  | ||||||
| 					new[] |  | ||||||
| 					{ |  | ||||||
| 						ImageQuality.High.ToString().ToLowerInvariant(), |  | ||||||
| 						ImageQuality.Medium.ToString().ToLowerInvariant(), |  | ||||||
| 						ImageQuality.Low.ToString().ToLowerInvariant(), |  | ||||||
| 					}.Select(quality => $"{x}.{quality}.webp") |  | ||||||
| 				); |  | ||||||
| 
 |  | ||||||
| 			foreach (string image in images) |  | ||||||
| 				File.Delete(image); |  | ||||||
| 			return Task.CompletedTask; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public async Task<Stream> GetUserImage(Guid userId) |  | ||||||
| 		{ |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				return File.Open($"/metadata/user/{userId}.webp", FileMode.Open); |  | ||||||
| 			} |  | ||||||
| 			catch (FileNotFoundException) { } |  | ||||||
| 			catch (DirectoryNotFoundException) { } |  | ||||||
| 
 |  | ||||||
| 			User user = await users.Value.Get(userId); |  | ||||||
| 			if (user.Email == null) |  | ||||||
| 				throw new ItemNotFoundException(); |  | ||||||
| 			using MD5 md5 = MD5.Create(); |  | ||||||
| 			string hash = Convert |  | ||||||
| 				.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email))) |  | ||||||
| 				.ToLower(); |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				HttpClient client = clientFactory.CreateClient(); |  | ||||||
| 				HttpResponseMessage response = await client.GetAsync( |  | ||||||
| 					$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250" |  | ||||||
| 				); |  | ||||||
| 				response.EnsureSuccessStatusCode(); |  | ||||||
| 				return await response.Content.ReadAsStreamAsync(); |  | ||||||
| 			} |  | ||||||
| 			catch |  | ||||||
| 			{ |  | ||||||
| 				throw new ItemNotFoundException(); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		public async Task SetUserImage(Guid userId, Stream? image) |  | ||||||
| 		{ |  | ||||||
| 			if (image == null) |  | ||||||
| 			{ |  | ||||||
| 				try |  | ||||||
| 				{ |  | ||||||
| 					File.Delete($"/metadata/user/{userId}.webp"); |  | ||||||
| 				} |  | ||||||
| 				catch { } |  | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 			using SKCodec codec = SKCodec.Create(image); | 
 | ||||||
| 			SKImageInfo info = codec.Info; | 			SKImageInfo info = codec.Info; | ||||||
| 			info.ColorType = SKColorType.Rgba8888; | 			info.ColorType = SKColorType.Rgba8888; | ||||||
| 			using SKBitmap original = SKBitmap.Decode(codec, info); | 			using SKBitmap original = SKBitmap.Decode(codec, info); | ||||||
| 			using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High); | 
 | ||||||
| 			Directory.CreateDirectory("/metadata/user"); | 			using SKBitmap high = original.Resize( | ||||||
| 			await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75); | 				new SKSizeI(original.Width, original.Height), | ||||||
|  | 				SKFilterQuality.High | ||||||
|  | 			); | ||||||
|  | 			await _WriteTo( | ||||||
|  | 				original, | ||||||
|  | 				$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp", | ||||||
|  | 				90 | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			using SKBitmap medium = high.Resize( | ||||||
|  | 				new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)), | ||||||
|  | 				SKFilterQuality.Medium | ||||||
|  | 			); | ||||||
|  | 			await _WriteTo( | ||||||
|  | 				medium, | ||||||
|  | 				$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp", | ||||||
|  | 				75 | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			using SKBitmap low = medium.Resize( | ||||||
|  | 				new SKSizeI(original.Width / 2, original.Height / 2), | ||||||
|  | 				SKFilterQuality.Low | ||||||
|  | 			); | ||||||
|  | 			await _WriteTo( | ||||||
|  | 				low, | ||||||
|  | 				$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp", | ||||||
|  | 				50 | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			image.Blurhash = Blurhasher.Encode(low, 4, 3); | ||||||
|  | 		} | ||||||
|  | 		catch (Exception ex) | ||||||
|  | 		{ | ||||||
|  | 			logger.LogError(ex, "{What} could not be downloaded", what); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public async Task DownloadImages<T>(T item) | ||||||
|  | 		where T : IThumbnails | ||||||
|  | 	{ | ||||||
|  | 		string name = item is IResource res ? res.Slug : "???"; | ||||||
|  | 
 | ||||||
|  | 		string posterPath = | ||||||
|  | 			$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp"; | ||||||
|  | 		bool duplicated = false; | ||||||
|  | 		TaskCompletionSource<object>? sync = null; | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			lock (_downloading) | ||||||
|  | 			{ | ||||||
|  | 				if (_downloading.ContainsKey(posterPath)) | ||||||
|  | 				{ | ||||||
|  | 					duplicated = true; | ||||||
|  | 					sync = _downloading.GetValueOrDefault(posterPath); | ||||||
|  | 				} | ||||||
|  | 				else | ||||||
|  | 				{ | ||||||
|  | 					sync = new(); | ||||||
|  | 					_downloading.Add(posterPath, sync); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if (duplicated) | ||||||
|  | 			{ | ||||||
|  | 				object? dup = sync != null ? await sync.Task : null; | ||||||
|  | 				if (dup != null) | ||||||
|  | 					throw new DuplicatedItemException(dup); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			await _DownloadImage( | ||||||
|  | 				item.Poster, | ||||||
|  | 				_GetBaseImagePath(item, "poster"), | ||||||
|  | 				$"The poster of {name}" | ||||||
|  | 			); | ||||||
|  | 			await _DownloadImage( | ||||||
|  | 				item.Thumbnail, | ||||||
|  | 				_GetBaseImagePath(item, "thumbnail"), | ||||||
|  | 				$"The poster of {name}" | ||||||
|  | 			); | ||||||
|  | 			await _DownloadImage( | ||||||
|  | 				item.Logo, | ||||||
|  | 				_GetBaseImagePath(item, "logo"), | ||||||
|  | 				$"The poster of {name}" | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
|  | 		finally | ||||||
|  | 		{ | ||||||
|  | 			if (!duplicated) | ||||||
|  | 			{ | ||||||
|  | 				lock (_downloading) | ||||||
|  | 				{ | ||||||
|  | 					_downloading.Remove(posterPath); | ||||||
|  | 					sync!.SetResult(item); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	private static string _GetBaseImagePath<T>(T item, string image) | ||||||
|  | 	{ | ||||||
|  | 		string directory = item switch | ||||||
|  | 		{ | ||||||
|  | 			IResource res | ||||||
|  | 				=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug), | ||||||
|  | 			_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant()) | ||||||
|  | 		}; | ||||||
|  | 		Directory.CreateDirectory(directory); | ||||||
|  | 		return Path.Combine(directory, image); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public string GetImagePath<T>(T item, string image, ImageQuality quality) | ||||||
|  | 		where T : IThumbnails | ||||||
|  | 	{ | ||||||
|  | 		return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp"; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public Task DeleteImages<T>(T item) | ||||||
|  | 		where T : IThumbnails | ||||||
|  | 	{ | ||||||
|  | 		IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" } | ||||||
|  | 			.SelectMany(x => _GetBaseImagePath(item, x)) | ||||||
|  | 			.SelectMany(x => | ||||||
|  | 				new[] | ||||||
|  | 				{ | ||||||
|  | 					ImageQuality.High.ToString().ToLowerInvariant(), | ||||||
|  | 					ImageQuality.Medium.ToString().ToLowerInvariant(), | ||||||
|  | 					ImageQuality.Low.ToString().ToLowerInvariant(), | ||||||
|  | 				}.Select(quality => $"{x}.{quality}.webp") | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		foreach (string image in images) | ||||||
|  | 			File.Delete(image); | ||||||
|  | 		return Task.CompletedTask; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public async Task<Stream> GetUserImage(Guid userId) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			return File.Open($"/metadata/user/{userId}.webp", FileMode.Open); | ||||||
|  | 		} | ||||||
|  | 		catch (FileNotFoundException) { } | ||||||
|  | 		catch (DirectoryNotFoundException) { } | ||||||
|  | 
 | ||||||
|  | 		User user = await users.Value.Get(userId); | ||||||
|  | 		if (user.Email == null) | ||||||
|  | 			throw new ItemNotFoundException(); | ||||||
|  | 		using MD5 md5 = MD5.Create(); | ||||||
|  | 		string hash = Convert | ||||||
|  | 			.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email))) | ||||||
|  | 			.ToLower(); | ||||||
|  | 		try | ||||||
|  | 		{ | ||||||
|  | 			HttpClient client = clientFactory.CreateClient(); | ||||||
|  | 			HttpResponseMessage response = await client.GetAsync( | ||||||
|  | 				$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250" | ||||||
|  | 			); | ||||||
|  | 			response.EnsureSuccessStatusCode(); | ||||||
|  | 			return await response.Content.ReadAsStreamAsync(); | ||||||
|  | 		} | ||||||
|  | 		catch | ||||||
|  | 		{ | ||||||
|  | 			throw new ItemNotFoundException(); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public async Task SetUserImage(Guid userId, Stream? image) | ||||||
|  | 	{ | ||||||
|  | 		if (image == null) | ||||||
|  | 		{ | ||||||
|  | 			try | ||||||
|  | 			{ | ||||||
|  | 				File.Delete($"/metadata/user/{userId}.webp"); | ||||||
|  | 			} | ||||||
|  | 			catch { } | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  | 		using SKCodec codec = SKCodec.Create(image); | ||||||
|  | 		SKImageInfo info = codec.Info; | ||||||
|  | 		info.ColorType = SKColorType.Rgba8888; | ||||||
|  | 		using SKBitmap original = SKBitmap.Decode(codec, info); | ||||||
|  | 		using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High); | ||||||
|  | 		Directory.CreateDirectory("/metadata/user"); | ||||||
|  | 		await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -33,116 +33,115 @@ using Microsoft.AspNetCore.Mvc; | |||||||
| using Microsoft.AspNetCore.Routing; | using Microsoft.AspNetCore.Routing; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core | namespace Kyoo.Core; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The core module containing default implementations | ||||||
|  | /// </summary> | ||||||
|  | public class CoreModule : IPlugin | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The core module containing default implementations | 	/// A service provider to access services in static context (in events for example). | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class CoreModule : IPlugin | 	/// <remarks>Don't forget to create a scope.</remarks> | ||||||
|  | 	public static IServiceProvider Services { get; set; } | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public string Name => "Core"; | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public void Configure(ContainerBuilder builder) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		builder | ||||||
| 		/// A service provider to access services in static context (in events for example). | 			.RegisterType<ThumbnailsManager>() | ||||||
| 		/// </summary> | 			.As<IThumbnailsManager>() | ||||||
| 		/// <remarks>Don't forget to create a scope.</remarks> | 			.InstancePerLifetimeScope(); | ||||||
| 		public static IServiceProvider Services { get; set; } | 		builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope(); | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		builder.RegisterRepository<LibraryItemRepository>(); | ||||||
| 		public string Name => "Core"; | 		builder.RegisterRepository<CollectionRepository>(); | ||||||
| 
 | 		builder.RegisterRepository<MovieRepository>(); | ||||||
| 		/// <inheritdoc /> | 		builder.RegisterRepository<ShowRepository>(); | ||||||
| 		public void Configure(ContainerBuilder builder) | 		builder.RegisterRepository<SeasonRepository>(); | ||||||
| 		{ | 		builder.RegisterRepository<EpisodeRepository>(); | ||||||
| 			builder | 		builder.RegisterRepository<StudioRepository>(); | ||||||
| 				.RegisterType<ThumbnailsManager>() | 		builder.RegisterRepository<UserRepository>().As<IUserRepository>(); | ||||||
| 				.As<IThumbnailsManager>() | 		builder.RegisterRepository<NewsRepository>(); | ||||||
| 				.InstancePerLifetimeScope(); | 		builder | ||||||
| 			builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope(); | 			.RegisterType<WatchStatusRepository>() | ||||||
| 
 | 			.As<IWatchStatusRepository>() | ||||||
| 			builder.RegisterRepository<LibraryItemRepository>(); | 			.AsSelf() | ||||||
| 			builder.RegisterRepository<CollectionRepository>(); | 			.InstancePerLifetimeScope(); | ||||||
| 			builder.RegisterRepository<MovieRepository>(); | 		builder | ||||||
| 			builder.RegisterRepository<ShowRepository>(); | 			.RegisterType<IssueRepository>() | ||||||
| 			builder.RegisterRepository<SeasonRepository>(); | 			.As<IIssueRepository>() | ||||||
| 			builder.RegisterRepository<EpisodeRepository>(); | 			.AsSelf() | ||||||
| 			builder.RegisterRepository<StudioRepository>(); | 			.InstancePerLifetimeScope(); | ||||||
| 			builder.RegisterRepository<UserRepository>().As<IUserRepository>(); | 		builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope(); | ||||||
| 			builder.RegisterRepository<NewsRepository>(); |  | ||||||
| 			builder |  | ||||||
| 				.RegisterType<WatchStatusRepository>() |  | ||||||
| 				.As<IWatchStatusRepository>() |  | ||||||
| 				.AsSelf() |  | ||||||
| 				.InstancePerLifetimeScope(); |  | ||||||
| 			builder |  | ||||||
| 				.RegisterType<IssueRepository>() |  | ||||||
| 				.As<IIssueRepository>() |  | ||||||
| 				.AsSelf() |  | ||||||
| 				.InstancePerLifetimeScope(); |  | ||||||
| 			builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void Configure(IServiceCollection services) |  | ||||||
| 		{ |  | ||||||
| 			services.AddHttpContextAccessor(); |  | ||||||
| 
 |  | ||||||
| 			services |  | ||||||
| 				.AddMvcCore(options => |  | ||||||
| 				{ |  | ||||||
| 					options.Filters.Add<ExceptionFilter>(); |  | ||||||
| 					options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); |  | ||||||
| 					options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider()); |  | ||||||
| 					options.ModelBinderProviders.Insert(0, new FilterBinder.Provider()); |  | ||||||
| 				}) |  | ||||||
| 				.AddJsonOptions(x => |  | ||||||
| 				{ |  | ||||||
| 					x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver() |  | ||||||
| 					{ |  | ||||||
| 						Modifiers = { WithKindResolver.HandleLoadableFields } |  | ||||||
| 					}; |  | ||||||
| 					x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); |  | ||||||
| 					x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; |  | ||||||
| 				}) |  | ||||||
| 				.AddDataAnnotations() |  | ||||||
| 				.AddControllersAsServices() |  | ||||||
| 				.AddApiExplorer() |  | ||||||
| 				.ConfigureApiBehaviorOptions(options => |  | ||||||
| 				{ |  | ||||||
| 					options.SuppressMapClientErrors = true; |  | ||||||
| 					options.InvalidModelStateResponseFactory = ctx => |  | ||||||
| 					{ |  | ||||||
| 						string[] errors = ctx |  | ||||||
| 							.ModelState.SelectMany(x => x.Value!.Errors) |  | ||||||
| 							.Select(x => x.ErrorMessage) |  | ||||||
| 							.ToArray(); |  | ||||||
| 						return new BadRequestObjectResult(new RequestError(errors)); |  | ||||||
| 					}; |  | ||||||
| 				}); |  | ||||||
| 
 |  | ||||||
| 			services.Configure<RouteOptions>(x => |  | ||||||
| 			{ |  | ||||||
| 				x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			services.AddResponseCompression(x => |  | ||||||
| 			{ |  | ||||||
| 				x.EnableForHttps = true; |  | ||||||
| 			}); |  | ||||||
| 
 |  | ||||||
| 			services.AddProxies(); |  | ||||||
| 			services.AddHttpClient(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IEnumerable<IStartupAction> ConfigureSteps => |  | ||||||
| 			new IStartupAction[] |  | ||||||
| 			{ |  | ||||||
| 				SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before), |  | ||||||
| 				SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1), |  | ||||||
| 				SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing), |  | ||||||
| 				SA.New<IApplicationBuilder>( |  | ||||||
| 					app => app.UseEndpoints(x => x.MapControllers()), |  | ||||||
| 					SA.Endpoint |  | ||||||
| 				) |  | ||||||
| 			}; |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public void Configure(IServiceCollection services) | ||||||
|  | 	{ | ||||||
|  | 		services.AddHttpContextAccessor(); | ||||||
|  | 
 | ||||||
|  | 		services | ||||||
|  | 			.AddMvcCore(options => | ||||||
|  | 			{ | ||||||
|  | 				options.Filters.Add<ExceptionFilter>(); | ||||||
|  | 				options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); | ||||||
|  | 				options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider()); | ||||||
|  | 				options.ModelBinderProviders.Insert(0, new FilterBinder.Provider()); | ||||||
|  | 			}) | ||||||
|  | 			.AddJsonOptions(x => | ||||||
|  | 			{ | ||||||
|  | 				x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver() | ||||||
|  | 				{ | ||||||
|  | 					Modifiers = { WithKindResolver.HandleLoadableFields } | ||||||
|  | 				}; | ||||||
|  | 				x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); | ||||||
|  | 				x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; | ||||||
|  | 			}) | ||||||
|  | 			.AddDataAnnotations() | ||||||
|  | 			.AddControllersAsServices() | ||||||
|  | 			.AddApiExplorer() | ||||||
|  | 			.ConfigureApiBehaviorOptions(options => | ||||||
|  | 			{ | ||||||
|  | 				options.SuppressMapClientErrors = true; | ||||||
|  | 				options.InvalidModelStateResponseFactory = ctx => | ||||||
|  | 				{ | ||||||
|  | 					string[] errors = ctx | ||||||
|  | 						.ModelState.SelectMany(x => x.Value!.Errors) | ||||||
|  | 						.Select(x => x.ErrorMessage) | ||||||
|  | 						.ToArray(); | ||||||
|  | 					return new BadRequestObjectResult(new RequestError(errors)); | ||||||
|  | 				}; | ||||||
|  | 			}); | ||||||
|  | 
 | ||||||
|  | 		services.Configure<RouteOptions>(x => | ||||||
|  | 		{ | ||||||
|  | 			x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		services.AddResponseCompression(x => | ||||||
|  | 		{ | ||||||
|  | 			x.EnableForHttps = true; | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 		services.AddProxies(); | ||||||
|  | 		services.AddHttpClient(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IEnumerable<IStartupAction> ConfigureSteps => | ||||||
|  | 		new IStartupAction[] | ||||||
|  | 		{ | ||||||
|  | 			SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before), | ||||||
|  | 			SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1), | ||||||
|  | 			SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing), | ||||||
|  | 			SA.New<IApplicationBuilder>( | ||||||
|  | 				app => app.UseEndpoints(x => x.MapControllers()), | ||||||
|  | 				SA.Endpoint | ||||||
|  | 			) | ||||||
|  | 		}; | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,59 +25,58 @@ using Microsoft.AspNetCore.Mvc; | |||||||
| using Microsoft.AspNetCore.Mvc.Filters; | using Microsoft.AspNetCore.Mvc.Filters; | ||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core | namespace Kyoo.Core; | ||||||
| { |  | ||||||
| 	/// <summary> |  | ||||||
| 	/// A middleware to handle errors globally. |  | ||||||
| 	/// </summary> |  | ||||||
| 	/// <remarks> |  | ||||||
| 	/// Initializes a new instance of the <see cref="ExceptionFilter"/> class. |  | ||||||
| 	/// </remarks> |  | ||||||
| 	/// <param name="logger">The logger used to log errors.</param> |  | ||||||
| 	public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter |  | ||||||
| 	{ |  | ||||||
| 		/// <inheritdoc/> |  | ||||||
| 		public void OnException(ExceptionContext context) |  | ||||||
| 		{ |  | ||||||
| 			switch (context.Exception) |  | ||||||
| 			{ |  | ||||||
| 				case ValidationException ex: |  | ||||||
| 					context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); |  | ||||||
| 					break; |  | ||||||
| 				case ItemNotFoundException ex: |  | ||||||
| 					context.Result = new NotFoundObjectResult(new RequestError(ex.Message)); |  | ||||||
| 					break; |  | ||||||
| 				case DuplicatedItemException ex when ex.Existing is not null: |  | ||||||
| 					context.Result = new ConflictObjectResult(ex.Existing); |  | ||||||
| 					break; |  | ||||||
| 				case DuplicatedItemException: |  | ||||||
| 					// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content |  | ||||||
| 					context.Result = new ConflictObjectResult(new RequestError("Duplicated item")); |  | ||||||
| 					break; |  | ||||||
| 				case UnauthorizedException ex: |  | ||||||
| 					context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message)); |  | ||||||
| 					break; |  | ||||||
| 				case Exception ex: |  | ||||||
| 					logger.LogError(ex, "Unhandled error"); |  | ||||||
| 					context.Result = new ServerErrorObjectResult( |  | ||||||
| 						new RequestError("Internal Server Error") |  | ||||||
| 					); |  | ||||||
| 					break; |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | /// <summary> | ||||||
| 		public class ServerErrorObjectResult : ObjectResult | /// A middleware to handle errors globally. | ||||||
|  | /// </summary> | ||||||
|  | /// <remarks> | ||||||
|  | /// Initializes a new instance of the <see cref="ExceptionFilter"/> class. | ||||||
|  | /// </remarks> | ||||||
|  | /// <param name="logger">The logger used to log errors.</param> | ||||||
|  | public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter | ||||||
|  | { | ||||||
|  | 	/// <inheritdoc/> | ||||||
|  | 	public void OnException(ExceptionContext context) | ||||||
|  | 	{ | ||||||
|  | 		switch (context.Exception) | ||||||
| 		{ | 		{ | ||||||
| 			/// <summary> | 			case ValidationException ex: | ||||||
| 			/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class. | 				context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); | ||||||
| 			/// </summary> | 				break; | ||||||
| 			/// <param name="value">The object to return.</param> | 			case ItemNotFoundException ex: | ||||||
| 			public ServerErrorObjectResult(object value) | 				context.Result = new NotFoundObjectResult(new RequestError(ex.Message)); | ||||||
| 				: base(value) | 				break; | ||||||
| 			{ | 			case DuplicatedItemException ex when ex.Existing is not null: | ||||||
| 				StatusCode = StatusCodes.Status500InternalServerError; | 				context.Result = new ConflictObjectResult(ex.Existing); | ||||||
| 			} | 				break; | ||||||
|  | 			case DuplicatedItemException: | ||||||
|  | 				// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content | ||||||
|  | 				context.Result = new ConflictObjectResult(new RequestError("Duplicated item")); | ||||||
|  | 				break; | ||||||
|  | 			case UnauthorizedException ex: | ||||||
|  | 				context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message)); | ||||||
|  | 				break; | ||||||
|  | 			case Exception ex: | ||||||
|  | 				logger.LogError(ex, "Unhandled error"); | ||||||
|  | 				context.Result = new ServerErrorObjectResult( | ||||||
|  | 					new RequestError("Internal Server Error") | ||||||
|  | 				); | ||||||
|  | 				break; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public class ServerErrorObjectResult : ObjectResult | ||||||
|  | 	{ | ||||||
|  | 		/// <summary> | ||||||
|  | 		/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class. | ||||||
|  | 		/// </summary> | ||||||
|  | 		/// <param name="value">The object to return.</param> | ||||||
|  | 		public ServerErrorObjectResult(object value) | ||||||
|  | 			: base(value) | ||||||
|  | 		{ | ||||||
|  | 			StatusCode = StatusCodes.Status500InternalServerError; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -22,54 +22,53 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.Extensions.Diagnostics.HealthChecks; | using Microsoft.Extensions.Diagnostics.HealthChecks; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An API endpoint to check the health. | ||||||
|  | /// </summary> | ||||||
|  | [Route("health")] | ||||||
|  | [ApiController] | ||||||
|  | [ApiDefinition("Health")] | ||||||
|  | public class Health : BaseApi | ||||||
| { | { | ||||||
|  | 	private readonly HealthCheckService _healthCheckService; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An API endpoint to check the health. | 	/// Create a new <see cref="Health"/>. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("health")] | 	/// <param name="healthCheckService">The service to check health.</param> | ||||||
| 	[ApiController] | 	public Health(HealthCheckService healthCheckService) | ||||||
| 	[ApiDefinition("Health")] |  | ||||||
| 	public class Health : BaseApi |  | ||||||
| 	{ | 	{ | ||||||
| 		private readonly HealthCheckService _healthCheckService; | 		_healthCheckService = healthCheckService; | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="Health"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="healthCheckService">The service to check health.</param> |  | ||||||
| 		public Health(HealthCheckService healthCheckService) |  | ||||||
| 		{ |  | ||||||
| 			_healthCheckService = healthCheckService; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Check if the api is ready to accept requests. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <returns>A status indicating the health of the api.</returns> |  | ||||||
| 		[HttpGet] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] |  | ||||||
| 		public async Task<IActionResult> CheckHealth() |  | ||||||
| 		{ |  | ||||||
| 			IHeaderDictionary headers = HttpContext.Response.Headers; |  | ||||||
| 			headers.CacheControl = "no-store, no-cache"; |  | ||||||
| 			headers.Pragma = "no-cache"; |  | ||||||
| 			headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT"; |  | ||||||
| 
 |  | ||||||
| 			HealthReport result = await _healthCheckService.CheckHealthAsync(); |  | ||||||
| 			return result.Status switch |  | ||||||
| 			{ |  | ||||||
| 				HealthStatus.Healthy => Ok(new HealthResult("Healthy")), |  | ||||||
| 				HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")), |  | ||||||
| 				HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable), |  | ||||||
| 				_ => StatusCode(StatusCodes.Status500InternalServerError), |  | ||||||
| 			}; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The result of a health operation. |  | ||||||
| 		/// </summary> |  | ||||||
| 		public record HealthResult(string Status); |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Check if the api is ready to accept requests. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <returns>A status indicating the health of the api.</returns> | ||||||
|  | 	[HttpGet] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] | ||||||
|  | 	public async Task<IActionResult> CheckHealth() | ||||||
|  | 	{ | ||||||
|  | 		IHeaderDictionary headers = HttpContext.Response.Headers; | ||||||
|  | 		headers.CacheControl = "no-store, no-cache"; | ||||||
|  | 		headers.Pragma = "no-cache"; | ||||||
|  | 		headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT"; | ||||||
|  | 
 | ||||||
|  | 		HealthReport result = await _healthCheckService.CheckHealthAsync(); | ||||||
|  | 		return result.Status switch | ||||||
|  | 		{ | ||||||
|  | 			HealthStatus.Healthy => Ok(new HealthResult("Healthy")), | ||||||
|  | 			HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")), | ||||||
|  | 			HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable), | ||||||
|  | 			_ => StatusCode(StatusCodes.Status500InternalServerError), | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The result of a health operation. | ||||||
|  | 	/// </summary> | ||||||
|  | 	public record HealthResult(string Status); | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,76 +25,75 @@ using Kyoo.Abstractions.Models; | |||||||
| using Kyoo.Utils; | using Kyoo.Utils; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A common API containing custom methods to help inheritors. | ||||||
|  | /// </summary> | ||||||
|  | public abstract class BaseApi : ControllerBase | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A common API containing custom methods to help inheritors. | 	/// Construct and return a page from an api. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public abstract class BaseApi : ControllerBase | 	/// <param name="resources">The list of resources that should be included in the current page.</param> | ||||||
|  | 	/// <param name="limit"> | ||||||
|  | 	/// The max number of items that should be present per page. This should be the same as in the request, | ||||||
|  | 	/// it is used to calculate if this is the last page and so on. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <typeparam name="TResult">The type of items on the page.</typeparam> | ||||||
|  | 	/// <returns>A Page representing the response.</returns> | ||||||
|  | 	protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit) | ||||||
|  | 		where TResult : IResource | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Dictionary<string, string> query = Request.Query.ToDictionary( | ||||||
| 		/// Construct and return a page from an api. | 			x => x.Key, | ||||||
| 		/// </summary> | 			x => x.Value.ToString(), | ||||||
| 		/// <param name="resources">The list of resources that should be included in the current page.</param> | 			StringComparer.InvariantCultureIgnoreCase | ||||||
| 		/// <param name="limit"> | 		); | ||||||
| 		/// The max number of items that should be present per page. This should be the same as in the request, | 
 | ||||||
| 		/// it is used to calculate if this is the last page and so on. | 		// If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) | ||||||
| 		/// </param> | 		if (query.ContainsKey("sortBy")) | ||||||
| 		/// <typeparam name="TResult">The type of items on the page.</typeparam> |  | ||||||
| 		/// <returns>A Page representing the response.</returns> |  | ||||||
| 		protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit) |  | ||||||
| 			where TResult : IResource |  | ||||||
| 		{ | 		{ | ||||||
| 			Dictionary<string, string> query = Request.Query.ToDictionary( | 			object seed = HttpContext.Items["seed"]!; | ||||||
| 				x => x.Key, |  | ||||||
| 				x => x.Value.ToString(), |  | ||||||
| 				StringComparer.InvariantCultureIgnoreCase |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			// If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) | 			query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); | ||||||
| 			if (query.ContainsKey("sortBy")) | 		} | ||||||
| 			{ | 		return new Page<TResult>(resources, Request.Path, query, limit); | ||||||
| 				object seed = HttpContext.Items["seed"]!; | 	} | ||||||
| 
 | 
 | ||||||
| 				query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); | 	protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result) | ||||||
| 			} | 		where TResult : IResource | ||||||
| 			return new Page<TResult>(resources, Request.Path, query, limit); | 	{ | ||||||
|  | 		Dictionary<string, string> query = Request.Query.ToDictionary( | ||||||
|  | 			x => x.Key, | ||||||
|  | 			x => x.Value.ToString(), | ||||||
|  | 			StringComparer.InvariantCultureIgnoreCase | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		string self = Request.Path + query.ToQueryString(); | ||||||
|  | 		string? previous = null; | ||||||
|  | 		string? next = null; | ||||||
|  | 		string first; | ||||||
|  | 		int limit = query.TryGetValue("limit", out string? limitStr) | ||||||
|  | 			? int.Parse(limitStr) | ||||||
|  | 			: new SearchPagination().Limit; | ||||||
|  | 		int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null; | ||||||
|  | 
 | ||||||
|  | 		if (skip != null) | ||||||
|  | 		{ | ||||||
|  | 			query["skip"] = Math.Max(0, skip.Value - limit).ToString(); | ||||||
|  | 			previous = Request.Path + query.ToQueryString(); | ||||||
|  | 		} | ||||||
|  | 		if (result.Items.Count == limit && limit > 0) | ||||||
|  | 		{ | ||||||
|  | 			int newSkip = skip.HasValue ? skip.Value + limit : limit; | ||||||
|  | 			query["skip"] = newSkip.ToString(); | ||||||
|  | 			next = Request.Path + query.ToQueryString(); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result) | 		query.Remove("skip"); | ||||||
| 			where TResult : IResource | 		first = Request.Path + query.ToQueryString(); | ||||||
| 		{ |  | ||||||
| 			Dictionary<string, string> query = Request.Query.ToDictionary( |  | ||||||
| 				x => x.Key, |  | ||||||
| 				x => x.Value.ToString(), |  | ||||||
| 				StringComparer.InvariantCultureIgnoreCase |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			string self = Request.Path + query.ToQueryString(); | 		return new SearchPage<TResult>(result, self, previous, next, first); | ||||||
| 			string? previous = null; |  | ||||||
| 			string? next = null; |  | ||||||
| 			string first; |  | ||||||
| 			int limit = query.TryGetValue("limit", out string? limitStr) |  | ||||||
| 				? int.Parse(limitStr) |  | ||||||
| 				: new SearchPagination().Limit; |  | ||||||
| 			int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null; |  | ||||||
| 
 |  | ||||||
| 			if (skip != null) |  | ||||||
| 			{ |  | ||||||
| 				query["skip"] = Math.Max(0, skip.Value - limit).ToString(); |  | ||||||
| 				previous = Request.Path + query.ToQueryString(); |  | ||||||
| 			} |  | ||||||
| 			if (result.Items.Count == limit && limit > 0) |  | ||||||
| 			{ |  | ||||||
| 				int newSkip = skip.HasValue ? skip.Value + limit : limit; |  | ||||||
| 				query["skip"] = newSkip.ToString(); |  | ||||||
| 				next = Request.Path + query.ToQueryString(); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			query.Remove("skip"); |  | ||||||
| 			first = Request.Path + query.ToQueryString(); |  | ||||||
| 
 |  | ||||||
| 			return new SearchPage<TResult>(result, self, previous, next, first); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -27,250 +27,246 @@ using Kyoo.Models; | |||||||
| using Microsoft.AspNetCore.Http; | using Microsoft.AspNetCore.Http; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam> | ||||||
|  | [ApiController] | ||||||
|  | public class CrudApi<T> : BaseApi | ||||||
|  | 	where T : class, IResource, IQuery | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>. | 	/// The repository of the resource, used to retrieve, save and do operations on the baking store. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam> | 	protected IRepository<T> Repository { get; } | ||||||
| 	[ApiController] | 
 | ||||||
| 	public class CrudApi<T> : BaseApi | 	/// <summary> | ||||||
| 		where T : class, IResource, IQuery | 	/// Create a new <see cref="CrudApi{T}"/> using the given repository and base url. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="repository"> | ||||||
|  | 	/// The repository to use as a baking store for the type <typeparamref name="T"/>. | ||||||
|  | 	/// </param> | ||||||
|  | 	public CrudApi(IRepository<T> repository) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		Repository = repository; | ||||||
| 		/// The repository of the resource, used to retrieve, save and do operations on the baking store. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		protected IRepository<T> Repository { get; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="CrudApi{T}"/> using the given repository and base url. | 	/// Get item | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="repository"> | 	/// <remarks> | ||||||
| 		/// The repository to use as a baking store for the type <typeparamref name="T"/>. | 	/// Get a specific resource via it's ID or it's slug. | ||||||
| 		/// </param> | 	/// </remarks> | ||||||
| 		public CrudApi(IRepository<T> repository) | 	/// <param name="identifier">The ID or slug of the resource to retrieve.</param> | ||||||
| 		{ | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 			Repository = repository; | 	/// <returns>The retrieved resource.</returns> | ||||||
| 		} | 	/// <response code="404">A resource with the given ID or slug does not exist.</response> | ||||||
|  | 	[HttpGet("{identifier:id}")] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<T>> Get(Identifier identifier, [FromQuery] Include<T>? fields) | ||||||
|  | 	{ | ||||||
|  | 		T? ret = await identifier.Match( | ||||||
|  | 			id => Repository.GetOrDefault(id, fields), | ||||||
|  | 			slug => Repository.GetOrDefault(slug, fields) | ||||||
|  | 		); | ||||||
|  | 		if (ret == null) | ||||||
|  | 			return NotFound(); | ||||||
|  | 		return ret; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get item | 	/// Get count | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get a specific resource via it's ID or it's slug. | 	/// Get the number of resources that match the filters. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the resource to retrieve.</param> | 	/// <param name="filter">A list of filters to respect.</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <returns>How many resources matched that filter.</returns> | ||||||
| 		/// <returns>The retrieved resource.</returns> | 	/// <response code="400">Invalid filters.</response> | ||||||
| 		/// <response code="404">A resource with the given ID or slug does not exist.</response> | 	[HttpGet("count")] | ||||||
| 		[HttpGet("{identifier:id}")] | 	[PartialPermission(Kind.Read)] | ||||||
| 		[PartialPermission(Kind.Read)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter) | ||||||
| 		public async Task<ActionResult<T>> Get( | 	{ | ||||||
| 			Identifier identifier, | 		return await Repository.GetCount(filter); | ||||||
| 			[FromQuery] Include<T>? fields | 	} | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			T? ret = await identifier.Match( |  | ||||||
| 				id => Repository.GetOrDefault(id, fields), |  | ||||||
| 				slug => Repository.GetOrDefault(slug, fields) |  | ||||||
| 			); |  | ||||||
| 			if (ret == null) |  | ||||||
| 				return NotFound(); |  | ||||||
| 			return ret; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get count | 	/// Get all | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get the number of resources that match the filters. | 	/// Get all resources that match the given filter. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="filter">A list of filters to respect.</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <returns>How many resources matched that filter.</returns> | 	/// <param name="filter">Filter the returned items.</param> | ||||||
| 		/// <response code="400">Invalid filters.</response> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		[HttpGet("count")] | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		[PartialPermission(Kind.Read)] | 	/// <returns>A list of resources that match every filters.</returns> | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	/// <response code="400">Invalid filters or sort information.</response> | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 	[HttpGet] | ||||||
| 		public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter) | 	[PartialPermission(Kind.Read)] | ||||||
| 		{ | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 			return await Repository.GetCount(filter); | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 		} | 	public async Task<ActionResult<Page<T>>> GetAll( | ||||||
|  | 		[FromQuery] Sort<T> sortBy, | ||||||
|  | 		[FromQuery] Filter<T>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<T>? fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		return Page(resources, pagination.Limit); | ||||||
| 		/// Get all | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Get all resources that match the given filter. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> |  | ||||||
| 		/// <param name="filter">Filter the returned items.</param> |  | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A list of resources that match every filters.</returns> |  | ||||||
| 		/// <response code="400">Invalid filters or sort information.</response> |  | ||||||
| 		[HttpGet] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<Page<T>>> GetAll( |  | ||||||
| 			[FromQuery] Sort<T> sortBy, |  | ||||||
| 			[FromQuery] Filter<T>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<T>? fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination); |  | ||||||
| 
 | 
 | ||||||
| 			return Page(resources, pagination.Limit); | 	/// <summary> | ||||||
| 		} | 	/// Create new | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="resource">The resource to create.</param> | ||||||
|  | 	/// <returns>The created resource.</returns> | ||||||
|  | 	/// <response code="400">The resource in the request body is invalid.</response> | ||||||
|  | 	/// <response code="409">This item already exists (maybe a duplicated slug).</response> | ||||||
|  | 	[HttpPost] | ||||||
|  | 	[PartialPermission(Kind.Create)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] | ||||||
|  | 	public virtual async Task<ActionResult<T>> Create([FromBody] T resource) | ||||||
|  | 	{ | ||||||
|  | 		return await Repository.Create(resource); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create new | 	/// Edit | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. | 	/// Edit an item. If the ID is specified it will be used to identify the resource. | ||||||
| 		/// </remarks> | 	/// If not, the slug will be used to identify it. | ||||||
| 		/// <param name="resource">The resource to create.</param> | 	/// </remarks> | ||||||
| 		/// <returns>The created resource.</returns> | 	/// <param name="resource">The resource to edit.</param> | ||||||
| 		/// <response code="400">The resource in the request body is invalid.</response> | 	/// <returns>The edited resource.</returns> | ||||||
| 		/// <response code="409">This item already exists (maybe a duplicated slug).</response> | 	/// <response code="400">The resource in the request body is invalid.</response> | ||||||
| 		[HttpPost] | 	/// <response code="404">No item found with the specified ID (or slug).</response> | ||||||
| 		[PartialPermission(Kind.Create)] | 	[HttpPut] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[PartialPermission(Kind.Write)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 		public virtual async Task<ActionResult<T>> Create([FromBody] T resource) | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		{ | 	public async Task<ActionResult<T>> Edit([FromBody] T resource) | ||||||
| 			return await Repository.Create(resource); | 	{ | ||||||
| 		} | 		if (resource.Id != Guid.Empty) | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Edit |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Edit an item. If the ID is specified it will be used to identify the resource. |  | ||||||
| 		/// If not, the slug will be used to identify it. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="resource">The resource to edit.</param> |  | ||||||
| 		/// <returns>The edited resource.</returns> |  | ||||||
| 		/// <response code="400">The resource in the request body is invalid.</response> |  | ||||||
| 		/// <response code="404">No item found with the specified ID (or slug).</response> |  | ||||||
| 		[HttpPut] |  | ||||||
| 		[PartialPermission(Kind.Write)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<T>> Edit([FromBody] T resource) |  | ||||||
| 		{ |  | ||||||
| 			if (resource.Id != Guid.Empty) |  | ||||||
| 				return await Repository.Edit(resource); |  | ||||||
| 
 |  | ||||||
| 			T old = await Repository.Get(resource.Slug); |  | ||||||
| 			resource.Id = old.Id; |  | ||||||
| 			return await Repository.Edit(resource); | 			return await Repository.Edit(resource); | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		T old = await Repository.Get(resource.Slug); | ||||||
| 		/// Patch | 		resource.Id = old.Id; | ||||||
| 		/// </summary> | 		return await Repository.Edit(resource); | ||||||
| 		/// <remarks> | 	} | ||||||
| 		/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. |  | ||||||
| 		/// If not, the slug will be used to identify it. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="patch">The resource to patch.</param> |  | ||||||
| 		/// <returns>The edited resource.</returns> |  | ||||||
| 		/// <response code="400">The resource in the request body is invalid.</response> |  | ||||||
| 		/// <response code="404">No item found with the specified ID (or slug).</response> |  | ||||||
| 		[HttpPatch] |  | ||||||
| 		[PartialPermission(Kind.Write)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch) |  | ||||||
| 		{ |  | ||||||
| 			if (patch.Id.HasValue) |  | ||||||
| 				return await Repository.Patch(patch.Id.Value, patch.Apply); |  | ||||||
| 			if (patch.Slug == null) |  | ||||||
| 				throw new ArgumentException( |  | ||||||
| 					"Either the Id or the slug of the resource has to be defined to edit it." |  | ||||||
| 				); |  | ||||||
| 
 | 
 | ||||||
| 			T old = await Repository.Get(patch.Slug); | 	/// <summary> | ||||||
| 			return await Repository.Patch(old.Id, patch.Apply); | 	/// Patch | ||||||
| 		} | 	/// </summary> | ||||||
| 
 | 	/// <remarks> | ||||||
| 		/// <summary> | 	/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. | ||||||
| 		/// Patch | 	/// If not, the slug will be used to identify it. | ||||||
| 		/// </summary> | 	/// </remarks> | ||||||
| 		/// <remarks> | 	/// <param name="patch">The resource to patch.</param> | ||||||
| 		/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. | 	/// <returns>The edited resource.</returns> | ||||||
| 		/// If not, the slug will be used to identify it. | 	/// <response code="400">The resource in the request body is invalid.</response> | ||||||
| 		/// </remarks> | 	/// <response code="404">No item found with the specified ID (or slug).</response> | ||||||
| 		/// <param name="identifier">The id or slug of the resource.</param> | 	[HttpPatch] | ||||||
| 		/// <param name="patch">The resource to patch.</param> | 	[PartialPermission(Kind.Write)] | ||||||
| 		/// <returns>The edited resource.</returns> | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		/// <response code="400">The resource in the request body is invalid.</response> | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 		/// <response code="404">No item found with the specified ID (or slug).</response> | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		[HttpPatch("{identifier:id}")] | 	public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch) | ||||||
| 		[PartialPermission(Kind.Write)] | 	{ | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 		if (patch.Id.HasValue) | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 			return await Repository.Patch(patch.Id.Value, patch.Apply); | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 		if (patch.Slug == null) | ||||||
| 		public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch) | 			throw new ArgumentException( | ||||||
| 		{ | 				"Either the Id or the slug of the resource has to be defined to edit it." | ||||||
| 			Guid id = await identifier.Match( |  | ||||||
| 				id => Task.FromResult(id), |  | ||||||
| 				async slug => (await Repository.Get(slug)).Id |  | ||||||
| 			); | 			); | ||||||
| 			if (patch.Id.HasValue && patch.Id.Value != id) |  | ||||||
| 				throw new ArgumentException("Can not edit id of a resource."); |  | ||||||
| 			return await Repository.Patch(id, patch.Apply); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		T old = await Repository.Get(patch.Slug); | ||||||
| 		/// Delete an item | 		return await Repository.Patch(old.Id, patch.Apply); | ||||||
| 		/// </summary> | 	} | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Delete one item via it's ID or it's slug. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the resource to delete.</param> |  | ||||||
| 		/// <returns>The item has successfully been deleted.</returns> |  | ||||||
| 		/// <response code="404">No item could be found with the given id or slug.</response> |  | ||||||
| 		[HttpDelete("{identifier:id}")] |  | ||||||
| 		[PartialPermission(Kind.Delete)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<IActionResult> Delete(Identifier identifier) |  | ||||||
| 		{ |  | ||||||
| 			await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug)); |  | ||||||
| 			return NoContent(); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Delete all where | 	/// Patch | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Delete all items matching the given filters. If no filter is specified, delete all items. | 	/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. | ||||||
| 		/// </remarks> | 	/// If not, the slug will be used to identify it. | ||||||
| 		/// <param name="filter">The list of filters.</param> | 	/// </remarks> | ||||||
| 		/// <returns>The item(s) has successfully been deleted.</returns> | 	/// <param name="identifier">The id or slug of the resource.</param> | ||||||
| 		/// <response code="400">One or multiple filters are invalid.</response> | 	/// <param name="patch">The resource to patch.</param> | ||||||
| 		[HttpDelete] | 	/// <returns>The edited resource.</returns> | ||||||
| 		[PartialPermission(Kind.Delete)] | 	/// <response code="400">The resource in the request body is invalid.</response> | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	/// <response code="404">No item found with the specified ID (or slug).</response> | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 	[HttpPatch("{identifier:id}")] | ||||||
| 		public async Task<IActionResult> Delete([FromQuery] Filter<T> filter) | 	[PartialPermission(Kind.Write)] | ||||||
| 		{ | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 			if (filter == null) | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 				return BadRequest( | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 					new RequestError("Incule a filter to delete items, all items won't be deleted.") | 	public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch) | ||||||
| 				); | 	{ | ||||||
|  | 		Guid id = await identifier.Match( | ||||||
|  | 			id => Task.FromResult(id), | ||||||
|  | 			async slug => (await Repository.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		if (patch.Id.HasValue && patch.Id.Value != id) | ||||||
|  | 			throw new ArgumentException("Can not edit id of a resource."); | ||||||
|  | 		return await Repository.Patch(id, patch.Apply); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 			await Repository.DeleteAll(filter); | 	/// <summary> | ||||||
| 			return NoContent(); | 	/// Delete an item | ||||||
| 		} | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Delete one item via it's ID or it's slug. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="identifier">The ID or slug of the resource to delete.</param> | ||||||
|  | 	/// <returns>The item has successfully been deleted.</returns> | ||||||
|  | 	/// <response code="404">No item could be found with the given id or slug.</response> | ||||||
|  | 	[HttpDelete("{identifier:id}")] | ||||||
|  | 	[PartialPermission(Kind.Delete)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<IActionResult> Delete(Identifier identifier) | ||||||
|  | 	{ | ||||||
|  | 		await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug)); | ||||||
|  | 		return NoContent(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Delete all where | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Delete all items matching the given filters. If no filter is specified, delete all items. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="filter">The list of filters.</param> | ||||||
|  | 	/// <returns>The item(s) has successfully been deleted.</returns> | ||||||
|  | 	/// <response code="400">One or multiple filters are invalid.</response> | ||||||
|  | 	[HttpDelete] | ||||||
|  | 	[PartialPermission(Kind.Delete)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<IActionResult> Delete([FromQuery] Filter<T> filter) | ||||||
|  | 	{ | ||||||
|  | 		if (filter == null) | ||||||
|  | 			return BadRequest( | ||||||
|  | 				new RequestError("Incule a filter to delete items, all items won't be deleted.") | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		await Repository.DeleteAll(filter); | ||||||
|  | 		return NoContent(); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,126 +26,119 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A base class to handle CRUD operations and services thumbnails for | ||||||
|  | /// a specific resource type <typeparamref name="T"/>. | ||||||
|  | /// </summary> | ||||||
|  | /// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam> | ||||||
|  | [ApiController] | ||||||
|  | public class CrudThumbsApi<T> : CrudApi<T> | ||||||
|  | 	where T : class, IResource, IThumbnails, IQuery | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A base class to handle CRUD operations and services thumbnails for | 	/// The thumbnail manager used to retrieve images paths. | ||||||
| 	/// a specific resource type <typeparamref name="T"/>. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam> | 	private readonly IThumbnailsManager _thumbs; | ||||||
| 	[ApiController] | 
 | ||||||
| 	public class CrudThumbsApi<T> : CrudApi<T> | 	/// <summary> | ||||||
| 		where T : class, IResource, IThumbnails, IQuery | 	/// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="repository"> | ||||||
|  | 	/// The repository to use as a baking store for the type <typeparamref name="T"/>. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | ||||||
|  | 	public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs) | ||||||
|  | 		: base(repository) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_thumbs = thumbs; | ||||||
| 		/// The thumbnail manager used to retrieve images paths. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly IThumbnailsManager _thumbs; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	private async Task<IActionResult> _GetImage( | ||||||
| 		/// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails. | 		Identifier identifier, | ||||||
| 		/// </summary> | 		string image, | ||||||
| 		/// <param name="repository"> | 		ImageQuality? quality | ||||||
| 		/// The repository to use as a baking store for the type <typeparamref name="T"/>. | 	) | ||||||
| 		/// </param> | 	{ | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | 		T? resource = await identifier.Match( | ||||||
| 		public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs) | 			id => Repository.GetOrDefault(id), | ||||||
| 			: base(repository) | 			slug => Repository.GetOrDefault(slug) | ||||||
|  | 		); | ||||||
|  | 		if (resource == null) | ||||||
|  | 			return NotFound(); | ||||||
|  | 		string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High); | ||||||
|  | 		if (!System.IO.File.Exists(path)) | ||||||
|  | 			return NotFound(); | ||||||
|  | 
 | ||||||
|  | 		if (!identifier.Match(id => false, slug => slug == "random")) | ||||||
| 		{ | 		{ | ||||||
| 			_thumbs = thumbs; | 			// Allow clients to cache the image for 6 month. | ||||||
|  | 			Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); | ||||||
| 		} | 		} | ||||||
|  | 		else | ||||||
|  | 			Response.Headers.Add("Cache-Control", $"public, no-store"); | ||||||
|  | 		return PhysicalFile(Path.GetFullPath(path), "image/webp", true); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		private async Task<IActionResult> _GetImage( | 	/// <summary> | ||||||
| 			Identifier identifier, | 	/// Get Poster | ||||||
| 			string image, | 	/// </summary> | ||||||
| 			ImageQuality? quality | 	/// <remarks> | ||||||
| 		) | 	/// Get the poster for the specified item. | ||||||
| 		{ | 	/// </remarks> | ||||||
| 			T? resource = await identifier.Match( | 	/// <param name="identifier">The ID or slug of the resource to get the image for.</param> | ||||||
| 				id => Repository.GetOrDefault(id), | 	/// <param name="quality">The quality of the image to retrieve.</param> | ||||||
| 				slug => Repository.GetOrDefault(slug) | 	/// <returns>The image asked.</returns> | ||||||
| 			); | 	/// <response code="404"> | ||||||
| 			if (resource == null) | 	/// No item exist with the specific identifier or the image does not exists on kyoo. | ||||||
| 				return NotFound(); | 	/// </response> | ||||||
| 			string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High); | 	[HttpGet("{identifier:id}/poster")] | ||||||
| 			if (!System.IO.File.Exists(path)) | 	[PartialPermission(Kind.Read)] | ||||||
| 				return NotFound(); | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality) | ||||||
|  | 	{ | ||||||
|  | 		return _GetImage(identifier, "poster", quality); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 			if (!identifier.Match(id => false, slug => slug == "random")) | 	/// <summary> | ||||||
| 			{ | 	/// Get Logo | ||||||
| 				// Allow clients to cache the image for 6 month. | 	/// </summary> | ||||||
| 				Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); | 	/// <remarks> | ||||||
| 			} | 	/// Get the logo for the specified item. | ||||||
| 			else | 	/// </remarks> | ||||||
| 				Response.Headers.Add("Cache-Control", $"public, no-store"); | 	/// <param name="identifier">The ID or slug of the resource to get the image for.</param> | ||||||
| 			return PhysicalFile(Path.GetFullPath(path), "image/webp", true); | 	/// <param name="quality">The quality of the image to retrieve.</param> | ||||||
| 		} | 	/// <returns>The image asked.</returns> | ||||||
|  | 	/// <response code="404"> | ||||||
|  | 	/// No item exist with the specific identifier or the image does not exists on kyoo. | ||||||
|  | 	/// </response> | ||||||
|  | 	[HttpGet("{identifier:id}/logo")] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) | ||||||
|  | 	{ | ||||||
|  | 		return _GetImage(identifier, "logo", quality); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get Poster | 	/// Get Thumbnail | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get the poster for the specified item. | 	/// Get the thumbnail for the specified item. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the resource to get the image for.</param> | 	/// <param name="identifier">The ID or slug of the resource to get the image for.</param> | ||||||
| 		/// <param name="quality">The quality of the image to retrieve.</param> | 	/// <param name="quality">The quality of the image to retrieve.</param> | ||||||
| 		/// <returns>The image asked.</returns> | 	/// <returns>The image asked.</returns> | ||||||
| 		/// <response code="404"> | 	/// <response code="404"> | ||||||
| 		/// No item exist with the specific identifier or the image does not exists on kyoo. | 	/// No item exist with the specific identifier or the image does not exists on kyoo. | ||||||
| 		/// </response> | 	/// </response> | ||||||
| 		[HttpGet("{identifier:id}/poster")] | 	[HttpGet("{identifier:id}/thumbnail")] | ||||||
| 		[PartialPermission(Kind.Read)] | 	[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality) | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	{ | ||||||
| 		public Task<IActionResult> GetPoster( | 		return _GetImage(identifier, "thumbnail", quality); | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] ImageQuality? quality |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return _GetImage(identifier, "poster", quality); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get Logo |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Get the logo for the specified item. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the resource to get the image for.</param> |  | ||||||
| 		/// <param name="quality">The quality of the image to retrieve.</param> |  | ||||||
| 		/// <returns>The image asked.</returns> |  | ||||||
| 		/// <response code="404"> |  | ||||||
| 		/// No item exist with the specific identifier or the image does not exists on kyoo. |  | ||||||
| 		/// </response> |  | ||||||
| 		[HttpGet("{identifier:id}/logo")] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) |  | ||||||
| 		{ |  | ||||||
| 			return _GetImage(identifier, "logo", quality); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get Thumbnail |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Get the thumbnail for the specified item. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the resource to get the image for.</param> |  | ||||||
| 		/// <param name="quality">The quality of the image to retrieve.</param> |  | ||||||
| 		/// <returns>The image asked.</returns> |  | ||||||
| 		/// <response code="404"> |  | ||||||
| 		/// No item exist with the specific identifier or the image does not exists on kyoo. |  | ||||||
| 		/// </response> |  | ||||||
| 		[HttpGet("{identifier:id}/thumbnail")] |  | ||||||
| 		[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] |  | ||||||
| 		public Task<IActionResult> GetBackdrop( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] ImageQuality? quality |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return _GetImage(identifier, "thumbnail", quality); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -28,76 +28,75 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Studio"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("studios")] | ||||||
|  | [Route("studio", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Show))] | ||||||
|  | [ApiDefinition("Studios", Group = MetadataGroup)] | ||||||
|  | public class StudioApi : CrudApi<Studio> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about one or multiple <see cref="Studio"/>. | 	/// The library manager used to modify or retrieve information in the data store. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("studios")] | 	private readonly ILibraryManager _libraryManager; | ||||||
| 	[Route("studio", Order = AlternativeRoute)] | 
 | ||||||
| 	[ApiController] | 	/// <summary> | ||||||
| 	[PartialPermission(nameof(Show))] | 	/// Create a new <see cref="StudioApi"/>. | ||||||
| 	[ApiDefinition("Studios", Group = MetadataGroup)] | 	/// </summary> | ||||||
| 	public class StudioApi : CrudApi<Studio> | 	/// <param name="libraryManager"> | ||||||
|  | 	/// The library manager used to modify or retrieve information in the data store. | ||||||
|  | 	/// </param> | ||||||
|  | 	public StudioApi(ILibraryManager libraryManager) | ||||||
|  | 		: base(libraryManager.Studios) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_libraryManager = libraryManager; | ||||||
| 		/// The library manager used to modify or retrieve information in the data store. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly ILibraryManager _libraryManager; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="StudioApi"/>. | 	/// Get shows | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="libraryManager"> | 	/// <remarks> | ||||||
| 		/// The library manager used to modify or retrieve information in the data store. | 	/// List shows that were made by this specific studio. | ||||||
| 		/// </param> | 	/// </remarks> | ||||||
| 		public StudioApi(ILibraryManager libraryManager) | 	/// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param> | ||||||
| 			: base(libraryManager.Studios) | 	/// <param name="sortBy">A key to sort shows by.</param> | ||||||
| 		{ | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 			_libraryManager = libraryManager; | 	/// <param name="pagination">The number of shows to return.</param> | ||||||
| 		} | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A page of shows.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No studio with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/shows")] | ||||||
|  | 	[HttpGet("{identifier:id}/show", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Show>>> GetShows( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Show> sortBy, | ||||||
|  | 		[FromQuery] Filter<Show>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Show> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Show> resources = await _libraryManager.Shows.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Get shows | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null | ||||||
| 		/// <remarks> |  | ||||||
| 		/// List shows that were made by this specific studio. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort shows by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of shows to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of shows.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No studio with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/shows")] |  | ||||||
| 		[HttpGet("{identifier:id}/show", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Show>>> GetShows( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Show> sortBy, |  | ||||||
| 			[FromQuery] Filter<Show>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Show> fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			ICollection<Show> resources = await _libraryManager.Shows.GetAll( | 		return Page(resources, pagination.Limit); | ||||||
| 				Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)), |  | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 |  | ||||||
| 			if ( |  | ||||||
| 				!resources.Any() |  | ||||||
| 				&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null |  | ||||||
| 			) |  | ||||||
| 				return NotFound(); |  | ||||||
| 			return Page(resources, pagination.Limit); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -30,234 +30,233 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Collection"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("collections")] | ||||||
|  | [Route("collection", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Collection))] | ||||||
|  | [ApiDefinition("Collections", Group = ResourcesGroup)] | ||||||
|  | public class CollectionApi : CrudThumbsApi<Collection> | ||||||
| { | { | ||||||
| 	/// <summary> | 	private readonly ILibraryManager _libraryManager; | ||||||
| 	/// Information about one or multiple <see cref="Collection"/>. | 	private readonly CollectionRepository _collections; | ||||||
| 	/// </summary> | 	private readonly LibraryItemRepository _items; | ||||||
| 	[Route("collections")] | 
 | ||||||
| 	[Route("collection", Order = AlternativeRoute)] | 	public CollectionApi( | ||||||
| 	[ApiController] | 		ILibraryManager libraryManager, | ||||||
| 	[PartialPermission(nameof(Collection))] | 		CollectionRepository collections, | ||||||
| 	[ApiDefinition("Collections", Group = ResourcesGroup)] | 		LibraryItemRepository items, | ||||||
| 	public class CollectionApi : CrudThumbsApi<Collection> | 		IThumbnailsManager thumbs | ||||||
|  | 	) | ||||||
|  | 		: base(libraryManager.Collections, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		private readonly ILibraryManager _libraryManager; | 		_libraryManager = libraryManager; | ||||||
| 		private readonly CollectionRepository _collections; | 		_collections = collections; | ||||||
| 		private readonly LibraryItemRepository _items; | 		_items = items; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		public CollectionApi( | 	/// <summary> | ||||||
| 			ILibraryManager libraryManager, | 	/// Add a movie | ||||||
| 			CollectionRepository collections, | 	/// </summary> | ||||||
| 			LibraryItemRepository items, | 	/// <remarks> | ||||||
| 			IThumbnailsManager thumbs | 	/// Add a movie in the collection. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | ||||||
|  | 	/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param> | ||||||
|  | 	/// <returns>Nothing if successful.</returns> | ||||||
|  | 	/// <response code="404">No collection or movie with the given ID could be found.</response> | ||||||
|  | 	/// <response code="409">The specified movie is already in this collection.</response> | ||||||
|  | 	[HttpPut("{identifier:id}/movies/{movie:id}")] | ||||||
|  | 	[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Write)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status409Conflict)] | ||||||
|  | 	public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie) | ||||||
|  | 	{ | ||||||
|  | 		Guid collectionId = await identifier.Match( | ||||||
|  | 			async id => (await _libraryManager.Collections.Get(id)).Id, | ||||||
|  | 			async slug => (await _libraryManager.Collections.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		Guid movieId = await movie.Match( | ||||||
|  | 			async id => (await _libraryManager.Movies.Get(id)).Id, | ||||||
|  | 			async slug => (await _libraryManager.Movies.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		await _collections.AddMovie(collectionId, movieId); | ||||||
|  | 		return NoContent(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Add a show | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Add a show in the collection. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | ||||||
|  | 	/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param> | ||||||
|  | 	/// <returns>Nothing if successful.</returns> | ||||||
|  | 	/// <response code="404">No collection or show with the given ID could be found.</response> | ||||||
|  | 	/// <response code="409">The specified show is already in this collection.</response> | ||||||
|  | 	[HttpPut("{identifier:id}/shows/{show:id}")] | ||||||
|  | 	[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Write)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status409Conflict)] | ||||||
|  | 	public async Task<ActionResult> AddShow(Identifier identifier, Identifier show) | ||||||
|  | 	{ | ||||||
|  | 		Guid collectionId = await identifier.Match( | ||||||
|  | 			async id => (await _libraryManager.Collections.Get(id)).Id, | ||||||
|  | 			async slug => (await _libraryManager.Collections.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		Guid showId = await show.Match( | ||||||
|  | 			async id => (await _libraryManager.Shows.Get(id)).Id, | ||||||
|  | 			async slug => (await _libraryManager.Shows.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		await _collections.AddShow(collectionId, showId); | ||||||
|  | 		return NoContent(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Get items in collection | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Lists the items that are contained in the collection with the given id or slug. | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | ||||||
|  | 	/// <param name="sortBy">A key to sort items by.</param> | ||||||
|  | 	/// <param name="filter">An optional list of filters.</param> | ||||||
|  | 	/// <param name="pagination">The number of items to return.</param> | ||||||
|  | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A page of items.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No collection with the given ID could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/items")] | ||||||
|  | 	[HttpGet("{identifier:id}/item", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<ILibraryItem>>> GetItems( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<ILibraryItem> sortBy, | ||||||
|  | 		[FromQuery] Filter<ILibraryItem>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<ILibraryItem>? fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		Guid collectionId = await identifier.Match( | ||||||
|  | 			id => Task.FromResult(id), | ||||||
|  | 			async slug => (await _libraryManager.Collections.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		ICollection<ILibraryItem> resources = await _items.GetAllOfCollection( | ||||||
|  | 			collectionId, | ||||||
|  | 			filter, | ||||||
|  | 			sortBy == new Sort<ILibraryItem>.Default() | ||||||
|  | 				? new Sort<ILibraryItem>.By(nameof(Movie.AirDate)) | ||||||
|  | 				: sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		if ( | ||||||
|  | 			!resources.Any() | ||||||
|  | 			&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) | ||||||
|  | 				== null | ||||||
| 		) | 		) | ||||||
| 			: base(libraryManager.Collections, thumbs) | 			return NotFound(); | ||||||
| 		{ | 		return Page(resources, pagination.Limit); | ||||||
| 			_libraryManager = libraryManager; | 	} | ||||||
| 			_collections = collections; |  | ||||||
| 			_items = items; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Add a movie | 	/// Get shows in collection | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Add a movie in the collection. | 	/// Lists the shows that are contained in the collection with the given id or slug. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | ||||||
| 		/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param> | 	/// <param name="sortBy">A key to sort shows by.</param> | ||||||
| 		/// <returns>Nothing if successful.</returns> | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 		/// <response code="404">No collection or movie with the given ID could be found.</response> | 	/// <param name="pagination">The number of shows to return.</param> | ||||||
| 		/// <response code="409">The specified movie is already in this collection.</response> | 	/// <param name="fields">The additional fields to include in the result.</param> | ||||||
| 		[HttpPut("{identifier:id}/movies/{movie:id}")] | 	/// <returns>A page of shows.</returns> | ||||||
| 		[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)] | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
| 		[PartialPermission(Kind.Write)] | 	/// <response code="404">No collection with the given ID could be found.</response> | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[HttpGet("{identifier:id}/shows")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	[HttpGet("{identifier:id}/show", Order = AlternativeRoute)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status409Conflict)] | 	[PartialPermission(Kind.Read)] | ||||||
| 		public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie) | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		{ | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
| 			Guid collectionId = await identifier.Match( | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 				async id => (await _libraryManager.Collections.Get(id)).Id, | 	public async Task<ActionResult<Page<Show>>> GetShows( | ||||||
| 				async slug => (await _libraryManager.Collections.Get(slug)).Id | 		Identifier identifier, | ||||||
| 			); | 		[FromQuery] Sort<Show> sortBy, | ||||||
| 			Guid movieId = await movie.Match( | 		[FromQuery] Filter<Show>? filter, | ||||||
| 				async id => (await _libraryManager.Movies.Get(id)).Id, | 		[FromQuery] Pagination pagination, | ||||||
| 				async slug => (await _libraryManager.Movies.Get(slug)).Id | 		[FromQuery] Include<Show>? fields | ||||||
| 			); | 	) | ||||||
| 			await _collections.AddMovie(collectionId, movieId); | 	{ | ||||||
| 			return NoContent(); | 		ICollection<Show> resources = await _libraryManager.Shows.GetAll( | ||||||
| 		} | 			Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)), | ||||||
|  | 			sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Add a show | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) | ||||||
| 		/// <remarks> | 				== null | ||||||
| 		/// Add a show in the collection. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> |  | ||||||
| 		/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param> |  | ||||||
| 		/// <returns>Nothing if successful.</returns> |  | ||||||
| 		/// <response code="404">No collection or show with the given ID could be found.</response> |  | ||||||
| 		/// <response code="409">The specified show is already in this collection.</response> |  | ||||||
| 		[HttpPut("{identifier:id}/shows/{show:id}")] |  | ||||||
| 		[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Write)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status409Conflict)] |  | ||||||
| 		public async Task<ActionResult> AddShow(Identifier identifier, Identifier show) |  | ||||||
| 		{ |  | ||||||
| 			Guid collectionId = await identifier.Match( |  | ||||||
| 				async id => (await _libraryManager.Collections.Get(id)).Id, |  | ||||||
| 				async slug => (await _libraryManager.Collections.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			Guid showId = await show.Match( |  | ||||||
| 				async id => (await _libraryManager.Shows.Get(id)).Id, |  | ||||||
| 				async slug => (await _libraryManager.Shows.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			await _collections.AddShow(collectionId, showId); |  | ||||||
| 			return NoContent(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get items in collection |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Lists the items that are contained in the collection with the given id or slug. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort items by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of items to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of items.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No collection with the given ID could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/items")] |  | ||||||
| 		[HttpGet("{identifier:id}/item", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<ILibraryItem>>> GetItems( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<ILibraryItem> sortBy, |  | ||||||
| 			[FromQuery] Filter<ILibraryItem>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<ILibraryItem>? fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			Guid collectionId = await identifier.Match( | 		return Page(resources, pagination.Limit); | ||||||
| 				id => Task.FromResult(id), | 	} | ||||||
| 				async slug => (await _libraryManager.Collections.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			ICollection<ILibraryItem> resources = await _items.GetAllOfCollection( |  | ||||||
| 				collectionId, |  | ||||||
| 				filter, |  | ||||||
| 				sortBy == new Sort<ILibraryItem>.Default() |  | ||||||
| 					? new Sort<ILibraryItem>.By(nameof(Movie.AirDate)) |  | ||||||
| 					: sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Get movies in collection | ||||||
| 				&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) | 	/// </summary> | ||||||
| 					== null | 	/// <remarks> | ||||||
| 			) | 	/// Lists the movies that are contained in the collection with the given id or slug. | ||||||
| 				return NotFound(); | 	/// </remarks> | ||||||
| 			return Page(resources, pagination.Limit); | 	/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> | ||||||
| 		} | 	/// <param name="sortBy">A key to sort movies by.</param> | ||||||
|  | 	/// <param name="filter">An optional list of filters.</param> | ||||||
|  | 	/// <param name="pagination">The number of movies to return.</param> | ||||||
|  | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A page of movies.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No collection with the given ID could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/movies")] | ||||||
|  | 	[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Movie>>> GetMovies( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Movie> sortBy, | ||||||
|  | 		[FromQuery] Filter<Movie>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Movie>? fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Movie> resources = await _libraryManager.Movies.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)), | ||||||
|  | 			sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Get shows in collection | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) | ||||||
| 		/// <remarks> | 				== null | ||||||
| 		/// Lists the shows that are contained in the collection with the given id or slug. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort shows by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of shows to return.</param> |  | ||||||
| 		/// <param name="fields">The additional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of shows.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No collection with the given ID could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/shows")] |  | ||||||
| 		[HttpGet("{identifier:id}/show", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Show>>> GetShows( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Show> sortBy, |  | ||||||
| 			[FromQuery] Filter<Show>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Show>? fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			ICollection<Show> resources = await _libraryManager.Shows.GetAll( | 		return Page(resources, pagination.Limit); | ||||||
| 				Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)), |  | ||||||
| 				sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 |  | ||||||
| 			if ( |  | ||||||
| 				!resources.Any() |  | ||||||
| 				&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) |  | ||||||
| 					== null |  | ||||||
| 			) |  | ||||||
| 				return NotFound(); |  | ||||||
| 			return Page(resources, pagination.Limit); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Get movies in collection |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Lists the movies that are contained in the collection with the given id or slug. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort movies by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of movies to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of movies.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No collection with the given ID could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/movies")] |  | ||||||
| 		[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Movie>>> GetMovies( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Movie> sortBy, |  | ||||||
| 			[FromQuery] Filter<Movie>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Movie>? fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ICollection<Movie> resources = await _libraryManager.Movies.GetAll( |  | ||||||
| 				Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)), |  | ||||||
| 				sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 |  | ||||||
| 			if ( |  | ||||||
| 				!resources.Any() |  | ||||||
| 				&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) |  | ||||||
| 					== null |  | ||||||
| 			) |  | ||||||
| 				return NotFound(); |  | ||||||
| 			return Page(resources, pagination.Limit); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -28,172 +28,171 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Episode"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("episodes")] | ||||||
|  | [Route("episode", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Episode))] | ||||||
|  | [ApiDefinition("Episodes", Group = ResourcesGroup)] | ||||||
|  | public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) | ||||||
|  | 	: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails) | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about one or multiple <see cref="Episode"/>. | 	/// Get episode's show | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("episodes")] | 	/// <remarks> | ||||||
| 	[Route("episode", Order = AlternativeRoute)] | 	/// Get the show that this episode is part of. | ||||||
| 	[ApiController] | 	/// </remarks> | ||||||
| 	[PartialPermission(nameof(Episode))] | 	/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | ||||||
| 	[ApiDefinition("Episodes", Group = ResourcesGroup)] | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 	public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) | 	/// <returns>The show that contains this episode.</returns> | ||||||
| 		: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails) | 	/// <response code="404">No episode with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/show")] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Show>> GetShow( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Include<Show> fields | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return await libraryManager.Shows.Get( | ||||||
| 		/// Get episode's show | 			identifier.IsContainedIn<Show, Episode>(x => x.Episodes!), | ||||||
| 		/// </summary> | 			fields | ||||||
| 		/// <remarks> | 		); | ||||||
| 		/// Get the show that this episode is part of. | 	} | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>The show that contains this episode.</returns> |  | ||||||
| 		/// <response code="404">No episode with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/show")] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Show>> GetShow( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Include<Show> fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return await libraryManager.Shows.Get( |  | ||||||
| 				identifier.IsContainedIn<Show, Episode>(x => x.Episodes!), |  | ||||||
| 				fields |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get episode's season | 	/// Get episode's season | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get the season that this episode is part of. | 	/// Get the season that this episode is part of. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>The season that contains this episode.</returns> | 	/// <returns>The season that contains this episode.</returns> | ||||||
| 		/// <response code="204">The episode is not part of a season.</response> | 	/// <response code="204">The episode is not part of a season.</response> | ||||||
| 		/// <response code="404">No episode with the given ID or slug could be found.</response> | 	/// <response code="404">No episode with the given ID or slug could be found.</response> | ||||||
| 		[HttpGet("{identifier:id}/season")] | 	[HttpGet("{identifier:id}/season")] | ||||||
| 		[PartialPermission(Kind.Read)] | 	[PartialPermission(Kind.Read)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		public async Task<ActionResult<Season>> GetSeason( | 	public async Task<ActionResult<Season>> GetSeason( | ||||||
| 			Identifier identifier, | 		Identifier identifier, | ||||||
| 			[FromQuery] Include<Season> fields | 		[FromQuery] Include<Season> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			Season? ret = await libraryManager.Seasons.GetOrDefault( | 		Season? ret = await libraryManager.Seasons.GetOrDefault( | ||||||
| 				identifier.IsContainedIn<Season, Episode>(x => x.Episodes!), | 			identifier.IsContainedIn<Season, Episode>(x => x.Episodes!), | ||||||
| 				fields | 			fields | ||||||
| 			); | 		); | ||||||
| 			if (ret != null) | 		if (ret != null) | ||||||
| 				return ret; | 			return ret; | ||||||
| 			Episode? episode = await identifier.Match( | 		Episode? episode = await identifier.Match( | ||||||
| 				id => libraryManager.Episodes.GetOrDefault(id), | 			id => libraryManager.Episodes.GetOrDefault(id), | ||||||
| 				slug => libraryManager.Episodes.GetOrDefault(slug) | 			slug => libraryManager.Episodes.GetOrDefault(slug) | ||||||
| 			); | 		); | ||||||
| 			return episode == null ? NotFound() : NoContent(); | 		return episode == null ? NotFound() : NoContent(); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get watch status | 	/// Get watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get when an item has been wathed and if it was watched. | 	/// Get when an item has been wathed and if it was watched. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | ||||||
| 		/// <returns>The status.</returns> | 	/// <returns>The status.</returns> | ||||||
| 		/// <response code="204">This episode does not have a specific status.</response> | 	/// <response code="204">This episode does not have a specific status.</response> | ||||||
| 		/// <response code="404">No episode with the given ID or slug could be found.</response> | 	/// <response code="404">No episode with the given ID or slug could be found.</response> | ||||||
| 		[HttpGet("{identifier:id}/watchStatus")] | 	[HttpGet("{identifier:id}/watchStatus")] | ||||||
| 		[UserOnly] | 	[UserOnly] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier) | 	public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier) | ||||||
| 		{ | 	{ | ||||||
| 			Guid id = await identifier.Match( | 		Guid id = await identifier.Match( | ||||||
| 				id => Task.FromResult(id), | 			id => Task.FromResult(id), | ||||||
| 				async slug => (await libraryManager.Episodes.Get(slug)).Id | 			async slug => (await libraryManager.Episodes.Get(slug)).Id | ||||||
| 			); | 		); | ||||||
| 			return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); | 		return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Set watch status | 	/// Set watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Set when an item has been wathed and if it was watched. | 	/// Set when an item has been wathed and if it was watched. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | ||||||
| 		/// <param name="status">The new watch status.</param> | 	/// <param name="status">The new watch status.</param> | ||||||
| 		/// <param name="watchedTime">Where the user stopped watching (in seconds).</param> | 	/// <param name="watchedTime">Where the user stopped watching (in seconds).</param> | ||||||
| 		/// <param name="percent">Where the user stopped watching (in percent).</param> | 	/// <param name="percent">Where the user stopped watching (in percent).</param> | ||||||
| 		/// <returns>The newly set status.</returns> | 	/// <returns>The newly set status.</returns> | ||||||
| 		/// <response code="200">The status has been set</response> | 	/// <response code="200">The status has been set</response> | ||||||
| 		/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> | 	/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> | ||||||
| 		/// <response code="404">No episode with the given ID or slug could be found.</response> | 	/// <response code="404">No episode with the given ID or slug could be found.</response> | ||||||
| 		[HttpPost("{identifier:id}/watchStatus")] | 	[HttpPost("{identifier:id}/watchStatus")] | ||||||
| 		[UserOnly] | 	[UserOnly] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest)] | 	[ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		public async Task<EpisodeWatchStatus?> SetWatchStatus( | 	public async Task<EpisodeWatchStatus?> SetWatchStatus( | ||||||
| 			Identifier identifier, | 		Identifier identifier, | ||||||
| 			WatchStatus status, | 		WatchStatus status, | ||||||
| 			int? watchedTime, | 		int? watchedTime, | ||||||
| 			int? percent | 		int? percent | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			Guid id = await identifier.Match( | 		Guid id = await identifier.Match( | ||||||
| 				id => Task.FromResult(id), | 			id => Task.FromResult(id), | ||||||
| 				async slug => (await libraryManager.Episodes.Get(slug)).Id | 			async slug => (await libraryManager.Episodes.Get(slug)).Id | ||||||
| 			); | 		); | ||||||
| 			return await libraryManager.WatchStatus.SetEpisodeStatus( | 		return await libraryManager.WatchStatus.SetEpisodeStatus( | ||||||
| 				id, | 			id, | ||||||
| 				User.GetIdOrThrow(), | 			User.GetIdOrThrow(), | ||||||
| 				status, | 			status, | ||||||
| 				watchedTime, | 			watchedTime, | ||||||
| 				percent | 			percent | ||||||
| 			); | 		); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Delete watch status | 	/// Delete watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Delete watch status (to rewatch for example). | 	/// Delete watch status (to rewatch for example). | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> | ||||||
| 		/// <returns>The newly set status.</returns> | 	/// <returns>The newly set status.</returns> | ||||||
| 		/// <response code="204">The status has been deleted.</response> | 	/// <response code="204">The status has been deleted.</response> | ||||||
| 		/// <response code="404">No episode with the given ID or slug could be found.</response> | 	/// <response code="404">No episode with the given ID or slug could be found.</response> | ||||||
| 		[HttpDelete("{identifier:id}/watchStatus")] | 	[HttpDelete("{identifier:id}/watchStatus")] | ||||||
| 		[UserOnly] | 	[UserOnly] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		public async Task DeleteWatchStatus(Identifier identifier) | 	public async Task DeleteWatchStatus(Identifier identifier) | ||||||
| 		{ | 	{ | ||||||
| 			Guid id = await identifier.Match( | 		Guid id = await identifier.Match( | ||||||
| 				id => Task.FromResult(id), | 			id => Task.FromResult(id), | ||||||
| 				async slug => (await libraryManager.Episodes.Get(slug)).Id | 			async slug => (await libraryManager.Episodes.Get(slug)).Id | ||||||
| 			); | 		); | ||||||
| 			await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); | 		await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		protected override async Task<(string path, string route)> GetPath(Identifier identifier) | 	protected override async Task<(string path, string route)> GetPath(Identifier identifier) | ||||||
| 		{ | 	{ | ||||||
| 			string path = await identifier.Match( | 		string path = await identifier.Match( | ||||||
| 				async id => (await Repository.Get(id)).Path, | 			async id => (await Repository.Get(id)).Path, | ||||||
| 				async slug => (await Repository.Get(slug)).Path | 			async slug => (await Repository.Get(slug)).Path | ||||||
| 			); | 		); | ||||||
| 			return (path, $"/episodes/{identifier}"); | 		return (path, $"/episodes/{identifier}"); | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,35 +23,34 @@ using Kyoo.Abstractions.Models.Permissions; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Endpoint for items that are not part of a specific library. | ||||||
|  | /// An item can ether represent a collection or a show. | ||||||
|  | /// </summary> | ||||||
|  | [Route("items")] | ||||||
|  | [Route("item", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission("LibraryItem")] | ||||||
|  | [ApiDefinition("Items", Group = ResourcesGroup)] | ||||||
|  | public class LibraryItemApi : CrudThumbsApi<ILibraryItem> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Endpoint for items that are not part of a specific library. | 	/// The library item repository used to modify or retrieve information in the data store. | ||||||
| 	/// An item can ether represent a collection or a show. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("items")] | 	private readonly IRepository<ILibraryItem> _libraryItems; | ||||||
| 	[Route("item", Order = AlternativeRoute)] |  | ||||||
| 	[ApiController] |  | ||||||
| 	[PartialPermission("LibraryItem")] |  | ||||||
| 	[ApiDefinition("Items", Group = ResourcesGroup)] |  | ||||||
| 	public class LibraryItemApi : CrudThumbsApi<ILibraryItem> |  | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The library item repository used to modify or retrieve information in the data store. |  | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly IRepository<ILibraryItem> _libraryItems; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="LibraryItemApi"/>. | 	/// Create a new <see cref="LibraryItemApi"/>. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="libraryItems"> | 	/// <param name="libraryItems"> | ||||||
| 		/// The library item repository used to modify or retrieve information in the data store. | 	/// The library item repository used to modify or retrieve information in the data store. | ||||||
| 		/// </param> | 	/// </param> | ||||||
| 		/// <param name="thumbs">Thumbnail manager to retrieve images.</param> | 	/// <param name="thumbs">Thumbnail manager to retrieve images.</param> | ||||||
| 		public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs) | 	public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs) | ||||||
| 			: base(libraryItems, thumbs) | 		: base(libraryItems, thumbs) | ||||||
| 		{ | 	{ | ||||||
| 			_libraryItems = libraryItems; | 		_libraryItems = libraryItems; | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -30,182 +30,181 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Movie"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("movies")] | ||||||
|  | [Route("movie", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Show))] | ||||||
|  | [ApiDefinition("Shows", Group = ResourcesGroup)] | ||||||
|  | public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | ||||||
|  | 	: TranscoderApi<Movie>(libraryManager.Movies, thumbs) | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about one or multiple <see cref="Movie"/>. | 	/// Get studio that made the show | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("movies")] | 	/// <remarks> | ||||||
| 	[Route("movie", Order = AlternativeRoute)] | 	/// Get the studio that made the show. | ||||||
| 	[ApiController] | 	/// </remarks> | ||||||
| 	[PartialPermission(nameof(Show))] | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
| 	[ApiDefinition("Shows", Group = ResourcesGroup)] | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 	public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | 	/// <returns>The studio that made the show.</returns> | ||||||
| 		: TranscoderApi<Movie>(libraryManager.Movies, thumbs) | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/studio")] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Studio>> GetStudio( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Include<Studio> fields | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		return await libraryManager.Studios.Get( | ||||||
| 		/// Get studio that made the show | 			identifier.IsContainedIn<Studio, Movie>(x => x.Movies!), | ||||||
| 		/// </summary> | 			fields | ||||||
| 		/// <remarks> | 		); | ||||||
| 		/// Get the studio that made the show. | 	} | ||||||
| 		/// </remarks> | 
 | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | 	/// <summary> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// Get collections containing this show | ||||||
| 		/// <returns>The studio that made the show.</returns> | 	/// </summary> | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> | 	/// <remarks> | ||||||
| 		[HttpGet("{identifier:id}/studio")] | 	/// List the collections that contain this show. | ||||||
| 		[PartialPermission(Kind.Read)] | 	/// </remarks> | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	/// <param name="sortBy">A key to sort collections by.</param> | ||||||
| 		public async Task<ActionResult<Studio>> GetStudio( | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 			Identifier identifier, | 	/// <param name="pagination">The number of collections to return.</param> | ||||||
| 			[FromQuery] Include<Studio> fields | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A page of collections.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/collections")] | ||||||
|  | 	[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Collection>>> GetCollections( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Collection> sortBy, | ||||||
|  | 		[FromQuery] Filter<Collection>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Collection> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Collection> resources = await libraryManager.Collections.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		if ( | ||||||
|  | 			!resources.Any() | ||||||
|  | 			&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			return await libraryManager.Studios.Get( | 		return Page(resources, pagination.Limit); | ||||||
| 				identifier.IsContainedIn<Studio, Movie>(x => x.Movies!), | 	} | ||||||
| 				fields |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get collections containing this show | 	/// Get watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// List the collections that contain this show. | 	/// Get when an item has been wathed and if it was watched. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | ||||||
| 		/// <param name="sortBy">A key to sort collections by.</param> | 	/// <returns>The status.</returns> | ||||||
| 		/// <param name="filter">An optional list of filters.</param> | 	/// <response code="204">This movie does not have a specific status.</response> | ||||||
| 		/// <param name="pagination">The number of collections to return.</param> | 	/// <response code="404">No movie with the given ID or slug could be found.</response> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	[HttpGet("{identifier:id}/watchStatus")] | ||||||
| 		/// <returns>A page of collections.</returns> | 	[UserOnly] | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[HttpGet("{identifier:id}/collections")] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] | 	public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier) | ||||||
| 		[PartialPermission(Kind.Read)] | 	{ | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 		Guid id = await identifier.Match( | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 			id => Task.FromResult(id), | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 			async slug => (await libraryManager.Movies.Get(slug)).Id | ||||||
| 		public async Task<ActionResult<Page<Collection>>> GetCollections( | 		); | ||||||
| 			Identifier identifier, | 		return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); | ||||||
| 			[FromQuery] Sort<Collection> sortBy, | 	} | ||||||
| 			[FromQuery] Filter<Collection>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Collection> fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ICollection<Collection> resources = await libraryManager.Collections.GetAll( |  | ||||||
| 				Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)), |  | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Set watch status | ||||||
| 				&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null | 	/// </summary> | ||||||
| 			) | 	/// <remarks> | ||||||
| 				return NotFound(); | 	/// Set when an item has been wathed and if it was watched. | ||||||
| 			return Page(resources, pagination.Limit); | 	/// </remarks> | ||||||
| 		} | 	/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | ||||||
|  | 	/// <param name="status">The new watch status.</param> | ||||||
|  | 	/// <param name="watchedTime">Where the user stopped watching.</param> | ||||||
|  | 	/// <param name="percent">Where the user stopped watching (in percent).</param> | ||||||
|  | 	/// <returns>The newly set status.</returns> | ||||||
|  | 	/// <response code="200">The status has been set</response> | ||||||
|  | 	/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> | ||||||
|  | 	/// <response code="400">WatchedTime can't be specified if status is not watching.</response> | ||||||
|  | 	/// <response code="404">No movie with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpPost("{identifier:id}/watchStatus")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<MovieWatchStatus?> SetWatchStatus( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		WatchStatus status, | ||||||
|  | 		int? watchedTime, | ||||||
|  | 		int? percent | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		Guid id = await identifier.Match( | ||||||
|  | 			id => Task.FromResult(id), | ||||||
|  | 			async slug => (await libraryManager.Movies.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		return await libraryManager.WatchStatus.SetMovieStatus( | ||||||
|  | 			id, | ||||||
|  | 			User.GetIdOrThrow(), | ||||||
|  | 			status, | ||||||
|  | 			watchedTime, | ||||||
|  | 			percent | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get watch status | 	/// Delete watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get when an item has been wathed and if it was watched. | 	/// Delete watch status (to rewatch for example). | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | ||||||
| 		/// <returns>The status.</returns> | 	/// <returns>The newly set status.</returns> | ||||||
| 		/// <response code="204">This movie does not have a specific status.</response> | 	/// <response code="204">The status has been deleted.</response> | ||||||
| 		/// <response code="404">No movie with the given ID or slug could be found.</response> | 	/// <response code="404">No movie with the given ID or slug could be found.</response> | ||||||
| 		[HttpGet("{identifier:id}/watchStatus")] | 	[HttpDelete("{identifier:id}/watchStatus")] | ||||||
| 		[UserOnly] | 	[UserOnly] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	public async Task DeleteWatchStatus(Identifier identifier) | ||||||
| 		public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier) | 	{ | ||||||
| 		{ | 		Guid id = await identifier.Match( | ||||||
| 			Guid id = await identifier.Match( | 			id => Task.FromResult(id), | ||||||
| 				id => Task.FromResult(id), | 			async slug => (await libraryManager.Movies.Get(slug)).Id | ||||||
| 				async slug => (await libraryManager.Movies.Get(slug)).Id | 		); | ||||||
| 			); | 		await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow()); | ||||||
| 			return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); | 	} | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	protected override async Task<(string path, string route)> GetPath(Identifier identifier) | ||||||
| 		/// Set watch status | 	{ | ||||||
| 		/// </summary> | 		string path = await identifier.Match( | ||||||
| 		/// <remarks> | 			async id => (await Repository.Get(id)).Path, | ||||||
| 		/// Set when an item has been wathed and if it was watched. | 			async slug => (await Repository.Get(slug)).Path | ||||||
| 		/// </remarks> | 		); | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> | 		return (path, $"/movies/{identifier}"); | ||||||
| 		/// <param name="status">The new watch status.</param> |  | ||||||
| 		/// <param name="watchedTime">Where the user stopped watching.</param> |  | ||||||
| 		/// <param name="percent">Where the user stopped watching (in percent).</param> |  | ||||||
| 		/// <returns>The newly set status.</returns> |  | ||||||
| 		/// <response code="200">The status has been set</response> |  | ||||||
| 		/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> |  | ||||||
| 		/// <response code="400">WatchedTime can't be specified if status is not watching.</response> |  | ||||||
| 		/// <response code="404">No movie with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpPost("{identifier:id}/watchStatus")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<MovieWatchStatus?> SetWatchStatus( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			WatchStatus status, |  | ||||||
| 			int? watchedTime, |  | ||||||
| 			int? percent |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			Guid id = await identifier.Match( |  | ||||||
| 				id => Task.FromResult(id), |  | ||||||
| 				async slug => (await libraryManager.Movies.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			return await libraryManager.WatchStatus.SetMovieStatus( |  | ||||||
| 				id, |  | ||||||
| 				User.GetIdOrThrow(), |  | ||||||
| 				status, |  | ||||||
| 				watchedTime, |  | ||||||
| 				percent |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete watch status |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Delete watch status (to rewatch for example). |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> |  | ||||||
| 		/// <returns>The newly set status.</returns> |  | ||||||
| 		/// <response code="204">The status has been deleted.</response> |  | ||||||
| 		/// <response code="404">No movie with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpDelete("{identifier:id}/watchStatus")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task DeleteWatchStatus(Identifier identifier) |  | ||||||
| 		{ |  | ||||||
| 			Guid id = await identifier.Match( |  | ||||||
| 				id => Task.FromResult(id), |  | ||||||
| 				async slug => (await libraryManager.Movies.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow()); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		protected override async Task<(string path, string route)> GetPath(Identifier identifier) |  | ||||||
| 		{ |  | ||||||
| 			string path = await identifier.Match( |  | ||||||
| 				async id => (await Repository.Get(id)).Path, |  | ||||||
| 				async slug => (await Repository.Get(slug)).Path |  | ||||||
| 			); |  | ||||||
| 			return (path, $"/movies/{identifier}"); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,19 +23,18 @@ using Kyoo.Abstractions.Models.Permissions; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// List new items added to kyoo. | ||||||
|  | /// </summary> | ||||||
|  | [Route("news")] | ||||||
|  | [Route("new", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission("LibraryItem")] | ||||||
|  | [ApiDefinition("News", Group = ResourcesGroup)] | ||||||
|  | public class NewsApi : CrudThumbsApi<INews> | ||||||
| { | { | ||||||
| 	/// <summary> | 	public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs) | ||||||
| 	/// List new items added to kyoo. | 		: base(news, thumbs) { } | ||||||
| 	/// </summary> |  | ||||||
| 	[Route("news")] |  | ||||||
| 	[Route("new", Order = AlternativeRoute)] |  | ||||||
| 	[ApiController] |  | ||||||
| 	[PartialPermission("LibraryItem")] |  | ||||||
| 	[ApiDefinition("News", Group = ResourcesGroup)] |  | ||||||
| 	public class NewsApi : CrudThumbsApi<INews> |  | ||||||
| 	{ |  | ||||||
| 		public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs) |  | ||||||
| 			: base(news, thumbs) { } |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,182 +26,179 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource | ||||||
|  | /// is available on the said endpoint. | ||||||
|  | /// </summary> | ||||||
|  | [Route("search")] | ||||||
|  | [ApiController] | ||||||
|  | [ApiDefinition("Search", Group = ResourcesGroup)] | ||||||
|  | public class SearchApi : BaseApi | ||||||
| { | { | ||||||
| 	/// <summary> | 	private readonly ISearchManager _searchManager; | ||||||
| 	/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource | 
 | ||||||
| 	/// is available on the said endpoint. | 	public SearchApi(ISearchManager searchManager) | ||||||
| 	/// </summary> |  | ||||||
| 	[Route("search")] |  | ||||||
| 	[ApiController] |  | ||||||
| 	[ApiDefinition("Search", Group = ResourcesGroup)] |  | ||||||
| 	public class SearchApi : BaseApi |  | ||||||
| 	{ | 	{ | ||||||
| 		private readonly ISearchManager _searchManager; | 		_searchManager = searchManager; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		public SearchApi(ISearchManager searchManager) | 	// TODO: add filters and facets | ||||||
| 		{ |  | ||||||
| 			_searchManager = searchManager; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		// TODO: add filters and facets | 	/// <summary> | ||||||
|  | 	/// Search collections | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Search for collections | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="q">The query to search for.</param> | ||||||
|  | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
|  | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
|  | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A list of collections found for the specified query.</returns> | ||||||
|  | 	[HttpGet("collections")] | ||||||
|  | 	[HttpGet("collection", Order = AlternativeRoute)] | ||||||
|  | 	[Permission(nameof(Collection), Kind.Read)] | ||||||
|  | 	[ApiDefinition("Collections")] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	public async Task<SearchPage<Collection>> SearchCollections( | ||||||
|  | 		[FromQuery] string? q, | ||||||
|  | 		[FromQuery] Sort<Collection> sortBy, | ||||||
|  | 		[FromQuery] SearchPagination pagination, | ||||||
|  | 		[FromQuery] Include<Collection> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields)); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Search collections | 	/// Search shows | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Search for collections | 	/// Search for shows | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="q">The query to search for.</param> | 	/// <param name="q">The query to search for.</param> | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>A list of collections found for the specified query.</returns> | 	/// <returns>A list of shows found for the specified query.</returns> | ||||||
| 		[HttpGet("collections")] | 	[HttpGet("shows")] | ||||||
| 		[HttpGet("collection", Order = AlternativeRoute)] | 	[HttpGet("show", Order = AlternativeRoute)] | ||||||
| 		[Permission(nameof(Collection), Kind.Read)] | 	[Permission(nameof(Show), Kind.Read)] | ||||||
| 		[ApiDefinition("Collections")] | 	[ApiDefinition("Show")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		public async Task<SearchPage<Collection>> SearchCollections( | 	public async Task<SearchPage<Show>> SearchShows( | ||||||
| 			[FromQuery] string? q, | 		[FromQuery] string? q, | ||||||
| 			[FromQuery] Sort<Collection> sortBy, | 		[FromQuery] Sort<Show> sortBy, | ||||||
| 			[FromQuery] SearchPagination pagination, | 		[FromQuery] SearchPagination pagination, | ||||||
| 			[FromQuery] Include<Collection> fields | 		[FromQuery] Include<Show> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			return SearchPage( | 		return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); | ||||||
| 				await _searchManager.SearchCollections(q, sortBy, pagination, fields) | 	} | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Search shows | 	/// Search movie | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Search for shows | 	/// Search for movie | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="q">The query to search for.</param> | 	/// <param name="q">The query to search for.</param> | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>A list of shows found for the specified query.</returns> | 	/// <returns>A list of movies found for the specified query.</returns> | ||||||
| 		[HttpGet("shows")] | 	[HttpGet("movies")] | ||||||
| 		[HttpGet("show", Order = AlternativeRoute)] | 	[HttpGet("movie", Order = AlternativeRoute)] | ||||||
| 		[Permission(nameof(Show), Kind.Read)] | 	[Permission(nameof(Movie), Kind.Read)] | ||||||
| 		[ApiDefinition("Show")] | 	[ApiDefinition("Movie")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		public async Task<SearchPage<Show>> SearchShows( | 	public async Task<SearchPage<Movie>> SearchMovies( | ||||||
| 			[FromQuery] string? q, | 		[FromQuery] string? q, | ||||||
| 			[FromQuery] Sort<Show> sortBy, | 		[FromQuery] Sort<Movie> sortBy, | ||||||
| 			[FromQuery] SearchPagination pagination, | 		[FromQuery] SearchPagination pagination, | ||||||
| 			[FromQuery] Include<Show> fields | 		[FromQuery] Include<Movie> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); | 		return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Search movie | 	/// Search items | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Search for movie | 	/// Search for items | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="q">The query to search for.</param> | 	/// <param name="q">The query to search for.</param> | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>A list of movies found for the specified query.</returns> | 	/// <returns>A list of items found for the specified query.</returns> | ||||||
| 		[HttpGet("movies")] | 	[HttpGet("items")] | ||||||
| 		[HttpGet("movie", Order = AlternativeRoute)] | 	[HttpGet("item", Order = AlternativeRoute)] | ||||||
| 		[Permission(nameof(Movie), Kind.Read)] | 	[Permission(nameof(ILibraryItem), Kind.Read)] | ||||||
| 		[ApiDefinition("Movie")] | 	[ApiDefinition("Item")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		public async Task<SearchPage<Movie>> SearchMovies( | 	public async Task<SearchPage<ILibraryItem>> SearchItems( | ||||||
| 			[FromQuery] string? q, | 		[FromQuery] string? q, | ||||||
| 			[FromQuery] Sort<Movie> sortBy, | 		[FromQuery] Sort<ILibraryItem> sortBy, | ||||||
| 			[FromQuery] SearchPagination pagination, | 		[FromQuery] SearchPagination pagination, | ||||||
| 			[FromQuery] Include<Movie> fields | 		[FromQuery] Include<ILibraryItem> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); | 		return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Search items | 	/// Search episodes | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Search for items | 	/// Search for episodes | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="q">The query to search for.</param> | 	/// <param name="q">The query to search for.</param> | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>A list of items found for the specified query.</returns> | 	/// <returns>A list of episodes found for the specified query.</returns> | ||||||
| 		[HttpGet("items")] | 	[HttpGet("episodes")] | ||||||
| 		[HttpGet("item", Order = AlternativeRoute)] | 	[HttpGet("episode", Order = AlternativeRoute)] | ||||||
| 		[Permission(nameof(ILibraryItem), Kind.Read)] | 	[Permission(nameof(Episode), Kind.Read)] | ||||||
| 		[ApiDefinition("Item")] | 	[ApiDefinition("Episodes")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		public async Task<SearchPage<ILibraryItem>> SearchItems( | 	public async Task<SearchPage<Episode>> SearchEpisodes( | ||||||
| 			[FromQuery] string? q, | 		[FromQuery] string? q, | ||||||
| 			[FromQuery] Sort<ILibraryItem> sortBy, | 		[FromQuery] Sort<Episode> sortBy, | ||||||
| 			[FromQuery] SearchPagination pagination, | 		[FromQuery] SearchPagination pagination, | ||||||
| 			[FromQuery] Include<ILibraryItem> fields | 		[FromQuery] Include<Episode> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); | 		return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); | ||||||
| 		} | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Search episodes | 	/// Search studios | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Search for episodes | 	/// Search for studios | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="q">The query to search for.</param> | 	/// <param name="q">The query to search for.</param> | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | 	/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <returns>A list of episodes found for the specified query.</returns> | 	/// <returns>A list of studios found for the specified query.</returns> | ||||||
| 		[HttpGet("episodes")] | 	[HttpGet("studios")] | ||||||
| 		[HttpGet("episode", Order = AlternativeRoute)] | 	[HttpGet("studio", Order = AlternativeRoute)] | ||||||
| 		[Permission(nameof(Episode), Kind.Read)] | 	[Permission(nameof(Studio), Kind.Read)] | ||||||
| 		[ApiDefinition("Episodes")] | 	[ApiDefinition("Studios")] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		public async Task<SearchPage<Episode>> SearchEpisodes( | 	public async Task<SearchPage<Studio>> SearchStudios( | ||||||
| 			[FromQuery] string? q, | 		[FromQuery] string? q, | ||||||
| 			[FromQuery] Sort<Episode> sortBy, | 		[FromQuery] Sort<Studio> sortBy, | ||||||
| 			[FromQuery] SearchPagination pagination, | 		[FromQuery] SearchPagination pagination, | ||||||
| 			[FromQuery] Include<Episode> fields | 		[FromQuery] Include<Studio> fields | ||||||
| 		) | 	) | ||||||
| 		{ | 	{ | ||||||
| 			return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); | 		return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Search studios |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Search for studios |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="q">The query to search for.</param> |  | ||||||
| 		/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> |  | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A list of studios found for the specified query.</returns> |  | ||||||
| 		[HttpGet("studios")] |  | ||||||
| 		[HttpGet("studio", Order = AlternativeRoute)] |  | ||||||
| 		[Permission(nameof(Studio), Kind.Read)] |  | ||||||
| 		[ApiDefinition("Studios")] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		public async Task<SearchPage<Studio>> SearchStudios( |  | ||||||
| 			[FromQuery] string? q, |  | ||||||
| 			[FromQuery] Sort<Studio> sortBy, |  | ||||||
| 			[FromQuery] SearchPagination pagination, |  | ||||||
| 			[FromQuery] Include<Studio> fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -28,108 +28,104 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Season"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("seasons")] | ||||||
|  | [Route("season", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Season))] | ||||||
|  | [ApiDefinition("Seasons", Group = ResourcesGroup)] | ||||||
|  | public class SeasonApi : CrudThumbsApi<Season> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about one or multiple <see cref="Season"/>. | 	/// The library manager used to modify or retrieve information in the data store. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("seasons")] | 	private readonly ILibraryManager _libraryManager; | ||||||
| 	[Route("season", Order = AlternativeRoute)] | 
 | ||||||
| 	[ApiController] | 	/// <summary> | ||||||
| 	[PartialPermission(nameof(Season))] | 	/// Create a new <see cref="SeasonApi"/>. | ||||||
| 	[ApiDefinition("Seasons", Group = ResourcesGroup)] | 	/// </summary> | ||||||
| 	public class SeasonApi : CrudThumbsApi<Season> | 	/// <param name="libraryManager"> | ||||||
|  | 	/// The library manager used to modify or retrieve information in the data store. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | ||||||
|  | 	public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | ||||||
|  | 		: base(libraryManager.Seasons, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_libraryManager = libraryManager; | ||||||
| 		/// The library manager used to modify or retrieve information in the data store. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly ILibraryManager _libraryManager; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="SeasonApi"/>. | 	/// Get episodes in the season | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="libraryManager"> | 	/// <remarks> | ||||||
| 		/// The library manager used to modify or retrieve information in the data store. | 	/// List the episodes that are part of the specified season. | ||||||
| 		/// </param> | 	/// </remarks> | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param> | ||||||
| 		public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | 	/// <param name="sortBy">A key to sort episodes by.</param> | ||||||
| 			: base(libraryManager.Seasons, thumbs) | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 		{ | 	/// <param name="pagination">The number of episodes to return.</param> | ||||||
| 			_libraryManager = libraryManager; | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		} | 	/// <returns>A page of episodes.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No season with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/episodes")] | ||||||
|  | 	[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Episode>>> GetEpisode( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Episode> sortBy, | ||||||
|  | 		[FromQuery] Filter<Episode>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Episode> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Get episodes in the season | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null | ||||||
| 		/// <remarks> |  | ||||||
| 		/// List the episodes that are part of the specified season. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort episodes by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of episodes to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of episodes.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No season with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/episodes")] |  | ||||||
| 		[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Episode>>> GetEpisode( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Episode> sortBy, |  | ||||||
| 			[FromQuery] Filter<Episode>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Episode> fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( | 		return Page(resources, pagination.Limit); | ||||||
| 				Filter.And( | 	} | ||||||
| 					filter, |  | ||||||
| 					identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug) |  | ||||||
| 				), |  | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Get season's show | ||||||
| 				&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null | 	/// </summary> | ||||||
| 			) | 	/// <remarks> | ||||||
| 				return NotFound(); | 	/// Get the show that this season is part of. | ||||||
| 			return Page(resources, pagination.Limit); | 	/// </remarks> | ||||||
| 		} | 	/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param> | ||||||
| 
 | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		/// <summary> | 	/// <returns>The show that contains this season.</returns> | ||||||
| 		/// Get season's show | 	/// <response code="404">No season with the given ID or slug could be found.</response> | ||||||
| 		/// </summary> | 	[HttpGet("{identifier:id}/show")] | ||||||
| 		/// <remarks> | 	[PartialPermission(Kind.Read)] | ||||||
| 		/// Get the show that this season is part of. | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		/// </remarks> | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param> | 	public async Task<ActionResult<Show>> GetShow( | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 		Identifier identifier, | ||||||
| 		/// <returns>The show that contains this season.</returns> | 		[FromQuery] Include<Show> fields | ||||||
| 		/// <response code="404">No season with the given ID or slug could be found.</response> | 	) | ||||||
| 		[HttpGet("{identifier:id}/show")] | 	{ | ||||||
| 		[PartialPermission(Kind.Read)] | 		Show? ret = await _libraryManager.Shows.GetOrDefault( | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 			identifier.IsContainedIn<Show, Season>(x => x.Seasons!), | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 			fields | ||||||
| 		public async Task<ActionResult<Show>> GetShow( | 		); | ||||||
| 			Identifier identifier, | 		if (ret == null) | ||||||
| 			[FromQuery] Include<Show> fields | 			return NotFound(); | ||||||
| 		) | 		return ret; | ||||||
| 		{ |  | ||||||
| 			Show? ret = await _libraryManager.Shows.GetOrDefault( |  | ||||||
| 				identifier.IsContainedIn<Show, Season>(x => x.Seasons!), |  | ||||||
| 				fields |  | ||||||
| 			); |  | ||||||
| 			if (ret == null) |  | ||||||
| 				return NotFound(); |  | ||||||
| 			return ret; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -30,265 +30,261 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Information about one or multiple <see cref="Show"/>. | ||||||
|  | /// </summary> | ||||||
|  | [Route("shows")] | ||||||
|  | [Route("show", Order = AlternativeRoute)] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission(nameof(Show))] | ||||||
|  | [ApiDefinition("Shows", Group = ResourcesGroup)] | ||||||
|  | public class ShowApi : CrudThumbsApi<Show> | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Information about one or multiple <see cref="Show"/>. | 	/// The library manager used to modify or retrieve information in the data store. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("shows")] | 	private readonly ILibraryManager _libraryManager; | ||||||
| 	[Route("show", Order = AlternativeRoute)] | 
 | ||||||
| 	[ApiController] | 	/// <summary> | ||||||
| 	[PartialPermission(nameof(Show))] | 	/// Create a new <see cref="ShowApi"/>. | ||||||
| 	[ApiDefinition("Shows", Group = ResourcesGroup)] | 	/// </summary> | ||||||
| 	public class ShowApi : CrudThumbsApi<Show> | 	/// <param name="libraryManager"> | ||||||
|  | 	/// The library manager used to modify or retrieve information about the data store. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | ||||||
|  | 	public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | ||||||
|  | 		: base(libraryManager.Shows, thumbs) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_libraryManager = libraryManager; | ||||||
| 		/// The library manager used to modify or retrieve information in the data store. | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly ILibraryManager _libraryManager; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="ShowApi"/>. | 	/// Get seasons of this show | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="libraryManager"> | 	/// <remarks> | ||||||
| 		/// The library manager used to modify or retrieve information about the data store. | 	/// List the seasons that are part of the specified show. | ||||||
| 		/// </param> | 	/// </remarks> | ||||||
| 		/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
| 		public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) | 	/// <param name="sortBy">A key to sort seasons by.</param> | ||||||
| 			: base(libraryManager.Shows, thumbs) | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 		{ | 	/// <param name="pagination">The number of seasons to return.</param> | ||||||
| 			_libraryManager = libraryManager; | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		} | 	/// <returns>A page of seasons.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/seasons")] | ||||||
|  | 	[HttpGet("{identifier:id}/season", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Season>>> GetSeasons( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Season> sortBy, | ||||||
|  | 		[FromQuery] Filter<Season>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Season> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Season> resources = await _libraryManager.Seasons.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Get seasons of this show | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | ||||||
| 		/// <remarks> |  | ||||||
| 		/// List the seasons that are part of the specified show. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort seasons by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of seasons to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of seasons.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/seasons")] |  | ||||||
| 		[HttpGet("{identifier:id}/season", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Season>>> GetSeasons( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Season> sortBy, |  | ||||||
| 			[FromQuery] Filter<Season>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Season> fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			ICollection<Season> resources = await _libraryManager.Seasons.GetAll( | 		return Page(resources, pagination.Limit); | ||||||
| 				Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)), | 	} | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Get episodes of this show | ||||||
| 				&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | 	/// </summary> | ||||||
| 			) | 	/// <remarks> | ||||||
| 				return NotFound(); | 	/// List the episodes that are part of the specified show. | ||||||
| 			return Page(resources, pagination.Limit); | 	/// </remarks> | ||||||
| 		} | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
|  | 	/// <param name="sortBy">A key to sort episodes by.</param> | ||||||
|  | 	/// <param name="filter">An optional list of filters.</param> | ||||||
|  | 	/// <param name="pagination">The number of episodes to return.</param> | ||||||
|  | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A page of episodes.</returns> | ||||||
|  | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
|  | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/episodes")] | ||||||
|  | 	[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Episode>>> GetEpisodes( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Episode> sortBy, | ||||||
|  | 		[FromQuery] Filter<Episode>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Episode> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		if ( | ||||||
| 		/// Get episodes of this show | 			!resources.Any() | ||||||
| 		/// </summary> | 			&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | ||||||
| 		/// <remarks> |  | ||||||
| 		/// List the episodes that are part of the specified show. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> |  | ||||||
| 		/// <param name="sortBy">A key to sort episodes by.</param> |  | ||||||
| 		/// <param name="filter">An optional list of filters.</param> |  | ||||||
| 		/// <param name="pagination">The number of episodes to return.</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A page of episodes.</returns> |  | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> |  | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpGet("{identifier:id}/episodes")] |  | ||||||
| 		[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ActionResult<Page<Episode>>> GetEpisodes( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			[FromQuery] Sort<Episode> sortBy, |  | ||||||
| 			[FromQuery] Filter<Episode>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Episode> fields |  | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( | 		return Page(resources, pagination.Limit); | ||||||
| 				Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)), | 	} | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Get studio that made the show | ||||||
| 				&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | 	/// </summary> | ||||||
| 			) | 	/// <remarks> | ||||||
| 				return NotFound(); | 	/// Get the studio that made the show. | ||||||
| 			return Page(resources, pagination.Limit); | 	/// </remarks> | ||||||
| 		} | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
|  | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>The studio that made the show.</returns> | ||||||
|  | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpGet("{identifier:id}/studio")] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Studio>> GetStudio( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Include<Studio> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		return await _libraryManager.Studios.Get( | ||||||
|  | 			identifier.IsContainedIn<Studio, Show>(x => x.Shows!), | ||||||
|  | 			fields | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get studio that made the show | 	/// Get collections containing this show | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get the studio that made the show. | 	/// List the collections that contain this show. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	/// <param name="sortBy">A key to sort collections by.</param> | ||||||
| 		/// <returns>The studio that made the show.</returns> | 	/// <param name="filter">An optional list of filters.</param> | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> | 	/// <param name="pagination">The number of collections to return.</param> | ||||||
| 		[HttpGet("{identifier:id}/studio")] | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
| 		[PartialPermission(Kind.Read)] | 	/// <returns>A page of collections.</returns> | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	/// <response code="400">The filters or the sort parameters are invalid.</response> | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
| 		public async Task<ActionResult<Studio>> GetStudio( | 	[HttpGet("{identifier:id}/collections")] | ||||||
| 			Identifier identifier, | 	[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] | ||||||
| 			[FromQuery] Include<Studio> fields | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ActionResult<Page<Collection>>> GetCollections( | ||||||
|  | 		Identifier identifier, | ||||||
|  | 		[FromQuery] Sort<Collection> sortBy, | ||||||
|  | 		[FromQuery] Filter<Collection>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<Collection> fields | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		ICollection<Collection> resources = await _libraryManager.Collections.GetAll( | ||||||
|  | 			Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)), | ||||||
|  | 			sortBy, | ||||||
|  | 			fields, | ||||||
|  | 			pagination | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		if ( | ||||||
|  | 			!resources.Any() | ||||||
|  | 			&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | ||||||
| 		) | 		) | ||||||
| 		{ | 			return NotFound(); | ||||||
| 			return await _libraryManager.Studios.Get( | 		return Page(resources, pagination.Limit); | ||||||
| 				identifier.IsContainedIn<Studio, Show>(x => x.Shows!), | 	} | ||||||
| 				fields |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get collections containing this show | 	/// Get watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// List the collections that contain this show. | 	/// Get when an item has been wathed and if it was watched. | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
| 		/// <param name="sortBy">A key to sort collections by.</param> | 	/// <returns>The status.</returns> | ||||||
| 		/// <param name="filter">An optional list of filters.</param> | 	/// <response code="204">This show does not have a specific status.</response> | ||||||
| 		/// <param name="pagination">The number of collections to return.</param> | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> | 	[HttpGet("{identifier:id}/watchStatus")] | ||||||
| 		/// <returns>A page of collections.</returns> | 	[UserOnly] | ||||||
| 		/// <response code="400">The filters or the sort parameters are invalid.</response> | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[HttpGet("{identifier:id}/collections")] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] | 	public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier) | ||||||
| 		[PartialPermission(Kind.Read)] | 	{ | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 		Guid id = await identifier.Match( | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | 			id => Task.FromResult(id), | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 			async slug => (await _libraryManager.Shows.Get(slug)).Id | ||||||
| 		public async Task<ActionResult<Page<Collection>>> GetCollections( | 		); | ||||||
| 			Identifier identifier, | 		return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow()); | ||||||
| 			[FromQuery] Sort<Collection> sortBy, | 	} | ||||||
| 			[FromQuery] Filter<Collection>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<Collection> fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			ICollection<Collection> resources = await _libraryManager.Collections.GetAll( |  | ||||||
| 				Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)), |  | ||||||
| 				sortBy, |  | ||||||
| 				fields, |  | ||||||
| 				pagination |  | ||||||
| 			); |  | ||||||
| 
 | 
 | ||||||
| 			if ( | 	/// <summary> | ||||||
| 				!resources.Any() | 	/// Set watch status | ||||||
| 				&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null | 	/// </summary> | ||||||
| 			) | 	/// <remarks> | ||||||
| 				return NotFound(); | 	/// Set when an item has been wathed and if it was watched. | ||||||
| 			return Page(resources, pagination.Limit); | 	/// </remarks> | ||||||
| 		} | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
|  | 	/// <param name="status">The new watch status.</param> | ||||||
|  | 	/// <returns>The newly set status.</returns> | ||||||
|  | 	/// <response code="200">The status has been set</response> | ||||||
|  | 	/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> | ||||||
|  | 	/// <response code="404">No movie with the given ID or slug could be found.</response> | ||||||
|  | 	[HttpPost("{identifier:id}/watchStatus")] | ||||||
|  | 	[UserOnly] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
|  | 	public async Task<ShowWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status) | ||||||
|  | 	{ | ||||||
|  | 		Guid id = await identifier.Match( | ||||||
|  | 			id => Task.FromResult(id), | ||||||
|  | 			async slug => (await _libraryManager.Shows.Get(slug)).Id | ||||||
|  | 		); | ||||||
|  | 		return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Get watch status | 	/// Delete watch status | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <remarks> | 	/// <remarks> | ||||||
| 		/// Get when an item has been wathed and if it was watched. | 	/// Delete watch status (to rewatch for example). | ||||||
| 		/// </remarks> | 	/// </remarks> | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | 	/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> | ||||||
| 		/// <returns>The status.</returns> | 	/// <returns>The newly set status.</returns> | ||||||
| 		/// <response code="204">This show does not have a specific status.</response> | 	/// <response code="204">The status has been deleted.</response> | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> | 	/// <response code="404">No show with the given ID or slug could be found.</response> | ||||||
| 		[HttpGet("{identifier:id}/watchStatus")] | 	[HttpDelete("{identifier:id}/watchStatus")] | ||||||
| 		[UserOnly] | 	[UserOnly] | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] | 	[ProducesResponseType(StatusCodes.Status204NoContent)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] | 	[ProducesResponseType(StatusCodes.Status404NotFound)] | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] | 	public async Task DeleteWatchStatus(Identifier identifier) | ||||||
| 		public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier) | 	{ | ||||||
| 		{ | 		Guid id = await identifier.Match( | ||||||
| 			Guid id = await identifier.Match( | 			id => Task.FromResult(id), | ||||||
| 				id => Task.FromResult(id), | 			async slug => (await _libraryManager.Shows.Get(slug)).Id | ||||||
| 				async slug => (await _libraryManager.Shows.Get(slug)).Id | 		); | ||||||
| 			); | 		await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow()); | ||||||
| 			return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow()); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Set watch status |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Set when an item has been wathed and if it was watched. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> |  | ||||||
| 		/// <param name="status">The new watch status.</param> |  | ||||||
| 		/// <returns>The newly set status.</returns> |  | ||||||
| 		/// <response code="200">The status has been set</response> |  | ||||||
| 		/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> |  | ||||||
| 		/// <response code="404">No movie with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpPost("{identifier:id}/watchStatus")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task<ShowWatchStatus?> SetWatchStatus( |  | ||||||
| 			Identifier identifier, |  | ||||||
| 			WatchStatus status |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			Guid id = await identifier.Match( |  | ||||||
| 				id => Task.FromResult(id), |  | ||||||
| 				async slug => (await _libraryManager.Shows.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Delete watch status |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Delete watch status (to rewatch for example). |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> |  | ||||||
| 		/// <returns>The newly set status.</returns> |  | ||||||
| 		/// <response code="204">The status has been deleted.</response> |  | ||||||
| 		/// <response code="404">No show with the given ID or slug could be found.</response> |  | ||||||
| 		[HttpDelete("{identifier:id}/watchStatus")] |  | ||||||
| 		[UserOnly] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status204NoContent)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status404NotFound)] |  | ||||||
| 		public async Task DeleteWatchStatus(Identifier identifier) |  | ||||||
| 		{ |  | ||||||
| 			Guid id = await identifier.Match( |  | ||||||
| 				id => Task.FromResult(id), |  | ||||||
| 				async slug => (await _libraryManager.Shows.Get(slug)).Id |  | ||||||
| 			); |  | ||||||
| 			await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow()); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -29,44 +29,43 @@ using Microsoft.AspNetCore.Http; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// List new items added to kyoo. | ||||||
|  | /// </summary> | ||||||
|  | [Route("watchlist")] | ||||||
|  | [ApiController] | ||||||
|  | [PartialPermission("LibraryItem")] | ||||||
|  | [ApiDefinition("News", Group = ResourcesGroup)] | ||||||
|  | [UserOnly] | ||||||
|  | public class WatchlistApi(IWatchStatusRepository repository) : BaseApi | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// List new items added to kyoo. | 	/// Get all | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	[Route("watchlist")] | 	/// <remarks> | ||||||
| 	[ApiController] | 	/// Get all resources that match the given filter. | ||||||
| 	[PartialPermission("LibraryItem")] | 	/// </remarks> | ||||||
| 	[ApiDefinition("News", Group = ResourcesGroup)] | 	/// <param name="filter">Filter the returned items.</param> | ||||||
| 	[UserOnly] | 	/// <param name="pagination">How many items per page should be returned, where should the page start...</param> | ||||||
| 	public class WatchlistApi(IWatchStatusRepository repository) : BaseApi | 	/// <param name="fields">The aditional fields to include in the result.</param> | ||||||
|  | 	/// <returns>A list of resources that match every filters.</returns> | ||||||
|  | 	/// <response code="400">Invalid filters or sort information.</response> | ||||||
|  | 	[HttpGet] | ||||||
|  | 	[PartialPermission(Kind.Read)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status200OK)] | ||||||
|  | 	[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] | ||||||
|  | 	public async Task<ActionResult<Page<IWatchlist>>> GetAll( | ||||||
|  | 		[FromQuery] Filter<IWatchlist>? filter, | ||||||
|  | 		[FromQuery] Pagination pagination, | ||||||
|  | 		[FromQuery] Include<IWatchlist>? fields | ||||||
|  | 	) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		if (User.GetId() == null) | ||||||
| 		/// Get all | 			throw new UnauthorizedException(); | ||||||
| 		/// </summary> | 		ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination); | ||||||
| 		/// <remarks> |  | ||||||
| 		/// Get all resources that match the given filter. |  | ||||||
| 		/// </remarks> |  | ||||||
| 		/// <param name="filter">Filter the returned items.</param> |  | ||||||
| 		/// <param name="pagination">How many items per page should be returned, where should the page start...</param> |  | ||||||
| 		/// <param name="fields">The aditional fields to include in the result.</param> |  | ||||||
| 		/// <returns>A list of resources that match every filters.</returns> |  | ||||||
| 		/// <response code="400">Invalid filters or sort information.</response> |  | ||||||
| 		[HttpGet] |  | ||||||
| 		[PartialPermission(Kind.Read)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status200OK)] |  | ||||||
| 		[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] |  | ||||||
| 		public async Task<ActionResult<Page<IWatchlist>>> GetAll( |  | ||||||
| 			[FromQuery] Filter<IWatchlist>? filter, |  | ||||||
| 			[FromQuery] Pagination pagination, |  | ||||||
| 			[FromQuery] Include<IWatchlist>? fields |  | ||||||
| 		) |  | ||||||
| 		{ |  | ||||||
| 			if (User.GetId() == null) |  | ||||||
| 				throw new UnauthorizedException(); |  | ||||||
| 			ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination); |  | ||||||
| 
 | 
 | ||||||
| 			return Page(resources, pagination.Limit); | 		return Page(resources, pagination.Limit); | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -28,69 +28,68 @@ using Kyoo.Utils; | |||||||
| using Microsoft.AspNetCore.Http; | using Microsoft.AspNetCore.Http; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Core.Api | namespace Kyoo.Core.Api; | ||||||
| { |  | ||||||
| 	/// <summary> |  | ||||||
| 	/// Proxy to other services |  | ||||||
| 	/// </summary> |  | ||||||
| 	[ApiController] |  | ||||||
| 	[Obsolete("Use /episode/id/master.m3u8 or routes like that")] |  | ||||||
| 	public class ProxyApi(ILibraryManager library) : Controller |  | ||||||
| 	{ |  | ||||||
| 		private Task _Proxy(string route, (string path, string route) info) |  | ||||||
| 		{ |  | ||||||
| 			HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder |  | ||||||
| 				.Instance.WithBeforeSend( |  | ||||||
| 					(ctx, req) => |  | ||||||
| 					{ |  | ||||||
| 						req.Headers.Add("X-Path", info.path); |  | ||||||
| 						req.Headers.Add("X-Route", info.route); |  | ||||||
| 						return Task.CompletedTask; |  | ||||||
| 					} |  | ||||||
| 				) |  | ||||||
| 				.WithHandleFailure( |  | ||||||
| 					async (context, exception) => |  | ||||||
| 					{ |  | ||||||
| 						context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; |  | ||||||
| 						await context.Response.WriteAsJsonAsync( |  | ||||||
| 							new RequestError("Service unavailable") |  | ||||||
| 						); |  | ||||||
| 					} |  | ||||||
| 				) |  | ||||||
| 				.Build(); |  | ||||||
| 			return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | /// <summary> | ||||||
| 		/// Transcoder proxy | /// Proxy to other services | ||||||
| 		/// </summary> | /// </summary> | ||||||
| 		/// <remarks> | [ApiController] | ||||||
| 		/// Simply proxy requests to the transcoder | [Obsolete("Use /episode/id/master.m3u8 or routes like that")] | ||||||
| 		/// </remarks> | public class ProxyApi(ILibraryManager library) : Controller | ||||||
| 		/// <param name="rest">The path of the transcoder.</param> | { | ||||||
| 		/// <returns>The return value of the transcoder.</returns> | 	private Task _Proxy(string route, (string path, string route) info) | ||||||
| 		[Route("video/{type}/{id:id}/{**rest}")] | 	{ | ||||||
| 		[Permission("video", Kind.Read)] | 		HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder | ||||||
| 		[Obsolete("Use /episode/id/master.m3u8 or routes like that")] | 			.Instance.WithBeforeSend( | ||||||
| 		public async Task Proxy( | 				(ctx, req) => | ||||||
| 			string type, | 				{ | ||||||
| 			Identifier id, | 					req.Headers.Add("X-Path", info.path); | ||||||
| 			string rest, | 					req.Headers.Add("X-Route", info.route); | ||||||
| 			[FromQuery] Dictionary<string, string> query | 					return Task.CompletedTask; | ||||||
| 		) | 				} | ||||||
| 		{ | 			) | ||||||
| 			string path = await ( | 			.WithHandleFailure( | ||||||
| 				type is "movie" or "movies" | 				async (context, exception) => | ||||||
| 					? id.Match( | 				{ | ||||||
| 						async id => (await library.Movies.Get(id)).Path, | 					context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; | ||||||
| 						async slug => (await library.Movies.Get(slug)).Path | 					await context.Response.WriteAsJsonAsync( | ||||||
| 					) | 						new RequestError("Service unavailable") | ||||||
| 					: id.Match( | 					); | ||||||
| 						async id => (await library.Episodes.Get(id)).Path, | 				} | ||||||
| 						async slug => (await library.Episodes.Get(slug)).Path | 			) | ||||||
| 					) | 			.Build(); | ||||||
| 			); | 		return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions); | ||||||
| 			await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}")); | 	} | ||||||
| 		} | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Transcoder proxy | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <remarks> | ||||||
|  | 	/// Simply proxy requests to the transcoder | ||||||
|  | 	/// </remarks> | ||||||
|  | 	/// <param name="rest">The path of the transcoder.</param> | ||||||
|  | 	/// <returns>The return value of the transcoder.</returns> | ||||||
|  | 	[Route("video/{type}/{id:id}/{**rest}")] | ||||||
|  | 	[Permission("video", Kind.Read)] | ||||||
|  | 	[Obsolete("Use /episode/id/master.m3u8 or routes like that")] | ||||||
|  | 	public async Task Proxy( | ||||||
|  | 		string type, | ||||||
|  | 		Identifier id, | ||||||
|  | 		string rest, | ||||||
|  | 		[FromQuery] Dictionary<string, string> query | ||||||
|  | 	) | ||||||
|  | 	{ | ||||||
|  | 		string path = await ( | ||||||
|  | 			type is "movie" or "movies" | ||||||
|  | 				? id.Match( | ||||||
|  | 					async id => (await library.Movies.Get(id)).Path, | ||||||
|  | 					async slug => (await library.Movies.Get(slug)).Path | ||||||
|  | 				) | ||||||
|  | 				: id.Match( | ||||||
|  | 					async id => (await library.Episodes.Get(id)).Path, | ||||||
|  | 					async slug => (await library.Episodes.Get(slug)).Path | ||||||
|  | 				) | ||||||
|  | 		); | ||||||
|  | 		await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}")); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -37,166 +37,160 @@ using Serilog.Templates; | |||||||
| using Serilog.Templates.Themes; | using Serilog.Templates.Themes; | ||||||
| using ILogger = Serilog.ILogger; | using ILogger = Serilog.ILogger; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Host | namespace Kyoo.Host; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/> | ||||||
|  | /// and return <see cref="Start(string[])"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class Application | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/> | 	/// The environment in witch Kyoo will run (ether "Production" or "Development"). | ||||||
| 	/// and return <see cref="Start(string[])"/>. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class Application | 	private readonly string _environment; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The logger used for startup and error messages. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private ILogger _logger; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="Application"/> that will use the specified environment. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="environment">The environment to run in.</param> | ||||||
|  | 	public Application(string environment) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_environment = environment; | ||||||
| 		/// The environment in witch Kyoo will run (ether "Production" or "Development"). | 	} | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly string _environment; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The logger used for startup and error messages. | 	/// Start the application with the given console args. | ||||||
| 		/// </summary> | 	/// This is generally called from the Main entrypoint of Kyoo. | ||||||
| 		private ILogger _logger; | 	/// </summary> | ||||||
|  | 	/// <param name="args">The console arguments to use for kyoo.</param> | ||||||
|  | 	/// <returns>A task representing the whole process</returns> | ||||||
|  | 	public Task Start(string[] args) | ||||||
|  | 	{ | ||||||
|  | 		return Start(args, _ => { }); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Create a new <see cref="Application"/> that will use the specified environment. | 	/// Start the application with the given console args. | ||||||
| 		/// </summary> | 	/// This is generally called from the Main entrypoint of Kyoo. | ||||||
| 		/// <param name="environment">The environment to run in.</param> | 	/// </summary> | ||||||
| 		public Application(string environment) | 	/// <param name="args">The console arguments to use for kyoo.</param> | ||||||
|  | 	/// <param name="configure">A custom action to configure the container before the start</param> | ||||||
|  | 	/// <returns>A task representing the whole process</returns> | ||||||
|  | 	public async Task Start(string[] args, Action<ContainerBuilder> configure) | ||||||
|  | 	{ | ||||||
|  | 		IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build(); | ||||||
|  | 		string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo")); | ||||||
|  | 		if (!Directory.Exists(path)) | ||||||
|  | 			Directory.CreateDirectory(path); | ||||||
|  | 		Environment.CurrentDirectory = path; | ||||||
|  | 
 | ||||||
|  | 		LoggerConfiguration config = new(); | ||||||
|  | 		_ConfigureLogging(config); | ||||||
|  | 		Log.Logger = config.CreateBootstrapLogger(); | ||||||
|  | 		_logger = Log.Logger.ForContext<Application>(); | ||||||
|  | 
 | ||||||
|  | 		AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush(); | ||||||
|  | 		AppDomain.CurrentDomain.UnhandledException += (_, ex) => | ||||||
|  | 			Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception"); | ||||||
|  | 
 | ||||||
|  | 		IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build(); | ||||||
|  | 
 | ||||||
|  | 		await using (AsyncServiceScope scope = host.Services.CreateAsyncScope()) | ||||||
| 		{ | 		{ | ||||||
| 			_environment = environment; | 			PostgresModule.Initialize(scope.ServiceProvider); | ||||||
|  | 			await MeilisearchModule.Initialize(scope.ServiceProvider); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		await _StartWithHost(host); | ||||||
| 		/// Start the application with the given console args. | 	} | ||||||
| 		/// This is generally called from the Main entrypoint of Kyoo. | 
 | ||||||
| 		/// </summary> | 	/// <summary> | ||||||
| 		/// <param name="args">The console arguments to use for kyoo.</param> | 	/// Start the given host and log failing exceptions. | ||||||
| 		/// <returns>A task representing the whole process</returns> | 	/// </summary> | ||||||
| 		public Task Start(string[] args) | 	/// <param name="host">The host to start.</param> | ||||||
|  | 	/// <param name="cancellationToken">A token to allow one to stop the host.</param> | ||||||
|  | 	private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default) | ||||||
|  | 	{ | ||||||
|  | 		try | ||||||
| 		{ | 		{ | ||||||
| 			return Start(args, _ => { }); | 			CoreModule.Services = host.Services; | ||||||
|  | 			_logger.Information( | ||||||
|  | 				"Version: {Version}", | ||||||
|  | 				Assembly.GetExecutingAssembly().GetName().Version.ToString(3) | ||||||
|  | 			); | ||||||
|  | 			_logger.Information("Data directory: {DataDirectory}", Environment.CurrentDirectory); | ||||||
|  | 			await host.RunAsync(cancellationToken); | ||||||
| 		} | 		} | ||||||
| 
 | 		catch (Exception ex) | ||||||
| 		/// <summary> |  | ||||||
| 		/// Start the application with the given console args. |  | ||||||
| 		/// This is generally called from the Main entrypoint of Kyoo. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="args">The console arguments to use for kyoo.</param> |  | ||||||
| 		/// <param name="configure">A custom action to configure the container before the start</param> |  | ||||||
| 		/// <returns>A task representing the whole process</returns> |  | ||||||
| 		public async Task Start(string[] args, Action<ContainerBuilder> configure) |  | ||||||
| 		{ | 		{ | ||||||
| 			IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build(); | 			_logger.Fatal(ex, "Unhandled exception"); | ||||||
| 			string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo")); |  | ||||||
| 			if (!Directory.Exists(path)) |  | ||||||
| 				Directory.CreateDirectory(path); |  | ||||||
| 			Environment.CurrentDirectory = path; |  | ||||||
| 
 |  | ||||||
| 			LoggerConfiguration config = new(); |  | ||||||
| 			_ConfigureLogging(config); |  | ||||||
| 			Log.Logger = config.CreateBootstrapLogger(); |  | ||||||
| 			_logger = Log.Logger.ForContext<Application>(); |  | ||||||
| 
 |  | ||||||
| 			AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush(); |  | ||||||
| 			AppDomain.CurrentDomain.UnhandledException += (_, ex) => |  | ||||||
| 				Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception"); |  | ||||||
| 
 |  | ||||||
| 			IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build(); |  | ||||||
| 
 |  | ||||||
| 			await using (AsyncServiceScope scope = host.Services.CreateAsyncScope()) |  | ||||||
| 			{ |  | ||||||
| 				PostgresModule.Initialize(scope.ServiceProvider); |  | ||||||
| 				await MeilisearchModule.Initialize(scope.ServiceProvider); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			await _StartWithHost(host); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Start the given host and log failing exceptions. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="host">The host to start.</param> |  | ||||||
| 		/// <param name="cancellationToken">A token to allow one to stop the host.</param> |  | ||||||
| 		private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default) |  | ||||||
| 		{ |  | ||||||
| 			try |  | ||||||
| 			{ |  | ||||||
| 				CoreModule.Services = host.Services; |  | ||||||
| 				_logger.Information( |  | ||||||
| 					"Version: {Version}", |  | ||||||
| 					Assembly.GetExecutingAssembly().GetName().Version.ToString(3) |  | ||||||
| 				); |  | ||||||
| 				_logger.Information( |  | ||||||
| 					"Data directory: {DataDirectory}", |  | ||||||
| 					Environment.CurrentDirectory |  | ||||||
| 				); |  | ||||||
| 				await host.RunAsync(cancellationToken); |  | ||||||
| 			} |  | ||||||
| 			catch (Exception ex) |  | ||||||
| 			{ |  | ||||||
| 				_logger.Fatal(ex, "Unhandled exception"); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a a web host |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="args">Command line parameters that can be handled by kestrel</param> |  | ||||||
| 		/// <returns>A new web host instance</returns> |  | ||||||
| 		private IHostBuilder _CreateWebHostBuilder(string[] args) |  | ||||||
| 		{ |  | ||||||
| 			return new HostBuilder() |  | ||||||
| 				.UseServiceProviderFactory(new AutofacServiceProviderFactory()) |  | ||||||
| 				.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory) |  | ||||||
| 				.UseEnvironment(_environment) |  | ||||||
| 				.ConfigureAppConfiguration(x => _SetupConfig(x, args)) |  | ||||||
| 				.UseSerilog((host, services, builder) => _ConfigureLogging(builder)) |  | ||||||
| 				.ConfigureServices(x => x.AddRouting()) |  | ||||||
| 				.ConfigureWebHost(x => |  | ||||||
| 					x.UseKestrel(options => |  | ||||||
| 						{ |  | ||||||
| 							options.AddServerHeader = false; |  | ||||||
| 						}) |  | ||||||
| 						.UseIIS() |  | ||||||
| 						.UseIISIntegration() |  | ||||||
| 						.UseUrls( |  | ||||||
| 							Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000" |  | ||||||
| 						) |  | ||||||
| 						.UseStartup(host => |  | ||||||
| 							PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog()) |  | ||||||
| 						) |  | ||||||
| 				); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Register settings.json, environment variables and command lines arguments as configuration. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="builder">The configuration builder to use</param> |  | ||||||
| 		/// <param name="args">The command line arguments</param> |  | ||||||
| 		/// <returns>The modified configuration builder</returns> |  | ||||||
| 		private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args) |  | ||||||
| 		{ |  | ||||||
| 			return builder |  | ||||||
| 				.AddEnvironmentVariables() |  | ||||||
| 				.AddEnvironmentVariables("KYOO_") |  | ||||||
| 				.AddCommandLine(args); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Configure the logging. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="builder">The logger builder to configure.</param> |  | ||||||
| 		private void _ConfigureLogging(LoggerConfiguration builder) |  | ||||||
| 		{ |  | ||||||
| 			const string template = |  | ||||||
| 				"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} " |  | ||||||
| 				+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}"; |  | ||||||
| 			builder |  | ||||||
| 				.MinimumLevel.Warning() |  | ||||||
| 				.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose) |  | ||||||
| 				.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose) |  | ||||||
| 				.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal) |  | ||||||
| 				.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code)) |  | ||||||
| 				.Enrich.WithThreadId() |  | ||||||
| 				.Enrich.FromLogContext(); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a a web host | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="args">Command line parameters that can be handled by kestrel</param> | ||||||
|  | 	/// <returns>A new web host instance</returns> | ||||||
|  | 	private IHostBuilder _CreateWebHostBuilder(string[] args) | ||||||
|  | 	{ | ||||||
|  | 		return new HostBuilder() | ||||||
|  | 			.UseServiceProviderFactory(new AutofacServiceProviderFactory()) | ||||||
|  | 			.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory) | ||||||
|  | 			.UseEnvironment(_environment) | ||||||
|  | 			.ConfigureAppConfiguration(x => _SetupConfig(x, args)) | ||||||
|  | 			.UseSerilog((host, services, builder) => _ConfigureLogging(builder)) | ||||||
|  | 			.ConfigureServices(x => x.AddRouting()) | ||||||
|  | 			.ConfigureWebHost(x => | ||||||
|  | 				x.UseKestrel(options => | ||||||
|  | 					{ | ||||||
|  | 						options.AddServerHeader = false; | ||||||
|  | 					}) | ||||||
|  | 					.UseIIS() | ||||||
|  | 					.UseIISIntegration() | ||||||
|  | 					.UseUrls(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000") | ||||||
|  | 					.UseStartup(host => | ||||||
|  | 						PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog()) | ||||||
|  | 					) | ||||||
|  | 			); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Register settings.json, environment variables and command lines arguments as configuration. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="builder">The configuration builder to use</param> | ||||||
|  | 	/// <param name="args">The command line arguments</param> | ||||||
|  | 	/// <returns>The modified configuration builder</returns> | ||||||
|  | 	private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args) | ||||||
|  | 	{ | ||||||
|  | 		return builder | ||||||
|  | 			.AddEnvironmentVariables() | ||||||
|  | 			.AddEnvironmentVariables("KYOO_") | ||||||
|  | 			.AddCommandLine(args); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Configure the logging. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="builder">The logger builder to configure.</param> | ||||||
|  | 	private void _ConfigureLogging(LoggerConfiguration builder) | ||||||
|  | 	{ | ||||||
|  | 		const string template = | ||||||
|  | 			"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} " | ||||||
|  | 			+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}"; | ||||||
|  | 		builder | ||||||
|  | 			.MinimumLevel.Warning() | ||||||
|  | 			.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose) | ||||||
|  | 			.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose) | ||||||
|  | 			.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal) | ||||||
|  | 			.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code)) | ||||||
|  | 			.Enrich.WithThreadId() | ||||||
|  | 			.Enrich.FromLogContext(); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,73 +23,70 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Host.Controllers | namespace Kyoo.Host.Controllers; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An implementation of <see cref="IPluginManager"/>. | ||||||
|  | /// This is used to load plugins and retrieve information from them. | ||||||
|  | /// </summary> | ||||||
|  | public class PluginManager : IPluginManager | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// An implementation of <see cref="IPluginManager"/>. | 	/// The service provider. It allow plugin's activation. | ||||||
| 	/// This is used to load plugins and retrieve information from them. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class PluginManager : IPluginManager | 	private readonly IServiceProvider _provider; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The logger used by this class. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly ILogger<PluginManager> _logger; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The list of plugins that are currently loaded. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly List<IPlugin> _plugins = new(); | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="PluginManager"/> instance. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="provider">A service container to allow initialization of plugins</param> | ||||||
|  | 	/// <param name="logger">The logger used by this class.</param> | ||||||
|  | 	public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		_provider = provider; | ||||||
| 		/// The service provider. It allow plugin's activation. | 		_logger = logger; | ||||||
| 		/// </summary> | 	} | ||||||
| 		private readonly IServiceProvider _provider; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// The logger used by this class. | 	public T GetPlugin<T>(string name) | ||||||
| 		/// </summary> | 	{ | ||||||
| 		private readonly ILogger<PluginManager> _logger; | 		return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// The list of plugins that are currently loaded. | 	public ICollection<T> GetPlugins<T>() | ||||||
| 		/// </summary> | 	{ | ||||||
| 		private readonly List<IPlugin> _plugins = new(); | 		return _plugins?.OfType<T>().ToArray(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Create a new <see cref="PluginManager"/> instance. | 	public ICollection<IPlugin> GetAllPlugins() | ||||||
| 		/// </summary> | 	{ | ||||||
| 		/// <param name="provider">A service container to allow initialization of plugins</param> | 		return _plugins; | ||||||
| 		/// <param name="logger">The logger used by this class.</param> | 	} | ||||||
| 		public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger) |  | ||||||
| 		{ |  | ||||||
| 			_provider = provider; |  | ||||||
| 			_logger = logger; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public T GetPlugin<T>(string name) | 	public void LoadPlugins(ICollection<IPlugin> plugins) | ||||||
| 		{ | 	{ | ||||||
| 			return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T); | 		_plugins.AddRange(plugins); | ||||||
| 		} | 		_logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name)); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 	/// <inheritdoc /> | ||||||
| 		public ICollection<T> GetPlugins<T>() | 	public void LoadPlugins(params Type[] plugins) | ||||||
| 		{ | 	{ | ||||||
| 			return _plugins?.OfType<T>().ToArray(); | 		LoadPlugins( | ||||||
| 		} | 			plugins.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)).ToArray() | ||||||
| 
 | 		); | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public ICollection<IPlugin> GetAllPlugins() |  | ||||||
| 		{ |  | ||||||
| 			return _plugins; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void LoadPlugins(ICollection<IPlugin> plugins) |  | ||||||
| 		{ |  | ||||||
| 			_plugins.AddRange(plugins); |  | ||||||
| 			_logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name)); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void LoadPlugins(params Type[] plugins) |  | ||||||
| 		{ |  | ||||||
| 			LoadPlugins( |  | ||||||
| 				plugins |  | ||||||
| 					.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)) |  | ||||||
| 					.ToArray() |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -23,39 +23,38 @@ using Kyoo.Abstractions.Controllers; | |||||||
| using Microsoft.AspNetCore.Builder; | using Microsoft.AspNetCore.Builder; | ||||||
| using Serilog; | using Serilog; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Host | namespace Kyoo.Host; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A module that registers host controllers and other needed things. | ||||||
|  | /// </summary> | ||||||
|  | public class HostModule : IPlugin | ||||||
| { | { | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public string Name => "Host"; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A module that registers host controllers and other needed things. | 	/// The plugin manager that loaded all plugins. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class HostModule : IPlugin | 	private readonly IPluginManager _plugins; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="HostModule"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="plugins">The plugin manager that loaded all plugins.</param> | ||||||
|  | 	public HostModule(IPluginManager plugins) | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		_plugins = plugins; | ||||||
| 		public string Name => "Host"; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The plugin manager that loaded all plugins. |  | ||||||
| 		/// </summary> |  | ||||||
| 		private readonly IPluginManager _plugins; |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Create a new <see cref="HostModule"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="plugins">The plugin manager that loaded all plugins.</param> |  | ||||||
| 		public HostModule(IPluginManager plugins) |  | ||||||
| 		{ |  | ||||||
| 			_plugins = plugins; |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void Configure(ContainerBuilder builder) |  | ||||||
| 		{ |  | ||||||
| 			builder.RegisterModule<AttributedMetadataModule>(); |  | ||||||
| 			builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned(); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IEnumerable<IStartupAction> ConfigureSteps => |  | ||||||
| 			new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) }; |  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public void Configure(ContainerBuilder builder) | ||||||
|  | 	{ | ||||||
|  | 		builder.RegisterModule<AttributedMetadataModule>(); | ||||||
|  | 		builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned(); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IEnumerable<IStartupAction> ConfigureSteps => | ||||||
|  | 		new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -36,167 +36,163 @@ using Microsoft.Extensions.DependencyInjection; | |||||||
| using Microsoft.Extensions.Hosting; | using Microsoft.Extensions.Hosting; | ||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Host | namespace Kyoo.Host; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// The Startup class is used to configure the AspNet's webhost. | ||||||
|  | /// </summary> | ||||||
|  | public class PluginsStartup | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// The Startup class is used to configure the AspNet's webhost. | 	/// A plugin manager used to load plugins and allow them to configure services / asp net. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class PluginsStartup | 	private readonly IPluginManager _plugins; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The plugin that adds controllers and tasks specific to this host. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly IPlugin _hostModule; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Created from the DI container, those services are needed to load information and instantiate plugins.s | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param> | ||||||
|  | 	public PluginsStartup(IPluginManager plugins) | ||||||
|  | 	{ | ||||||
|  | 		_plugins = plugins; | ||||||
|  | 		_hostModule = new HostModule(_plugins); | ||||||
|  | 		_plugins.LoadPlugins( | ||||||
|  | 			typeof(CoreModule), | ||||||
|  | 			typeof(AuthenticationModule), | ||||||
|  | 			typeof(PostgresModule), | ||||||
|  | 			typeof(MeilisearchModule), | ||||||
|  | 			typeof(SwaggerModule) | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new <see cref="PluginsStartup"/> from a webhost. | ||||||
|  | 	/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="host">The context of the web host.</param> | ||||||
|  | 	/// <param name="logger"> | ||||||
|  | 	/// The logger factory used to log while the application is setting itself up. | ||||||
|  | 	/// </param> | ||||||
|  | 	/// <returns>A new <see cref="PluginsStartup"/>.</returns> | ||||||
|  | 	public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger) | ||||||
|  | 	{ | ||||||
|  | 		HostServiceProvider hostProvider = new(host.HostingEnvironment, host.Configuration, logger); | ||||||
|  | 		PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>()); | ||||||
|  | 		return new PluginsStartup(plugins); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Configure the services context via the <see cref="PluginManager"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="services">The service collection to fill.</param> | ||||||
|  | 	public void ConfigureServices(IServiceCollection services) | ||||||
|  | 	{ | ||||||
|  | 		foreach (Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly)) | ||||||
|  | 			services.AddMvcCore().AddApplicationPart(assembly); | ||||||
|  | 
 | ||||||
|  | 		_hostModule.Configure(services); | ||||||
|  | 		foreach (IPlugin plugin in _plugins.GetAllPlugins()) | ||||||
|  | 			plugin.Configure(services); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Configure the autofac container via the <see cref="PluginManager"/>. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="builder">The builder to configure.</param> | ||||||
|  | 	public void ConfigureContainer(ContainerBuilder builder) | ||||||
|  | 	{ | ||||||
|  | 		_hostModule.Configure(builder); | ||||||
|  | 		foreach (IPlugin plugin in _plugins.GetAllPlugins()) | ||||||
|  | 			plugin.Configure(builder); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Configure the asp net host. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="app">The asp net host to configure</param> | ||||||
|  | 	/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param> | ||||||
|  | 	public void Configure(IApplicationBuilder app, ILifetimeScope container) | ||||||
|  | 	{ | ||||||
|  | 		IEnumerable<IStartupAction> steps = _plugins | ||||||
|  | 			.GetAllPlugins() | ||||||
|  | 			.Append(_hostModule) | ||||||
|  | 			.SelectMany(x => x.ConfigureSteps) | ||||||
|  | 			.OrderByDescending(x => x.Priority); | ||||||
|  | 
 | ||||||
|  | 		using ILifetimeScope scope = container.BeginLifetimeScope(x => | ||||||
|  | 			x.RegisterInstance(app).SingleInstance().ExternallyOwned() | ||||||
|  | 		); | ||||||
|  | 		IServiceProvider provider = scope.Resolve<IServiceProvider>(); | ||||||
|  | 		foreach (IStartupAction step in steps) | ||||||
|  | 			step.Run(provider); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// A simple host service provider used to activate plugins instance. | ||||||
|  | 	/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private class HostServiceProvider : IServiceProvider | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// A plugin manager used to load plugins and allow them to configure services / asp net. | 		/// The host environment that could be used by plugins to configure themself. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		private readonly IPluginManager _plugins; | 		private readonly IWebHostEnvironment _hostEnvironment; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// The plugin that adds controllers and tasks specific to this host. | 		/// The configuration context. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		private readonly IPlugin _hostModule; | 		private readonly IConfiguration _configuration; | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Created from the DI container, those services are needed to load information and instantiate plugins.s | 		/// A logger factory used to create a logger for the plugin manager. | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param> | 		private readonly ILoggerFactory _loggerFactory; | ||||||
| 		public PluginsStartup(IPluginManager plugins) |  | ||||||
| 		{ |  | ||||||
| 			_plugins = plugins; |  | ||||||
| 			_hostModule = new HostModule(_plugins); |  | ||||||
| 			_plugins.LoadPlugins( |  | ||||||
| 				typeof(CoreModule), |  | ||||||
| 				typeof(AuthenticationModule), |  | ||||||
| 				typeof(PostgresModule), |  | ||||||
| 				typeof(MeilisearchModule), |  | ||||||
| 				typeof(SwaggerModule) |  | ||||||
| 			); |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <summary> | ||||||
| 		/// Create a new <see cref="PluginsStartup"/> from a webhost. | 		/// Create a new <see cref="HostServiceProvider"/> that will return given services when asked. | ||||||
| 		/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>. |  | ||||||
| 		/// </summary> | 		/// </summary> | ||||||
| 		/// <param name="host">The context of the web host.</param> | 		/// <param name="hostEnvironment"> | ||||||
| 		/// <param name="logger"> | 		/// The host environment that could be used by plugins to configure themself. | ||||||
| 		/// The logger factory used to log while the application is setting itself up. |  | ||||||
| 		/// </param> | 		/// </param> | ||||||
| 		/// <returns>A new <see cref="PluginsStartup"/>.</returns> | 		/// <param name="configuration">The configuration context</param> | ||||||
| 		public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger) | 		/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param> | ||||||
|  | 		public HostServiceProvider( | ||||||
|  | 			IWebHostEnvironment hostEnvironment, | ||||||
|  | 			IConfiguration configuration, | ||||||
|  | 			ILoggerFactory loggerFactory | ||||||
|  | 		) | ||||||
| 		{ | 		{ | ||||||
| 			HostServiceProvider hostProvider = | 			_hostEnvironment = hostEnvironment; | ||||||
| 				new(host.HostingEnvironment, host.Configuration, logger); | 			_configuration = configuration; | ||||||
| 			PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>()); | 			_loggerFactory = loggerFactory; | ||||||
| 			return new PluginsStartup(plugins); |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		/// <inheritdoc /> | ||||||
| 		/// Configure the services context via the <see cref="PluginManager"/>. | 		public object GetService(Type serviceType) | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="services">The service collection to fill.</param> |  | ||||||
| 		public void ConfigureServices(IServiceCollection services) |  | ||||||
| 		{ | 		{ | ||||||
| 			foreach ( | 			if ( | ||||||
| 				Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly) | 				serviceType == typeof(IWebHostEnvironment) | ||||||
| 			) | 				|| serviceType == typeof(IHostEnvironment) | ||||||
| 				services.AddMvcCore().AddApplicationPart(assembly); |  | ||||||
| 
 |  | ||||||
| 			_hostModule.Configure(services); |  | ||||||
| 			foreach (IPlugin plugin in _plugins.GetAllPlugins()) |  | ||||||
| 				plugin.Configure(services); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Configure the autofac container via the <see cref="PluginManager"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="builder">The builder to configure.</param> |  | ||||||
| 		public void ConfigureContainer(ContainerBuilder builder) |  | ||||||
| 		{ |  | ||||||
| 			_hostModule.Configure(builder); |  | ||||||
| 			foreach (IPlugin plugin in _plugins.GetAllPlugins()) |  | ||||||
| 				plugin.Configure(builder); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Configure the asp net host. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="app">The asp net host to configure</param> |  | ||||||
| 		/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param> |  | ||||||
| 		public void Configure(IApplicationBuilder app, ILifetimeScope container) |  | ||||||
| 		{ |  | ||||||
| 			IEnumerable<IStartupAction> steps = _plugins |  | ||||||
| 				.GetAllPlugins() |  | ||||||
| 				.Append(_hostModule) |  | ||||||
| 				.SelectMany(x => x.ConfigureSteps) |  | ||||||
| 				.OrderByDescending(x => x.Priority); |  | ||||||
| 
 |  | ||||||
| 			using ILifetimeScope scope = container.BeginLifetimeScope(x => |  | ||||||
| 				x.RegisterInstance(app).SingleInstance().ExternallyOwned() |  | ||||||
| 			); |  | ||||||
| 			IServiceProvider provider = scope.Resolve<IServiceProvider>(); |  | ||||||
| 			foreach (IStartupAction step in steps) |  | ||||||
| 				step.Run(provider); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// A simple host service provider used to activate plugins instance. |  | ||||||
| 		/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added. |  | ||||||
| 		/// </summary> |  | ||||||
| 		private class HostServiceProvider : IServiceProvider |  | ||||||
| 		{ |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The host environment that could be used by plugins to configure themself. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly IWebHostEnvironment _hostEnvironment; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// The configuration context. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly IConfiguration _configuration; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// A logger factory used to create a logger for the plugin manager. |  | ||||||
| 			/// </summary> |  | ||||||
| 			private readonly ILoggerFactory _loggerFactory; |  | ||||||
| 
 |  | ||||||
| 			/// <summary> |  | ||||||
| 			/// Create a new <see cref="HostServiceProvider"/> that will return given services when asked. |  | ||||||
| 			/// </summary> |  | ||||||
| 			/// <param name="hostEnvironment"> |  | ||||||
| 			/// The host environment that could be used by plugins to configure themself. |  | ||||||
| 			/// </param> |  | ||||||
| 			/// <param name="configuration">The configuration context</param> |  | ||||||
| 			/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param> |  | ||||||
| 			public HostServiceProvider( |  | ||||||
| 				IWebHostEnvironment hostEnvironment, |  | ||||||
| 				IConfiguration configuration, |  | ||||||
| 				ILoggerFactory loggerFactory |  | ||||||
| 			) | 			) | ||||||
|  | 				return _hostEnvironment; | ||||||
|  | 			if (serviceType == typeof(IConfiguration)) | ||||||
|  | 				return _configuration; | ||||||
|  | 			if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>)) | ||||||
| 			{ | 			{ | ||||||
| 				_hostEnvironment = hostEnvironment; | 				return Utility.RunGenericMethod<object>( | ||||||
| 				_configuration = configuration; | 					typeof(LoggerFactoryExtensions), | ||||||
| 				_loggerFactory = loggerFactory; | 					nameof(LoggerFactoryExtensions.CreateLogger), | ||||||
|  | 					serviceType.GetGenericArguments().First(), | ||||||
|  | 					_loggerFactory | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			/// <inheritdoc /> | 			return null; | ||||||
| 			public object GetService(Type serviceType) |  | ||||||
| 			{ |  | ||||||
| 				if ( |  | ||||||
| 					serviceType == typeof(IWebHostEnvironment) |  | ||||||
| 					|| serviceType == typeof(IHostEnvironment) |  | ||||||
| 				) |  | ||||||
| 					return _hostEnvironment; |  | ||||||
| 				if (serviceType == typeof(IConfiguration)) |  | ||||||
| 					return _configuration; |  | ||||||
| 				if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>)) |  | ||||||
| 				{ |  | ||||||
| 					return Utility.RunGenericMethod<object>( |  | ||||||
| 						typeof(LoggerFactoryExtensions), |  | ||||||
| 						nameof(LoggerFactoryExtensions.CreateLogger), |  | ||||||
| 						serviceType.GetGenericArguments().First(), |  | ||||||
| 						_loggerFactory |  | ||||||
| 					); |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				return null; |  | ||||||
| 			} |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -19,31 +19,30 @@ | |||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Microsoft.AspNetCore.Hosting; | using Microsoft.AspNetCore.Hosting; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Host | namespace Kyoo.Host; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// Program entrypoint. | ||||||
|  | /// </summary> | ||||||
|  | public static class Program | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// Program entrypoint. | 	/// The string representation of the environment used in <see cref="IWebHostEnvironment"/>. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class Program |  | ||||||
| 	{ |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// The string representation of the environment used in <see cref="IWebHostEnvironment"/>. |  | ||||||
| 		/// </summary> |  | ||||||
| #if DEBUG | #if DEBUG | ||||||
| 		private const string Environment = "Development"; | 	private const string Environment = "Development"; | ||||||
| #else | #else | ||||||
| 		private const string Environment = "Production"; | 	private const string Environment = "Production"; | ||||||
| #endif | #endif | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Main function of the program | 	/// Main function of the program | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="args">Command line arguments</param> | 	/// <param name="args">Command line arguments</param> | ||||||
| 		/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns> | 	/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns> | ||||||
| 		public static Task Main(string[] args) | 	public static Task Main(string[] args) | ||||||
| 		{ | 	{ | ||||||
| 			Application application = new(Environment); | 		Application application = new(Environment); | ||||||
| 			return application.Start(args); | 		return application.Start(args); | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,171 +24,166 @@ using Microsoft.Extensions.Configuration; | |||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using static System.Text.Json.JsonNamingPolicy; | using static System.Text.Json.JsonNamingPolicy; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Meiliseach | namespace Kyoo.Meiliseach; | ||||||
|  | 
 | ||||||
|  | public class MeilisearchModule : IPlugin | ||||||
| { | { | ||||||
| 	public class MeilisearchModule : IPlugin | 	/// <inheritdoc /> | ||||||
|  | 	public string Name => "Meilisearch"; | ||||||
|  | 
 | ||||||
|  | 	private readonly IConfiguration _configuration; | ||||||
|  | 
 | ||||||
|  | 	public static Dictionary<string, Settings> IndexSettings => | ||||||
|  | 		new() | ||||||
|  | 		{ | ||||||
|  | 			{ | ||||||
|  | 				"items", | ||||||
|  | 				new Settings() | ||||||
|  | 				{ | ||||||
|  | 					SearchableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Name)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Slug)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Aliases)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Path)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Tags)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Overview)), | ||||||
|  | 					}, | ||||||
|  | 					FilterableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Genres)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Status)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.AirDate)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.StudioId)), | ||||||
|  | 						"kind" | ||||||
|  | 					}, | ||||||
|  | 					SortableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.AirDate)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.AddedDate)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Rating)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Movie.Runtime)), | ||||||
|  | 					}, | ||||||
|  | 					DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Movie.Id)), "kind" }, | ||||||
|  | 					RankingRules = new[] | ||||||
|  | 					{ | ||||||
|  | 						"words", | ||||||
|  | 						"typo", | ||||||
|  | 						"proximity", | ||||||
|  | 						"attribute", | ||||||
|  | 						"sort", | ||||||
|  | 						"exactness", | ||||||
|  | 						$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc", | ||||||
|  | 					} | ||||||
|  | 					// TODO: Add stopwords | ||||||
|  | 				} | ||||||
|  | 			}, | ||||||
|  | 			{ | ||||||
|  | 				nameof(Episode), | ||||||
|  | 				new Settings() | ||||||
|  | 				{ | ||||||
|  | 					SearchableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.Name)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.Overview)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.Slug)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.Path)), | ||||||
|  | 					}, | ||||||
|  | 					FilterableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.SeasonNumber)), | ||||||
|  | 					}, | ||||||
|  | 					SortableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.ReleaseDate)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.AddedDate)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.SeasonNumber)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), | ||||||
|  | 					}, | ||||||
|  | 					DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), }, | ||||||
|  | 					// TODO: Add stopwords | ||||||
|  | 				} | ||||||
|  | 			}, | ||||||
|  | 			{ | ||||||
|  | 				nameof(Studio), | ||||||
|  | 				new Settings() | ||||||
|  | 				{ | ||||||
|  | 					SearchableAttributes = new[] | ||||||
|  | 					{ | ||||||
|  | 						CamelCase.ConvertName(nameof(Studio.Name)), | ||||||
|  | 						CamelCase.ConvertName(nameof(Studio.Slug)), | ||||||
|  | 					}, | ||||||
|  | 					FilterableAttributes = Array.Empty<string>(), | ||||||
|  | 					SortableAttributes = Array.Empty<string>(), | ||||||
|  | 					DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), }, | ||||||
|  | 					// TODO: Add stopwords | ||||||
|  | 				} | ||||||
|  | 			}, | ||||||
|  | 		}; | ||||||
|  | 
 | ||||||
|  | 	public MeilisearchModule(IConfiguration configuration) | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		_configuration = configuration; | ||||||
| 		public string Name => "Meilisearch"; | 	} | ||||||
| 
 | 
 | ||||||
| 		private readonly IConfiguration _configuration; | 	/// <summary> | ||||||
|  | 	/// Init meilisearch indexes. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="provider">The service list to retrieve the meilisearch client</param> | ||||||
|  | 	/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | ||||||
|  | 	public static async Task Initialize(IServiceProvider provider) | ||||||
|  | 	{ | ||||||
|  | 		MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>(); | ||||||
| 
 | 
 | ||||||
| 		public static Dictionary<string, Settings> IndexSettings => | 		await _CreateIndex(client, "items", true); | ||||||
| 			new() | 		await _CreateIndex(client, nameof(Episode), false); | ||||||
| 			{ | 		await _CreateIndex(client, nameof(Studio), false); | ||||||
| 				{ |  | ||||||
| 					"items", |  | ||||||
| 					new Settings() |  | ||||||
| 					{ |  | ||||||
| 						SearchableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Name)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Slug)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Aliases)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Path)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Tags)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Overview)), |  | ||||||
| 						}, |  | ||||||
| 						FilterableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Genres)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Status)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.AirDate)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.StudioId)), |  | ||||||
| 							"kind" |  | ||||||
| 						}, |  | ||||||
| 						SortableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.AirDate)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.AddedDate)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Rating)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Runtime)), |  | ||||||
| 						}, |  | ||||||
| 						DisplayedAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Movie.Id)), |  | ||||||
| 							"kind" |  | ||||||
| 						}, |  | ||||||
| 						RankingRules = new[] |  | ||||||
| 						{ |  | ||||||
| 							"words", |  | ||||||
| 							"typo", |  | ||||||
| 							"proximity", |  | ||||||
| 							"attribute", |  | ||||||
| 							"sort", |  | ||||||
| 							"exactness", |  | ||||||
| 							$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc", |  | ||||||
| 						} |  | ||||||
| 						// TODO: Add stopwords |  | ||||||
| 					} |  | ||||||
| 				}, |  | ||||||
| 				{ |  | ||||||
| 					nameof(Episode), |  | ||||||
| 					new Settings() |  | ||||||
| 					{ |  | ||||||
| 						SearchableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.Name)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.Overview)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.Slug)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.Path)), |  | ||||||
| 						}, |  | ||||||
| 						FilterableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.SeasonNumber)), |  | ||||||
| 						}, |  | ||||||
| 						SortableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.ReleaseDate)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.AddedDate)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.SeasonNumber)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), |  | ||||||
| 						}, |  | ||||||
| 						DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), }, |  | ||||||
| 						// TODO: Add stopwords |  | ||||||
| 					} |  | ||||||
| 				}, |  | ||||||
| 				{ |  | ||||||
| 					nameof(Studio), |  | ||||||
| 					new Settings() |  | ||||||
| 					{ |  | ||||||
| 						SearchableAttributes = new[] |  | ||||||
| 						{ |  | ||||||
| 							CamelCase.ConvertName(nameof(Studio.Name)), |  | ||||||
| 							CamelCase.ConvertName(nameof(Studio.Slug)), |  | ||||||
| 						}, |  | ||||||
| 						FilterableAttributes = Array.Empty<string>(), |  | ||||||
| 						SortableAttributes = Array.Empty<string>(), |  | ||||||
| 						DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), }, |  | ||||||
| 						// TODO: Add stopwords |  | ||||||
| 					} |  | ||||||
| 				}, |  | ||||||
| 			}; |  | ||||||
| 
 | 
 | ||||||
| 		public MeilisearchModule(IConfiguration configuration) | 		IndexStats info = await client.Index("items").GetStatsAsync(); | ||||||
|  | 		// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch. | ||||||
|  | 		if (info.NumberOfDocuments == 0) | ||||||
| 		{ | 		{ | ||||||
| 			_configuration = configuration; | 			ILibraryManager database = provider.GetRequiredService<ILibraryManager>(); | ||||||
| 		} | 			MeiliSync search = provider.GetRequiredService<MeiliSync>(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 			// This is a naive implementation that absolutly does not care about performances. | ||||||
| 		/// Init meilisearch indexes. | 			// This will run only once on users that already had a database when they upgrade. | ||||||
| 		/// </summary> | 			foreach (Movie movie in await database.Movies.GetAll(limit: 0)) | ||||||
| 		/// <param name="provider">The service list to retrieve the meilisearch client</param> | 				await search.CreateOrUpdate("items", movie, nameof(Movie)); | ||||||
| 		/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> | 			foreach (Show show in await database.Shows.GetAll(limit: 0)) | ||||||
| 		public static async Task Initialize(IServiceProvider provider) | 				await search.CreateOrUpdate("items", show, nameof(Show)); | ||||||
| 		{ | 			foreach (Collection collection in await database.Collections.GetAll(limit: 0)) | ||||||
| 			MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>(); | 				await search.CreateOrUpdate("items", collection, nameof(Collection)); | ||||||
| 
 | 			foreach (Episode episode in await database.Episodes.GetAll(limit: 0)) | ||||||
| 			await _CreateIndex(client, "items", true); | 				await search.CreateOrUpdate(nameof(Episode), episode); | ||||||
| 			await _CreateIndex(client, nameof(Episode), false); | 			foreach (Studio studio in await database.Studios.GetAll(limit: 0)) | ||||||
| 			await _CreateIndex(client, nameof(Studio), false); | 				await search.CreateOrUpdate(nameof(Studio), studio); | ||||||
| 
 |  | ||||||
| 			IndexStats info = await client.Index("items").GetStatsAsync(); |  | ||||||
| 			// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch. |  | ||||||
| 			if (info.NumberOfDocuments == 0) |  | ||||||
| 			{ |  | ||||||
| 				ILibraryManager database = provider.GetRequiredService<ILibraryManager>(); |  | ||||||
| 				MeiliSync search = provider.GetRequiredService<MeiliSync>(); |  | ||||||
| 
 |  | ||||||
| 				// This is a naive implementation that absolutly does not care about performances. |  | ||||||
| 				// This will run only once on users that already had a database when they upgrade. |  | ||||||
| 				foreach (Movie movie in await database.Movies.GetAll(limit: 0)) |  | ||||||
| 					await search.CreateOrUpdate("items", movie, nameof(Movie)); |  | ||||||
| 				foreach (Show show in await database.Shows.GetAll(limit: 0)) |  | ||||||
| 					await search.CreateOrUpdate("items", show, nameof(Show)); |  | ||||||
| 				foreach (Collection collection in await database.Collections.GetAll(limit: 0)) |  | ||||||
| 					await search.CreateOrUpdate("items", collection, nameof(Collection)); |  | ||||||
| 				foreach (Episode episode in await database.Episodes.GetAll(limit: 0)) |  | ||||||
| 					await search.CreateOrUpdate(nameof(Episode), episode); |  | ||||||
| 				foreach (Studio studio in await database.Studios.GetAll(limit: 0)) |  | ||||||
| 					await search.CreateOrUpdate(nameof(Studio), studio); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind) |  | ||||||
| 		{ |  | ||||||
| 			TaskInfo task = await client.CreateIndexAsync( |  | ||||||
| 				index, |  | ||||||
| 				hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id)) |  | ||||||
| 			); |  | ||||||
| 			await client.WaitForTaskAsync(task.TaskUid); |  | ||||||
| 			await client.Index(index).UpdateSettingsAsync(IndexSettings[index]); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void Configure(ContainerBuilder builder) |  | ||||||
| 		{ |  | ||||||
| 			builder |  | ||||||
| 				.RegisterInstance( |  | ||||||
| 					new MeilisearchClient( |  | ||||||
| 						_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), |  | ||||||
| 						_configuration.GetValue<string?>("MEILI_MASTER_KEY") |  | ||||||
| 					) |  | ||||||
| 				) |  | ||||||
| 				.SingleInstance(); |  | ||||||
| 			builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate(); |  | ||||||
| 			builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope(); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind) | ||||||
|  | 	{ | ||||||
|  | 		TaskInfo task = await client.CreateIndexAsync( | ||||||
|  | 			index, | ||||||
|  | 			hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id)) | ||||||
|  | 		); | ||||||
|  | 		await client.WaitForTaskAsync(task.TaskUid); | ||||||
|  | 		await client.Index(index).UpdateSettingsAsync(IndexSettings[index]); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public void Configure(ContainerBuilder builder) | ||||||
|  | 	{ | ||||||
|  | 		builder | ||||||
|  | 			.RegisterInstance( | ||||||
|  | 				new MeilisearchClient( | ||||||
|  | 					_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), | ||||||
|  | 					_configuration.GetValue<string?>("MEILI_MASTER_KEY") | ||||||
|  | 				) | ||||||
|  | 			) | ||||||
|  | 			.SingleInstance(); | ||||||
|  | 		builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate(); | ||||||
|  | 		builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope(); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -25,114 +25,113 @@ using Microsoft.EntityFrameworkCore; | |||||||
| using Microsoft.EntityFrameworkCore.Query.SqlExpressions; | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; | ||||||
| using Npgsql; | using Npgsql; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Postgresql | namespace Kyoo.Postgresql; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A postgresql implementation of <see cref="DatabaseContext"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class PostgresContext : DatabaseContext | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A postgresql implementation of <see cref="DatabaseContext"/>. | 	/// Is this instance in debug mode? | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class PostgresContext : DatabaseContext | 	private readonly bool _debugMode; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Should the configure step be skipped? This is used when the database is created via DbContextOptions. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly bool _skipConfigure; | ||||||
|  | 
 | ||||||
|  | 	// TODO: This needs ot be updated but ef-core still does not offer a way to use this. | ||||||
|  | 	[Obsolete] | ||||||
|  | 	static PostgresContext() | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>(); | ||||||
| 		/// Is this instance in debug mode? | 		NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>(); | ||||||
| 		/// </summary> | 		NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>(); | ||||||
| 		private readonly bool _debugMode; | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Should the configure step be skipped? This is used when the database is created via DbContextOptions. | 	/// Design time constructor (dotnet ef migrations add). Do not use | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		private readonly bool _skipConfigure; | 	public PostgresContext() | ||||||
|  | 		: base(null!) { } | ||||||
| 
 | 
 | ||||||
| 		// TODO: This needs ot be updated but ef-core still does not offer a way to use this. | 	public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) | ||||||
| 		[Obsolete] | 		: base(options, accessor) | ||||||
| 		static PostgresContext() | 	{ | ||||||
|  | 		_skipConfigure = true; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor) | ||||||
|  | 		: base(accessor) | ||||||
|  | 	{ | ||||||
|  | 		_debugMode = debugMode; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Set connection information for this database context | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="optionsBuilder">An option builder to fill.</param> | ||||||
|  | 	protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | ||||||
|  | 	{ | ||||||
|  | 		if (!_skipConfigure) | ||||||
| 		{ | 		{ | ||||||
| 			NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>(); | 			optionsBuilder.UseNpgsql(); | ||||||
| 			NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>(); | 			if (_debugMode) | ||||||
| 			NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>(); | 				optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging(); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		optionsBuilder.UseSnakeCaseNamingConvention(); | ||||||
| 		/// Design time constructor (dotnet ef migrations add). Do not use | 		base.OnConfiguring(optionsBuilder); | ||||||
| 		/// </summary> | 	} | ||||||
| 		public PostgresContext() |  | ||||||
| 			: base(null!) { } |  | ||||||
| 
 | 
 | ||||||
| 		public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) | 	/// <summary> | ||||||
| 			: base(options, accessor) | 	/// Set database parameters to support every types of Kyoo. | ||||||
| 		{ | 	/// </summary> | ||||||
| 			_skipConfigure = true; | 	/// <param name="modelBuilder">The database's model builder.</param> | ||||||
| 		} | 	protected override void OnModelCreating(ModelBuilder modelBuilder) | ||||||
|  | 	{ | ||||||
|  | 		modelBuilder.HasPostgresEnum<Status>(); | ||||||
|  | 		modelBuilder.HasPostgresEnum<Genre>(); | ||||||
|  | 		modelBuilder.HasPostgresEnum<WatchStatus>(); | ||||||
| 
 | 
 | ||||||
| 		public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor) | 		modelBuilder | ||||||
| 			: base(accessor) | 			.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!) | ||||||
| 		{ | 			.HasTranslation(args => new SqlFunctionExpression( | ||||||
| 			_debugMode = debugMode; | 				"md5", | ||||||
| 		} | 				args, | ||||||
|  | 				nullable: true, | ||||||
|  | 				argumentsPropagateNullability: new[] { false }, | ||||||
|  | 				type: args[0].Type, | ||||||
|  | 				typeMapping: args[0].TypeMapping | ||||||
|  | 			)); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		base.OnModelCreating(modelBuilder); | ||||||
| 		/// Set connection information for this database context | 	} | ||||||
| 		/// </summary> | 
 | ||||||
| 		/// <param name="optionsBuilder">An option builder to fill.</param> | 	/// <inheritdoc /> | ||||||
| 		protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) | 	protected override string LinkName<T, T2>() | ||||||
| 		{ | 	{ | ||||||
| 			if (!_skipConfigure) | 		SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); | ||||||
|  | 		return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override string LinkNameFk<T>() | ||||||
|  | 	{ | ||||||
|  | 		SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); | ||||||
|  | 		return rewriter.RewriteName(typeof(T).Name + "ID"); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	protected override bool IsDuplicateException(Exception ex) | ||||||
|  | 	{ | ||||||
|  | 		return ex.InnerException | ||||||
|  | 			is PostgresException | ||||||
| 			{ | 			{ | ||||||
| 				optionsBuilder.UseNpgsql(); | 				SqlState: PostgresErrorCodes.UniqueViolation | ||||||
| 				if (_debugMode) | 					or PostgresErrorCodes.ForeignKeyViolation | ||||||
| 					optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging(); | 			}; | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			optionsBuilder.UseSnakeCaseNamingConvention(); |  | ||||||
| 			base.OnConfiguring(optionsBuilder); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <summary> |  | ||||||
| 		/// Set database parameters to support every types of Kyoo. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="modelBuilder">The database's model builder.</param> |  | ||||||
| 		protected override void OnModelCreating(ModelBuilder modelBuilder) |  | ||||||
| 		{ |  | ||||||
| 			modelBuilder.HasPostgresEnum<Status>(); |  | ||||||
| 			modelBuilder.HasPostgresEnum<Genre>(); |  | ||||||
| 			modelBuilder.HasPostgresEnum<WatchStatus>(); |  | ||||||
| 
 |  | ||||||
| 			modelBuilder |  | ||||||
| 				.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!) |  | ||||||
| 				.HasTranslation(args => new SqlFunctionExpression( |  | ||||||
| 					"md5", |  | ||||||
| 					args, |  | ||||||
| 					nullable: true, |  | ||||||
| 					argumentsPropagateNullability: new[] { false }, |  | ||||||
| 					type: args[0].Type, |  | ||||||
| 					typeMapping: args[0].TypeMapping |  | ||||||
| 				)); |  | ||||||
| 
 |  | ||||||
| 			base.OnModelCreating(modelBuilder); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override string LinkName<T, T2>() |  | ||||||
| 		{ |  | ||||||
| 			SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); |  | ||||||
| 			return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override string LinkNameFk<T>() |  | ||||||
| 		{ |  | ||||||
| 			SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); |  | ||||||
| 			return rewriter.RewriteName(typeof(T).Name + "ID"); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		protected override bool IsDuplicateException(Exception ex) |  | ||||||
| 		{ |  | ||||||
| 			return ex.InnerException |  | ||||||
| 				is PostgresException |  | ||||||
| 				{ |  | ||||||
| 					SqlState: PostgresErrorCodes.UniqueViolation |  | ||||||
| 						or PostgresErrorCodes.ForeignKeyViolation |  | ||||||
| 				}; |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -33,115 +33,112 @@ using Microsoft.Extensions.DependencyInjection; | |||||||
| using Microsoft.Extensions.Hosting; | using Microsoft.Extensions.Hosting; | ||||||
| using Npgsql; | using Npgsql; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Postgresql | namespace Kyoo.Postgresql; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A module to add postgresql capacity to the app. | ||||||
|  | /// </summary> | ||||||
|  | public class PostgresModule : IPlugin | ||||||
| { | { | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public string Name => "Postgresql"; | ||||||
|  | 
 | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A module to add postgresql capacity to the app. | 	/// The configuration to use. The database connection string is pulled from it. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class PostgresModule : IPlugin | 	private readonly IConfiguration _configuration; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// The host environment to check if the app is in debug mode. | ||||||
|  | 	/// </summary> | ||||||
|  | 	private readonly IWebHostEnvironment _environment; | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Create a new postgres module instance and use the given configuration and environment. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="configuration">The configuration to use</param> | ||||||
|  | 	/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param> | ||||||
|  | 	public PostgresModule(IConfiguration configuration, IWebHostEnvironment env) | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		_configuration = configuration; | ||||||
| 		public string Name => "Postgresql"; | 		_environment = env; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The configuration to use. The database connection string is pulled from it. | 	/// Migrate the database. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		private readonly IConfiguration _configuration; | 	/// <param name="provider">The service list to retrieve the database context</param> | ||||||
|  | 	public static void Initialize(IServiceProvider provider) | ||||||
|  | 	{ | ||||||
|  | 		DatabaseContext context = provider.GetRequiredService<DatabaseContext>(); | ||||||
|  | 		context.Database.Migrate(); | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection(); | ||||||
| 		/// The host environment to check if the app is in debug mode. | 		conn.Open(); | ||||||
| 		/// </summary> | 		conn.ReloadTypes(); | ||||||
| 		private readonly IWebHostEnvironment _environment; |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		SqlMapper.TypeMapProvider = (type) => | ||||||
| 		/// Create a new postgres module instance and use the given configuration and environment. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="configuration">The configuration to use</param> |  | ||||||
| 		/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param> |  | ||||||
| 		public PostgresModule(IConfiguration configuration, IWebHostEnvironment env) |  | ||||||
| 		{ | 		{ | ||||||
| 			_configuration = configuration; | 			return new CustomPropertyTypeMap( | ||||||
| 			_environment = env; | 				type, | ||||||
| 		} | 				(type, name) => | ||||||
|  | 				{ | ||||||
|  | 					string newName = Regex.Replace( | ||||||
|  | 						name, | ||||||
|  | 						"(^|_)([a-z])", | ||||||
|  | 						(match) => match.Groups[2].Value.ToUpperInvariant() | ||||||
|  | 					); | ||||||
|  | 					// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source | ||||||
|  | 					return type.GetProperty(newName)!; | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
|  | 		}; | ||||||
|  | 		SqlMapper.AddTypeHandler( | ||||||
|  | 			typeof(Dictionary<string, MetadataId>), | ||||||
|  | 			new JsonTypeHandler<Dictionary<string, MetadataId>>() | ||||||
|  | 		); | ||||||
|  | 		SqlMapper.AddTypeHandler( | ||||||
|  | 			typeof(Dictionary<string, string>), | ||||||
|  | 			new JsonTypeHandler<Dictionary<string, string>>() | ||||||
|  | 		); | ||||||
|  | 		SqlMapper.AddTypeHandler( | ||||||
|  | 			typeof(Dictionary<string, ExternalToken>), | ||||||
|  | 			new JsonTypeHandler<Dictionary<string, ExternalToken>>() | ||||||
|  | 		); | ||||||
|  | 		SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>()); | ||||||
|  | 		SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>()); | ||||||
|  | 		SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); | ||||||
|  | 		InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; | ||||||
|  | 		InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <inheritdoc /> | ||||||
| 		/// Migrate the database. | 	public void Configure(IServiceCollection services) | ||||||
| 		/// </summary> | 	{ | ||||||
| 		/// <param name="provider">The service list to retrieve the database context</param> | 		DbConnectionStringBuilder builder = | ||||||
| 		public static void Initialize(IServiceProvider provider) | 			new() | ||||||
| 		{ |  | ||||||
| 			DatabaseContext context = provider.GetRequiredService<DatabaseContext>(); |  | ||||||
| 			context.Database.Migrate(); |  | ||||||
| 
 |  | ||||||
| 			using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection(); |  | ||||||
| 			conn.Open(); |  | ||||||
| 			conn.ReloadTypes(); |  | ||||||
| 
 |  | ||||||
| 			SqlMapper.TypeMapProvider = (type) => |  | ||||||
| 			{ | 			{ | ||||||
| 				return new CustomPropertyTypeMap( | 				["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), | ||||||
| 					type, | 				["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), | ||||||
| 					(type, name) => | 				["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"), | ||||||
| 					{ | 				["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"), | ||||||
| 						string newName = Regex.Replace( | 				["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"), | ||||||
| 							name, | 				["POOLING"] = "true", | ||||||
| 							"(^|_)([a-z])", | 				["MAXPOOLSIZE"] = "95", | ||||||
| 							(match) => match.Groups[2].Value.ToUpperInvariant() | 				["TIMEOUT"] = "30" | ||||||
| 						); |  | ||||||
| 						// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source |  | ||||||
| 						return type.GetProperty(newName)!; |  | ||||||
| 					} |  | ||||||
| 				); |  | ||||||
| 			}; | 			}; | ||||||
| 			SqlMapper.AddTypeHandler( |  | ||||||
| 				typeof(Dictionary<string, MetadataId>), |  | ||||||
| 				new JsonTypeHandler<Dictionary<string, MetadataId>>() |  | ||||||
| 			); |  | ||||||
| 			SqlMapper.AddTypeHandler( |  | ||||||
| 				typeof(Dictionary<string, string>), |  | ||||||
| 				new JsonTypeHandler<Dictionary<string, string>>() |  | ||||||
| 			); |  | ||||||
| 			SqlMapper.AddTypeHandler( |  | ||||||
| 				typeof(Dictionary<string, ExternalToken>), |  | ||||||
| 				new JsonTypeHandler<Dictionary<string, ExternalToken>>() |  | ||||||
| 			); |  | ||||||
| 			SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>()); |  | ||||||
| 			SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>()); |  | ||||||
| 			SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); |  | ||||||
| 			InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; |  | ||||||
| 			InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <inheritdoc /> | 		services.AddDbContext<DatabaseContext, PostgresContext>( | ||||||
| 		public void Configure(IServiceCollection services) | 			x => | ||||||
| 		{ | 			{ | ||||||
| 			DbConnectionStringBuilder builder = | 				x.UseNpgsql(builder.ConnectionString).UseProjectables(); | ||||||
| 				new() | 				if (_environment.IsDevelopment()) | ||||||
| 				{ | 					x.EnableDetailedErrors().EnableSensitiveDataLogging(); | ||||||
| 					["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), | 			}, | ||||||
| 					["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), | 			ServiceLifetime.Transient | ||||||
| 					["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"), | 		); | ||||||
| 					["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"), | 		services.AddTransient<DbConnection>((_) => new NpgsqlConnection(builder.ConnectionString)); | ||||||
| 					["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"), |  | ||||||
| 					["POOLING"] = "true", |  | ||||||
| 					["MAXPOOLSIZE"] = "95", |  | ||||||
| 					["TIMEOUT"] = "30" |  | ||||||
| 				}; |  | ||||||
| 
 | 
 | ||||||
| 			services.AddDbContext<DatabaseContext, PostgresContext>( | 		services.AddHealthChecks().AddDbContextCheck<DatabaseContext>(); | ||||||
| 				x => |  | ||||||
| 				{ |  | ||||||
| 					x.UseNpgsql(builder.ConnectionString).UseProjectables(); |  | ||||||
| 					if (_environment.IsDevelopment()) |  | ||||||
| 						x.EnableDetailedErrors().EnableSensitiveDataLogging(); |  | ||||||
| 				}, |  | ||||||
| 				ServiceLifetime.Transient |  | ||||||
| 			); |  | ||||||
| 			services.AddTransient<DbConnection>( |  | ||||||
| 				(_) => new NpgsqlConnection(builder.ConnectionString) |  | ||||||
| 			); |  | ||||||
| 
 |  | ||||||
| 			services.AddHealthChecks().AddDbContextCheck<DatabaseContext>(); |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -22,45 +22,44 @@ using Kyoo.Swagger.Models; | |||||||
| using NSwag; | using NSwag; | ||||||
| using NSwag.Generation.AspNetCore; | using NSwag.Generation.AspNetCore; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger | namespace Kyoo.Swagger; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class to sort apis. | ||||||
|  | /// </summary> | ||||||
|  | public static class ApiSorter | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class to sort apis. | 	/// Sort apis by alphabetical orders. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class ApiSorter | 	/// <param name="options">The swagger settings to update.</param> | ||||||
|  | 	public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		options.PostProcess += postProcess => | ||||||
| 		/// Sort apis by alphabetical orders. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="options">The swagger settings to update.</param> |  | ||||||
| 		public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options) |  | ||||||
| 		{ | 		{ | ||||||
| 			options.PostProcess += postProcess => | 			// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. | ||||||
| 			{ | 			List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess | ||||||
| 				// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. | 				.Paths.OrderBy(x => x.Key) | ||||||
| 				List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess | 				.ToList(); | ||||||
| 					.Paths.OrderBy(x => x.Key) | 			postProcess.Paths.Clear(); | ||||||
| 					.ToList(); | 			foreach ((string key, OpenApiPathItem value) in sorted) | ||||||
| 				postProcess.Paths.Clear(); | 				postProcess.Paths.Add(key, value); | ||||||
| 				foreach ((string key, OpenApiPathItem value) in sorted) | 		}; | ||||||
| 					postProcess.Paths.Add(key, value); |  | ||||||
| 			}; |  | ||||||
| 
 | 
 | ||||||
| 			options.PostProcess += postProcess => | 		options.PostProcess += postProcess => | ||||||
| 			{ | 		{ | ||||||
| 				if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) | 			if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) | ||||||
| 					return; | 				return; | ||||||
| 				List<TagGroups> tagGroups = (List<TagGroups>)list; | 			List<TagGroups> tagGroups = (List<TagGroups>)list; | ||||||
| 				postProcess.ExtensionData["x-tagGroups"] = tagGroups | 			postProcess.ExtensionData["x-tagGroups"] = tagGroups | ||||||
| 					.OrderBy(x => x.Name) | 				.OrderBy(x => x.Name) | ||||||
| 					.Select(x => | 				.Select(x => | ||||||
| 					{ | 				{ | ||||||
| 						x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; | 					x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; | ||||||
| 						x.Tags = x.Tags.OrderBy(y => y).ToList(); | 					x.Tags = x.Tags.OrderBy(y => y).ToList(); | ||||||
| 						return x; | 					return x; | ||||||
| 					}) | 				}) | ||||||
| 					.ToList(); | 				.ToList(); | ||||||
| 			}; | 		}; | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,99 +26,98 @@ using NSwag; | |||||||
| using NSwag.Generation.AspNetCore; | using NSwag.Generation.AspNetCore; | ||||||
| using NSwag.Generation.Processors.Contexts; | using NSwag.Generation.Processors.Contexts; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger | namespace Kyoo.Swagger; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class to handle Api Groups (OpenApi tags and x-tagGroups). | ||||||
|  | /// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the | ||||||
|  | /// <see cref="OpenApiDocument"/>. | ||||||
|  | /// </summary> | ||||||
|  | public static class ApiTagsFilter | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class to handle Api Groups (OpenApi tags and x-tagGroups). | 	/// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>. | ||||||
| 	/// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the |  | ||||||
| 	/// <see cref="OpenApiDocument"/>. |  | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public static class ApiTagsFilter | 	/// <param name="context">The processor context, this is given by the AddOperationFilter method.</param> | ||||||
|  | 	/// <returns>This always return <c>true</c> since it should not remove operations.</returns> | ||||||
|  | 	public static bool OperationFilter(OperationProcessorContext context) | ||||||
| 	{ | 	{ | ||||||
| 		/// <summary> | 		ApiDefinitionAttribute def = | ||||||
| 		/// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>. | 			context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>(); | ||||||
| 		/// </summary> | 		string name = def?.Name ?? context.ControllerType.Name; | ||||||
| 		/// <param name="context">The processor context, this is given by the AddOperationFilter method.</param> | 
 | ||||||
| 		/// <returns>This always return <c>true</c> since it should not remove operations.</returns> | 		ApiDefinitionAttribute methodOverride = | ||||||
| 		public static bool OperationFilter(OperationProcessorContext context) | 			context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>(); | ||||||
|  | 		if (methodOverride != null) | ||||||
|  | 			name = methodOverride.Name; | ||||||
|  | 
 | ||||||
|  | 		context.OperationDescription.Operation.Tags.Add(name); | ||||||
|  | 		if (context.Document.Tags.All(x => x.Name != name)) | ||||||
| 		{ | 		{ | ||||||
| 			ApiDefinitionAttribute def = | 			context.Document.Tags.Add( | ||||||
| 				context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>(); | 				new OpenApiTag | ||||||
| 			string name = def?.Name ?? context.ControllerType.Name; | 				{ | ||||||
| 
 | 					Name = name, | ||||||
| 			ApiDefinitionAttribute methodOverride = | 					Description = context.ControllerType.GetXmlDocsSummary() | ||||||
| 				context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>(); | 				} | ||||||
| 			if (methodOverride != null) | 			); | ||||||
| 				name = methodOverride.Name; | 		} | ||||||
| 
 |  | ||||||
| 			context.OperationDescription.Operation.Tags.Add(name); |  | ||||||
| 			if (context.Document.Tags.All(x => x.Name != name)) |  | ||||||
| 			{ |  | ||||||
| 				context.Document.Tags.Add( |  | ||||||
| 					new OpenApiTag |  | ||||||
| 					{ |  | ||||||
| 						Name = name, |  | ||||||
| 						Description = context.ControllerType.GetXmlDocsSummary() |  | ||||||
| 					} |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			if (def?.Group == null) |  | ||||||
| 				return true; |  | ||||||
| 
 |  | ||||||
| 			context.Document.ExtensionData ??= new Dictionary<string, object>(); |  | ||||||
| 			context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>()); |  | ||||||
| 			List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"]; |  | ||||||
| 			TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group); |  | ||||||
| 			if (existing != null) |  | ||||||
| 			{ |  | ||||||
| 				if (!existing.Tags.Contains(def.Name)) |  | ||||||
| 					existing.Tags.Add(def.Name); |  | ||||||
| 			} |  | ||||||
| 			else |  | ||||||
| 			{ |  | ||||||
| 				obj.Add( |  | ||||||
| 					new TagGroups |  | ||||||
| 					{ |  | ||||||
| 						Name = def.Group, |  | ||||||
| 						Tags = new List<string> { def.Name } |  | ||||||
| 					} |  | ||||||
| 				); |  | ||||||
| 			} |  | ||||||
| 
 | 
 | ||||||
|  | 		if (def?.Group == null) | ||||||
| 			return true; | 			return true; | ||||||
|  | 
 | ||||||
|  | 		context.Document.ExtensionData ??= new Dictionary<string, object>(); | ||||||
|  | 		context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>()); | ||||||
|  | 		List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"]; | ||||||
|  | 		TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group); | ||||||
|  | 		if (existing != null) | ||||||
|  | 		{ | ||||||
|  | 			if (!existing.Tags.Contains(def.Name)) | ||||||
|  | 				existing.Tags.Add(def.Name); | ||||||
|  | 		} | ||||||
|  | 		else | ||||||
|  | 		{ | ||||||
|  | 			obj.Add( | ||||||
|  | 				new TagGroups | ||||||
|  | 				{ | ||||||
|  | 					Name = def.Group, | ||||||
|  | 					Tags = new List<string> { def.Name } | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 		return true; | ||||||
| 		/// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other". | 	} | ||||||
| 		/// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed. |  | ||||||
| 		/// </summary> |  | ||||||
| 		/// <param name="postProcess"> |  | ||||||
| 		/// The document to do this for. This should be done in the PostProcess part of the document or after |  | ||||||
| 		/// the main operation filter (see <see cref="OperationFilter"/>) has finished. |  | ||||||
| 		/// </param> |  | ||||||
| 		public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) |  | ||||||
| 		{ |  | ||||||
| 			List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"]; |  | ||||||
| 			List<string> tagsWithoutGroup = postProcess |  | ||||||
| 				.Tags.Select(x => x.Name) |  | ||||||
| 				.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x)) |  | ||||||
| 				.ToList(); |  | ||||||
| 			if (tagsWithoutGroup.Any()) |  | ||||||
| 			{ |  | ||||||
| 				tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup }); |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger | 	/// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other". | ||||||
| 		/// document. | 	/// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		/// <param name="options">The settings of the swagger document.</param> | 	/// <param name="postProcess"> | ||||||
| 		public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) | 	/// The document to do this for. This should be done in the PostProcess part of the document or after | ||||||
|  | 	/// the main operation filter (see <see cref="OperationFilter"/>) has finished. | ||||||
|  | 	/// </param> | ||||||
|  | 	public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) | ||||||
|  | 	{ | ||||||
|  | 		List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"]; | ||||||
|  | 		List<string> tagsWithoutGroup = postProcess | ||||||
|  | 			.Tags.Select(x => x.Name) | ||||||
|  | 			.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x)) | ||||||
|  | 			.ToList(); | ||||||
|  | 		if (tagsWithoutGroup.Any()) | ||||||
| 		{ | 		{ | ||||||
| 			options.AddOperationFilter(OperationFilter); | 			tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup }); | ||||||
| 			options.PostProcess += x => x.AddLeftoversToOthersGroup(); |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <summary> | ||||||
|  | 	/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger | ||||||
|  | 	/// document. | ||||||
|  | 	/// </summary> | ||||||
|  | 	/// <param name="options">The settings of the swagger document.</param> | ||||||
|  | 	public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) | ||||||
|  | 	{ | ||||||
|  | 		options.AddOperationFilter(OperationFilter); | ||||||
|  | 		options.PostProcess += x => x.AddLeftoversToOthersGroup(); | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -24,45 +24,44 @@ using Kyoo.Utils; | |||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
| using Microsoft.AspNetCore.Mvc.ApplicationModels; | using Microsoft.AspNetCore.Mvc.ApplicationModels; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger | namespace Kyoo.Swagger; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s | ||||||
|  | /// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the | ||||||
|  | /// return type of the method. | ||||||
|  | /// </summary> | ||||||
|  | /// <remarks> | ||||||
|  | /// This is only useful when the return type of the method is a generics type and that can't be specified in the | ||||||
|  | /// attribute directly (since attributes don't support generics). This should not be used otherwise. | ||||||
|  | /// </remarks> | ||||||
|  | public class GenericResponseProvider : IApplicationModelProvider | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <inheritdoc /> | ||||||
| 	/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s | 	public int Order => -1; | ||||||
| 	/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the | 
 | ||||||
| 	/// return type of the method. | 	/// <inheritdoc /> | ||||||
| 	/// </summary> | 	public void OnProvidersExecuted(ApplicationModelProviderContext context) { } | ||||||
| 	/// <remarks> | 
 | ||||||
| 	/// This is only useful when the return type of the method is a generics type and that can't be specified in the | 	/// <inheritdoc /> | ||||||
| 	/// attribute directly (since attributes don't support generics). This should not be used otherwise. | 	public void OnProvidersExecuting(ApplicationModelProviderContext context) | ||||||
| 	/// </remarks> |  | ||||||
| 	public class GenericResponseProvider : IApplicationModelProvider |  | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) | ||||||
| 		public int Order => -1; |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void OnProvidersExecuted(ApplicationModelProviderContext context) { } |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void OnProvidersExecuting(ApplicationModelProviderContext context) |  | ||||||
| 		{ | 		{ | ||||||
| 			foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) | 			IEnumerable<ProducesResponseTypeAttribute> responses = action | ||||||
|  | 				.Filters.OfType<ProducesResponseTypeAttribute>() | ||||||
|  | 				.Where(x => x.Type == typeof(ActionResult<>)); | ||||||
|  | 			foreach (ProducesResponseTypeAttribute response in responses) | ||||||
| 			{ | 			{ | ||||||
| 				IEnumerable<ProducesResponseTypeAttribute> responses = action | 				Type type = action.ActionMethod.ReturnType; | ||||||
| 					.Filters.OfType<ProducesResponseTypeAttribute>() | 				type = | ||||||
| 					.Where(x => x.Type == typeof(ActionResult<>)); | 					Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] | ||||||
| 				foreach (ProducesResponseTypeAttribute response in responses) | 					?? type; | ||||||
| 				{ | 				type = | ||||||
| 					Type type = action.ActionMethod.ReturnType; | 					Utility | ||||||
| 					type = | 						.GetGenericDefinition(type, typeof(ActionResult<>)) | ||||||
| 						Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] | 						?.GetGenericArguments()[0] ?? type; | ||||||
| 						?? type; | 				response.Type = type; | ||||||
| 					type = |  | ||||||
| 						Utility |  | ||||||
| 							.GetGenericDefinition(type, typeof(ActionResult<>)) |  | ||||||
| 							?.GetGenericArguments()[0] ?? type; |  | ||||||
| 					response.Type = type; |  | ||||||
| 				} |  | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | |||||||
| @ -20,23 +20,22 @@ using System.Collections.Generic; | |||||||
| using Newtonsoft.Json; | using Newtonsoft.Json; | ||||||
| using NSwag; | using NSwag; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger.Models | namespace Kyoo.Swagger.Models; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A class representing a group of tags in the <see cref="OpenApiDocument"/> | ||||||
|  | /// </summary> | ||||||
|  | public class TagGroups | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <summary> | ||||||
| 	/// A class representing a group of tags in the <see cref="OpenApiDocument"/> | 	/// The name of the tag group. | ||||||
| 	/// </summary> | 	/// </summary> | ||||||
| 	public class TagGroups | 	[JsonProperty(PropertyName = "name")] | ||||||
| 	{ | 	public string Name { get; set; } | ||||||
| 		/// <summary> |  | ||||||
| 		/// The name of the tag group. |  | ||||||
| 		/// </summary> |  | ||||||
| 		[JsonProperty(PropertyName = "name")] |  | ||||||
| 		public string Name { get; set; } |  | ||||||
| 
 | 
 | ||||||
| 		/// <summary> | 	/// <summary> | ||||||
| 		/// The list of tags in this group. | 	/// The list of tags in this group. | ||||||
| 		/// </summary> | 	/// </summary> | ||||||
| 		[JsonProperty(PropertyName = "tags")] | 	[JsonProperty(PropertyName = "tags")] | ||||||
| 		public List<string> Tags { get; set; } | 	public List<string> Tags { get; set; } | ||||||
| 	} |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,80 +25,78 @@ using NSwag; | |||||||
| using NSwag.Generation.Processors; | using NSwag.Generation.Processors; | ||||||
| using NSwag.Generation.Processors.Contexts; | using NSwag.Generation.Processors.Contexts; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger | namespace Kyoo.Swagger; | ||||||
| { |  | ||||||
| 	/// <summary> |  | ||||||
| 	/// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the |  | ||||||
| 	/// <see cref="PartialPermissionAttribute"/>. |  | ||||||
| 	/// </summary> |  | ||||||
| 	public class OperationPermissionProcessor : IOperationProcessor |  | ||||||
| 	{ |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public bool Process(OperationProcessorContext context) |  | ||||||
| 		{ |  | ||||||
| 			context.OperationDescription.Operation.Security ??= |  | ||||||
| 				new List<OpenApiSecurityRequirement>(); |  | ||||||
| 			OpenApiSecurityRequirement perms = context |  | ||||||
| 				.MethodInfo.GetCustomAttributes<UserOnlyAttribute>() |  | ||||||
| 				.Aggregate( |  | ||||||
| 					new OpenApiSecurityRequirement(), |  | ||||||
| 					(agg, _) => |  | ||||||
| 					{ |  | ||||||
| 						agg[nameof(Kyoo)] = Array.Empty<string>(); |  | ||||||
| 						return agg; |  | ||||||
| 					} |  | ||||||
| 				); |  | ||||||
| 
 | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the | ||||||
|  | /// <see cref="PartialPermissionAttribute"/>. | ||||||
|  | /// </summary> | ||||||
|  | public class OperationPermissionProcessor : IOperationProcessor | ||||||
|  | { | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public bool Process(OperationProcessorContext context) | ||||||
|  | 	{ | ||||||
|  | 		context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>(); | ||||||
|  | 		OpenApiSecurityRequirement perms = context | ||||||
|  | 			.MethodInfo.GetCustomAttributes<UserOnlyAttribute>() | ||||||
|  | 			.Aggregate( | ||||||
|  | 				new OpenApiSecurityRequirement(), | ||||||
|  | 				(agg, _) => | ||||||
|  | 				{ | ||||||
|  | 					agg[nameof(Kyoo)] = Array.Empty<string>(); | ||||||
|  | 					return agg; | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		perms = context | ||||||
|  | 			.MethodInfo.GetCustomAttributes<PermissionAttribute>() | ||||||
|  | 			.Aggregate( | ||||||
|  | 				perms, | ||||||
|  | 				(agg, cur) => | ||||||
|  | 				{ | ||||||
|  | 					ICollection<string> permissions = _GetPermissionsList(agg, cur.Group); | ||||||
|  | 					permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); | ||||||
|  | 					agg[nameof(Kyoo)] = permissions; | ||||||
|  | 					return agg; | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 		PartialPermissionAttribute controller = | ||||||
|  | 			context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>(); | ||||||
|  | 		if (controller != null) | ||||||
|  | 		{ | ||||||
| 			perms = context | 			perms = context | ||||||
| 				.MethodInfo.GetCustomAttributes<PermissionAttribute>() | 				.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>() | ||||||
| 				.Aggregate( | 				.Aggregate( | ||||||
| 					perms, | 					perms, | ||||||
| 					(agg, cur) => | 					(agg, cur) => | ||||||
| 					{ | 					{ | ||||||
| 						ICollection<string> permissions = _GetPermissionsList(agg, cur.Group); | 						Group? group = | ||||||
| 						permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); | 							controller.Group != Group.Overall ? controller.Group : cur.Group; | ||||||
|  | 						string type = controller.Type ?? cur.Type; | ||||||
|  | 						Kind? kind = controller.Type == null ? controller.Kind : cur.Kind; | ||||||
|  | 						ICollection<string> permissions = _GetPermissionsList( | ||||||
|  | 							agg, | ||||||
|  | 							group ?? Group.Overall | ||||||
|  | 						); | ||||||
|  | 						permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}"); | ||||||
| 						agg[nameof(Kyoo)] = permissions; | 						agg[nameof(Kyoo)] = permissions; | ||||||
| 						return agg; | 						return agg; | ||||||
| 					} | 					} | ||||||
| 				); | 				); | ||||||
| 
 |  | ||||||
| 			PartialPermissionAttribute controller = |  | ||||||
| 				context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>(); |  | ||||||
| 			if (controller != null) |  | ||||||
| 			{ |  | ||||||
| 				perms = context |  | ||||||
| 					.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>() |  | ||||||
| 					.Aggregate( |  | ||||||
| 						perms, |  | ||||||
| 						(agg, cur) => |  | ||||||
| 						{ |  | ||||||
| 							Group? group = |  | ||||||
| 								controller.Group != Group.Overall ? controller.Group : cur.Group; |  | ||||||
| 							string type = controller.Type ?? cur.Type; |  | ||||||
| 							Kind? kind = controller.Type == null ? controller.Kind : cur.Kind; |  | ||||||
| 							ICollection<string> permissions = _GetPermissionsList( |  | ||||||
| 								agg, |  | ||||||
| 								group ?? Group.Overall |  | ||||||
| 							); |  | ||||||
| 							permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}"); |  | ||||||
| 							agg[nameof(Kyoo)] = permissions; |  | ||||||
| 							return agg; |  | ||||||
| 						} |  | ||||||
| 					); |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			context.OperationDescription.Operation.Security.Add(perms); |  | ||||||
| 			return true; |  | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		private static ICollection<string> _GetPermissionsList( | 		context.OperationDescription.Operation.Security.Add(perms); | ||||||
| 			OpenApiSecurityRequirement security, | 		return true; | ||||||
| 			Group group | 	} | ||||||
| 		) | 
 | ||||||
| 		{ | 	private static ICollection<string> _GetPermissionsList( | ||||||
| 			return security.TryGetValue(group.ToString(), out IEnumerable<string> perms) | 		OpenApiSecurityRequirement security, | ||||||
| 				? perms.ToList() | 		Group group | ||||||
| 				: new List<string>(); | 	) | ||||||
| 		} | 	{ | ||||||
|  | 		return security.TryGetValue(group.ToString(), out IEnumerable<string> perms) | ||||||
|  | 			? perms.ToList() | ||||||
|  | 			: new List<string>(); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
| @ -29,103 +29,102 @@ using NSwag; | |||||||
| using NSwag.Generation.AspNetCore; | using NSwag.Generation.AspNetCore; | ||||||
| using static Kyoo.Abstractions.Models.Utils.Constants; | using static Kyoo.Abstractions.Models.Utils.Constants; | ||||||
| 
 | 
 | ||||||
| namespace Kyoo.Swagger | namespace Kyoo.Swagger; | ||||||
|  | 
 | ||||||
|  | /// <summary> | ||||||
|  | /// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo. | ||||||
|  | /// </summary> | ||||||
|  | public class SwaggerModule : IPlugin | ||||||
| { | { | ||||||
| 	/// <summary> | 	/// <inheritdoc /> | ||||||
| 	/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo. | 	public string Name => "Swagger"; | ||||||
| 	/// </summary> | 
 | ||||||
| 	public class SwaggerModule : IPlugin | 	/// <inheritdoc /> | ||||||
|  | 	public void Configure(IServiceCollection services) | ||||||
| 	{ | 	{ | ||||||
| 		/// <inheritdoc /> | 		services.AddTransient<IApplicationModelProvider, GenericResponseProvider>(); | ||||||
| 		public string Name => "Swagger"; | 		services.AddOpenApiDocument(document => | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public void Configure(IServiceCollection services) |  | ||||||
| 		{ | 		{ | ||||||
| 			services.AddTransient<IApplicationModelProvider, GenericResponseProvider>(); | 			document.Title = "Kyoo API"; | ||||||
| 			services.AddOpenApiDocument(document => | 			// TODO use a real multi-line description in markdown. | ||||||
|  | 			document.Description = "The Kyoo's public API"; | ||||||
|  | 			document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); | ||||||
|  | 			document.DocumentName = "v1"; | ||||||
|  | 			document.UseControllerSummaryAsTagDescription = true; | ||||||
|  | 			document.GenerateExamples = true; | ||||||
|  | 			document.PostProcess = options => | ||||||
| 			{ | 			{ | ||||||
| 				document.Title = "Kyoo API"; | 				options.Info.Contact = new OpenApiContact | ||||||
| 				// TODO use a real multi-line description in markdown. |  | ||||||
| 				document.Description = "The Kyoo's public API"; |  | ||||||
| 				document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3); |  | ||||||
| 				document.DocumentName = "v1"; |  | ||||||
| 				document.UseControllerSummaryAsTagDescription = true; |  | ||||||
| 				document.GenerateExamples = true; |  | ||||||
| 				document.PostProcess = options => |  | ||||||
| 				{ | 				{ | ||||||
| 					options.Info.Contact = new OpenApiContact | 					Name = "Kyoo's github", | ||||||
| 					{ | 					Url = "https://github.com/zoriya/Kyoo" | ||||||
| 						Name = "Kyoo's github", |  | ||||||
| 						Url = "https://github.com/zoriya/Kyoo" |  | ||||||
| 					}; |  | ||||||
| 					options.Info.License = new OpenApiLicense |  | ||||||
| 					{ |  | ||||||
| 						Name = "GPL-3.0-or-later", |  | ||||||
| 						Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE" |  | ||||||
| 					}; |  | ||||||
| 
 |  | ||||||
| 					options.Info.ExtensionData ??= new Dictionary<string, object>(); |  | ||||||
| 					options.Info.ExtensionData["x-logo"] = new |  | ||||||
| 					{ |  | ||||||
| 						url = "/banner.png", |  | ||||||
| 						backgroundColor = "#FFFFFF", |  | ||||||
| 						altText = "Kyoo's logo" |  | ||||||
| 					}; |  | ||||||
| 				}; | 				}; | ||||||
| 				document.UseApiTags(); | 				options.Info.License = new OpenApiLicense | ||||||
| 				document.SortApis(); |  | ||||||
| 				document.AddOperationFilter(x => |  | ||||||
| 				{ | 				{ | ||||||
| 					if (x is AspNetCoreOperationProcessorContext ctx) | 					Name = "GPL-3.0-or-later", | ||||||
| 						return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order | 					Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE" | ||||||
| 							!= AlternativeRoute; | 				}; | ||||||
| 					return true; |  | ||||||
| 				}); |  | ||||||
| 				document.SchemaGenerator.Settings.TypeMappers.Add( |  | ||||||
| 					new PrimitiveTypeMapper( |  | ||||||
| 						typeof(Identifier), |  | ||||||
| 						x => |  | ||||||
| 						{ |  | ||||||
| 							x.IsNullableRaw = false; |  | ||||||
| 							x.Type = JsonObjectType.String | JsonObjectType.Integer; |  | ||||||
| 						} |  | ||||||
| 					) |  | ||||||
| 				); |  | ||||||
| 
 | 
 | ||||||
| 				document.AddSecurity( | 				options.Info.ExtensionData ??= new Dictionary<string, object>(); | ||||||
| 					nameof(Kyoo), | 				options.Info.ExtensionData["x-logo"] = new | ||||||
| 					new OpenApiSecurityScheme | 				{ | ||||||
| 					{ | 					url = "/banner.png", | ||||||
| 						Type = OpenApiSecuritySchemeType.Http, | 					backgroundColor = "#FFFFFF", | ||||||
| 						Scheme = "Bearer", | 					altText = "Kyoo's logo" | ||||||
| 						BearerFormat = "JWT", | 				}; | ||||||
| 						Description = "The user's bearer" |  | ||||||
| 					} |  | ||||||
| 				); |  | ||||||
| 				document.OperationProcessors.Add(new OperationPermissionProcessor()); |  | ||||||
| 			}); |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		/// <inheritdoc /> |  | ||||||
| 		public IEnumerable<IStartupAction> ConfigureSteps => |  | ||||||
| 			new IStartupAction[] |  | ||||||
| 			{ |  | ||||||
| 				SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1), |  | ||||||
| 				SA.New<IApplicationBuilder>( |  | ||||||
| 					app => |  | ||||||
| 						app.UseReDoc(x => |  | ||||||
| 						{ |  | ||||||
| 							x.Path = "/doc"; |  | ||||||
| 							x.TransformToExternalPath = (internalUiRoute, _) => |  | ||||||
| 								"/api" + internalUiRoute; |  | ||||||
| 							x.AdditionalSettings["theme"] = new |  | ||||||
| 							{ |  | ||||||
| 								colors = new { primary = new { main = "#e13e13" } } |  | ||||||
| 							}; |  | ||||||
| 						}), |  | ||||||
| 					SA.Before |  | ||||||
| 				) |  | ||||||
| 			}; | 			}; | ||||||
|  | 			document.UseApiTags(); | ||||||
|  | 			document.SortApis(); | ||||||
|  | 			document.AddOperationFilter(x => | ||||||
|  | 			{ | ||||||
|  | 				if (x is AspNetCoreOperationProcessorContext ctx) | ||||||
|  | 					return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order | ||||||
|  | 						!= AlternativeRoute; | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  | 			document.SchemaGenerator.Settings.TypeMappers.Add( | ||||||
|  | 				new PrimitiveTypeMapper( | ||||||
|  | 					typeof(Identifier), | ||||||
|  | 					x => | ||||||
|  | 					{ | ||||||
|  | 						x.IsNullableRaw = false; | ||||||
|  | 						x.Type = JsonObjectType.String | JsonObjectType.Integer; | ||||||
|  | 					} | ||||||
|  | 				) | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			document.AddSecurity( | ||||||
|  | 				nameof(Kyoo), | ||||||
|  | 				new OpenApiSecurityScheme | ||||||
|  | 				{ | ||||||
|  | 					Type = OpenApiSecuritySchemeType.Http, | ||||||
|  | 					Scheme = "Bearer", | ||||||
|  | 					BearerFormat = "JWT", | ||||||
|  | 					Description = "The user's bearer" | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
|  | 			document.OperationProcessors.Add(new OperationPermissionProcessor()); | ||||||
|  | 		}); | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	/// <inheritdoc /> | ||||||
|  | 	public IEnumerable<IStartupAction> ConfigureSteps => | ||||||
|  | 		new IStartupAction[] | ||||||
|  | 		{ | ||||||
|  | 			SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1), | ||||||
|  | 			SA.New<IApplicationBuilder>( | ||||||
|  | 				app => | ||||||
|  | 					app.UseReDoc(x => | ||||||
|  | 					{ | ||||||
|  | 						x.Path = "/doc"; | ||||||
|  | 						x.TransformToExternalPath = (internalUiRoute, _) => | ||||||
|  | 							"/api" + internalUiRoute; | ||||||
|  | 						x.AdditionalSettings["theme"] = new | ||||||
|  | 						{ | ||||||
|  | 							colors = new { primary = new { main = "#e13e13" } } | ||||||
|  | 						}; | ||||||
|  | 					}), | ||||||
|  | 				SA.Before | ||||||
|  | 			) | ||||||
|  | 		}; | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user