From 18e301f26acd7f2e97eac26c5f48377fa13956f5 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 23 Mar 2024 00:14:34 +0100 Subject: [PATCH] Switch to file scopped namespaces --- back/.editorconfig | 2 + .../Controllers/ILibraryManager.cs | 95 +- .../Controllers/IPermissionValidator.cs | 41 +- .../Kyoo.Abstractions/Controllers/IPlugin.cs | 71 +- .../Controllers/IPluginManager.cs | 79 +- .../Controllers/IRepository.cs | 472 ++++--- .../Controllers/IThumbnailsManager.cs | 95 +- .../Controllers/StartupAction.cs | 446 +++---- back/src/Kyoo.Abstractions/Extensions.cs | 63 +- .../Attributes/ApiDefinitionAttribute.cs | 51 +- .../Models/Attributes/ComputedAttribute.cs | 15 +- .../Attributes/LoadableRelationAttribute.cs | 55 +- .../Attributes/NotMergeableAttribute.cs | 31 +- .../Permission/PartialPermissionAttribute.cs | 117 +- .../Permission/PermissionAttribute.cs | 199 ++- .../Permission/UserOnlyAttribute.cs | 17 +- .../Exceptions/DuplicatedItemException.cs | 51 +- .../Exceptions/ItemNotFoundException.cs | 47 +- .../Exceptions/UnauthorizedException.cs | 21 +- back/src/Kyoo.Abstractions/Models/Genre.cs | 49 +- .../Kyoo.Abstractions/Models/MetadataID.cs | 25 +- back/src/Kyoo.Abstractions/Models/Page.cs | 151 +-- .../Models/Resources/Collection.cs | 115 +- .../Models/Resources/Episode.cs | 466 ++++--- .../Models/Resources/Interfaces/IAddedDate.cs | 17 +- .../Models/Resources/Interfaces/IMetadata.cs | 17 +- .../Models/Resources/Interfaces/IResource.cs | 45 +- .../Resources/Interfaces/IThumbnails.cs | 178 ++- .../Models/Resources/Movie.cs | 303 +++-- .../Models/Resources/Season.cs | 227 ++-- .../Models/Resources/Show.cs | 417 +++--- .../Models/Resources/Studio.cs | 95 +- .../Models/Resources/User.cs | 167 ++- .../Models/Resources/WatchStatus.cs | 403 +++--- .../Kyoo.Abstractions/Models/SearchPage.cs | 53 +- .../Kyoo.Abstractions/Models/Utils/Claims.cs | 57 +- .../Models/Utils/Constants.cs | 61 +- .../Models/Utils/Identifier.cs | 413 +++--- .../Models/Utils/Pagination.cs | 93 +- .../Models/Utils/RequestError.cs | 58 +- .../Models/Utils/SearchPagination.cs | 25 +- .../Kyoo.Abstractions/Models/Utils/Sort.cs | 188 ++- .../Kyoo.Abstractions/Models/VideoLinks.cs | 25 +- back/src/Kyoo.Abstractions/Module.cs | 91 +- .../Utility/EnumerableExtensions.cs | 75 +- back/src/Kyoo.Abstractions/Utility/Merger.cs | 195 ++- back/src/Kyoo.Abstractions/Utility/Utility.cs | 621 +++++---- .../AuthenticationModule.cs | 273 ++-- .../Controllers/ITokenController.cs | 49 +- .../Controllers/PermissionValidator.cs | 422 +++--- .../Controllers/TokenController.cs | 179 ++- .../Models/DTO/LoginRequest.cs | 45 +- .../Models/DTO/RegisterRequest.cs | 91 +- .../Models/Options/AuthenticationOption.cs | 41 +- back/src/Kyoo.Authentication/Views/AuthApi.cs | 870 ++++++------ .../Controllers/IdentifierRouteConstraint.cs | 31 +- .../Kyoo.Core/Controllers/LibraryManager.cs | 133 +- .../Repositories/CollectionRepository.cs | 123 +- .../Repositories/EpisodeRepository.cs | 226 ++-- .../Repositories/LibraryItemRepository.cs | 181 ++- .../Repositories/LocalRepository.cs | 806 ++++++----- .../Repositories/MovieRepository.cs | 143 +- .../Repositories/NewsRepository.cs | 65 +- .../Repositories/SeasonRepository.cs | 176 ++- .../Repositories/ShowRepository.cs | 143 +- .../Repositories/StudioRepository.cs | 89 +- .../Controllers/ThumbnailsManager.cs | 440 +++--- back/src/Kyoo.Core/CoreModule.cs | 211 ++- back/src/Kyoo.Core/ExceptionFilter.cs | 101 +- back/src/Kyoo.Core/Views/Health.cs | 89 +- back/src/Kyoo.Core/Views/Helper/BaseApi.cs | 121 +- back/src/Kyoo.Core/Views/Helper/CrudApi.cs | 452 ++++--- .../Kyoo.Core/Views/Helper/CrudThumbsApi.cs | 215 ++- .../src/Kyoo.Core/Views/Metadata/StudioApi.cs | 127 +- .../Views/Resources/CollectionApi.cs | 437 +++--- .../Kyoo.Core/Views/Resources/EpisodeApi.cs | 315 +++-- .../Views/Resources/LibraryItemApi.cs | 51 +- .../src/Kyoo.Core/Views/Resources/MovieApi.cs | 335 +++-- back/src/Kyoo.Core/Views/Resources/NewsApi.cs | 27 +- .../Kyoo.Core/Views/Resources/SearchApi.cs | 333 +++-- .../Kyoo.Core/Views/Resources/SeasonApi.cs | 186 ++- back/src/Kyoo.Core/Views/Resources/ShowApi.cs | 484 ++++--- .../Kyoo.Core/Views/Resources/WatchlistApi.cs | 67 +- back/src/Kyoo.Core/Views/Watch/ProxyApi.cs | 125 +- back/src/Kyoo.Host/Application.cs | 290 ++-- .../src/Kyoo.Host/Contollers/PluginManager.cs | 115 +- back/src/Kyoo.Host/HostModule.cs | 59 +- back/src/Kyoo.Host/PluginsStartup.cs | 270 ++-- back/src/Kyoo.Host/Program.cs | 37 +- .../src/Kyoo.Meilisearch/MeilisearchModule.cs | 311 +++-- back/src/Kyoo.Postgresql/DatabaseContext.cs | 1184 ++++++++--------- back/src/Kyoo.Postgresql/PostgresContext.cs | 191 ++- back/src/Kyoo.Postgresql/PostgresModule.cs | 191 ++- back/src/Kyoo.Swagger/ApiSorter.cs | 67 +- back/src/Kyoo.Swagger/ApiTagsFilter.cs | 163 ++- .../Kyoo.Swagger/GenericResponseProvider.cs | 69 +- back/src/Kyoo.Swagger/Models/TagGroups.cs | 29 +- .../OperationPermissionProcessor.cs | 124 +- back/src/Kyoo.Swagger/SwaggerModule.cs | 177 ++- 99 files changed, 8936 insertions(+), 9141 deletions(-) diff --git a/back/.editorconfig b/back/.editorconfig index 12a6f984..30728be6 100644 --- a/back/.editorconfig +++ b/back/.editorconfig @@ -16,6 +16,8 @@ dotnet_diagnostic.IDE0055.severity = none dotnet_diagnostic.IDE0058.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 dotnet_sort_system_directives_first = true csharp_using_directive_placement = outside_namespace:warning diff --git a/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs b/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs index 12bb63fe..8a1516b2 100644 --- a/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs +++ b/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs @@ -18,64 +18,63 @@ using Kyoo.Abstractions.Models; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// An interface to interact with the database. Every repository is mapped through here. +/// +public interface ILibraryManager { + IRepository Repository() + where T : IResource, IQuery; + /// - /// 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). /// - public interface ILibraryManager - { - IRepository Repository() - where T : IResource, IQuery; + IRepository LibraryItems { get; } - /// - /// The repository that handle libraries items (a wrapper around shows and collections). - /// - IRepository LibraryItems { get; } + /// + /// The repository that handle new items. + /// + IRepository News { get; } - /// - /// The repository that handle new items. - /// - IRepository News { get; } + /// + /// The repository that handle watched items. + /// + IWatchStatusRepository WatchStatus { get; } - /// - /// The repository that handle watched items. - /// - IWatchStatusRepository WatchStatus { get; } + /// + /// The repository that handle collections. + /// + IRepository Collections { get; } - /// - /// The repository that handle collections. - /// - IRepository Collections { get; } + /// + /// The repository that handle shows. + /// + IRepository Movies { get; } - /// - /// The repository that handle shows. - /// - IRepository Movies { get; } + /// + /// The repository that handle shows. + /// + IRepository Shows { get; } - /// - /// The repository that handle shows. - /// - IRepository Shows { get; } + /// + /// The repository that handle seasons. + /// + IRepository Seasons { get; } - /// - /// The repository that handle seasons. - /// - IRepository Seasons { get; } + /// + /// The repository that handle episodes. + /// + IRepository Episodes { get; } - /// - /// The repository that handle episodes. - /// - IRepository Episodes { get; } + /// + /// The repository that handle studios. + /// + IRepository Studios { get; } - /// - /// The repository that handle studios. - /// - IRepository Studios { get; } - - /// - /// The repository that handle users. - /// - IRepository Users { get; } - } + /// + /// The repository that handle users. + /// + IRepository Users { get; } } diff --git a/back/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs b/back/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs index 363cecfe..4aa35625 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IPermissionValidator.cs @@ -19,29 +19,28 @@ using Kyoo.Abstractions.Models.Permissions; using Microsoft.AspNetCore.Mvc.Filters; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// A service to validate permissions. +/// +public interface IPermissionValidator { /// - /// A service to validate permissions. + /// Create an IAuthorizationFilter that will be used to validate permissions. + /// This can registered with any lifetime. /// - public interface IPermissionValidator - { - /// - /// Create an IAuthorizationFilter that will be used to validate permissions. - /// This can registered with any lifetime. - /// - /// The permission attribute to validate. - /// An authorization filter used to validate the permission. - IFilterMetadata Create(PermissionAttribute attribute); + /// The permission attribute to validate. + /// An authorization filter used to validate the permission. + IFilterMetadata Create(PermissionAttribute attribute); - /// - /// Create an IAuthorizationFilter that will be used to validate permissions. - /// This can registered with any lifetime. - /// - /// - /// A partial attribute to validate. See . - /// - /// An authorization filter used to validate the permission. - IFilterMetadata Create(PartialPermissionAttribute attribute); - } + /// + /// Create an IAuthorizationFilter that will be used to validate permissions. + /// This can registered with any lifetime. + /// + /// + /// A partial attribute to validate. See . + /// + /// An authorization filter used to validate the permission. + IFilterMetadata Create(PartialPermissionAttribute attribute); } diff --git a/back/src/Kyoo.Abstractions/Controllers/IPlugin.cs b/back/src/Kyoo.Abstractions/Controllers/IPlugin.cs index 3363a4ff..a227f6e9 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IPlugin.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IPlugin.cs @@ -21,46 +21,45 @@ using System.Collections.Generic; using Autofac; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// A common interface used to discord plugins +/// +/// +/// You can inject services in the IPlugin constructor. +/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. +/// +public interface IPlugin { /// - /// A common interface used to discord plugins + /// The name of the plugin /// - /// - /// You can inject services in the IPlugin constructor. - /// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. - /// - public interface IPlugin + string Name { get; } + + /// + /// An optional configuration step to allow a plugin to change asp net configurations. + /// + /// + IEnumerable ConfigureSteps => ArraySegment.Empty; + + /// + /// A configure method that will be run on plugin's startup. + /// + /// The autofac service container to register services. + void Configure(ContainerBuilder builder) { - /// - /// The name of the plugin - /// - string Name { get; } + // Skipped + } - /// - /// An optional configuration step to allow a plugin to change asp net configurations. - /// - /// - IEnumerable ConfigureSteps => ArraySegment.Empty; - - /// - /// A configure method that will be run on plugin's startup. - /// - /// The autofac service container to register services. - void Configure(ContainerBuilder builder) - { - // Skipped - } - - /// - /// A configure method that will be run on plugin's startup. - /// This is available for libraries that build upon a , for more precise - /// configuration use . - /// - /// A service container to register new services. - void Configure(IServiceCollection services) - { - // Skipped - } + /// + /// A configure method that will be run on plugin's startup. + /// This is available for libraries that build upon a , for more precise + /// configuration use . + /// + /// A service container to register new services. + void Configure(IServiceCollection services) + { + // Skipped } } diff --git a/back/src/Kyoo.Abstractions/Controllers/IPluginManager.cs b/back/src/Kyoo.Abstractions/Controllers/IPluginManager.cs index 11aea0b4..4998588c 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IPluginManager.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IPluginManager.cs @@ -20,51 +20,50 @@ using System; using System.Collections.Generic; using Kyoo.Abstractions.Models.Exceptions; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// A manager to load plugins and retrieve information from them. +/// +public interface IPluginManager { /// - /// A manager to load plugins and retrieve information from them. + /// Get a single plugin that match the type and name given. /// - public interface IPluginManager - { - /// - /// Get a single plugin that match the type and name given. - /// - /// The name of the plugin - /// The type of the plugin - /// If no plugins match the query - /// A plugin that match the queries - public T GetPlugin(string name); + /// The name of the plugin + /// The type of the plugin + /// If no plugins match the query + /// A plugin that match the queries + public T GetPlugin(string name); - /// - /// Get all plugins of the given type. - /// - /// The type of plugins to get - /// A list of plugins matching the given type or an empty list of none match. - public ICollection GetPlugins(); + /// + /// Get all plugins of the given type. + /// + /// The type of plugins to get + /// A list of plugins matching the given type or an empty list of none match. + public ICollection GetPlugins(); - /// - /// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted. - /// - /// All plugins currently loaded. - public ICollection GetAllPlugins(); + /// + /// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted. + /// + /// All plugins currently loaded. + public ICollection GetAllPlugins(); - /// - /// Load plugins and their dependencies from the plugin directory. - /// - /// - /// An initial plugin list to use. - /// You should not try to put plugins from the plugins directory here as they will get automatically loaded. - /// - public void LoadPlugins(ICollection plugins); + /// + /// Load plugins and their dependencies from the plugin directory. + /// + /// + /// An initial plugin list to use. + /// You should not try to put plugins from the plugins directory here as they will get automatically loaded. + /// + public void LoadPlugins(ICollection plugins); - /// - /// Load plugins and their dependencies from the plugin directory. - /// - /// - /// An initial plugin list to use. - /// You should not try to put plugins from the plugins directory here as they will get automatically loaded. - /// - public void LoadPlugins(params Type[] plugins); - } + /// + /// Load plugins and their dependencies from the plugin directory. + /// + /// + /// An initial plugin list to use. + /// You should not try to put plugins from the plugins directory here as they will get automatically loaded. + /// + public void LoadPlugins(params Type[] plugins); } diff --git a/back/src/Kyoo.Abstractions/Controllers/IRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IRepository.cs index 197c43ec..4dbdd953 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IRepository.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IRepository.cs @@ -23,249 +23,245 @@ using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Utils; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// A common repository for every resources. +/// +/// The resource's type that this repository manage. +public interface IRepository : IBaseRepository + where T : IResource, IQuery { /// - /// A common repository for every resources. + /// The event handler type for all events of this repository. /// - /// The resource's type that this repository manage. - public interface IRepository : IBaseRepository - where T : IResource, IQuery - { - /// - /// The event handler type for all events of this repository. - /// - /// The resource created/modified/deleted - /// A representing the asynchronous operation. - public delegate Task ResourceEventHandler(T resource); - - /// - /// Get a resource from it's ID. - /// - /// The id of the resource - /// The related fields to include. - /// If the item could not be found. - /// The resource found - Task Get(Guid id, Include? include = default); - - /// - /// Get a resource from it's slug. - /// - /// The slug of the resource - /// The related fields to include. - /// If the item could not be found. - /// The resource found - Task Get(string slug, Include? include = default); - - /// - /// Get the first resource that match the predicate. - /// - /// A predicate to filter the resource. - /// The related fields to include. - /// A custom sort method to handle cases where multiples items match the filters. - /// Reverse the sort. - /// Select the first element after this id if it was in a list. - /// If the item could not be found. - /// The resource found - Task Get( - Filter filter, - Include? include = default, - Sort? sortBy = default, - bool reverse = false, - Guid? afterId = default - ); - - /// - /// Get a resource from it's ID or null if it is not found. - /// - /// The id of the resource - /// The related fields to include. - /// The resource found - Task GetOrDefault(Guid id, Include? include = default); - - /// - /// Get a resource from it's slug or null if it is not found. - /// - /// The slug of the resource - /// The related fields to include. - /// The resource found - Task GetOrDefault(string slug, Include? include = default); - - /// - /// Get the first resource that match the predicate or null if it is not found. - /// - /// A predicate to filter the resource. - /// The related fields to include. - /// A custom sort method to handle cases where multiples items match the filters. - /// Reverse the sort. - /// Select the first element after this id if it was in a list. - /// The resource found - Task GetOrDefault( - Filter? filter, - Include? include = default, - Sort? sortBy = default, - bool reverse = false, - Guid? afterId = default - ); - - /// - /// Search for resources with the database. - /// - /// The query string. - /// The related fields to include. - /// A list of resources found - Task> Search(string query, Include? include = default); - - /// - /// Get every resources that match all filters - /// - /// A filter predicate - /// Sort information about the query (sort by, sort order) - /// The related fields to include. - /// How pagination should be done (where to start and how many to return) - /// A list of resources that match every filters - Task> GetAll( - Filter? filter = null, - Sort? sort = default, - Include? include = default, - Pagination? limit = default - ); - - /// - /// Get the number of resources that match the filter's predicate. - /// - /// A filter predicate - /// How many resources matched that filter - Task GetCount(Filter? filter = null); - - /// - /// Map a list of ids to a list of items (keep the order). - /// - /// The list of items id. - /// The related fields to include. - /// A list of resources mapped from ids. - Task> FromIds(IList ids, Include? include = default); - - /// - /// Create a new resource. - /// - /// The item to register - /// The resource registers and completed by database's information (related items and so on) - Task Create(T obj); - - /// - /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. - /// - /// The object to create - /// The newly created item or the existing value if it existed. - Task CreateIfNotExists(T obj); - - /// - /// Called when a resource has been created. - /// - static event ResourceEventHandler OnCreated; - - /// - /// Callback that should be called after a resource has been created. - /// - /// The resource newly created. - /// A representing the asynchronous operation. - protected static Task OnResourceCreated(T obj) => - OnCreated?.Invoke(obj) ?? Task.CompletedTask; - - /// - /// Edit a resource and replace every property - /// - /// The resource to edit, it's ID can't change. - /// If the item is not found - /// The resource edited and completed by database's information (related items and so on) - Task Edit(T edited); - - /// - /// Edit only specific properties of a resource - /// - /// The id of the resource to edit - /// - /// A method that will be called when you need to update every properties that you want to - /// persist. - /// - /// If the item is not found - /// The resource edited and completed by database's information (related items and so on) - Task Patch(Guid id, Func patch); - - /// - /// Called when a resource has been edited. - /// - static event ResourceEventHandler OnEdited; - - /// - /// Callback that should be called after a resource has been edited. - /// - /// The resource newly edited. - /// A representing the asynchronous operation. - protected static Task OnResourceEdited(T obj) => - OnEdited?.Invoke(obj) ?? Task.CompletedTask; - - /// - /// Delete a resource by it's ID - /// - /// The ID of the resource - /// If the item is not found - /// A representing the asynchronous operation. - Task Delete(Guid id); - - /// - /// Delete a resource by it's slug - /// - /// The slug of the resource - /// If the item is not found - /// A representing the asynchronous operation. - Task Delete(string slug); - - /// - /// Delete a resource - /// - /// The resource to delete - /// If the item is not found - /// A representing the asynchronous operation. - Task Delete(T obj); - - /// - /// Delete all resources that match the predicate. - /// - /// A predicate to filter resources to delete. Every resource that match this will be deleted. - /// A representing the asynchronous operation. - Task DeleteAll(Filter filter); - - /// - /// Called when a resource has been edited. - /// - static event ResourceEventHandler OnDeleted; - - /// - /// Callback that should be called after a resource has been deleted. - /// - /// The resource newly deleted. - /// A representing the asynchronous operation. - protected static Task OnResourceDeleted(T obj) => - OnDeleted?.Invoke(obj) ?? Task.CompletedTask; - } + /// The resource created/modified/deleted + /// A representing the asynchronous operation. + public delegate Task ResourceEventHandler(T resource); /// - /// A base class for repositories. Every service implementing this will be handled by the . + /// Get a resource from it's ID. /// - public interface IBaseRepository - { - /// - /// The type for witch this repository is responsible or null if non applicable. - /// - Type RepositoryType { get; } - } + /// The id of the resource + /// The related fields to include. + /// If the item could not be found. + /// The resource found + Task Get(Guid id, Include? include = default); - public interface IUserRepository : IRepository - { - Task GetByExternalId(string provider, string id); - Task AddExternalToken(Guid userId, string provider, ExternalToken token); - Task DeleteExternalToken(Guid userId, string provider); - } + /// + /// Get a resource from it's slug. + /// + /// The slug of the resource + /// The related fields to include. + /// If the item could not be found. + /// The resource found + Task Get(string slug, Include? include = default); + + /// + /// Get the first resource that match the predicate. + /// + /// A predicate to filter the resource. + /// The related fields to include. + /// A custom sort method to handle cases where multiples items match the filters. + /// Reverse the sort. + /// Select the first element after this id if it was in a list. + /// If the item could not be found. + /// The resource found + Task Get( + Filter filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ); + + /// + /// Get a resource from it's ID or null if it is not found. + /// + /// The id of the resource + /// The related fields to include. + /// The resource found + Task GetOrDefault(Guid id, Include? include = default); + + /// + /// Get a resource from it's slug or null if it is not found. + /// + /// The slug of the resource + /// The related fields to include. + /// The resource found + Task GetOrDefault(string slug, Include? include = default); + + /// + /// Get the first resource that match the predicate or null if it is not found. + /// + /// A predicate to filter the resource. + /// The related fields to include. + /// A custom sort method to handle cases where multiples items match the filters. + /// Reverse the sort. + /// Select the first element after this id if it was in a list. + /// The resource found + Task GetOrDefault( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ); + + /// + /// Search for resources with the database. + /// + /// The query string. + /// The related fields to include. + /// A list of resources found + Task> Search(string query, Include? include = default); + + /// + /// Get every resources that match all filters + /// + /// A filter predicate + /// Sort information about the query (sort by, sort order) + /// The related fields to include. + /// How pagination should be done (where to start and how many to return) + /// A list of resources that match every filters + Task> GetAll( + Filter? filter = null, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ); + + /// + /// Get the number of resources that match the filter's predicate. + /// + /// A filter predicate + /// How many resources matched that filter + Task GetCount(Filter? filter = null); + + /// + /// Map a list of ids to a list of items (keep the order). + /// + /// The list of items id. + /// The related fields to include. + /// A list of resources mapped from ids. + Task> FromIds(IList ids, Include? include = default); + + /// + /// Create a new resource. + /// + /// The item to register + /// The resource registers and completed by database's information (related items and so on) + Task Create(T obj); + + /// + /// Create a new resource if it does not exist already. If it does, the existing value is returned instead. + /// + /// The object to create + /// The newly created item or the existing value if it existed. + Task CreateIfNotExists(T obj); + + /// + /// Called when a resource has been created. + /// + static event ResourceEventHandler OnCreated; + + /// + /// Callback that should be called after a resource has been created. + /// + /// The resource newly created. + /// A representing the asynchronous operation. + protected static Task OnResourceCreated(T obj) => OnCreated?.Invoke(obj) ?? Task.CompletedTask; + + /// + /// Edit a resource and replace every property + /// + /// The resource to edit, it's ID can't change. + /// If the item is not found + /// The resource edited and completed by database's information (related items and so on) + Task Edit(T edited); + + /// + /// Edit only specific properties of a resource + /// + /// The id of the resource to edit + /// + /// A method that will be called when you need to update every properties that you want to + /// persist. + /// + /// If the item is not found + /// The resource edited and completed by database's information (related items and so on) + Task Patch(Guid id, Func patch); + + /// + /// Called when a resource has been edited. + /// + static event ResourceEventHandler OnEdited; + + /// + /// Callback that should be called after a resource has been edited. + /// + /// The resource newly edited. + /// A representing the asynchronous operation. + protected static Task OnResourceEdited(T obj) => OnEdited?.Invoke(obj) ?? Task.CompletedTask; + + /// + /// Delete a resource by it's ID + /// + /// The ID of the resource + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(Guid id); + + /// + /// Delete a resource by it's slug + /// + /// The slug of the resource + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(string slug); + + /// + /// Delete a resource + /// + /// The resource to delete + /// If the item is not found + /// A representing the asynchronous operation. + Task Delete(T obj); + + /// + /// Delete all resources that match the predicate. + /// + /// A predicate to filter resources to delete. Every resource that match this will be deleted. + /// A representing the asynchronous operation. + Task DeleteAll(Filter filter); + + /// + /// Called when a resource has been edited. + /// + static event ResourceEventHandler OnDeleted; + + /// + /// Callback that should be called after a resource has been deleted. + /// + /// The resource newly deleted. + /// A representing the asynchronous operation. + protected static Task OnResourceDeleted(T obj) => OnDeleted?.Invoke(obj) ?? Task.CompletedTask; +} + +/// +/// A base class for repositories. Every service implementing this will be handled by the . +/// +public interface IBaseRepository +{ + /// + /// The type for witch this repository is responsible or null if non applicable. + /// + Type RepositoryType { get; } +} + +public interface IUserRepository : IRepository +{ + Task GetByExternalId(string provider, string id); + Task AddExternalToken(Guid userId, string provider, ExternalToken token); + Task DeleteExternalToken(Guid userId, string provider); } diff --git a/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs b/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs index 1f7fc03c..715fbedc 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IThumbnailsManager.cs @@ -21,59 +21,58 @@ using System.IO; using System.Threading.Tasks; using Kyoo.Abstractions.Models; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// Download images and retrieve the path of those images for a resource. +/// +public interface IThumbnailsManager { /// - /// 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. /// - public interface IThumbnailsManager - { - /// - /// Download images of a specified item. - /// If no images is available to download, do nothing and silently return. - /// - /// - /// The item to cache images. - /// - /// The type of the item - /// A representing the asynchronous operation. - Task DownloadImages(T item) - where T : IThumbnails; + /// + /// The item to cache images. + /// + /// The type of the item + /// A representing the asynchronous operation. + Task DownloadImages(T item) + where T : IThumbnails; - /// - /// Retrieve the local path of an image of the given item. - /// - /// The item to retrieve the poster from. - /// The ID of the image. - /// The quality of the image - /// The type of the item - /// The path of the image for the given resource or null if it does not exists. - string GetImagePath(T item, string image, ImageQuality quality) - where T : IThumbnails; + /// + /// Retrieve the local path of an image of the given item. + /// + /// The item to retrieve the poster from. + /// The ID of the image. + /// The quality of the image + /// The type of the item + /// The path of the image for the given resource or null if it does not exists. + string GetImagePath(T item, string image, ImageQuality quality) + where T : IThumbnails; - /// - /// Delete images associated with the item. - /// - /// - /// The item with cached images. - /// - /// The type of the item - /// A representing the asynchronous operation. - Task DeleteImages(T item) - where T : IThumbnails; + /// + /// Delete images associated with the item. + /// + /// + /// The item with cached images. + /// + /// The type of the item + /// A representing the asynchronous operation. + Task DeleteImages(T item) + where T : IThumbnails; - /// - /// Set the user's profile picture - /// - /// The id of the user. - /// The byte stream of the image. Null if no image exist. - Task GetUserImage(Guid userId); + /// + /// Set the user's profile picture + /// + /// The id of the user. + /// The byte stream of the image. Null if no image exist. + Task GetUserImage(Guid userId); - /// - /// Set the user's profile picture - /// - /// The id of the user. - /// The byte stream of the image. Null to delete the image. - Task SetUserImage(Guid userId, Stream? image); - } + /// + /// Set the user's profile picture + /// + /// The id of the user. + /// The byte stream of the image. Null to delete the image. + Task SetUserImage(Guid userId, Stream? image); } diff --git a/back/src/Kyoo.Abstractions/Controllers/StartupAction.cs b/back/src/Kyoo.Abstractions/Controllers/StartupAction.cs index ea9d9acf..944b899d 100644 --- a/back/src/Kyoo.Abstractions/Controllers/StartupAction.cs +++ b/back/src/Kyoo.Abstractions/Controllers/StartupAction.cs @@ -19,256 +19,252 @@ using System; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// A list of constant priorities used for 's . +/// It also contains helper methods for creating new . +/// +public static class SA { /// - /// A list of constant priorities used for 's . - /// It also contains helper methods for creating new . + /// The highest predefined priority existing for . /// - public static class SA + public const int Before = 5000; + + /// + /// Items defining routing (see IApplicationBuilder.UseRouting use this priority. + /// + public const int Routing = 4000; + + /// + /// Actions defining new static files router use this priority. + /// + public const int StaticFiles = 3000; + + /// + /// Actions calling IApplicationBuilder.UseAuthentication use this priority. + /// + public const int Authentication = 2000; + + /// + /// Actions calling IApplicationBuilder.UseAuthorization use this priority. + /// + public const int Authorization = 1000; + + /// + /// Action adding endpoint should use this priority (with a negative modificator if there is a catchall). + /// + public const int Endpoint = 0; + + /// + /// The lowest predefined priority existing for . + /// It should run after all other actions. + /// + public const int After = -1000; + + /// + /// Create a new . + /// + /// The action to run + /// The priority of the new action + /// A new + public static StartupAction New(Action action, int priority) => new(action, priority); + + /// + /// Create a new . + /// + /// The action to run + /// The priority of the new action + /// A dependency that this action will use. + /// A new + public static StartupAction New(Action action, int priority) + where T : notnull => new(action, priority); + + /// + /// Create a new . + /// + /// The action to run + /// The priority of the new action + /// A dependency that this action will use. + /// A second dependency that this action will use. + /// A new + public static StartupAction New(Action action, int priority) + where T : notnull + where T2 : notnull => new(action, priority); + + /// + /// Create a new . + /// + /// The action to run + /// The priority of the new action + /// A dependency that this action will use. + /// A second dependency that this action will use. + /// A third dependency that this action will use. + /// A new + public static StartupAction New(Action action, int priority) + where T : notnull + where T2 : notnull + where T3 : notnull => new(action, priority); + + /// + /// A with no dependencies. + /// + public class StartupAction : IStartupAction { /// - /// The highest predefined priority existing for . + /// The action to execute at startup. /// - public const int Before = 5000; + private readonly Action _action; - /// - /// Items defining routing (see IApplicationBuilder.UseRouting use this priority. - /// - public const int Routing = 4000; - - /// - /// Actions defining new static files router use this priority. - /// - public const int StaticFiles = 3000; - - /// - /// Actions calling IApplicationBuilder.UseAuthentication use this priority. - /// - public const int Authentication = 2000; - - /// - /// Actions calling IApplicationBuilder.UseAuthorization use this priority. - /// - public const int Authorization = 1000; - - /// - /// Action adding endpoint should use this priority (with a negative modificator if there is a catchall). - /// - public const int Endpoint = 0; - - /// - /// The lowest predefined priority existing for . - /// It should run after all other actions. - /// - public const int After = -1000; + /// + public int Priority { get; } /// /// Create a new . /// - /// The action to run - /// The priority of the new action - /// A new - public static StartupAction New(Action action, int priority) => new(action, priority); - - /// - /// Create a new . - /// - /// The action to run - /// The priority of the new action - /// A dependency that this action will use. - /// A new - public static StartupAction New(Action action, int priority) - where T : notnull => new(action, priority); - - /// - /// Create a new . - /// - /// The action to run - /// The priority of the new action - /// A dependency that this action will use. - /// A second dependency that this action will use. - /// A new - public static StartupAction New(Action action, int priority) - where T : notnull - where T2 : notnull => new(action, priority); - - /// - /// Create a new . - /// - /// The action to run - /// The priority of the new action - /// A dependency that this action will use. - /// A second dependency that this action will use. - /// A third dependency that this action will use. - /// A new - public static StartupAction New( - Action action, - int priority - ) - where T : notnull - where T2 : notnull - where T3 : notnull => new(action, priority); - - /// - /// A with no dependencies. - /// - public class StartupAction : IStartupAction + /// The action to execute on startup. + /// The priority of this action (see ). + public StartupAction(Action action, int priority) { - /// - /// The action to execute at startup. - /// - private readonly Action _action; - - /// - public int Priority { get; } - - /// - /// Create a new . - /// - /// The action to execute on startup. - /// The priority of this action (see ). - public StartupAction(Action action, int priority) - { - _action = action; - Priority = priority; - } - - /// - public void Run(IServiceProvider provider) - { - _action.Invoke(); - } + _action = action; + Priority = priority; } - /// - /// A with one dependencies. - /// - /// The dependency to use. - public class StartupAction : IStartupAction - where T : notnull + /// + public void Run(IServiceProvider provider) { - /// - /// The action to execute at startup. - /// - private readonly Action _action; - - /// - public int Priority { get; } - - /// - /// Create a new . - /// - /// The action to execute on startup. - /// The priority of this action (see ). - public StartupAction(Action action, int priority) - { - _action = action; - Priority = priority; - } - - /// - public void Run(IServiceProvider provider) - { - _action.Invoke(provider.GetRequiredService()); - } - } - - /// - /// A with two dependencies. - /// - /// The dependency to use. - /// The second dependency to use. - public class StartupAction : IStartupAction - where T : notnull - where T2 : notnull - { - /// - /// The action to execute at startup. - /// - private readonly Action _action; - - /// - public int Priority { get; } - - /// - /// Create a new . - /// - /// The action to execute on startup. - /// The priority of this action (see ). - public StartupAction(Action action, int priority) - { - _action = action; - Priority = priority; - } - - /// - public void Run(IServiceProvider provider) - { - _action.Invoke(provider.GetRequiredService(), provider.GetRequiredService()); - } - } - - /// - /// A with three dependencies. - /// - /// The dependency to use. - /// The second dependency to use. - /// The third dependency to use. - public class StartupAction : IStartupAction - where T : notnull - where T2 : notnull - where T3 : notnull - { - /// - /// The action to execute at startup. - /// - private readonly Action _action; - - /// - public int Priority { get; } - - /// - /// Create a new . - /// - /// The action to execute on startup. - /// The priority of this action (see ). - public StartupAction(Action action, int priority) - { - _action = action; - Priority = priority; - } - - /// - public void Run(IServiceProvider provider) - { - _action.Invoke( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService() - ); - } + _action.Invoke(); } } /// - /// An action executed on kyoo's startup to initialize the asp-net container. + /// A with one dependencies. /// - /// - /// This is the base interface, see for a simpler use of this. - /// - public interface IStartupAction + /// The dependency to use. + public class StartupAction : IStartupAction + where T : notnull { /// - /// 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. + /// The action to execute at startup. /// - int Priority { get; } + private readonly Action _action; + + /// + public int Priority { get; } /// - /// Run this action to configure the container, a service provider containing all services can be used. + /// Create a new . /// - /// The service provider containing all services can be used. - void Run(IServiceProvider provider); + /// The action to execute on startup. + /// The priority of this action (see ). + public StartupAction(Action action, int priority) + { + _action = action; + Priority = priority; + } + + /// + public void Run(IServiceProvider provider) + { + _action.Invoke(provider.GetRequiredService()); + } + } + + /// + /// A with two dependencies. + /// + /// The dependency to use. + /// The second dependency to use. + public class StartupAction : IStartupAction + where T : notnull + where T2 : notnull + { + /// + /// The action to execute at startup. + /// + private readonly Action _action; + + /// + public int Priority { get; } + + /// + /// Create a new . + /// + /// The action to execute on startup. + /// The priority of this action (see ). + public StartupAction(Action action, int priority) + { + _action = action; + Priority = priority; + } + + /// + public void Run(IServiceProvider provider) + { + _action.Invoke(provider.GetRequiredService(), provider.GetRequiredService()); + } + } + + /// + /// A with three dependencies. + /// + /// The dependency to use. + /// The second dependency to use. + /// The third dependency to use. + public class StartupAction : IStartupAction + where T : notnull + where T2 : notnull + where T3 : notnull + { + /// + /// The action to execute at startup. + /// + private readonly Action _action; + + /// + public int Priority { get; } + + /// + /// Create a new . + /// + /// The action to execute on startup. + /// The priority of this action (see ). + public StartupAction(Action action, int priority) + { + _action = action; + Priority = priority; + } + + /// + public void Run(IServiceProvider provider) + { + _action.Invoke( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService() + ); + } } } + +/// +/// An action executed on kyoo's startup to initialize the asp-net container. +/// +/// +/// This is the base interface, see for a simpler use of this. +/// +public interface IStartupAction +{ + /// + /// 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. + /// + int Priority { get; } + + /// + /// Run this action to configure the container, a service provider containing all services can be used. + /// + /// The service provider containing all services can be used. + void Run(IServiceProvider provider); +} diff --git a/back/src/Kyoo.Abstractions/Extensions.cs b/back/src/Kyoo.Abstractions/Extensions.cs index 05756ba6..42f4b0af 100644 --- a/back/src/Kyoo.Abstractions/Extensions.cs +++ b/back/src/Kyoo.Abstractions/Extensions.cs @@ -23,43 +23,42 @@ using System.Security.Claims; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Authentication.Models; -namespace Kyoo.Authentication +namespace Kyoo.Authentication; + +/// +/// Extension methods. +/// +public static class Extensions { /// - /// Extension methods. + /// Get the permissions of an user. /// - public static class Extensions + /// The user + /// The list of permissions + public static ICollection GetPermissions(this ClaimsPrincipal user) { - /// - /// Get the permissions of an user. - /// - /// The user - /// The list of permissions - public static ICollection GetPermissions(this ClaimsPrincipal user) - { - return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') - ?? Array.Empty(); - } + return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',') + ?? Array.Empty(); + } - /// - /// Get the id of the current user or null if unlogged or invalid. - /// - /// The user. - /// The id of the user or null. - public static Guid? GetId(this ClaimsPrincipal user) - { - Claim? value = user.FindFirst(Claims.Id); - if (Guid.TryParse(value?.Value, out Guid id)) - return id; - return null; - } + /// + /// Get the id of the current user or null if unlogged or invalid. + /// + /// The user. + /// The id of the user or null. + public static Guid? GetId(this ClaimsPrincipal user) + { + Claim? value = user.FindFirst(Claims.Id); + if (Guid.TryParse(value?.Value, out Guid id)) + return id; + return null; + } - public static Guid GetIdOrThrow(this ClaimsPrincipal user) - { - Guid? ret = user.GetId(); - if (ret == null) - throw new UnauthorizedException(); - return ret.Value; - } + public static Guid GetIdOrThrow(this ClaimsPrincipal user) + { + Guid? ret = user.GetId(); + if (ret == null) + throw new UnauthorizedException(); + return ret.Value; } } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs index 785c8b55..46014a69 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/ApiDefinitionAttribute.cs @@ -18,35 +18,34 @@ using System; -namespace Kyoo.Abstractions.Models.Attributes +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// 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. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class ApiDefinitionAttribute : Attribute { /// - /// 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. + /// The public name of this api. /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] - public class ApiDefinitionAttribute : Attribute + public string Name { get; } + + /// + /// The name of the group in witch this API is. You can also specify a custom sort order using the following + /// format: order:name. Everything before the first : will be removed but kept for + /// th alphabetical ordering. + /// + public string? Group { get; set; } + + /// + /// Create a new . + /// + /// The name of the api that will be used on the documentation page. + public ApiDefinitionAttribute(string name) { - /// - /// The public name of this api. - /// - public string Name { get; } - - /// - /// The name of the group in witch this API is. You can also specify a custom sort order using the following - /// format: order:name. Everything before the first : will be removed but kept for - /// th alphabetical ordering. - /// - public string? Group { get; set; } - - /// - /// Create a new . - /// - /// The name of the api that will be used on the documentation page. - public ApiDefinitionAttribute(string name) - { - Name = name; - } + Name = name; } } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs index fd4d37b5..2fde7a84 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/ComputedAttribute.cs @@ -18,11 +18,10 @@ using System; -namespace Kyoo.Abstractions.Models.Attributes -{ - /// - /// An attribute to inform that the property is computed automatically and can't be assigned manually. - /// - [AttributeUsage(AttributeTargets.Property)] - public class ComputedAttribute : NotMergeableAttribute { } -} +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// An attribute to inform that the property is computed automatically and can't be assigned manually. +/// +[AttributeUsage(AttributeTargets.Property)] +public class ComputedAttribute : NotMergeableAttribute { } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs index bb7c2b05..8b40679e 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/LoadableRelationAttribute.cs @@ -18,37 +18,36 @@ using System; -namespace Kyoo.Abstractions.Models.Attributes +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// The targeted relation can be loaded. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LoadableRelationAttribute : Attribute { /// - /// The targeted relation can be loaded. + /// The name of the field containing the related resource's ID. /// - [AttributeUsage(AttributeTargets.Property)] - public class LoadableRelationAttribute : Attribute + public string? RelationID { get; } + + public string? Sql { get; set; } + + public string? On { get; set; } + + public string? Projected { get; set; } + + /// + /// Create a new . + /// + public LoadableRelationAttribute() { } + + /// + /// Create a new with a baking relationID field. + /// + /// The name of the RelationID field. + public LoadableRelationAttribute(string relationID) { - /// - /// The name of the field containing the related resource's ID. - /// - public string? RelationID { get; } - - public string? Sql { get; set; } - - public string? On { get; set; } - - public string? Projected { get; set; } - - /// - /// Create a new . - /// - public LoadableRelationAttribute() { } - - /// - /// Create a new with a baking relationID field. - /// - /// The name of the RelationID field. - public LoadableRelationAttribute(string relationID) - { - RelationID = relationID; - } + RelationID = relationID; } } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs index c63acb06..138ec9a8 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/NotMergeableAttribute.cs @@ -18,23 +18,22 @@ using System; -namespace Kyoo.Abstractions.Models.Attributes +namespace Kyoo.Abstractions.Models.Attributes; + +/// +/// Specify that a property can't be merged. +/// +[AttributeUsage(AttributeTargets.Property)] +public class NotMergeableAttribute : Attribute { } + +/// +/// An interface with a method called when this object is merged. +/// +public interface IOnMerge { /// - /// Specify that a property can't be merged. + /// This function is called after the object has been merged. /// - [AttributeUsage(AttributeTargets.Property)] - public class NotMergeableAttribute : Attribute { } - - /// - /// An interface with a method called when this object is merged. - /// - public interface IOnMerge - { - /// - /// This function is called after the object has been merged. - /// - /// The object that has been merged with this. - void OnMerge(object merged); - } + /// The object that has been merged with this. + void OnMerge(object merged); } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs index 438b9ac8..ea300823 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PartialPermissionAttribute.cs @@ -21,68 +21,67 @@ using Kyoo.Abstractions.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Abstractions.Models.Permissions +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// Specify one part of a permissions needed for the API (the kind or the type). +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class PartialPermissionAttribute : Attribute, IFilterFactory { /// - /// Specify one part of a permissions needed for the API (the kind or the type). + /// The needed permission type. /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] - public class PartialPermissionAttribute : Attribute, IFilterFactory + public string? Type { get; } + + /// + /// The needed permission kind. + /// + public Kind? Kind { get; } + + /// + /// The group of this permission. + /// + public Group Group { get; set; } + + /// + /// Ask a permission to run an action. + /// + /// + /// 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. + /// + /// The type of the action + public PartialPermissionAttribute(string type) { - /// - /// The needed permission type. - /// - public string? Type { get; } - - /// - /// The needed permission kind. - /// - public Kind? Kind { get; } - - /// - /// The group of this permission. - /// - public Group Group { get; set; } - - /// - /// Ask a permission to run an action. - /// - /// - /// 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. - /// - /// The type of the action - public PartialPermissionAttribute(string type) - { - Type = type.ToLower(); - } - - /// - /// Ask a permission to run an action. - /// - /// - /// 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. - /// - /// The kind of permission needed. - public PartialPermissionAttribute(Kind permission) - { - Kind = permission; - } - - /// - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) - { - return serviceProvider.GetRequiredService().Create(this); - } - - /// - public bool IsReusable => true; + Type = type.ToLower(); } + + /// + /// Ask a permission to run an action. + /// + /// + /// 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. + /// + /// The kind of permission needed. + public PartialPermissionAttribute(Kind permission) + { + Kind = permission; + } + + /// + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredService().Create(this); + } + + /// + public bool IsReusable => true; } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs index 9e5d27f9..29aad1e4 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/PermissionAttribute.cs @@ -21,117 +21,116 @@ using Kyoo.Abstractions.Controllers; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Abstractions.Models.Permissions +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// The kind of permission needed. +/// +public enum Kind { /// + /// Allow the user to read for this kind of data. + /// + Read, + + /// + /// Allow the user to write for this kind of data. + /// + Write, + + /// + /// Allow the user to create this kind of data. + /// + Create, + + /// + /// Allow the user to delete this kind of data. + /// + Delete, + + /// + /// Allow the user to play this file. + /// + Play, +} + +/// +/// The group of the permission. +/// +public enum Group +{ + /// + /// Default group indicating no value. + /// + None, + + /// + /// Allow all operations on basic items types. + /// + Overall, + + /// + /// Allow operation on sensitive items like libraries path, configurations and so on. + /// + Admin +} + +/// +/// Specify permissions needed for the API. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class PermissionAttribute : Attribute, IFilterFactory +{ + /// + /// The needed permission as string. + /// + public string Type { get; } + + /// + /// The needed permission kind. + /// + public Kind Kind { get; } + + /// + /// The group of this permission. + /// + public Group Group { get; } + + /// + /// Ask a permission to run an action. + /// + /// + /// The type of the action + /// + /// /// The kind of permission needed. - /// - public enum Kind + /// + /// + /// The group of this permission (allow grouped permission like overall.read + /// for all read permissions of this group). + /// + public PermissionAttribute(string type, Kind permission, Group group = Group.Overall) { - /// - /// Allow the user to read for this kind of data. - /// - Read, - - /// - /// Allow the user to write for this kind of data. - /// - Write, - - /// - /// Allow the user to create this kind of data. - /// - Create, - - /// - /// Allow the user to delete this kind of data. - /// - Delete, - - /// - /// Allow the user to play this file. - /// - Play, + Type = type.ToLower(); + Kind = permission; + Group = group; } - /// - /// The group of the permission. - /// - public enum Group + /// + public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) { - /// - /// Default group indicating no value. - /// - None, - - /// - /// Allow all operations on basic items types. - /// - Overall, - - /// - /// Allow operation on sensitive items like libraries path, configurations and so on. - /// - Admin + return serviceProvider.GetRequiredService().Create(this); } + /// + public bool IsReusable => true; + /// - /// Specify permissions needed for the API. + /// Return this permission attribute as a string. /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] - public class PermissionAttribute : Attribute, IFilterFactory + /// The string representation. + public string AsPermissionString() { - /// - /// The needed permission as string. - /// - public string Type { get; } - - /// - /// The needed permission kind. - /// - public Kind Kind { get; } - - /// - /// The group of this permission. - /// - public Group Group { get; } - - /// - /// Ask a permission to run an action. - /// - /// - /// The type of the action - /// - /// - /// The kind of permission needed. - /// - /// - /// The group of this permission (allow grouped permission like overall.read - /// for all read permissions of this group). - /// - public PermissionAttribute(string type, Kind permission, Group group = Group.Overall) - { - Type = type.ToLower(); - Kind = permission; - Group = group; - } - - /// - public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) - { - return serviceProvider.GetRequiredService().Create(this); - } - - /// - public bool IsReusable => true; - - /// - /// Return this permission attribute as a string. - /// - /// The string representation. - public string AsPermissionString() - { - return Type; - } + return Type; } } diff --git a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs index 884187a0..a50424a1 100644 --- a/back/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs +++ b/back/src/Kyoo.Abstractions/Models/Attributes/Permission/UserOnlyAttribute.cs @@ -18,14 +18,13 @@ using System; -namespace Kyoo.Abstractions.Models.Permissions +namespace Kyoo.Abstractions.Models.Permissions; + +/// +/// The annotated route can only be accessed by a logged in user. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class UserOnlyAttribute : Attribute { - /// - /// The annotated route can only be accessed by a logged in user. - /// - [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. - } + // TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation. } diff --git a/back/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs b/back/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs index 635d4f71..817b8487 100644 --- a/back/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs +++ b/back/src/Kyoo.Abstractions/Models/Exceptions/DuplicatedItemException.cs @@ -19,35 +19,34 @@ using System; using System.Runtime.Serialization; -namespace Kyoo.Abstractions.Models.Exceptions +namespace Kyoo.Abstractions.Models.Exceptions; + +/// +/// An exception raised when an item already exists in the database. +/// +[Serializable] +public class DuplicatedItemException : Exception { /// - /// An exception raised when an item already exists in the database. + /// The existing object. /// - [Serializable] - public class DuplicatedItemException : Exception + public object? Existing { get; } + + /// + /// Create a new with the default message. + /// + /// The existing object. + public DuplicatedItemException(object? existing = null) + : base("Already exists in the database.") { - /// - /// The existing object. - /// - public object? Existing { get; } - - /// - /// Create a new with the default message. - /// - /// The existing object. - public DuplicatedItemException(object? existing = null) - : base("Already exists in the database.") - { - Existing = existing; - } - - /// - /// The serialization constructor. - /// - /// Serialization infos - /// The serialization context - protected DuplicatedItemException(SerializationInfo info, StreamingContext context) - : base(info, context) { } + Existing = existing; } + + /// + /// The serialization constructor. + /// + /// Serialization infos + /// The serialization context + protected DuplicatedItemException(SerializationInfo info, StreamingContext context) + : base(info, context) { } } diff --git a/back/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs b/back/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs index 0fd5825f..eeeffc79 100644 --- a/back/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs +++ b/back/src/Kyoo.Abstractions/Models/Exceptions/ItemNotFoundException.cs @@ -19,33 +19,32 @@ using System; using System.Runtime.Serialization; -namespace Kyoo.Abstractions.Models.Exceptions +namespace Kyoo.Abstractions.Models.Exceptions; + +/// +/// An exception raised when an item could not be found. +/// +[Serializable] +public class ItemNotFoundException : Exception { /// - /// An exception raised when an item could not be found. + /// Create a default with no message. /// - [Serializable] - public class ItemNotFoundException : Exception - { - /// - /// Create a default with no message. - /// - public ItemNotFoundException() - : base("Item not found") { } + public ItemNotFoundException() + : base("Item not found") { } - /// - /// Create a new with a message - /// - /// The message of the exception - public ItemNotFoundException(string message) - : base(message) { } + /// + /// Create a new with a message + /// + /// The message of the exception + public ItemNotFoundException(string message) + : base(message) { } - /// - /// The serialization constructor - /// - /// Serialization infos - /// The serialization context - protected ItemNotFoundException(SerializationInfo info, StreamingContext context) - : base(info, context) { } - } + /// + /// The serialization constructor + /// + /// Serialization infos + /// The serialization context + protected ItemNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) { } } diff --git a/back/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs b/back/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs index 27415a97..af0eae6c 100644 --- a/back/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs +++ b/back/src/Kyoo.Abstractions/Models/Exceptions/UnauthorizedException.cs @@ -19,18 +19,17 @@ using System; using System.Runtime.Serialization; -namespace Kyoo.Abstractions.Models.Exceptions +namespace Kyoo.Abstractions.Models.Exceptions; + +[Serializable] +public class UnauthorizedException : Exception { - [Serializable] - public class UnauthorizedException : Exception - { - public UnauthorizedException() - : base("User not authenticated or token invalid.") { } + public UnauthorizedException() + : base("User not authenticated or token invalid.") { } - public UnauthorizedException(string message) - : base(message) { } + public UnauthorizedException(string message) + : base(message) { } - protected UnauthorizedException(SerializationInfo info, StreamingContext context) - : base(info, context) { } - } + protected UnauthorizedException(SerializationInfo info, StreamingContext context) + : base(info, context) { } } diff --git a/back/src/Kyoo.Abstractions/Models/Genre.cs b/back/src/Kyoo.Abstractions/Models/Genre.cs index 2b7e16f0..fa2af8cb 100644 --- a/back/src/Kyoo.Abstractions/Models/Genre.cs +++ b/back/src/Kyoo.Abstractions/Models/Genre.cs @@ -16,30 +16,29 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A genre that allow one to specify categories for shows. +/// +public enum Genre { - /// - /// A genre that allow one to specify categories for shows. - /// - public enum Genre - { - Action, - Adventure, - Animation, - Comedy, - Crime, - Documentary, - Drama, - Family, - Fantasy, - History, - Horror, - Music, - Mystery, - Romance, - ScienceFiction, - Thriller, - War, - Western, - } + Action, + Adventure, + Animation, + Comedy, + Crime, + Documentary, + Drama, + Family, + Fantasy, + History, + Horror, + Music, + Mystery, + Romance, + ScienceFiction, + Thriller, + War, + Western, } diff --git a/back/src/Kyoo.Abstractions/Models/MetadataID.cs b/back/src/Kyoo.Abstractions/Models/MetadataID.cs index f16c59ae..37919c10 100644 --- a/back/src/Kyoo.Abstractions/Models/MetadataID.cs +++ b/back/src/Kyoo.Abstractions/Models/MetadataID.cs @@ -16,21 +16,20 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// ID and link of an item on an external provider. +/// +public class MetadataId { /// - /// ID and link of an item on an external provider. + /// The ID of the resource on the external provider. /// - public class MetadataId - { - /// - /// The ID of the resource on the external provider. - /// - public string DataId { get; set; } + public string DataId { get; set; } - /// - /// The URL of the resource on the external provider. - /// - public string? Link { get; set; } - } + /// + /// The URL of the resource on the external provider. + /// + public string? Link { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/Page.cs b/back/src/Kyoo.Abstractions/Models/Page.cs index 8f64d258..75c0f18e 100644 --- a/back/src/Kyoo.Abstractions/Models/Page.cs +++ b/back/src/Kyoo.Abstractions/Models/Page.cs @@ -20,93 +20,86 @@ using System.Collections.Generic; using System.Linq; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A page of resource that contains information about the pagination of resources. +/// +/// The type of resource contained in this page. +public class Page + where T : IResource { /// - /// A page of resource that contains information about the pagination of resources. + /// The link of the current page. /// - /// The type of resource contained in this page. - public class Page - where T : IResource + public string This { get; } + + /// + /// The link of the first page. + /// + public string First { get; } + + /// + /// The link of the previous page. + /// + public string? Previous { get; } + + /// + /// The link of the next page. + /// + public string? Next { get; } + + /// + /// The number of items in the current page. + /// + public int Count => Items.Count; + + /// + /// The list of items in the page. + /// + public ICollection Items { get; } + + /// + /// Create a new . + /// + /// The list of items in the page. + /// The link of the current page. + /// The link of the previous page. + /// The link of the next page. + /// The link of the first page. + public Page(ICollection items, string @this, string? previous, string? next, string first) { - /// - /// The link of the current page. - /// - public string This { get; } + Items = items; + This = @this; + Previous = previous; + Next = next; + First = first; + } - /// - /// The link of the first page. - /// - public string First { get; } - - /// - /// The link of the previous page. - /// - public string? Previous { get; } - - /// - /// The link of the next page. - /// - public string? Next { get; } - - /// - /// The number of items in the current page. - /// - public int Count => Items.Count; - - /// - /// The list of items in the page. - /// - public ICollection Items { get; } - - /// - /// Create a new . - /// - /// The list of items in the page. - /// The link of the current page. - /// The link of the previous page. - /// The link of the next page. - /// The link of the first page. - public Page( - ICollection items, - string @this, - string? previous, - string? next, - string first - ) + /// + /// Create a new and compute the urls. + /// + /// The list of items in the page. + /// The base url of the resources available from this page. + /// The list of query strings of the current page + /// The number of items requested for the current page. + public Page(ICollection items, string url, Dictionary query, int limit) + { + Items = items; + This = url + query.ToQueryString(); + if (items.Count > 0 && query.ContainsKey("afterID")) { - Items = items; - This = @this; - Previous = previous; - Next = next; - First = first; + query["afterID"] = items.First().Id.ToString(); + query["reverse"] = "true"; + Previous = url + query.ToQueryString(); } - - /// - /// Create a new and compute the urls. - /// - /// The list of items in the page. - /// The base url of the resources available from this page. - /// The list of query strings of the current page - /// The number of items requested for the current page. - public Page(ICollection items, string url, Dictionary query, int limit) + query.Remove("reverse"); + if (items.Count == limit && limit > 0) { - Items = items; - This = 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["afterID"] = items.Last().Id.ToString(); + Next = url + query.ToQueryString(); } + query.Remove("afterID"); + First = url + query.ToQueryString(); } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs b/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs index 23dd12ba..eb1bda94 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Collection.cs @@ -23,69 +23,68 @@ using System.Text.Json.Serialization; using Kyoo.Abstractions.Controllers; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A class representing collections of . +/// +public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem { + public static Sort DefaultSort => new Sort.By(nameof(Collection.Name)); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + /// - /// A class representing collections of . + /// The name of this collection. /// - public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem + public string Name { get; set; } + + /// + /// The description of this collection. + /// + public string? Overview { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + /// The list of movies contained in this collection. + /// + [JsonIgnore] + public ICollection? Movies { get; set; } + + /// + /// The list of shows contained in this collection. + /// + [JsonIgnore] + public ICollection? Shows { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + public Collection() { } + + [JsonConstructor] + public Collection(string name) { - public static Sort DefaultSort => new Sort.By(nameof(Collection.Name)); - - /// - public Guid Id { get; set; } - - /// - [MaxLength(256)] - public string Slug { get; set; } - - /// - /// The name of this collection. - /// - public string Name { get; set; } - - /// - /// The description of this collection. - /// - public string? Overview { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - public Image? Poster { get; set; } - - /// - public Image? Thumbnail { get; set; } - - /// - public Image? Logo { get; set; } - - /// - /// The list of movies contained in this collection. - /// - [JsonIgnore] - public ICollection? Movies { get; set; } - - /// - /// The list of shows contained in this collection. - /// - [JsonIgnore] - public ICollection? Shows { get; set; } - - /// - public Dictionary ExternalId { get; set; } = new(); - - public Collection() { } - - [JsonConstructor] - public Collection(string name) + if (name != null) { - if (name != null) - { - Slug = Utility.ToSlug(name); - Name = name; - } + Slug = Utility.ToSlug(name); + Name = name; } } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs index d8325426..75fe4746 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Episode.cs @@ -26,280 +26,274 @@ using EntityFrameworkCore.Projectables; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A class to represent a single show's episode. +/// +public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews { - /// - /// A class to represent a single show's episode. - /// - public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews + // Use absolute numbers by default and fallback to season/episodes if it does not exists. + public static Sort DefaultSort => + new Sort.Conglomerate( + new Sort.By(x => x.AbsoluteNumber), + new Sort.By(x => x.SeasonNumber), + new Sort.By(x => x.EpisodeNumber) + ); + + /// + public Guid Id { get; set; } + + /// + [Computed] + [MaxLength(256)] + public string Slug { - // Use absolute numbers by default and fallback to season/episodes if it does not exists. - public static Sort DefaultSort => - new Sort.Conglomerate( - new Sort.By(x => x.AbsoluteNumber), - new Sort.By(x => x.SeasonNumber), - new Sort.By(x => x.EpisodeNumber) - ); - - /// - public Guid Id { get; set; } - - /// - [Computed] - [MaxLength(256)] - public string Slug + get { - get - { - if (ShowSlug != null || Show?.Slug != null) - return GetSlug( - ShowSlug ?? Show!.Slug, - SeasonNumber, - EpisodeNumber, - AbsoluteNumber - ); - return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); - } - private set - { - Match match = Regex.Match(value, @"(?.+)-s(?\d+)e(?\d+)"); + if (ShowSlug != null || Show?.Slug != null) + return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); + return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); + } + private set + { + Match match = Regex.Match(value, @"(?.+)-s(?\d+)e(?\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, @"(?.+)-(?\d+)"); if (match.Success) { ShowSlug = match.Groups["show"].Value; - SeasonNumber = int.Parse(match.Groups["season"].Value); - EpisodeNumber = int.Parse(match.Groups["episode"].Value); + AbsoluteNumber = int.Parse(match.Groups["absolute"].Value); } else - { - match = Regex.Match(value, @"(?.+)-(?\d+)"); - if (match.Success) - { - ShowSlug = match.Groups["show"].Value; - AbsoluteNumber = int.Parse(match.Groups["absolute"].Value); - } - else - ShowSlug = value; - SeasonNumber = null; - EpisodeNumber = null; - } + ShowSlug = value; + SeasonNumber = null; + EpisodeNumber = null; } } + } - /// - /// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. - /// - [JsonIgnore] - public string? ShowSlug { private get; set; } + /// + /// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. + /// + [JsonIgnore] + public string? ShowSlug { private get; set; } - /// - /// The ID of the Show containing this episode. - /// - public Guid ShowId { get; set; } + /// + /// The ID of the Show containing this episode. + /// + public Guid ShowId { get; set; } - /// - /// The show that contains this episode. - /// - [LoadableRelation(nameof(ShowId))] - public Show? Show { get; set; } + /// + /// The show that contains this episode. + /// + [LoadableRelation(nameof(ShowId))] + public Show? Show { get; set; } - /// - /// The ID of the Season containing this episode. - /// - public Guid? SeasonId { get; set; } + /// + /// The ID of the Season containing this episode. + /// + public Guid? SeasonId { get; set; } - /// - /// The season that contains this episode. - /// - /// - /// This can be null if the season is unknown and the episode is only identified - /// by it's . - /// - [LoadableRelation(nameof(SeasonId))] - public Season? Season { get; set; } + /// + /// The season that contains this episode. + /// + /// + /// This can be null if the season is unknown and the episode is only identified + /// by it's . + /// + [LoadableRelation(nameof(SeasonId))] + public Season? Season { get; set; } - /// - /// The season in witch this episode is in. - /// - public int? SeasonNumber { get; set; } + /// + /// The season in witch this episode is in. + /// + public int? SeasonNumber { get; set; } - /// - /// The number of this episode in it's season. - /// - public int? EpisodeNumber { get; set; } + /// + /// The number of this episode in it's season. + /// + public int? EpisodeNumber { get; set; } - /// - /// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. - /// - public int? AbsoluteNumber { get; set; } + /// + /// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. + /// + public int? AbsoluteNumber { get; set; } - /// - /// The path of the video file for this episode. - /// - public string Path { get; set; } + /// + /// The path of the video file for this episode. + /// + public string Path { get; set; } - /// - /// The title of this episode. - /// - public string? Name { get; set; } + /// + /// The title of this episode. + /// + public string? Name { get; set; } - /// - /// The overview of this episode. - /// - public string? Overview { get; set; } + /// + /// The overview of this episode. + /// + public string? Overview { get; set; } - /// - /// How long is this episode? (in minutes) - /// - public int? Runtime { get; set; } + /// + /// How long is this episode? (in minutes) + /// + public int? Runtime { get; set; } - /// - /// The release date of this episode. It can be null if unknown. - /// - public DateTime? ReleaseDate { get; set; } + /// + /// The release date of this episode. It can be null if unknown. + /// + public DateTime? ReleaseDate { get; set; } - /// - public DateTime AddedDate { get; set; } + /// + public DateTime AddedDate { get; set; } - /// - public Image? Poster { get; set; } + /// + public Image? Poster { get; set; } - /// - public Image? Thumbnail { get; set; } + /// + public Image? Thumbnail { get; set; } - /// - public Image? Logo { get; set; } + /// + public Image? Logo { get; set; } - /// - public Dictionary ExternalId { get; set; } = new(); + /// + public Dictionary ExternalId { get; set; } = new(); - /// - /// The previous episode that should be seen before viewing this one. - /// - [Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] - [LoadableRelation( - // language=PostgreSQL - Sql = """ - select - pe.* -- Episode as pe - from - episodes as "pe" - where - pe.show_id = "this".show_id - and (pe.absolute_number < "this".absolute_number - or pe.season_number < "this".season_number - or (pe.season_number = "this".season_number - and e.episode_number < "this".episode_number)) - order by - pe.absolute_number desc nulls last, - pe.season_number desc, - pe.episode_number desc - limit 1 - """ - )] - public Episode? PreviousEpisode { get; set; } + /// + /// The previous episode that should be seen before viewing this one. + /// + [Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + pe.* -- Episode as pe + from + episodes as "pe" + where + pe.show_id = "this".show_id + and (pe.absolute_number < "this".absolute_number + or pe.season_number < "this".season_number + or (pe.season_number = "this".season_number + and e.episode_number < "this".episode_number)) + order by + pe.absolute_number desc nulls last, + pe.season_number desc, + pe.episode_number desc + limit 1 + """ + )] + public Episode? PreviousEpisode { get; set; } - private Episode? _PreviousEpisode => - Show! - .Episodes!.OrderBy(x => x.AbsoluteNumber == null) - .ThenByDescending(x => x.AbsoluteNumber) - .ThenByDescending(x => x.SeasonNumber) - .ThenByDescending(x => x.EpisodeNumber) - .FirstOrDefault(x => - x.AbsoluteNumber < AbsoluteNumber - || x.SeasonNumber < SeasonNumber - || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) - ); + private Episode? _PreviousEpisode => + Show! + .Episodes!.OrderBy(x => x.AbsoluteNumber == null) + .ThenByDescending(x => x.AbsoluteNumber) + .ThenByDescending(x => x.SeasonNumber) + .ThenByDescending(x => x.EpisodeNumber) + .FirstOrDefault(x => + x.AbsoluteNumber < AbsoluteNumber + || x.SeasonNumber < SeasonNumber + || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) + ); - /// - /// The next episode to watch after this one. - /// - [Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] - [LoadableRelation( - // language=PostgreSQL - Sql = """ - select - ne.* -- Episode as ne - from - episodes as "ne" - where - ne.show_id = "this".show_id - and (ne.absolute_number > "this".absolute_number - or ne.season_number > "this".season_number - or (ne.season_number = "this".season_number - and e.episode_number > "this".episode_number)) - order by - ne.absolute_number, - ne.season_number, - ne.episode_number - limit 1 - """ - )] - public Episode? NextEpisode { get; set; } + /// + /// The next episode to watch after this one. + /// + [Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + ne.* -- Episode as ne + from + episodes as "ne" + where + ne.show_id = "this".show_id + and (ne.absolute_number > "this".absolute_number + or ne.season_number > "this".season_number + or (ne.season_number = "this".season_number + and e.episode_number > "this".episode_number)) + order by + ne.absolute_number, + ne.season_number, + ne.episode_number + limit 1 + """ + )] + public Episode? NextEpisode { get; set; } - private Episode? _NextEpisode => - Show! - .Episodes!.OrderBy(x => x.AbsoluteNumber) - .ThenBy(x => x.SeasonNumber) - .ThenBy(x => x.EpisodeNumber) - .FirstOrDefault(x => - x.AbsoluteNumber > AbsoluteNumber - || x.SeasonNumber > SeasonNumber - || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) - ); + private Episode? _NextEpisode => + Show! + .Episodes!.OrderBy(x => x.AbsoluteNumber) + .ThenBy(x => x.SeasonNumber) + .ThenBy(x => x.EpisodeNumber) + .FirstOrDefault(x => + x.AbsoluteNumber > AbsoluteNumber + || x.SeasonNumber > SeasonNumber + || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) + ); - [JsonIgnore] - public ICollection? Watched { get; set; } + [JsonIgnore] + public ICollection? Watched { get; set; } - /// - /// Metadata of what an user as started/planned to watch. - /// - [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] - [LoadableRelation( - Sql = "episode_watch_status", - On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" - )] - public EpisodeWatchStatus? WatchStatus { get; set; } + /// + /// Metadata of what an user as started/planned to watch. + /// + [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] + [LoadableRelation( + Sql = "episode_watch_status", + On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" + )] + public EpisodeWatchStatus? WatchStatus { get; set; } - // There is a global query filter to filter by user so we just need to do single. - private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); + // There is a global query filter to filter by user so we just need to do single. + private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); - /// - /// Links to watch this episode. - /// - public VideoLinks Links => - new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; + /// + /// Links to watch this episode. + /// + public VideoLinks Links => + new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; - /// - /// Get the slug of an episode. - /// - /// The slug of the show. It can't be null. - /// - /// 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. - /// - /// - /// 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. - /// - /// - /// The absolute number of this show. - /// If you don't know it or this is a movie, use null - /// - /// The slug corresponding to the given arguments - public static string GetSlug( - string showSlug, - int? seasonNumber, - int? episodeNumber, - int? absoluteNumber = null - ) + /// + /// Get the slug of an episode. + /// + /// The slug of the show. It can't be null. + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// The absolute number of this show. + /// If you don't know it or this is a movie, use null + /// + /// The slug corresponding to the given arguments + public static string GetSlug( + string showSlug, + int? seasonNumber, + int? episodeNumber, + int? absoluteNumber = null + ) + { + return seasonNumber switch { - return seasonNumber switch - { - null when absoluteNumber == null => showSlug, - null => $"{showSlug}-{absoluteNumber}", - _ => $"{showSlug}-s{seasonNumber}e{episodeNumber}" - }; - } + null when absoluteNumber == null => showSlug, + null => $"{showSlug}-{absoluteNumber}", + _ => $"{showSlug}-s{seasonNumber}e{episodeNumber}" + }; } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs index be2af353..8b64b613 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IAddedDate.cs @@ -18,16 +18,15 @@ using System; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// An interface applied to resources. +/// +public interface IAddedDate { /// - /// An interface applied to resources. + /// The date at which this resource was added to kyoo. /// - public interface IAddedDate - { - /// - /// The date at which this resource was added to kyoo. - /// - public DateTime AddedDate { get; set; } - } + public DateTime AddedDate { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs index 9cfb2595..db840cae 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IMetadata.cs @@ -18,16 +18,15 @@ using System.Collections.Generic; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// An interface applied to resources containing external metadata. +/// +public interface IMetadata { /// - /// An interface applied to resources containing external metadata. + /// The link to metadata providers that this show has. See for more information. /// - public interface IMetadata - { - /// - /// The link to metadata providers that this show has. See for more information. - /// - public Dictionary ExternalId { get; set; } - } + public Dictionary ExternalId { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs index 2356965e..87796456 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IResource.cs @@ -20,31 +20,30 @@ using System; using System.ComponentModel.DataAnnotations; using Kyoo.Abstractions.Controllers; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// An interface to represent a resource that can be retrieved from the database. +/// +public interface IResource : IQuery { /// - /// 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. /// - public interface IResource : IQuery - { - /// - /// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. - /// - /// - /// You don't need to specify an ID manually when creating a new resource, - /// this field is automatically assigned by the . - /// - public Guid Id { get; set; } + /// + /// You don't need to specify an ID manually when creating a new resource, + /// this field is automatically assigned by the . + /// + public Guid Id { get; set; } - /// - /// 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. - /// - /// - /// There is no setter for a slug since it can be computed from other fields. - /// For example, a season slug is {ShowSlug}-s{SeasonNumber}. - /// - [MaxLength(256)] - public string Slug { get; } - } + /// + /// 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. + /// + /// + /// There is no setter for a slug since it can be computed from other fields. + /// For example, a season slug is {ShowSlug}-s{SeasonNumber}. + /// + [MaxLength(256)] + public string Slug { get; } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs index aed090b1..459f02a0 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Interfaces/IThumbnails.cs @@ -23,105 +23,101 @@ using System.Globalization; using System.Text.Json.Serialization; using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// An interface representing items that contains images (like posters, thumbnails, logo, banners...) +/// +public interface IThumbnails { /// - /// 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. /// - public interface IThumbnails - { - /// - /// A poster is a 2/3 format image with the cover of the resource. - /// - public Image? Poster { get; set; } - - /// - /// 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. - /// - public Image? Thumbnail { get; set; } - - /// - /// A logo is a small image representing the resource. - /// - public Image? Logo { get; set; } - } - - [TypeConverter(typeof(ImageConvertor))] - [SqlFirstColumn(nameof(Source))] - public class Image - { - /// - /// The original image from another server. - /// - public string Source { get; set; } - - /// - /// A hash to display as placeholder while the image is loading. - /// - [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 - { - /// - public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) - { - if (sourceType == typeof(string)) - return true; - return base.CanConvertFrom(context, sourceType); - } - - /// - 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); - } - - /// - public override bool CanConvertTo( - ITypeDescriptorContext? context, - Type? destinationType - ) - { - return false; - } - } - } + public Image? Poster { get; set; } /// - /// 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. /// - public enum ImageQuality + public Image? Thumbnail { get; set; } + + /// + /// A logo is a small image representing the resource. + /// + public Image? Logo { get; set; } +} + +[TypeConverter(typeof(ImageConvertor))] +[SqlFirstColumn(nameof(Source))] +public class Image +{ + /// + /// The original image from another server. + /// + public string Source { get; set; } + + /// + /// A hash to display as placeholder while the image is loading. + /// + [MaxLength(32)] + public string Blurhash { get; set; } + + public Image() { } + + [JsonConstructor] + public Image(string source, string? blurhash = null) { - /// - /// Small - /// - Low, + Source = source; + Blurhash = blurhash ?? "000000"; + } - /// - /// Medium - /// - Medium, + public class ImageConvertor : TypeConverter + { + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + if (sourceType == typeof(string)) + return true; + return base.CanConvertFrom(context, sourceType); + } - /// - /// Large - /// - High, + /// + 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); + } + + /// + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + { + return false; + } } } + +/// +/// The quality of an image +/// +public enum ImageQuality +{ + /// + /// Small + /// + Low, + + /// + /// Medium + /// + Medium, + + /// + /// Large + /// + High, +} diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs index 257ddcb8..3223c5e8 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Movie.cs @@ -27,163 +27,162 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Attributes; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A series or a movie. +/// +public class Movie + : IQuery, + IResource, + IMetadata, + IThumbnails, + IAddedDate, + ILibraryItem, + INews, + IWatchlist { + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + /// - /// A series or a movie. + /// The title of this show. /// - public class Movie - : IQuery, - IResource, - IMetadata, - IThumbnails, - IAddedDate, - ILibraryItem, - INews, - IWatchlist + public string Name { get; set; } + + /// + /// A catchphrase for this movie. + /// + public string? Tagline { get; set; } + + /// + /// The list of alternative titles of this show. + /// + public string[] Aliases { get; set; } = Array.Empty(); + + /// + /// The path of the movie video file. + /// + public string Path { get; set; } + + /// + /// The summary of this show. + /// + public string? Overview { get; set; } + + /// + /// A list of tags that match this movie. + /// + public string[] Tags { get; set; } = Array.Empty(); + + /// + /// The list of genres (themes) this show has. + /// + public Genre[] Genres { get; set; } = Array.Empty(); + + /// + /// Is this show airing, not aired yet or finished? + /// + public Status Status { get; set; } + + /// + /// How well this item is rated? (from 0 to 100). + /// + public int Rating { get; set; } + + /// + /// How long is this movie? (in minutes) + /// + public int? Runtime { get; set; } + + /// + /// The date this movie aired. + /// + public DateTime? AirDate { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + [JsonIgnore] + [Column("air_date")] + public DateTime? StartAir => AirDate; + + [JsonIgnore] + [Column("air_date")] + public DateTime? EndAir => AirDate; + + /// + /// A video of a few minutes that tease the content. + /// + public string? Trailer { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + /// The ID of the Studio that made this show. + /// + [JsonIgnore] + public Guid? StudioId { get; set; } + + /// + /// The Studio that made this show. + /// + [LoadableRelation(nameof(StudioId))] + public Studio? Studio { get; set; } + + /// + /// The list of collections that contains this show. + /// + [JsonIgnore] + public ICollection? Collections { get; set; } + + /// + /// Links to watch this movie. + /// + public VideoLinks Links => + new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", }; + + [JsonIgnore] + public ICollection? Watched { get; set; } + + /// + /// Metadata of what an user as started/planned to watch. + /// + [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.By(x => x.Name); - - /// - public Guid Id { get; set; } - - /// - [MaxLength(256)] - public string Slug { get; set; } - - /// - /// The title of this show. - /// - public string Name { get; set; } - - /// - /// A catchphrase for this movie. - /// - public string? Tagline { get; set; } - - /// - /// The list of alternative titles of this show. - /// - public string[] Aliases { get; set; } = Array.Empty(); - - /// - /// The path of the movie video file. - /// - public string Path { get; set; } - - /// - /// The summary of this show. - /// - public string? Overview { get; set; } - - /// - /// A list of tags that match this movie. - /// - public string[] Tags { get; set; } = Array.Empty(); - - /// - /// The list of genres (themes) this show has. - /// - public Genre[] Genres { get; set; } = Array.Empty(); - - /// - /// Is this show airing, not aired yet or finished? - /// - public Status Status { get; set; } - - /// - /// How well this item is rated? (from 0 to 100). - /// - public int Rating { get; set; } - - /// - /// How long is this movie? (in minutes) - /// - public int? Runtime { get; set; } - - /// - /// The date this movie aired. - /// - public DateTime? AirDate { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - public Image? Poster { get; set; } - - /// - public Image? Thumbnail { get; set; } - - /// - public Image? Logo { get; set; } - - [JsonIgnore] - [Column("air_date")] - public DateTime? StartAir => AirDate; - - [JsonIgnore] - [Column("air_date")] - public DateTime? EndAir => AirDate; - - /// - /// A video of a few minutes that tease the content. - /// - public string? Trailer { get; set; } - - /// - public Dictionary ExternalId { get; set; } = new(); - - /// - /// The ID of the Studio that made this show. - /// - [JsonIgnore] - public Guid? StudioId { get; set; } - - /// - /// The Studio that made this show. - /// - [LoadableRelation(nameof(StudioId))] - public Studio? Studio { get; set; } - - /// - /// The list of collections that contains this show. - /// - [JsonIgnore] - public ICollection? Collections { get; set; } - - /// - /// Links to watch this movie. - /// - public VideoLinks Links => - new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", }; - - [JsonIgnore] - public ICollection? Watched { get; set; } - - /// - /// Metadata of what an user as started/planned to watch. - /// - [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) { - if (name != null) - { - Slug = Utility.ToSlug(name); - Name = name; - } + Slug = Utility.ToSlug(name); + Name = name; } } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Season.cs b/back/src/Kyoo.Abstractions/Models/Resources/Season.cs index 64c3cfe2..e88680a9 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Season.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Season.cs @@ -26,124 +26,123 @@ using EntityFrameworkCore.Projectables; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A season of a . +/// +public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate { - /// - /// A season of a . - /// - public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate + public static Sort DefaultSort => new Sort.By(x => x.SeasonNumber); + + /// + public Guid Id { get; set; } + + /// + [Computed] + [MaxLength(256)] + public string Slug { - public static Sort DefaultSort => new Sort.By(x => x.SeasonNumber); - - /// - public Guid Id { get; set; } - - /// - [Computed] - [MaxLength(256)] - public string Slug + get { - get - { - if (ShowSlug == null && Show == null) - return $"{ShowId}-s{SeasonNumber}"; - return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; - } - private set - { - Match match = Regex.Match(value, @"(?.+)-s(?\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); - } + if (ShowSlug == null && Show == null) + return $"{ShowId}-s{SeasonNumber}"; + return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; } + private set + { + Match match = Regex.Match(value, @"(?.+)-s(?\d+)"); - /// - /// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. - /// - [JsonIgnore] - public string? ShowSlug { private get; set; } - - /// - /// The ID of the Show containing this season. - /// - public Guid ShowId { get; set; } - - /// - /// The show that contains this season. - /// - [LoadableRelation(nameof(ShowId))] - public Show? Show { get; set; } - - /// - /// The number of this season. This can be set to 0 to indicate specials. - /// - public int SeasonNumber { get; set; } - - /// - /// The title of this season. - /// - public string? Name { get; set; } - - /// - /// A quick overview of this season. - /// - public string? Overview { get; set; } - - /// - /// The starting air date of this season. - /// - public DateTime? StartDate { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - /// The ending date of this season. - /// - public DateTime? EndDate { get; set; } - - /// - public Image? Poster { get; set; } - - /// - public Image? Thumbnail { get; set; } - - /// - public Image? Logo { get; set; } - - /// - public Dictionary ExternalId { get; set; } = new(); - - /// - /// The list of episodes that this season contains. - /// - [JsonIgnore] - public ICollection? Episodes { get; set; } - - /// - /// The number of episodes in this season. - /// - [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; + 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); + } } + + /// + /// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. + /// + [JsonIgnore] + public string? ShowSlug { private get; set; } + + /// + /// The ID of the Show containing this season. + /// + public Guid ShowId { get; set; } + + /// + /// The show that contains this season. + /// + [LoadableRelation(nameof(ShowId))] + public Show? Show { get; set; } + + /// + /// The number of this season. This can be set to 0 to indicate specials. + /// + public int SeasonNumber { get; set; } + + /// + /// The title of this season. + /// + public string? Name { get; set; } + + /// + /// A quick overview of this season. + /// + public string? Overview { get; set; } + + /// + /// The starting air date of this season. + /// + public DateTime? StartDate { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The ending date of this season. + /// + public DateTime? EndDate { get; set; } + + /// + public Image? Poster { get; set; } + + /// + public Image? Thumbnail { get; set; } + + /// + public Image? Logo { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + /// The list of episodes that this season contains. + /// + [JsonIgnore] + public ICollection? Episodes { get; set; } + + /// + /// The number of episodes in this season. + /// + [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; } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs index a9c8b832..6967a1e7 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Show.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Show.cs @@ -27,254 +27,253 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Attributes; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A series or a movie. +/// +public class Show + : IQuery, + IResource, + IMetadata, + IOnMerge, + IThumbnails, + IAddedDate, + ILibraryItem, + IWatchlist { + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + /// - /// A series or a movie. + /// The title of this show. /// - public class Show - : IQuery, - IResource, - IMetadata, - IOnMerge, - IThumbnails, - IAddedDate, - ILibraryItem, - IWatchlist - { - public static Sort DefaultSort => new Sort.By(x => x.Name); + public string Name { get; set; } - /// - public Guid Id { get; set; } + /// + /// A catchphrase for this show. + /// + public string? Tagline { get; set; } - /// - [MaxLength(256)] - public string Slug { get; set; } + /// + /// The list of alternative titles of this show. + /// + public List Aliases { get; set; } = new(); - /// - /// The title of this show. - /// - public string Name { get; set; } + /// + /// The summary of this show. + /// + public string? Overview { get; set; } - /// - /// A catchphrase for this show. - /// - public string? Tagline { get; set; } + /// + /// A list of tags that match this movie. + /// + public List Tags { get; set; } = new(); - /// - /// The list of alternative titles of this show. - /// - public List Aliases { get; set; } = new(); + /// + /// The list of genres (themes) this show has. + /// + public List Genres { get; set; } = new(); - /// - /// The summary of this show. - /// - public string? Overview { get; set; } + /// + /// Is this show airing, not aired yet or finished? + /// + public Status Status { get; set; } - /// - /// A list of tags that match this movie. - /// - public List Tags { get; set; } = new(); + /// + /// How well this item is rated? (from 0 to 100). + /// + public int Rating { get; set; } - /// - /// The list of genres (themes) this show has. - /// - public List Genres { get; set; } = new(); + /// + /// The date this show started airing. It can be null if this is unknown. + /// + public DateTime? StartAir { get; set; } - /// - /// Is this show airing, not aired yet or finished? - /// - public Status Status { get; set; } + /// + /// The date this show finished airing. + /// It can also be null if this is unknown. + /// + public DateTime? EndAir { get; set; } - /// - /// How well this item is rated? (from 0 to 100). - /// - public int Rating { get; set; } + /// + public DateTime AddedDate { get; set; } - /// - /// The date this show started airing. It can be null if this is unknown. - /// - public DateTime? StartAir { get; set; } + /// + public Image? Poster { get; set; } - /// - /// The date this show finished airing. - /// It can also be null if this is unknown. - /// - public DateTime? EndAir { get; set; } + /// + public Image? Thumbnail { get; set; } - /// - public DateTime AddedDate { get; set; } + /// + public Image? Logo { get; set; } - /// - public Image? Poster { get; set; } + /// + /// A video of a few minutes that tease the content. + /// + public string? Trailer { get; set; } - /// - public Image? Thumbnail { get; set; } + [JsonIgnore] + [Column("start_air")] + public DateTime? AirDate => StartAir; - /// - public Image? Logo { get; set; } + /// + public Dictionary ExternalId { get; set; } = new(); - /// - /// A video of a few minutes that tease the content. - /// - public string? Trailer { get; set; } + /// + /// The ID of the Studio that made this show. + /// + public Guid? StudioId { get; set; } - [JsonIgnore] - [Column("start_air")] - public DateTime? AirDate => StartAir; + /// + /// The Studio that made this show. + /// + [LoadableRelation(nameof(StudioId))] + public Studio? Studio { get; set; } - /// - public Dictionary ExternalId { get; set; } = new(); + /// + /// The different seasons in this show. If this is a movie, this list is always null or empty. + /// + [JsonIgnore] + public ICollection? Seasons { get; set; } - /// - /// The ID of the Studio that made this show. - /// - public Guid? StudioId { get; set; } + /// + /// The list of episodes in this show. + /// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). + /// Having an episode is necessary to store metadata and tracks. + /// + [JsonIgnore] + public ICollection? Episodes { get; set; } - /// - /// The Studio that made this show. - /// - [LoadableRelation(nameof(StudioId))] - public Studio? Studio { get; set; } + /// + /// The list of collections that contains this show. + /// + [JsonIgnore] + public ICollection? Collections { get; set; } - /// - /// The different seasons in this show. If this is a movie, this list is always null or empty. - /// - [JsonIgnore] - public ICollection? Seasons { get; set; } - - /// - /// The list of episodes in this show. - /// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). - /// Having an episode is necessary to store metadata and tracks. - /// - [JsonIgnore] - public ICollection? Episodes { get; set; } - - /// - /// The list of collections that contains this show. - /// - [JsonIgnore] - public ICollection? Collections { get; set; } - - /// - /// The first episode of this show. - /// - [Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)] - [LoadableRelation( - // language=PostgreSQL - Sql = """ + /// + /// The first episode of this show. + /// + [Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)] + [LoadableRelation( + // language=PostgreSQL + Sql = """ + select + fe.* -- Episode as fe + from ( select - fe.* -- Episode as fe - from ( - select - e.*, - row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number - from - episodes as e) as "fe" + e.*, + row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number + from + episodes as e) as "fe" + where + fe.number <= 1 + """, + 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(); + + /// + /// The number of episodes in this show. + /// + [Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)] + [NotMapped] + [LoadableRelation( + // language=PostgreSQL + Projected = """ + ( + select + count(*)::int + from + episodes as e where - fe.number <= 1 - """, - On = "show_id = \"this\".id" - )] - public Episode? FirstEpisode { get; set; } + e.show_id = "this".id) as episodes_count + """ + )] + public int EpisodesCount { get; set; } - private Episode? _FirstEpisode => - Episodes! - .OrderBy(x => x.AbsoluteNumber) - .ThenBy(x => x.SeasonNumber) - .ThenBy(x => x.EpisodeNumber) - .FirstOrDefault(); + private int _EpisodesCount => Episodes!.Count; - /// - /// The number of episodes in this show. - /// - [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; } + [JsonIgnore] + public ICollection? Watched { get; set; } - private int _EpisodesCount => Episodes!.Count; + /// + /// Metadata of what an user as started/planned to watch. + /// + [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] - public ICollection? Watched { get; set; } + // There is a global query filter to filter by user so we just need to do single. + private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); - /// - /// Metadata of what an user as started/planned to watch. - /// - [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; } - - // There is a global query filter to filter by user so we just need to do single. - private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); - - /// - public void OnMerge(object merged) + /// + public void OnMerge(object merged) + { + if (Seasons != null) { - if (Seasons != null) - { - foreach (Season season in Seasons) - season.Show = this; - } - - if (Episodes != null) - { - foreach (Episode episode in Episodes) - episode.Show = this; - } + foreach (Season season in Seasons) + season.Show = this; } - public Show() { } - - [JsonConstructor] - public Show(string name) + if (Episodes != null) { - if (name != null) - { - Slug = Utility.ToSlug(name); - Name = name; - } + foreach (Episode episode in Episodes) + episode.Show = this; } } - /// - /// The enum containing show's status. - /// - public enum Status + public Show() { } + + [JsonConstructor] + public Show(string name) { - /// - /// The status of the show is not known. - /// - Unknown, - - /// - /// The show has finished airing. - /// - Finished, - - /// - /// The show is still actively airing. - /// - Airing, - - /// - /// This show has not aired yet but has been announced. - /// - Planned + if (name != null) + { + Slug = Utility.ToSlug(name); + Name = name; + } } } + +/// +/// The enum containing show's status. +/// +public enum Status +{ + /// + /// The status of the show is not known. + /// + Unknown, + + /// + /// The show has finished airing. + /// + Finished, + + /// + /// The show is still actively airing. + /// + Airing, + + /// + /// This show has not aired yet but has been announced. + /// + Planned +} diff --git a/back/src/Kyoo.Abstractions/Models/Resources/Studio.cs b/back/src/Kyoo.Abstractions/Models/Resources/Studio.cs index 4f9f69f2..9b6a5575 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/Studio.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/Studio.cs @@ -23,59 +23,58 @@ using System.Text.Json.Serialization; using Kyoo.Abstractions.Controllers; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A studio that make shows. +/// +public class Studio : IQuery, IResource, IMetadata { + public static Sort DefaultSort => new Sort.By(x => x.Name); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + /// - /// A studio that make shows. + /// The name of this studio. /// - public class Studio : IQuery, IResource, IMetadata + public string Name { get; set; } + + /// + /// The list of shows that are made by this studio. + /// + [JsonIgnore] + public ICollection? Shows { get; set; } + + /// + /// The list of movies that are made by this studio. + /// + [JsonIgnore] + public ICollection? Movies { get; set; } + + /// + public Dictionary ExternalId { get; set; } = new(); + + /// + /// Create a new, empty, . + /// + public Studio() { } + + /// + /// Create a new with a specific name, the slug is calculated automatically. + /// + /// The name of the studio. + [JsonConstructor] + public Studio(string name) { - public static Sort DefaultSort => new Sort.By(x => x.Name); - - /// - public Guid Id { get; set; } - - /// - [MaxLength(256)] - public string Slug { get; set; } - - /// - /// The name of this studio. - /// - public string Name { get; set; } - - /// - /// The list of shows that are made by this studio. - /// - [JsonIgnore] - public ICollection? Shows { get; set; } - - /// - /// The list of movies that are made by this studio. - /// - [JsonIgnore] - public ICollection? Movies { get; set; } - - /// - public Dictionary ExternalId { get; set; } = new(); - - /// - /// Create a new, empty, . - /// - public Studio() { } - - /// - /// Create a new with a specific name, the slug is calculated automatically. - /// - /// The name of the studio. - [JsonConstructor] - public Studio(string name) + if (name != null) { - if (name != null) - { - Slug = Utility.ToSlug(name); - Name = name; - } + Slug = Utility.ToSlug(name); + Name = name; } } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/User.cs b/back/src/Kyoo.Abstractions/Models/Resources/User.cs index 027d086d..2dfffc70 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/User.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/User.cs @@ -23,95 +23,94 @@ using System.Text.Json.Serialization; using Kyoo.Abstractions.Controllers; using Kyoo.Utils; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// A single user of the app. +/// +public class User : IQuery, IResource, IAddedDate { + public static Sort DefaultSort => new Sort.By(x => x.Username); + + /// + public Guid Id { get; set; } + + /// + [MaxLength(256)] + public string Slug { get; set; } + /// - /// A single user of the app. + /// A username displayed to the user. /// - public class User : IQuery, IResource, IAddedDate + public string Username { get; set; } + + /// + /// The user email address. + /// + public string Email { get; set; } + + /// + /// The user password (hashed, it can't be read like that). The hashing format is implementation defined. + /// + [JsonIgnore] + public string? Password { get; set; } + + /// + /// Does the user can sign-in with a password or only via oidc? + /// + public bool HasPassword => Password != null; + + /// + /// The list of permissions of the user. The format of this is implementation dependent. + /// + public string[] Permissions { get; set; } = Array.Empty(); + + /// + public DateTime AddedDate { get; set; } + + /// + /// User settings + /// + public Dictionary Settings { get; set; } = new(); + + /// + /// User accounts on other services. + /// + public Dictionary ExternalId { get; set; } = new(); + + public User() { } + + [JsonConstructor] + public User(string username) { - public static Sort DefaultSort => new Sort.By(x => x.Username); - - /// - public Guid Id { get; set; } - - /// - [MaxLength(256)] - public string Slug { get; set; } - - /// - /// A username displayed to the user. - /// - public string Username { get; set; } - - /// - /// The user email address. - /// - public string Email { get; set; } - - /// - /// The user password (hashed, it can't be read like that). The hashing format is implementation defined. - /// - [JsonIgnore] - public string? Password { get; set; } - - /// - /// Does the user can sign-in with a password or only via oidc? - /// - public bool HasPassword => Password != null; - - /// - /// The list of permissions of the user. The format of this is implementation dependent. - /// - public string[] Permissions { get; set; } = Array.Empty(); - - /// - public DateTime AddedDate { get; set; } - - /// - /// User settings - /// - public Dictionary Settings { get; set; } = new(); - - /// - /// User accounts on other services. - /// - public Dictionary ExternalId { get; set; } = new(); - - public User() { } - - [JsonConstructor] - public User(string username) + if (username != null) { - if (username != null) - { - Slug = Utility.ToSlug(username); - Username = username; - } + Slug = Utility.ToSlug(username); + Username = username; } } - - public class ExternalToken - { - /// - /// The id of this user on the external service. - /// - public string Id { get; set; } - - /// - /// The username on the external service. - /// - public string Username { get; set; } - - /// - /// The link to the user profile on this website. Null if it does not exist. - /// - public string? ProfileUrl { get; set; } - - /// - /// A jwt token used to interact with the service. - /// Do not forget to refresh it when using it if necessary. - /// - public JwtToken Token { get; set; } - } +} + +public class ExternalToken +{ + /// + /// The id of this user on the external service. + /// + public string Id { get; set; } + + /// + /// The username on the external service. + /// + public string Username { get; set; } + + /// + /// The link to the user profile on this website. Null if it does not exist. + /// + public string? ProfileUrl { get; set; } + + /// + /// A jwt token used to interact with the service. + /// Do not forget to refresh it when using it if necessary. + /// + public JwtToken Token { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs index 38b73cbd..928cd640 100644 --- a/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs +++ b/back/src/Kyoo.Abstractions/Models/Resources/WatchStatus.cs @@ -20,214 +20,213 @@ using System; using System.Text.Json.Serialization; using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// Has the user started watching, is it planned? +/// +public enum WatchStatus { + /// + /// The user has already watched this. + /// + Completed, + + /// + /// The user started watching this but has not finished. + /// + Watching, + + /// + /// The user does not plan to continue watching. + /// + Droped, + + /// + /// The user has not started watching this but plans to. + /// + Planned, +} + +/// +/// Metadata of what an user as started/planned to watch. +/// +[SqlFirstColumn(nameof(UserId))] +public class MovieWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the movie started. + /// + public Guid MovieId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Movie Movie { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + /// /// Has the user started watching, is it planned? /// - public enum WatchStatus - { - /// - /// The user has already watched this. - /// - Completed, - - /// - /// The user started watching this but has not finished. - /// - Watching, - - /// - /// The user does not plan to continue watching. - /// - Droped, - - /// - /// The user has not started watching this but plans to. - /// - Planned, - } + public WatchStatus Status { get; set; } /// - /// Metadata of what an user as started/planned to watch. + /// Where the player has stopped watching the movie (in seconds). /// - [SqlFirstColumn(nameof(UserId))] - public class MovieWatchStatus : IAddedDate - { - /// - /// The ID of the user that started watching this episode. - /// - public Guid UserId { get; set; } + /// + /// Null if the status is not Watching. + /// + public int? WatchedTime { get; set; } - /// - /// The user that started watching this episode. - /// - [JsonIgnore] - public User User { get; set; } - - /// - /// The ID of the movie started. - /// - public Guid MovieId { get; set; } - - /// - /// The started. - /// - [JsonIgnore] - public Movie Movie { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - /// The date at which this item was played. - /// - public DateTime? PlayedDate { get; set; } - - /// - /// Has the user started watching, is it planned? - /// - public WatchStatus Status { get; set; } - - /// - /// Where the player has stopped watching the movie (in seconds). - /// - /// - /// Null if the status is not Watching. - /// - public int? WatchedTime { get; set; } - - /// - /// Where the player has stopped watching the movie (in percentage between 0 and 100). - /// - /// - /// Null if the status is not Watching. - /// - public int? WatchedPercent { get; set; } - } - - [SqlFirstColumn(nameof(UserId))] - public class EpisodeWatchStatus : IAddedDate - { - /// - /// The ID of the user that started watching this episode. - /// - public Guid UserId { get; set; } - - /// - /// The user that started watching this episode. - /// - [JsonIgnore] - public User User { get; set; } - - /// - /// The ID of the episode started. - /// - public Guid? EpisodeId { get; set; } - - /// - /// The started. - /// - [JsonIgnore] - public Episode Episode { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - /// The date at which this item was played. - /// - public DateTime? PlayedDate { get; set; } - - /// - /// Has the user started watching, is it planned? - /// - public WatchStatus Status { get; set; } - - /// - /// Where the player has stopped watching the episode (in seconds). - /// - /// - /// Null if the status is not Watching. - /// - public int? WatchedTime { get; set; } - - /// - /// Where the player has stopped watching the episode (in percentage between 0 and 100). - /// - /// - /// Null if the status is not Watching or if the next episode is not started. - /// - public int? WatchedPercent { get; set; } - } - - [SqlFirstColumn(nameof(UserId))] - public class ShowWatchStatus : IAddedDate - { - /// - /// The ID of the user that started watching this episode. - /// - public Guid UserId { get; set; } - - /// - /// The user that started watching this episode. - /// - [JsonIgnore] - public User User { get; set; } - - /// - /// The ID of the show started. - /// - public Guid ShowId { get; set; } - - /// - /// The started. - /// - [JsonIgnore] - public Show Show { get; set; } - - /// - public DateTime AddedDate { get; set; } - - /// - /// The date at which this item was played. - /// - public DateTime? PlayedDate { get; set; } - - /// - /// Has the user started watching, is it planned? - /// - public WatchStatus Status { get; set; } - - /// - /// The number of episodes the user has not seen. - /// - public int UnseenEpisodesCount { get; set; } - - /// - /// The ID of the episode started. - /// - public Guid? NextEpisodeId { get; set; } - - /// - /// The next to watch. - /// - public Episode? NextEpisode { get; set; } - - /// - /// Where the player has stopped watching the episode (in seconds). - /// - /// - /// Null if the status is not Watching or if the next episode is not started. - /// - public int? WatchedTime { get; set; } - - /// - /// Where the player has stopped watching the episode (in percentage between 0 and 100). - /// - /// - /// Null if the status is not Watching or if the next episode is not started. - /// - public int? WatchedPercent { get; set; } - } + /// + /// Where the player has stopped watching the movie (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedPercent { get; set; } +} + +[SqlFirstColumn(nameof(UserId))] +public class EpisodeWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the episode started. + /// + public Guid? EpisodeId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Episode Episode { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Has the user started watching, is it planned? + /// + public WatchStatus Status { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } +} + +[SqlFirstColumn(nameof(UserId))] +public class ShowWatchStatus : IAddedDate +{ + /// + /// The ID of the user that started watching this episode. + /// + public Guid UserId { get; set; } + + /// + /// The user that started watching this episode. + /// + [JsonIgnore] + public User User { get; set; } + + /// + /// The ID of the show started. + /// + public Guid ShowId { get; set; } + + /// + /// The started. + /// + [JsonIgnore] + public Show Show { get; set; } + + /// + public DateTime AddedDate { get; set; } + + /// + /// The date at which this item was played. + /// + public DateTime? PlayedDate { get; set; } + + /// + /// Has the user started watching, is it planned? + /// + public WatchStatus Status { get; set; } + + /// + /// The number of episodes the user has not seen. + /// + public int UnseenEpisodesCount { get; set; } + + /// + /// The ID of the episode started. + /// + public Guid? NextEpisodeId { get; set; } + + /// + /// The next to watch. + /// + public Episode? NextEpisode { get; set; } + + /// + /// Where the player has stopped watching the episode (in seconds). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedTime { get; set; } + + /// + /// Where the player has stopped watching the episode (in percentage between 0 and 100). + /// + /// + /// Null if the status is not Watching or if the next episode is not started. + /// + public int? WatchedPercent { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/SearchPage.cs b/back/src/Kyoo.Abstractions/Models/SearchPage.cs index cf4d858d..8ce2043a 100644 --- a/back/src/Kyoo.Abstractions/Models/SearchPage.cs +++ b/back/src/Kyoo.Abstractions/Models/SearchPage.cs @@ -18,37 +18,36 @@ using System.Collections.Generic; -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// Results of a search request. +/// +/// The search item's type. +public class SearchPage : Page + where T : IResource { - /// - /// Results of a search request. - /// - /// The search item's type. - public class SearchPage : Page - where T : IResource + public SearchPage( + SearchResult result, + string @this, + string? previous, + string? next, + string first + ) + : base(result.Items, @this, previous, next, first) { - public SearchPage( - SearchResult result, - string @this, - string? previous, - string? next, - string first - ) - : base(result.Items, @this, previous, next, first) - { - Query = result.Query; - } + Query = result.Query; + } - /// - /// The query of the search request. - /// - public string? Query { get; init; } + /// + /// The query of the search request. + /// + public string? Query { get; init; } - public class SearchResult - { - public string? Query { get; set; } + public class SearchResult + { + public string? Query { get; set; } - public ICollection Items { get; set; } - } + public ICollection Items { get; set; } } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Claims.cs b/back/src/Kyoo.Abstractions/Models/Utils/Claims.cs index bf2256aa..c8d8c3c7 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Claims.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Claims.cs @@ -16,41 +16,40 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Authentication.Models +namespace Kyoo.Authentication.Models; + +/// +/// List of well known claims of kyoo +/// +public static class Claims { /// - /// List of well known claims of kyoo + /// The id of the user /// - public static class Claims - { - /// - /// The id of the user - /// - public static string Id => "id"; + public static string Id => "id"; - /// - /// The name of the user - /// - public static string Name => "name"; + /// + /// The name of the user + /// + public static string Name => "name"; - /// - /// The email of the user. - /// - public static string Email => "email"; + /// + /// The email of the user. + /// + public static string Email => "email"; - /// - /// The list of permissions that the user has. - /// - public static string Permissions => "permissions"; + /// + /// The list of permissions that the user has. + /// + public static string Permissions => "permissions"; - /// - /// The type of the token (either "access" or "refresh"). - /// - public static string Type => "type"; + /// + /// The type of the token (either "access" or "refresh"). + /// + public static string Type => "type"; - /// - /// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. - /// - public static string Guid => "guid"; - } + /// + /// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. + /// + public static string Guid => "guid"; } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Constants.cs b/back/src/Kyoo.Abstractions/Models/Utils/Constants.cs index 1640d779..6db78c51 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Constants.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Constants.cs @@ -18,43 +18,42 @@ using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Abstractions.Models.Utils +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// A class containing constant numbers. +/// +public static class Constants { /// - /// 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. /// - public static class Constants - { - /// - /// 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. - /// - public const int AlternativeRoute = 1; + public const int AlternativeRoute = 1; - /// - /// A group name for . It should be used for endpoints used by users. - /// - public const string UsersGroup = "0:Users"; + /// + /// A group name for . It should be used for endpoints used by users. + /// + public const string UsersGroup = "0:Users"; - /// - /// A group name for . It should be used for main resources of kyoo. - /// - public const string ResourcesGroup = "1:Resources"; + /// + /// A group name for . It should be used for main resources of kyoo. + /// + public const string ResourcesGroup = "1:Resources"; - /// - /// A group name for . - /// It should be used for sub resources of kyoo that help define the main resources. - /// - public const string MetadataGroup = "2:Metadata"; + /// + /// A group name for . + /// It should be used for sub resources of kyoo that help define the main resources. + /// + public const string MetadataGroup = "2:Metadata"; - /// - /// A group name for . It should be used for endpoints useful for playback. - /// - public const string WatchGroup = "3:Watch"; + /// + /// A group name for . It should be used for endpoints useful for playback. + /// + public const string WatchGroup = "3:Watch"; - /// - /// A group name for . It should be used for endpoints used by admins. - /// - public const string AdminGroup = "4:Admin"; - } + /// + /// A group name for . It should be used for endpoints used by admins. + /// + public const string AdminGroup = "4:Admin"; } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Identifier.cs b/back/src/Kyoo.Abstractions/Models/Utils/Identifier.cs index 5c44b7d5..f63a6afd 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Identifier.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Identifier.cs @@ -24,225 +24,222 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -namespace Kyoo.Abstractions.Models.Utils +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// 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 . +/// +[TypeConverter(typeof(IdentifierConvertor))] +public class Identifier { /// - /// 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 . + /// The ID of the resource or null if the slug is specified. /// - [TypeConverter(typeof(IdentifierConvertor))] - public class Identifier + private readonly Guid? _id; + + /// + /// The slug of the resource or null if the id is specified. + /// + private readonly string? _slug; + + /// + /// Create a new for the given id. + /// + /// The id of the resource. + public Identifier(Guid id) { - /// - /// The ID of the resource or null if the slug is specified. - /// - private readonly Guid? _id; + _id = id; + } - /// - /// The slug of the resource or null if the id is specified. - /// - private readonly string? _slug; + /// + /// Create a new for the given slug. + /// + /// The slug of the resource. + public Identifier(string slug) + { + _slug = slug; + } - /// - /// Create a new for the given id. - /// - /// The id of the resource. - public Identifier(Guid id) + /// + /// Pattern match out of the identifier to a resource. + /// + /// The function to match the ID to a type . + /// The function to match the slug to a type . + /// The return type that will be converted to from an ID or a slug. + /// + /// The result of the or depending on the pattern. + /// + /// + /// Example usage: + /// + /// T ret = await identifier.Match( + /// id => _repository.GetOrDefault(id), + /// slug => _repository.GetOrDefault(slug) + /// ); + /// + /// + public T Match(Func idFunc, Func slugFunc) + { + return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!); + } + + /// + /// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details). + /// + /// An expression to retrieve an ID from the type . + /// An expression to retrieve a slug from the type . + /// The type to match against this identifier. + /// An expression to match the type to this identifier. + /// + /// + /// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug) + /// + /// + public Filter Matcher( + Expression> idGetter, + Expression> slugGetter + ) + { + ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); + BinaryExpression equal = Expression.Equal( + _id.HasValue ? idGetter.Body : slugGetter.Body, + self + ); + ICollection parameters = _id.HasValue + ? idGetter.Parameters + : slugGetter.Parameters; + Expression> lambda = Expression.Lambda>(equal, parameters); + return new Filter.Lambda(lambda); + } + + /// + /// A matcher overload for nullable IDs. See + /// + /// for more details. + /// + /// An expression to retrieve an ID from the type . + /// An expression to retrieve a slug from the type . + /// The type to match against this identifier. + /// An expression to match the type to this identifier. + public Filter Matcher( + Expression> idGetter, + Expression> slugGetter + ) + { + ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); + BinaryExpression equal = Expression.Equal( + _id.HasValue ? idGetter.Body : slugGetter.Body, + self + ); + ICollection parameters = _id.HasValue + ? idGetter.Parameters + : slugGetter.Parameters; + Expression> lambda = Expression.Lambda>(equal, parameters); + return new Filter.Lambda(lambda); + } + + /// + /// Return true if this match a resource. + /// + /// The resource to match + /// + /// true if the match this identifier, false otherwise. + /// + public bool IsSame(IResource resource) + { + return Match(id => resource.Id == id, slug => resource.Slug == slug); + } + + /// + /// Return a filter to get this match a given resource. + /// + /// The type of resource to match against. + /// + /// true if the given resource match this identifier, false otherwise. + /// + public Filter IsSame() + where T : IResource + { + return _id.HasValue ? new Filter.Eq("Id", _id.Value) : new Filter.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> _IsSameExpression() + where T : IResource + { + return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug; + } + + /// + /// Return an expression that return true if this is containing in a collection. + /// + /// An expression to retrieve the list to check. + /// The type that contain the list to check. + /// The type of resource to check this identifier against. + /// An expression to check if this is contained. + public Filter IsContainedIn(Expression?>> 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() + ); + Expression> lambda = Expression.Lambda>( + call, + listGetter.Parameters + ); + return new Filter.Lambda(lambda); + } + + /// + public override string ToString() + { + return _id.HasValue ? _id.Value.ToString() : _slug!; + } + + /// + /// A custom used to convert int or strings to an . + /// + public class IdentifierConvertor : TypeConverter + { + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) { - _id = id; - } - - /// - /// Create a new for the given slug. - /// - /// The slug of the resource. - public Identifier(string slug) - { - _slug = slug; - } - - /// - /// Pattern match out of the identifier to a resource. - /// - /// The function to match the ID to a type . - /// The function to match the slug to a type . - /// The return type that will be converted to from an ID or a slug. - /// - /// The result of the or depending on the pattern. - /// - /// - /// Example usage: - /// - /// T ret = await identifier.Match( - /// id => _repository.GetOrDefault(id), - /// slug => _repository.GetOrDefault(slug) - /// ); - /// - /// - public T Match(Func idFunc, Func slugFunc) - { - return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!); - } - - /// - /// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details). - /// - /// An expression to retrieve an ID from the type . - /// An expression to retrieve a slug from the type . - /// The type to match against this identifier. - /// An expression to match the type to this identifier. - /// - /// - /// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug) - /// - /// - public Filter Matcher( - Expression> idGetter, - Expression> slugGetter - ) - { - ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); - BinaryExpression equal = Expression.Equal( - _id.HasValue ? idGetter.Body : slugGetter.Body, - self - ); - ICollection parameters = _id.HasValue - ? idGetter.Parameters - : slugGetter.Parameters; - Expression> lambda = Expression.Lambda>(equal, parameters); - return new Filter.Lambda(lambda); - } - - /// - /// A matcher overload for nullable IDs. See - /// - /// for more details. - /// - /// An expression to retrieve an ID from the type . - /// An expression to retrieve a slug from the type . - /// The type to match against this identifier. - /// An expression to match the type to this identifier. - public Filter Matcher( - Expression> idGetter, - Expression> slugGetter - ) - { - ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); - BinaryExpression equal = Expression.Equal( - _id.HasValue ? idGetter.Body : slugGetter.Body, - self - ); - ICollection parameters = _id.HasValue - ? idGetter.Parameters - : slugGetter.Parameters; - Expression> lambda = Expression.Lambda>(equal, parameters); - return new Filter.Lambda(lambda); - } - - /// - /// Return true if this match a resource. - /// - /// The resource to match - /// - /// true if the match this identifier, false otherwise. - /// - public bool IsSame(IResource resource) - { - return Match(id => resource.Id == id, slug => resource.Slug == slug); - } - - /// - /// Return a filter to get this match a given resource. - /// - /// The type of resource to match against. - /// - /// true if the given resource match this identifier, false otherwise. - /// - public Filter IsSame() - where T : IResource - { - return _id.HasValue - ? new Filter.Eq("Id", _id.Value) - : new Filter.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> _IsSameExpression() - where T : IResource - { - return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug; - } - - /// - /// Return an expression that return true if this is containing in a collection. - /// - /// An expression to retrieve the list to check. - /// The type that contain the list to check. - /// The type of resource to check this identifier against. - /// An expression to check if this is contained. - public Filter IsContainedIn(Expression?>> 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() - ); - Expression> lambda = Expression.Lambda>( - call, - listGetter.Parameters - ); - return new Filter.Lambda(lambda); + if (sourceType == typeof(Guid) || sourceType == typeof(string)) + return true; + return base.CanConvertFrom(context, sourceType); } /// - public override string ToString() + public override object ConvertFrom( + ITypeDescriptorContext? context, + CultureInfo? culture, + object value + ) { - return _id.HasValue ? _id.Value.ToString() : _slug!; - } - - /// - /// A custom used to convert int or strings to an . - /// - public class IdentifierConvertor : TypeConverter - { - /// - public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) - { - if (sourceType == typeof(Guid) || sourceType == typeof(string)) - return true; - return base.CanConvertFrom(context, sourceType); - } - - /// - 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); - } + 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); } } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs b/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs index 67d5e463..66cfe74f 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs @@ -18,56 +18,55 @@ using System; -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// Information about the pagination. How many items should be displayed and where to start. +/// +public class Pagination { /// - /// Information about the pagination. How many items should be displayed and where to start. + /// The count of items to return. /// - public class Pagination + public int Limit { get; set; } + + /// + /// Where to start? Using the given sort. + /// + public Guid? AfterID { get; set; } + + /// + /// Should the previous page be returned instead of the next? + /// + public bool Reverse { get; set; } + + /// + /// Create a new with default values. + /// + public Pagination() { - /// - /// The count of items to return. - /// - public int Limit { get; set; } - - /// - /// Where to start? Using the given sort. - /// - public Guid? AfterID { get; set; } - - /// - /// Should the previous page be returned instead of the next? - /// - public bool Reverse { get; set; } - - /// - /// Create a new with default values. - /// - public Pagination() - { - Limit = 50; - AfterID = null; - Reverse = false; - } - - /// - /// Create a new instance. - /// - /// Set the value - /// Set the value. If not specified, it will start from the start - /// Should the previous page be returned instead of the next? - public Pagination(int count, Guid? afterID = null, bool reverse = false) - { - Limit = count; - AfterID = afterID; - Reverse = reverse; - } - - /// - /// Implicitly create a new pagination from a limit number. - /// - /// Set the value - /// A new instance - public static implicit operator Pagination(int limit) => new(limit); + Limit = 50; + AfterID = null; + Reverse = false; } + + /// + /// Create a new instance. + /// + /// Set the value + /// Set the value. If not specified, it will start from the start + /// Should the previous page be returned instead of the next? + public Pagination(int count, Guid? afterID = null, bool reverse = false) + { + Limit = count; + AfterID = afterID; + Reverse = reverse; + } + + /// + /// Implicitly create a new pagination from a limit number. + /// + /// Set the value + /// A new instance + public static implicit operator Pagination(int limit) => new(limit); } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/RequestError.cs b/back/src/Kyoo.Abstractions/Models/Utils/RequestError.cs index 7c512237..f3ea8205 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/RequestError.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/RequestError.cs @@ -19,42 +19,38 @@ using System; using System.Linq; -namespace Kyoo.Abstractions.Models.Utils +namespace Kyoo.Abstractions.Models.Utils; + +/// +/// The list of errors that where made in the request. +/// +public class RequestError { /// /// The list of errors that where made in the request. /// - public class RequestError + /// ["InvalidFilter: no field 'startYear' on a collection"] + public string[] Errors { get; set; } + + /// + /// Create a new with one error. + /// + /// The error to specify in the response. + public RequestError(string error) { - /// - /// The list of errors that where made in the request. - /// - /// ["InvalidFilter: no field 'startYear' on a collection"] - public string[] Errors { get; set; } + if (error == null) + throw new ArgumentNullException(nameof(error)); + Errors = new[] { error }; + } - /// - /// Create a new with one error. - /// - /// The error to specify in the response. - public RequestError(string error) - { - if (error == null) - throw new ArgumentNullException(nameof(error)); - Errors = new[] { error }; - } - - /// - /// Create a new with multiple errors. - /// - /// The errors to specify in the response. - public RequestError(string[] errors) - { - if (errors == null || !errors.Any()) - throw new ArgumentException( - "Errors must be non null and not empty", - nameof(errors) - ); - Errors = errors; - } + /// + /// Create a new with multiple errors. + /// + /// The errors to specify in the response. + public RequestError(string[] errors) + { + if (errors == null || !errors.Any()) + throw new ArgumentException("Errors must be non null and not empty", nameof(errors)); + Errors = errors; } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs b/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs index 173d4eee..30002988 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs @@ -16,21 +16,20 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Abstractions.Controllers +namespace Kyoo.Abstractions.Controllers; + +/// +/// Information about the pagination. How many items should be displayed and where to start. +/// +public class SearchPagination { /// - /// Information about the pagination. How many items should be displayed and where to start. + /// The count of items to return. /// - public class SearchPagination - { - /// - /// The count of items to return. - /// - public int Limit { get; set; } = 50; + public int Limit { get; set; } = 50; - /// - /// Where to start? How many items to skip? - /// - public int? Skip { get; set; } - } + /// + /// Where to start? How many items to skip? + /// + public int? Skip { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs b/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs index 951c0f98..ab8a5a6c 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs @@ -25,111 +25,109 @@ using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Attributes; using Kyoo.Utils; -namespace Kyoo.Abstractions.Controllers -{ - public record Sort; +namespace Kyoo.Abstractions.Controllers; +public record Sort; + +/// +/// Information about how a query should be sorted. What factor should decide the sort and in which order. +/// +/// For witch type this sort applies +public record Sort : Sort + where T : IQuery +{ /// - /// Information about how a query should be sorted. What factor should decide the sort and in which order. + /// Sort by a specific key /// - /// For witch type this sort applies - public record Sort : Sort - where T : IQuery + /// The sort keys. This members will be used to sort the results. + /// + /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. + /// + public record By(string Key, bool Desendant = false) : Sort { /// /// Sort by a specific key /// - /// The sort keys. This members will be used to sort the results. - /// + /// The sort keys. This members will be used to sort the results. + /// /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. /// - public record By(string Key, bool Desendant = false) : Sort + public By(Expression> key, bool desendant = false) + : this(Utility.GetPropertyName(key), desendant) { } + } + + /// + /// Sort by multiple keys. + /// + /// The list of keys to sort by. + public record Conglomerate(params Sort[] List) : Sort; + + /// Sort randomly items + public record Random(uint Seed) : Sort + { + public Random() + : this(0) { - /// - /// Sort by a specific key - /// - /// The sort keys. This members will be used to sort the results. - /// - /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. - /// - public By(Expression> key, bool desendant = false) - : this(Utility.GetPropertyName(key), desendant) { } - } - - /// - /// Sort by multiple keys. - /// - /// The list of keys to sort by. - public record Conglomerate(params Sort[] List) : Sort; - - /// Sort randomly items - public record Random(uint Seed) : Sort - { - public Random() - : this(0) - { - uint seed = BitConverter.ToUInt32( - BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)), - 0 - ); - Seed = seed; - } - } - - /// The default sort method for the given type. - public record Default : Sort - { - public void Deconstruct(out Sort value) - { - value = (Sort)T.DefaultSort; - } - } - - /// - /// Create a new instance from a key's name (case insensitive). - /// - /// A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key". - /// The random seed. - /// An invalid key or sort specifier as been given. - /// A for the given string - public static Sort 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()?.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); + uint seed = BitConverter.ToUInt32( + BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)), + 0 + ); + Seed = seed; } } + + /// The default sort method for the given type. + public record Default : Sort + { + public void Deconstruct(out Sort value) + { + value = (Sort)T.DefaultSort; + } + } + + /// + /// Create a new instance from a key's name (case insensitive). + /// + /// A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key". + /// The random seed. + /// An invalid key or sort specifier as been given. + /// A for the given string + public static Sort 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()?.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); + } } diff --git a/back/src/Kyoo.Abstractions/Models/VideoLinks.cs b/back/src/Kyoo.Abstractions/Models/VideoLinks.cs index b0c2cb3b..36998e96 100644 --- a/back/src/Kyoo.Abstractions/Models/VideoLinks.cs +++ b/back/src/Kyoo.Abstractions/Models/VideoLinks.cs @@ -16,21 +16,20 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Models; + +/// +/// The links to see a movie or an episode. +/// +public class VideoLinks { /// - /// The links to see a movie or an episode. + /// The direct link to the unprocessed video (pristine quality). /// - public class VideoLinks - { - /// - /// The direct link to the unprocessed video (pristine quality). - /// - public string Direct { get; set; } + public string Direct { get; set; } - /// - /// The link to an HLS master playlist containing all qualities available for this video. - /// - public string Hls { get; set; } - } + /// + /// The link to an HLS master playlist containing all qualities available for this video. + /// + public string Hls { get; set; } } diff --git a/back/src/Kyoo.Abstractions/Module.cs b/back/src/Kyoo.Abstractions/Module.cs index e3408dd8..6bdd299a 100644 --- a/back/src/Kyoo.Abstractions/Module.cs +++ b/back/src/Kyoo.Abstractions/Module.cs @@ -21,56 +21,55 @@ using Autofac.Builder; using Kyoo.Abstractions.Controllers; using Kyoo.Utils; -namespace Kyoo.Abstractions +namespace Kyoo.Abstractions; + +/// +/// A static class with helper functions to setup external modules +/// +public static class Module { /// - /// A static class with helper functions to setup external modules + /// Register a new repository to the container. /// - public static class Module + /// The container + /// The type of the repository. + /// + /// If your repository implements a special interface, please use + /// + /// The initial container. + public static IRegistrationBuilder< + T, + ConcreteReflectionActivatorData, + SingleRegistrationStyle + > RegisterRepository(this ContainerBuilder builder) + where T : IBaseRepository { - /// - /// Register a new repository to the container. - /// - /// The container - /// The type of the repository. - /// - /// If your repository implements a special interface, please use - /// - /// The initial container. - public static IRegistrationBuilder< - T, - ConcreteReflectionActivatorData, - SingleRegistrationStyle - > RegisterRepository(this ContainerBuilder builder) - where T : IBaseRepository - { - return builder - .RegisterType() - .AsSelf() - .As() - .As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!) - .InstancePerLifetimeScope(); - } + return builder + .RegisterType() + .AsSelf() + .As() + .As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!) + .InstancePerLifetimeScope(); + } - /// - /// Register a new repository with a custom mapping to the container. - /// - /// The container - /// The custom mapping you have for your repository. - /// The type of the repository. - /// - /// If your repository does not implements a special interface, please use - /// - /// The initial container. - public static IRegistrationBuilder< - T2, - ConcreteReflectionActivatorData, - SingleRegistrationStyle - > RegisterRepository(this ContainerBuilder builder) - where T : notnull - where T2 : IBaseRepository, T - { - return builder.RegisterRepository().AsSelf().As(); - } + /// + /// Register a new repository with a custom mapping to the container. + /// + /// The container + /// The custom mapping you have for your repository. + /// The type of the repository. + /// + /// If your repository does not implements a special interface, please use + /// + /// The initial container. + public static IRegistrationBuilder< + T2, + ConcreteReflectionActivatorData, + SingleRegistrationStyle + > RegisterRepository(this ContainerBuilder builder) + where T : notnull + where T2 : IBaseRepository, T + { + return builder.RegisterRepository().AsSelf().As(); } } diff --git a/back/src/Kyoo.Abstractions/Utility/EnumerableExtensions.cs b/back/src/Kyoo.Abstractions/Utility/EnumerableExtensions.cs index f0ae5ffe..4c3b72b0 100644 --- a/back/src/Kyoo.Abstractions/Utility/EnumerableExtensions.cs +++ b/back/src/Kyoo.Abstractions/Utility/EnumerableExtensions.cs @@ -19,53 +19,52 @@ using System; using System.Collections.Generic; -namespace Kyoo.Utils +namespace Kyoo.Utils; + +/// +/// A set of extensions class for enumerable. +/// +public static class EnumerableExtensions { /// - /// A set of extensions class for enumerable. + /// If the enumerable is empty, execute an action. /// - public static class EnumerableExtensions + /// The enumerable to check + /// The action to execute is the list is empty + /// The type of items inside the list + /// The iterator proxied, there is no dual iterations. + public static IEnumerable IfEmpty(this IEnumerable self, Action action) { - /// - /// If the enumerable is empty, execute an action. - /// - /// The enumerable to check - /// The action to execute is the list is empty - /// The type of items inside the list - /// The iterator proxied, there is no dual iterations. - public static IEnumerable IfEmpty(this IEnumerable self, Action action) + static IEnumerable Generator(IEnumerable self, Action action) { - static IEnumerable Generator(IEnumerable self, Action action) + using IEnumerator enumerator = self.GetEnumerator(); + + if (!enumerator.MoveNext()) { - using IEnumerator enumerator = self.GetEnumerator(); - - if (!enumerator.MoveNext()) - { - action(); - yield break; - } - - do - { - yield return enumerator.Current; - } while (enumerator.MoveNext()); + action(); + yield break; } - return Generator(self, action); + do + { + yield return enumerator.Current; + } while (enumerator.MoveNext()); } - /// - /// A foreach used as a function with a little specificity: the list can be null. - /// - /// The list to enumerate. If this is null, the function result in a no-op - /// The action to execute for each arguments - /// The type of items in the list - public static void ForEach(this IEnumerable? self, Action action) - { - if (self == null) - return; - foreach (T i in self) - action(i); - } + return Generator(self, action); + } + + /// + /// A foreach used as a function with a little specificity: the list can be null. + /// + /// The list to enumerate. If this is null, the function result in a no-op + /// The action to execute for each arguments + /// The type of items in the list + public static void ForEach(this IEnumerable? self, Action action) + { + if (self == null) + return; + foreach (T i in self) + action(i); } } diff --git a/back/src/Kyoo.Abstractions/Utility/Merger.cs b/back/src/Kyoo.Abstractions/Utility/Merger.cs index d60b2ae5..a97530ef 100644 --- a/back/src/Kyoo.Abstractions/Utility/Merger.cs +++ b/back/src/Kyoo.Abstractions/Utility/Merger.cs @@ -22,113 +22,112 @@ using System.Linq; using System.Reflection; using Kyoo.Abstractions.Models.Attributes; -namespace Kyoo.Utils +namespace Kyoo.Utils; + +/// +/// A class containing helper methods to merge objects. +/// +public static class Merger { /// - /// 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. /// - public static class Merger + /// The first dictionary to merge + /// The second dictionary to merge + /// + /// true if a new items has been added to the dictionary, false otherwise. + /// + /// The type of the keys in dictionaries + /// The type of values in the dictionaries + /// + /// A dictionary with the missing elements of + /// set to those of . + /// + public static IDictionary? CompleteDictionaries( + IDictionary? first, + IDictionary? second, + out bool hasChanged + ) { - /// - /// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept. - /// - /// The first dictionary to merge - /// The second dictionary to merge - /// - /// true if a new items has been added to the dictionary, false otherwise. - /// - /// The type of the keys in dictionaries - /// The type of values in the dictionaries - /// - /// A dictionary with the missing elements of - /// set to those of . - /// - public static IDictionary? CompleteDictionaries( - IDictionary? first, - IDictionary? second, - out bool hasChanged - ) + if (first == null) { - if (first == null) - { - 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); + hasChanged = true; return second; } - /// - /// 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 - /// - /// - /// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"} - /// - /// - /// The object to complete - /// - /// - /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. - /// - /// - /// Filter fields that will be merged - /// - /// Fields of T will be completed - /// - public static T Complete(T first, T? second, Func? where = null) - { - if (second == null) - return first; - - Type type = typeof(T); - IEnumerable 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( - 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); + 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; + } + + /// + /// 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 + /// + /// + /// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"} + /// + /// + /// The object to complete + /// + /// + /// Missing fields of first will be completed by fields of this item. If second is null, the function no-op. + /// + /// + /// Filter fields that will be merged + /// + /// Fields of T will be completed + /// + public static T Complete(T first, T? second, Func? where = null) + { + if (second == null) + return first; + + Type type = typeof(T); + IEnumerable 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( + 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; } } diff --git a/back/src/Kyoo.Abstractions/Utility/Utility.cs b/back/src/Kyoo.Abstractions/Utility/Utility.cs index 933149ae..a964d1e4 100644 --- a/back/src/Kyoo.Abstractions/Utility/Utility.cs +++ b/back/src/Kyoo.Abstractions/Utility/Utility.cs @@ -25,341 +25,338 @@ using System.Reflection; using System.Text; using System.Text.RegularExpressions; -namespace Kyoo.Utils +namespace Kyoo.Utils; + +/// +/// A set of utility functions that can be used everywhere. +/// +public static class Utility { /// - /// 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 /// - public static class Utility + /// The string to convert. + /// The string in snake case + public static string ToSnakeCase(this string name) { - /// - /// Convert a string to snake case. Stollen from - /// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs - /// - /// The string to convert. - /// The string in snake case - public static string ToSnakeCase(this string name) + StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); + UnicodeCategory? previousCategory = default; + + for (int currentIndex = 0; currentIndex < name.Length; currentIndex++) { - StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); - UnicodeCategory? previousCategory = default; - - for (int currentIndex = 0; currentIndex < name.Length; currentIndex++) + char currentChar = name[currentIndex]; + if (currentChar == '_') { - char currentChar = name[currentIndex]; - if (currentChar == '_') - { - builder.Append('_'); - previousCategory = null; - continue; - } + builder.Append('_'); + previousCategory = null; + continue; + } - UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); - switch (currentCategory) - { - case UnicodeCategory.UppercaseLetter: - case UnicodeCategory.TitlecaseLetter: - if ( - previousCategory == UnicodeCategory.SpaceSeparator - || previousCategory == UnicodeCategory.LowercaseLetter - || ( - previousCategory != UnicodeCategory.DecimalDigitNumber - && previousCategory != null - && currentIndex > 0 - && currentIndex + 1 < name.Length - && char.IsLower(name[currentIndex + 1]) - ) + UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); + switch (currentCategory) + { + case UnicodeCategory.UppercaseLetter: + case UnicodeCategory.TitlecaseLetter: + if ( + previousCategory == UnicodeCategory.SpaceSeparator + || previousCategory == UnicodeCategory.LowercaseLetter + || ( + previousCategory != UnicodeCategory.DecimalDigitNumber + && previousCategory != null + && currentIndex > 0 + && currentIndex + 1 < name.Length + && char.IsLower(name[currentIndex + 1]) ) - { - builder.Append('_'); - } + ) + { + builder.Append('_'); + } - currentChar = char.ToLowerInvariant(currentChar); - break; + currentChar = char.ToLowerInvariant(currentChar); + break; - case UnicodeCategory.LowercaseLetter: - case UnicodeCategory.DecimalDigitNumber: - if (previousCategory == UnicodeCategory.SpaceSeparator) - { - builder.Append('_'); - } - break; + case UnicodeCategory.LowercaseLetter: + case UnicodeCategory.DecimalDigitNumber: + if (previousCategory == UnicodeCategory.SpaceSeparator) + { + builder.Append('_'); + } + break; - default: - if (previousCategory != null) - { - previousCategory = UnicodeCategory.SpaceSeparator; - } - continue; - } - - builder.Append(currentChar); - previousCategory = currentCategory; + default: + if (previousCategory != null) + { + previousCategory = UnicodeCategory.SpaceSeparator; + } + continue; } - return builder.ToString(); + builder.Append(currentChar); + previousCategory = currentCategory; } - /// - /// Is the lambda expression a member (like x => x.Body). - /// - /// The expression that should be checked - /// True if the expression is a member, false otherwise - public static bool IsPropertyExpression(LambdaExpression ex) - { - return ex.Body is MemberExpression - || ( - ex.Body.NodeType == ExpressionType.Convert - && ((UnaryExpression)ex.Body).Operand is MemberExpression - ); - } + return builder.ToString(); + } - /// - /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) - /// - /// The expression - /// The name of the expression - /// If the expression is not a property, ArgumentException is thrown. - public static string GetPropertyName(LambdaExpression ex) - { - if (!IsPropertyExpression(ex)) - throw new ArgumentException($"{ex} is not a property expression."); - MemberExpression? member = + /// + /// Is the lambda expression a member (like x => x.Body). + /// + /// The expression that should be checked + /// True if the expression is a member, false otherwise + public static bool IsPropertyExpression(LambdaExpression ex) + { + return ex.Body is MemberExpression + || ( ex.Body.NodeType == ExpressionType.Convert - ? ((UnaryExpression)ex.Body).Operand as MemberExpression - : ex.Body as MemberExpression; - return member!.Member.Name; - } - - /// - /// Slugify a string (Replace spaces by -, Uniformize accents) - /// - /// The string to slugify - /// The slug version of the given string - 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; - } - - /// - /// Return every in the inheritance tree of the parameter (interfaces are not returned) - /// - /// The starting type - /// A list of types - public static IEnumerable GetInheritanceTree(this Type self) - { - for (Type? type = self; type != null; type = type.BaseType) - yield return type; - } - - /// - /// Check if inherit from a generic type . - /// - /// The type to check - /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>). - /// True if obj inherit from genericType. False otherwise - public static bool IsOfGenericType(Type type, Type genericType) - { - if (!genericType.IsGenericType) - throw new ArgumentException($"{nameof(genericType)} is not a generic type."); - - IEnumerable types = genericType.IsInterface - ? type.GetInterfaces() - : type.GetInheritanceTree(); - return types - .Prepend(type) - .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); - } - - /// - /// Get the generic definition of . - /// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string> - /// - /// The type to check - /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>). - /// The generic definition of genericType that type inherit or null if type does not implement the generic type. - /// must be a generic type - public static Type? GetGenericDefinition(Type type, Type genericType) - { - if (!genericType.IsGenericType) - throw new ArgumentException($"{nameof(genericType)} is not a generic type."); - - IEnumerable types = genericType.IsInterface - ? type.GetInterfaces() - : type.GetInheritanceTree(); - return types - .Prepend(type) - .FirstOrDefault(x => - x.IsGenericType && x.GetGenericTypeDefinition() == genericType - ); - } - - /// - /// Retrieve a method from an with the given name and respect the - /// amount of parameters and generic parameters. This works for polymorphic methods. - /// - /// - /// The type owning the method. For non static methods, this is the this. - /// - /// - /// The binding flags of the method. This allow you to specify public/private and so on. - /// - /// - /// The name of the method. - /// - /// - /// The list of generic parameters. - /// - /// - /// The list of parameters. - /// - /// No method match the given constraints. - /// The method handle of the matching method. - 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 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." + && ((UnaryExpression)ex.Body).Operand is MemberExpression ); - } + } - /// - /// Run a generic static method for a runtime . - /// - /// - /// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, - /// you could do: - /// - /// Utility.RunGenericMethod<object>( - /// typeof(Utility), - /// nameof(MergeLists), - /// enumerableType, - /// oldValue, newValue, equalityComparer) - /// - /// - /// The type that owns the method. For non static methods, the type of this. - /// The name of the method. You should use the nameof keyword. - /// The generic type to run the method with. - /// The list of arguments of the method - /// - /// The return type of the method. You can put for an unknown one. - /// - /// No method match the given constraints. - /// The return of the method you wanted to run. - /// - public static T? RunGenericMethod( - Type owner, - string methodName, - Type type, - params object[] args - ) - { - return RunGenericMethod(owner, methodName, new[] { type }, args); - } + /// + /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) + /// + /// The expression + /// The name of the expression + /// If the expression is not a property, ArgumentException is thrown. + public static string GetPropertyName(LambdaExpression ex) + { + if (!IsPropertyExpression(ex)) + throw new ArgumentException($"{ex} is not a property expression."); + MemberExpression? member = + ex.Body.NodeType == ExpressionType.Convert + ? ((UnaryExpression)ex.Body).Operand as MemberExpression + : ex.Body as MemberExpression; + return member!.Member.Name; + } - /// - /// Run a generic static method for a multiple runtime . - /// If your generic method only needs one type, see - /// - /// - /// - /// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, - /// you could do: - /// - /// Utility.RunGenericMethod<object>( - /// typeof(Utility), - /// nameof(MergeLists), - /// enumerableType, - /// oldValue, newValue, equalityComparer) - /// - /// - /// The type that owns the method. For non static methods, the type of this. - /// The name of the method. You should use the nameof keyword. - /// The list of generic types to run the method with. - /// The list of arguments of the method - /// - /// The return type of the method. You can put for an unknown one. - /// - /// No method match the given constraints. - /// The return of the method you wanted to run. - /// - public static T? RunGenericMethod( - Type owner, - string methodName, - Type[] types, - params object?[] args - ) + /// + /// Slugify a string (Replace spaces by -, Uniformize accents) + /// + /// The string to slugify + /// The slug version of the given string + public static string ToSlug(string str) + { + str = str.ToLowerInvariant(); + + string normalizedString = str.Normalize(NormalizationForm.FormD); + StringBuilder stringBuilder = new(); + foreach (char c in normalizedString) { - 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; + } + + /// + /// Return every in the inheritance tree of the parameter (interfaces are not returned) + /// + /// The starting type + /// A list of types + public static IEnumerable GetInheritanceTree(this Type self) + { + for (Type? type = self; type != null; type = type.BaseType) + yield return type; + } + + /// + /// Check if inherit from a generic type . + /// + /// The type to check + /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>). + /// True if obj inherit from genericType. False otherwise + public static bool IsOfGenericType(Type type, Type genericType) + { + if (!genericType.IsGenericType) + throw new ArgumentException($"{nameof(genericType)} is not a generic type."); + + IEnumerable types = genericType.IsInterface + ? type.GetInterfaces() + : type.GetInheritanceTree(); + return types + .Prepend(type) + .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); + } + + /// + /// Get the generic definition of . + /// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string> + /// + /// The type to check + /// The generic type to check against (Only generic types are supported like typeof(IEnumerable<>). + /// The generic definition of genericType that type inherit or null if type does not implement the generic type. + /// must be a generic type + public static Type? GetGenericDefinition(Type type, Type genericType) + { + if (!genericType.IsGenericType) + throw new ArgumentException($"{nameof(genericType)} is not a generic type."); + + IEnumerable types = genericType.IsInterface + ? type.GetInterfaces() + : type.GetInheritanceTree(); + return types + .Prepend(type) + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); + } + + /// + /// Retrieve a method from an with the given name and respect the + /// amount of parameters and generic parameters. This works for polymorphic methods. + /// + /// + /// The type owning the method. For non static methods, this is the this. + /// + /// + /// The binding flags of the method. This allow you to specify public/private and so on. + /// + /// + /// The name of the method. + /// + /// + /// The list of generic parameters. + /// + /// + /// The list of parameters. + /// + /// No method match the given constraints. + /// The method handle of the matching method. + 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( - $"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.")) - /// - /// Convert a dictionary to a query string. - /// - /// The list of query parameters. - /// A valid query string with all items in the dictionary. - public static string ToQueryString(this Dictionary query) - { - if (!query.Any()) - return string.Empty; - return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}")); - } + // TODO this won't work for Type 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." + ); + } + + /// + /// Run a generic static method for a runtime . + /// + /// + /// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The generic type to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + public static T? RunGenericMethod( + Type owner, + string methodName, + Type type, + params object[] args + ) + { + return RunGenericMethod(owner, methodName, new[] { type }, args); + } + + /// + /// Run a generic static method for a multiple runtime . + /// If your generic method only needs one type, see + /// + /// + /// + /// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, + /// you could do: + /// + /// Utility.RunGenericMethod<object>( + /// typeof(Utility), + /// nameof(MergeLists), + /// enumerableType, + /// oldValue, newValue, equalityComparer) + /// + /// + /// The type that owns the method. For non static methods, the type of this. + /// The name of the method. You should use the nameof keyword. + /// The list of generic types to run the method with. + /// The list of arguments of the method + /// + /// The return type of the method. You can put for an unknown one. + /// + /// No method match the given constraints. + /// The return of the method you wanted to run. + /// + public static T? RunGenericMethod( + 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); + } + + /// + /// Convert a dictionary to a query string. + /// + /// The list of query parameters. + /// A valid query string with all items in the dictionary. + public static string ToQueryString(this Dictionary query) + { + if (!query.Any()) + return string.Empty; + return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}")); } } diff --git a/back/src/Kyoo.Authentication/AuthenticationModule.cs b/back/src/Kyoo.Authentication/AuthenticationModule.cs index e1fe7757..f500cd42 100644 --- a/back/src/Kyoo.Authentication/AuthenticationModule.cs +++ b/back/src/Kyoo.Authentication/AuthenticationModule.cs @@ -32,158 +32,151 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; -namespace Kyoo.Authentication +namespace Kyoo.Authentication; + +/// +/// A module that enable OpenID authentication for Kyoo. +/// +/// +/// Create a new authentication module instance and use the given configuration. +/// +public class AuthenticationModule( + IConfiguration configuration, + ILogger logger +) : IPlugin { + /// + public string Name => "Authentication"; + /// - /// A module that enable OpenID authentication for Kyoo. + /// The configuration to use. /// - /// - /// Create a new authentication module instance and use the given configuration. - /// - public class AuthenticationModule( - IConfiguration configuration, - ILogger logger - ) : IPlugin + private readonly IConfiguration _configuration = configuration; + + /// + public void Configure(ContainerBuilder builder) { - /// - public string Name => "Authentication"; + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + } - /// - /// The configuration to use. - /// - private readonly IConfiguration _configuration = configuration; - - /// - public void Configure(ContainerBuilder builder) - { - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - } - - /// - public void Configure(IServiceCollection services) - { - string secret = _configuration.GetValue( - "AUTHENTICATION_SECRET", - AuthenticationOption.DefaultSecret - )!; - PermissionOption options = - new() - { - Default = _configuration - .GetValue("UNLOGGED_PERMISSIONS", "")! - .Split(',') - .Where(x => x.Length > 0) - .ToArray(), - NewUser = _configuration - .GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! - .Split(','), - RequireVerification = _configuration.GetValue( - "REQUIRE_ACCOUNT_VERIFICATION", - true - ), - PublicUrl = - _configuration.GetValue("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(), - (acc, val) => + /// + public void Configure(IServiceCollection services) + { + string secret = _configuration.GetValue( + "AUTHENTICATION_SECRET", + AuthenticationOption.DefaultSecret + )!; + PermissionOption options = + new() + { + Default = _configuration + .GetValue("UNLOGGED_PERMISSIONS", "")! + .Split(',') + .Where(x => x.Length > 0) + .ToArray(), + NewUser = _configuration + .GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! + .Split(','), + RequireVerification = _configuration.GetValue("REQUIRE_ACCOUNT_VERIFICATION", true), + PublicUrl = + _configuration.GetValue("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(), + (acc, val) => + { + if (val.Value is null) + return acc; + if (val.Key.Split("_") is not ["OIDC", string provider, string key]) { - if (val.Value is null) - 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; - } + logger.LogError("Invalid oidc config value: {Key}", val.Key); return acc; } - ), - }; - services.AddSingleton(options); - services.AddSingleton( - new AuthenticationOption() { Secret = secret, Permissions = options, } - ); + provider = provider.ToLowerInvariant(); + key = key.ToLowerInvariant(); - 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) - ) + if (!acc.ContainsKey(provider)) + acc.Add(provider, new(provider)); + switch (key) { - 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 Task.CompletedTask; + return acc; } - }; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = false, - ValidateAudience = false, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)) - }; - }); - } - - /// - public IEnumerable ConfigureSteps => - new IStartupAction[] - { - SA.New(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)) + }; + }); } + + /// + public IEnumerable ConfigureSteps => + new IStartupAction[] + { + SA.New(app => app.UseAuthentication(), SA.Authentication), + }; } diff --git a/back/src/Kyoo.Authentication/Controllers/ITokenController.cs b/back/src/Kyoo.Authentication/Controllers/ITokenController.cs index 419f325b..b0599e0e 100644 --- a/back/src/Kyoo.Authentication/Controllers/ITokenController.cs +++ b/back/src/Kyoo.Authentication/Controllers/ITokenController.cs @@ -21,34 +21,33 @@ using System.Threading.Tasks; using Kyoo.Abstractions.Models; using Microsoft.IdentityModel.Tokens; -namespace Kyoo.Authentication +namespace Kyoo.Authentication; + +/// +/// The service that controls jwt creation and validation. +/// +public interface ITokenController { /// - /// The service that controls jwt creation and validation. + /// Create a new access token for the given user. /// - public interface ITokenController - { - /// - /// Create a new access token for the given user. - /// - /// The user to create a token for. - /// When this token will expire. - /// A new, valid access token. - string CreateAccessToken(User user, out TimeSpan expireIn); + /// The user to create a token for. + /// When this token will expire. + /// A new, valid access token. + string CreateAccessToken(User user, out TimeSpan expireIn); - /// - /// Create a new refresh token for the given user. - /// - /// The user to create a token for. - /// A new, valid refresh token. - Task CreateRefreshToken(User user); + /// + /// Create a new refresh token for the given user. + /// + /// The user to create a token for. + /// A new, valid refresh token. + Task CreateRefreshToken(User user); - /// - /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. - /// - /// The refresh token to validate. - /// The given refresh token is not valid. - /// The id of the token's user. - Guid GetRefreshTokenUserID(string refreshToken); - } + /// + /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. + /// + /// The refresh token to validate. + /// The given refresh token is not valid. + /// The id of the token's user. + Guid GetRefreshTokenUserID(string refreshToken); } diff --git a/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs b/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs index 45f63a3a..5b723163 100644 --- a/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs +++ b/back/src/Kyoo.Authentication/Controllers/PermissionValidator.cs @@ -32,267 +32,253 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Primitives; -namespace Kyoo.Authentication +namespace Kyoo.Authentication; + +/// +/// A permission validator to validate permission with user Permission array +/// or the default array from the configurations if the user is not logged. +/// +public class PermissionValidator : IPermissionValidator { /// - /// A permission validator to validate permission with user Permission array - /// or the default array from the configurations if the user is not logged. + /// The permissions options to retrieve default permissions. /// - public class PermissionValidator : IPermissionValidator + private readonly PermissionOption _options; + + /// + /// Create a new factory with the given options. + /// + /// The option containing default values. + public PermissionValidator(PermissionOption options) { + _options = options; + } + + /// + public IFilterMetadata Create(PermissionAttribute attribute) + { + return new PermissionValidatorFilter( + attribute.Type, + attribute.Kind, + attribute.Group, + _options + ); + } + + /// + public IFilterMetadata Create(PartialPermissionAttribute attribute) + { + return new PermissionValidatorFilter( + ((object?)attribute.Type ?? attribute.Kind)!, + attribute.Group, + _options + ); + } + + /// + /// The authorization filter used by . + /// + private class PermissionValidatorFilter : IAsyncAuthorizationFilter + { + /// + /// The permission to validate. + /// + private readonly string? _permission; + + /// + /// The kind of permission needed. + /// + private readonly Kind? _kind; + + /// + /// The group of he permission. + /// + private Group _group; + /// /// The permissions options to retrieve default permissions. /// private readonly PermissionOption _options; /// - /// Create a new factory with the given options. + /// Create a new permission validator with the given options. /// + /// The permission to validate. + /// The kind of permission needed. + /// The group of the permission. /// The option containing default values. - public PermissionValidator(PermissionOption options) + public PermissionValidatorFilter( + string permission, + Kind kind, + Group group, + PermissionOption options + ) { + _permission = permission; + _kind = kind; + _group = group; + _options = options; + } + + /// + /// Create a new permission validator with the given options. + /// + /// The partial permission to validate. + /// The group of the permission. + /// The option containing default values. + 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; } /// - public IFilterMetadata Create(PermissionAttribute attribute) + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { - return new PermissionValidatorFilter( - attribute.Type, - attribute.Kind, - attribute.Group, - _options - ); - } + string? permission = _permission; + Kind? kind = _kind; - /// - public IFilterMetadata Create(PartialPermissionAttribute attribute) - { - return new PermissionValidatorFilter( - ((object?)attribute.Type ?? attribute.Kind)!, - attribute.Group, - _options - ); - } - - /// - /// The authorization filter used by . - /// - private class PermissionValidatorFilter : IAsyncAuthorizationFilter - { - /// - /// The permission to validate. - /// - private readonly string? _permission; - - /// - /// The kind of permission needed. - /// - private readonly Kind? _kind; - - /// - /// The group of he permission. - /// - private Group _group; - - /// - /// The permissions options to retrieve default permissions. - /// - private readonly PermissionOption _options; - - /// - /// Create a new permission validator with the given options. - /// - /// The permission to validate. - /// The kind of permission needed. - /// The group of the permission. - /// The option containing default values. - public PermissionValidatorFilter( - string permission, - Kind kind, - Group group, - PermissionOption options - ) + if (permission == null || kind == null) { - _permission = permission; - _kind = kind; - _group = group; - _options = options; - } + if (context.HttpContext.Items["PermissionGroup"] is Group group and not Group.None) + _group = group; + else if (_group == Group.None) + _group = Group.Overall; + else + context.HttpContext.Items["PermissionGroup"] = _group; - /// - /// Create a new permission validator with the given options. - /// - /// The partial permission to validate. - /// The group of the permission. - /// The option containing default values. - public PermissionValidatorFilter( - object partialInfo, - Group? group, - PermissionOption options - ) - { - switch (partialInfo) + switch (context.HttpContext.Items["PermissionType"]) { - case Kind kind: - _kind = kind; - break; case string perm: - _permission = 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( - $"{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; - } - - /// - public async Task OnAuthorizationAsync(AuthorizationFilterContext context) - { - string? permission = _permission; - Kind? kind = _kind; - if (permission == null || kind == null) { - if ( - context.HttpContext.Items["PermissionGroup"] - is Group group - 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." - ); - } + 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 overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; - AuthenticateResult res = _ApiKeyCheck(context); - if (res.None) - res = await _JwtCheck(context); + string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}"; + string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; + AuthenticateResult res = _ApiKeyCheck(context); + if (res.None) + res = await _JwtCheck(context); - if (res.Succeeded) - { - ICollection permissions = res.Principal.GetPermissions(); - if (permissions.All(x => x != permStr && x != overallStr)) - context.Result = _ErrorResult( - $"Missing permission {permStr} or {overallStr}", - StatusCodes.Status403Forbidden - ); - } - else if (res.None) - { - ICollection permissions = _options.Default ?? Array.Empty(); - 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) + if (res.Succeeded) + { + ICollection permissions = res.Principal.GetPermissions(); + if (permissions.All(x => x != permStr && x != overallStr)) context.Result = _ErrorResult( - res.Failure.Message, + $"Missing permission {permStr} or {overallStr}", StatusCodes.Status403Forbidden ); - else + } + else if (res.None) + { + ICollection permissions = _options.Default ?? Array.Empty(); + if (permissions.All(x => x != permStr && x != overallStr)) + { context.Result = _ErrorResult( - "Authentication panic", - StatusCodes.Status500InternalServerError + $"Unlogged user does not have permission {permStr} or {overallStr}", + StatusCodes.Status401Unauthorized ); + } } - - private AuthenticateResult _ApiKeyCheck(ActionContext context) - { - if ( - !context.HttpContext.Request.Headers.TryGetValue( - "X-API-Key", - out StringValues apiKey - ) - ) - return AuthenticateResult.NoResult(); - if (!_options.ApiKeys.Contains(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" - ) + else if (res.Failure != null) + context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden); + else + context.Result = _ErrorResult( + "Authentication panic", + StatusCodes.Status500InternalServerError ); - } - - private async Task _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; - } } - /// - /// Create a new action result with the given error message and error code. - /// - /// The error message. - /// The status code of the error. - /// The resulting error action. - private static IActionResult _ErrorResult(string error, int code) + private AuthenticateResult _ApiKeyCheck(ActionContext context) { - 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(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 _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; } } + + /// + /// Create a new action result with the given error message and error code. + /// + /// The error message. + /// The status code of the error. + /// The resulting error action. + private static IActionResult _ErrorResult(string error, int code) + { + return new ObjectResult(new RequestError(error)) { StatusCode = code }; + } } diff --git a/back/src/Kyoo.Authentication/Controllers/TokenController.cs b/back/src/Kyoo.Authentication/Controllers/TokenController.cs index 7922f45b..f4ca0723 100644 --- a/back/src/Kyoo.Authentication/Controllers/TokenController.cs +++ b/back/src/Kyoo.Authentication/Controllers/TokenController.cs @@ -27,109 +27,108 @@ using Kyoo.Abstractions.Models; using Kyoo.Authentication.Models; using Microsoft.IdentityModel.Tokens; -namespace Kyoo.Authentication +namespace Kyoo.Authentication; + +/// +/// The service that controls jwt creation and validation. +/// +public class TokenController : ITokenController { /// - /// The service that controls jwt creation and validation. + /// The options that this controller will use. /// - public class TokenController : ITokenController + private readonly AuthenticationOption _options; + + /// + /// Create a new . + /// + /// The options that this controller will use. + public TokenController(AuthenticationOption options) { - /// - /// The options that this controller will use. - /// - private readonly AuthenticationOption _options; + _options = options; + } - /// - /// Create a new . - /// - /// The options that this controller will use. - public TokenController(AuthenticationOption options) - { - _options = options; - } + /// + public string CreateAccessToken(User user, out TimeSpan expireIn) + { + expireIn = new TimeSpan(1, 0, 0); - /// - public string CreateAccessToken(User user, out TimeSpan expireIn) - { - expireIn = new TimeSpan(1, 0, 0); + SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); + SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); + string permissions = + user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; + List 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)); - SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); - string permissions = - user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; - List claims = - new() + /// + public Task CreateRefreshToken(User user) + { + SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); + SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); + JwtSecurityToken token = + new( + signingCredentials: credential, + 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); - } + new Claim(Claims.Guid, Guid.NewGuid().ToString()), + new Claim(Claims.Type, "refresh") + }, + 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)); + } - /// - public Task 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)); - SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); - JwtSecurityToken token = - new( - signingCredentials: credential, - claims: new[] - { - new Claim(Claims.Id, user.Id.ToString()), - new Claim(Claims.Guid, Guid.NewGuid().ToString()), - new Claim(Claims.Type, "refresh") - }, - 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)); + principal = tokenHandler.ValidateToken( + refreshToken, + new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + IssuerSigningKey = key + }, + out SecurityToken _ + ); } - - /// - public Guid GetRefreshTokenUserID(string refreshToken) + catch (Exception) { - SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); - 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."); + 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."); } } diff --git a/back/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs b/back/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs index 5e092fb5..c93730f6 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/LoginRequest.cs @@ -16,32 +16,31 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Authentication.Models.DTO +namespace Kyoo.Authentication.Models.DTO; + +/// +/// A model only used on login requests. +/// +public class LoginRequest { /// - /// A model only used on login requests. + /// The user's username. /// - public class LoginRequest + public string Username { get; set; } + + /// + /// The user's password. + /// + public string Password { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user's username. + /// The user's password. + public LoginRequest(string username, string password) { - /// - /// The user's username. - /// - public string Username { get; set; } - - /// - /// The user's password. - /// - public string Password { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// The user's username. - /// The user's password. - public LoginRequest(string username, string password) - { - Username = username; - Password = password; - } + Username = username; + Password = password; } } diff --git a/back/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs b/back/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs index 7bb524e7..0ee10211 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/RegisterRequest.cs @@ -21,57 +21,56 @@ using Kyoo.Abstractions.Models; using Kyoo.Utils; using BCryptNet = BCrypt.Net.BCrypt; -namespace Kyoo.Authentication.Models.DTO +namespace Kyoo.Authentication.Models.DTO; + +/// +/// A model only used on register requests. +/// +public class RegisterRequest { /// - /// A model only used on register requests. + /// The user email address /// - public class RegisterRequest + [EmailAddress(ErrorMessage = "The email must be a valid email address")] + public string Email { get; set; } + + /// + /// The user's username. + /// + [MinLength(4, ErrorMessage = "The username must have at least {1} characters")] + public string Username { get; set; } + + /// + /// The user's password. + /// + [MinLength(4, ErrorMessage = "The password must have at least {1} characters")] + public string Password { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The user email address. + /// The user's username. + /// The user's password. + public RegisterRequest(string email, string username, string password) { - /// - /// The user email address - /// - [EmailAddress(ErrorMessage = "The email must be a valid email address")] - public string Email { get; set; } + Email = email; + Username = username; + Password = password; + } - /// - /// The user's username. - /// - [MinLength(4, ErrorMessage = "The username must have at least {1} characters")] - public string Username { get; set; } - - /// - /// The user's password. - /// - [MinLength(4, ErrorMessage = "The password must have at least {1} characters")] - public string Password { get; set; } - - /// - /// Initializes a new instance of the class. - /// - /// The user email address. - /// The user's username. - /// The user's password. - public RegisterRequest(string email, string username, string password) + /// + /// Convert this register request to a new class. + /// + /// A user representing this request. + public User ToUser() + { + return new User { - Email = email; - Username = username; - Password = password; - } - - /// - /// Convert this register request to a new class. - /// - /// A user representing this request. - public User ToUser() - { - return new User - { - Slug = Utility.ToSlug(Username), - Username = Username, - Password = BCryptNet.HashPassword(Password), - Email = Email, - }; - } + Slug = Utility.ToSlug(Username), + Username = Username, + Password = BCryptNet.HashPassword(Password), + Email = Email, + }; } } diff --git a/back/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs b/back/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs index a5f22d75..c7e1032a 100644 --- a/back/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs +++ b/back/src/Kyoo.Authentication/Models/Options/AuthenticationOption.cs @@ -16,31 +16,30 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -namespace Kyoo.Authentication.Models +namespace Kyoo.Authentication.Models; + +/// +/// The main authentication options. +/// +public class AuthenticationOption { /// - /// The main authentication options. + /// The path to get this option from the root configuration. /// - public class AuthenticationOption - { - /// - /// The path to get this option from the root configuration. - /// - public const string Path = "authentication"; + public const string Path = "authentication"; - /// - /// The default jwt secret. - /// - public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?"; + /// + /// The default jwt secret. + /// + public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?"; - /// - /// The secret used to encrypt the jwt. - /// - public string Secret { get; set; } = DefaultSecret; + /// + /// The secret used to encrypt the jwt. + /// + public string Secret { get; set; } = DefaultSecret; - /// - /// Options for permissions - /// - public PermissionOption Permissions { get; set; } = new(); - } + /// + /// Options for permissions + /// + public PermissionOption Permissions { get; set; } = new(); } diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index e4469804..4ab46f64 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -35,464 +35,458 @@ using Microsoft.IdentityModel.Tokens; using static Kyoo.Abstractions.Models.Utils.Constants; using BCryptNet = BCrypt.Net.BCrypt; -namespace Kyoo.Authentication.Views +namespace Kyoo.Authentication.Views; + +/// +/// Sign in, Sign up or refresh tokens. +/// +[ApiController] +[Route("auth")] +[ApiDefinition("Authentication", Group = UsersGroup)] +public class AuthApi( + IUserRepository users, + OidcController oidc, + ITokenController tokenController, + IThumbnailsManager thumbs, + PermissionOption options +) : ControllerBase { /// - /// Sign in, Sign up or refresh tokens. + /// Create a new Forbidden result from an object. /// - [ApiController] - [Route("auth")] - [ApiDefinition("Authentication", Group = UsersGroup)] - public class AuthApi( - IUserRepository users, - OidcController oidc, - ITokenController tokenController, - IThumbnailsManager thumbs, - PermissionOption options - ) : ControllerBase + /// The json value to output on the response. + /// A new forbidden result with the given json object. + public static ObjectResult Forbid(object value) { - /// - /// Create a new Forbidden result from an object. - /// - /// The json value to output on the response. - /// A new forbidden result with the given json object. - public static ObjectResult Forbid(object value) - { - return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; - } + return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden }; + } - private static string _BuildUrl(string baseUrl, Dictionary queryParams) + private static string _BuildUrl(string baseUrl, Dictionary queryParams) + { + char querySep = baseUrl.Contains('?') ? '&' : '?'; + foreach ((string key, string? val) in queryParams) { - char querySep = baseUrl.Contains('?') ? '&' : '?'; - foreach ((string key, string? val) in queryParams) - { - if (val is null) - continue; - baseUrl += $"{querySep}{key}={val}"; - querySep = '&'; - } - return baseUrl; + if (val is null) + continue; + baseUrl += $"{querySep}{key}={val}"; + querySep = '&'; } + return baseUrl; + } - /// - /// Oauth Login. - /// - /// - /// Login via a registered oauth provider. - /// - /// The provider code. - /// - /// A url where you will be redirected with the query params provider, code and error. It can be a deep link. - /// - /// A redirect to the provider's login page. - /// The provider is not register with this instance of kyoo. - [HttpGet("login/{provider}")] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] - public ActionResult LoginVia(string provider, [FromQuery] string redirectUrl) + /// + /// Oauth Login. + /// + /// + /// Login via a registered oauth provider. + /// + /// The provider code. + /// + /// A url where you will be redirected with the query params provider, code and error. It can be a deep link. + /// + /// A redirect to the provider's login page. + /// The provider is not register with this instance of kyoo. + [HttpGet("login/{provider}")] + [ProducesResponseType(StatusCodes.Status302Found)] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] + public ActionResult 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( - $"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, - } + return NotFound( + new RequestError( + $"Invalid provider. {provider} is not registered no this instance of kyoo." ) ); } - - /// - /// Oauth Code Redirect. - /// - /// - /// 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. - /// - /// A redirect to the provider's login page. - /// The provider gave an error. - [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, - } - ) - ); - } - - /// - /// Oauth callback - /// - /// - /// This route should be manually called by the page that got redirected to after a call to /login/{provider}. - /// - /// A jwt token - /// Bad provider or code - [HttpPost("callback/{provider}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - public async Task> 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 - ); - } - - /// - /// Unlink account - /// - /// - /// Unlink your account from an external account. - /// - /// The provider code. - /// Your updated user account - [HttpDelete("login/{provider}")] - [ProducesResponseType(StatusCodes.Status200OK)] - [UserOnly] - public Task UnlinkAccount(string provider) - { - Guid id = User.GetIdOrThrow(); - return users.DeleteExternalToken(id, provider); - } - - /// - /// Login. - /// - /// - /// Login as a user and retrieve an access and a refresh token. - /// - /// The body of the request. - /// A new access and a refresh token. - /// The user and password does not match. - [HttpPost("login")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> Login([FromBody] LoginRequest request) - { - User? user = await users.GetOrDefault( - new Filter.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 - ); - } - - /// - /// Register. - /// - /// - /// Register a new user and get a new access/refresh token for this new user. - /// - /// The body of the request. - /// A new access and a refresh token. - /// The request is invalid. - /// A user already exists with this username or email address. - [HttpPost("register")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] - public async Task> 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.")); - } - } - - /// - /// Refresh a token. - /// - /// - /// Refresh an access token using the given refresh token. A new access and refresh token are generated. - /// - /// A valid refresh token. - /// A new access and refresh token. - /// The given refresh token is invalid. - [HttpGet("refresh")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> 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)); - } - } - - /// - /// Reset your password - /// - /// - /// Change your password. - /// - /// The old and new password - /// Your account info. - /// The old password is invalid. - [HttpPost("password-reset")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> 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) => + OidcProvider prov = options.OIDC[provider]; + return Redirect( + _BuildUrl( + prov.AuthorizationUrl, + new() { - user.Password = BCryptNet.HashPassword(request.NewPassword); - return user; + ["response_type"] = "code", + ["client_id"] = prov.ClientId, + ["redirect_uri"] = + $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["scope"] = prov.Scope, + ["state"] = redirectUrl, } + ) + ); + } + + /// + /// Oauth Code Redirect. + /// + /// + /// 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. + /// + /// A redirect to the provider's login page. + /// The provider gave an error. + [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, + } + ) + ); + } + + /// + /// Oauth callback + /// + /// + /// This route should be manually called by the page that got redirected to after a call to /login/{provider}. + /// + /// A jwt token + /// Bad provider or code + [HttpPost("callback/{provider}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> 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.")); - /// - /// Get authenticated user. - /// - /// - /// Get information about the currently authenticated user. This can also be used to ensure that you are - /// logged in. - /// - /// The currently authenticated user. - /// The user is not authenticated. - /// The given access token is invalid. - [HttpGet("me")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> GetMe() + 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 + ); + } + + /// + /// Unlink account + /// + /// + /// Unlink your account from an external account. + /// + /// The provider code. + /// Your updated user account + [HttpDelete("login/{provider}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [UserOnly] + public Task UnlinkAccount(string provider) + { + Guid id = User.GetIdOrThrow(); + return users.DeleteExternalToken(id, provider); + } + + /// + /// Login. + /// + /// + /// Login as a user and retrieve an access and a refresh token. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The user and password does not match. + [HttpPost("login")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> Login([FromBody] LoginRequest request) + { + User? user = await users.GetOrDefault( + new Filter.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 + ); + } + + /// + /// Register. + /// + /// + /// Register a new user and get a new access/refresh token for this new user. + /// + /// The body of the request. + /// A new access and a refresh token. + /// The request is invalid. + /// A user already exists with this username or email address. + [HttpPost("register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))] + public async Task> Register([FromBody] RegisterRequest request) + { + try { - try - { - return await users.Get(User.GetIdOrThrow()); - } - catch (ItemNotFoundException) - { - return Forbid(new RequestError("Invalid token")); - } + User user = await users.Create(request.ToUser()); + return new JwtToken( + tokenController.CreateAccessToken(user, out TimeSpan expireIn), + await tokenController.CreateRefreshToken(user), + expireIn + ); } - - /// - /// Edit self - /// - /// - /// Edit information about the currently authenticated user. - /// - /// The new data for the current user. - /// The currently authenticated user after modifications. - /// The user is not authenticated. - /// The given access token is invalid. - [HttpPut("me")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> EditMe(User user) + catch (DuplicatedItemException) { - try - { - user.Id = User.GetIdOrThrow(); - return await users.Edit(user); - } - catch (ItemNotFoundException) - { - return Forbid(new RequestError("Invalid token")); - } - } - - /// - /// Patch self - /// - /// - /// Edit only provided informations about the currently authenticated user. - /// - /// The new data for the current user. - /// The currently authenticated user after modifications. - /// The user is not authenticated. - /// The given access token is invalid. - [HttpPatch("me")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> PatchMe([FromBody] Patch 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")); - } - } - - /// - /// Delete account - /// - /// - /// Delete the current account. - /// - /// The user is not authenticated. - /// The given access token is invalid. - [HttpDelete("me")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task> DeleteMe() - { - try - { - await users.Delete(User.GetIdOrThrow()); - return NoContent(); - } - catch (ItemNotFoundException) - { - return Forbid(new RequestError("Invalid token")); - } - } - - /// - /// Get profile picture - /// - /// - /// Get your profile picture - /// - /// The user is not authenticated. - /// The given access token is invalid. - [HttpGet("me/logo")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task 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); - } - - /// - /// Set profile picture - /// - /// - /// Set your profile picture - /// - /// The user is not authenticated. - /// The given access token is invalid. - [HttpPost("me/logo")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task SetProfilePicture(IFormFile picture) - { - if (picture == null || picture.Length == 0) - return BadRequest(); - await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); - return NoContent(); - } - - /// - /// Delete profile picture - /// - /// - /// Delete your profile picture - /// - /// The user is not authenticated. - /// The given access token is invalid. - [HttpDelete("me/logo")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] - public async Task DeleteProfilePicture() - { - await thumbs.SetUserImage(User.GetIdOrThrow(), null); - return NoContent(); + return Conflict(new RequestError("A user already exists with this username.")); } } + + /// + /// Refresh a token. + /// + /// + /// Refresh an access token using the given refresh token. A new access and refresh token are generated. + /// + /// A valid refresh token. + /// A new access and refresh token. + /// The given refresh token is invalid. + [HttpGet("refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> 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)); + } + } + + /// + /// Reset your password + /// + /// + /// Change your password. + /// + /// The old and new password + /// Your account info. + /// The old password is invalid. + [HttpPost("password-reset")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> 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; + } + ); + } + + /// + /// Get authenticated user. + /// + /// + /// Get information about the currently authenticated user. This can also be used to ensure that you are + /// logged in. + /// + /// The currently authenticated user. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpGet("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> GetMe() + { + try + { + return await users.Get(User.GetIdOrThrow()); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Edit self + /// + /// + /// Edit information about the currently authenticated user. + /// + /// The new data for the current user. + /// The currently authenticated user after modifications. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPut("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> EditMe(User user) + { + try + { + user.Id = User.GetIdOrThrow(); + return await users.Edit(user); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Patch self + /// + /// + /// Edit only provided informations about the currently authenticated user. + /// + /// The new data for the current user. + /// The currently authenticated user after modifications. + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPatch("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> PatchMe([FromBody] Patch 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")); + } + } + + /// + /// Delete account + /// + /// + /// Delete the current account. + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpDelete("me")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task> DeleteMe() + { + try + { + await users.Delete(User.GetIdOrThrow()); + return NoContent(); + } + catch (ItemNotFoundException) + { + return Forbid(new RequestError("Invalid token")); + } + } + + /// + /// Get profile picture + /// + /// + /// Get your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpGet("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task 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); + } + + /// + /// Set profile picture + /// + /// + /// Set your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpPost("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task SetProfilePicture(IFormFile picture) + { + if (picture == null || picture.Length == 0) + return BadRequest(); + await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream()); + return NoContent(); + } + + /// + /// Delete profile picture + /// + /// + /// Delete your profile picture + /// + /// The user is not authenticated. + /// The given access token is invalid. + [HttpDelete("me/logo")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + public async Task DeleteProfilePicture() + { + await thumbs.SetUserImage(User.GetIdOrThrow(), null); + return NoContent(); + } } diff --git a/back/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs b/back/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs index 8053dedb..a6ffd777 100644 --- a/back/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs +++ b/back/src/Kyoo.Core/Controllers/IdentifierRouteConstraint.cs @@ -20,23 +20,22 @@ using Kyoo.Abstractions.Models.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// The route constraint that goes with the . +/// +public class IdentifierRouteConstraint : IRouteConstraint { - /// - /// The route constraint that goes with the . - /// - public class IdentifierRouteConstraint : IRouteConstraint + /// + public bool Match( + HttpContext? httpContext, + IRouter? route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection + ) { - /// - public bool Match( - HttpContext? httpContext, - IRouter? route, - string routeKey, - RouteValueDictionary values, - RouteDirection routeDirection - ) - { - return values.ContainsKey(routeKey); - } + return values.ContainsKey(routeKey); } } diff --git a/back/src/Kyoo.Core/Controllers/LibraryManager.cs b/back/src/Kyoo.Core/Controllers/LibraryManager.cs index 74ccfa4c..a3236e9d 100644 --- a/back/src/Kyoo.Core/Controllers/LibraryManager.cs +++ b/back/src/Kyoo.Core/Controllers/LibraryManager.cs @@ -20,87 +20,86 @@ using System.Linq; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// An class to interact with the database. Every repository is mapped through here. +/// +public class LibraryManager : ILibraryManager { - /// - /// An class to interact with the database. Every repository is mapped through here. - /// - public class LibraryManager : ILibraryManager + private readonly IBaseRepository[] _repositories; + + public LibraryManager( + IRepository libraryItemRepository, + IRepository newsRepository, + IWatchStatusRepository watchStatusRepository, + IRepository collectionRepository, + IRepository movieRepository, + IRepository showRepository, + IRepository seasonRepository, + IRepository episodeRepository, + IRepository studioRepository, + IRepository 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( - IRepository libraryItemRepository, - IRepository newsRepository, - IWatchStatusRepository watchStatusRepository, - IRepository collectionRepository, - IRepository movieRepository, - IRepository showRepository, - IRepository seasonRepository, - IRepository episodeRepository, - IRepository studioRepository, - IRepository userRepository - ) + _repositories = new IBaseRepository[] { - LibraryItems = libraryItemRepository; - News = newsRepository; - WatchStatus = watchStatusRepository; - Collections = collectionRepository; - Movies = movieRepository; - Shows = showRepository; - Seasons = seasonRepository; - Episodes = episodeRepository; - Studios = studioRepository; - Users = userRepository; + LibraryItems, + News, + Collections, + Movies, + Shows, + Seasons, + Episodes, + Studios, + Users + }; + } - _repositories = new IBaseRepository[] - { - LibraryItems, - News, - Collections, - Movies, - Shows, - Seasons, - Episodes, - Studios, - Users - }; - } + /// + public IRepository LibraryItems { get; } - /// - public IRepository LibraryItems { get; } + /// + public IRepository News { get; } - /// - public IRepository News { get; } + /// + public IWatchStatusRepository WatchStatus { get; } - /// - public IWatchStatusRepository WatchStatus { get; } + /// + public IRepository Collections { get; } - /// - public IRepository Collections { get; } + /// + public IRepository Movies { get; } - /// - public IRepository Movies { get; } + /// + public IRepository Shows { get; } - /// - public IRepository Shows { get; } + /// + public IRepository Seasons { get; } - /// - public IRepository Seasons { get; } + /// + public IRepository Episodes { get; } - /// - public IRepository Episodes { get; } + /// + public IRepository Studios { get; } - /// - public IRepository Studios { get; } + /// + public IRepository Users { get; } - /// - public IRepository Users { get; } - - public IRepository Repository() - where T : IResource, IQuery - { - return (IRepository)_repositories.First(x => x.RepositoryType == typeof(T)); - } + public IRepository Repository() + where T : IResource, IQuery + { + return (IRepository)_repositories.First(x => x.RepositoryType == typeof(T)); } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs index a3797d7c..a217fc85 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/CollectionRepository.cs @@ -26,78 +26,77 @@ using Kyoo.Abstractions.Models.Utils; using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle collections +/// +public class CollectionRepository : LocalRepository { /// - /// A local repository to handle collections + /// The database handle /// - public class CollectionRepository : LocalRepository + private readonly DatabaseContext _database; + + /// + /// Create a new . + /// + /// The database handle to use + /// The thumbnail manager used to store images. + public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs) + : base(database, thumbs) { - /// - /// The database handle - /// - private readonly DatabaseContext _database; + _database = database; + } - /// - /// Create a new . - /// - /// The database handle to use - /// The thumbnail manager used to store images. - public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs) - : base(database, thumbs) - { - _database = database; - } + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Collections, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Collections, include) - .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) - .Take(20) - .ToListAsync(); - } + /// + public override async Task Create(Collection obj) + { + await base.Create(obj); + _database.Entry(obj).State = EntityState.Added; + await _database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } - /// - public override async Task Create(Collection obj) - { - await base.Create(obj); - _database.Entry(obj).State = EntityState.Added; - await _database.SaveChangesAsync(() => Get(obj.Slug)); - await IRepository.OnResourceCreated(obj); - return obj; - } + /// + protected override async Task Validate(Collection resource) + { + await base.Validate(resource); - /// - protected override async Task Validate(Collection resource) - { - await base.Validate(resource); + if (string.IsNullOrEmpty(resource.Name)) + throw new ArgumentException("The collection's name must be set and not empty"); + } - if (string.IsNullOrEmpty(resource.Name)) - throw new ArgumentException("The collection's name must be set and not empty"); - } + public async Task AddMovie(Guid id, Guid movieId) + { + _database.AddLinks(id, movieId); + await _database.SaveChangesAsync(); + } - public async Task AddMovie(Guid id, Guid movieId) - { - _database.AddLinks(id, movieId); - await _database.SaveChangesAsync(); - } + public async Task AddShow(Guid id, Guid showId) + { + _database.AddLinks(id, showId); + await _database.SaveChangesAsync(); + } - public async Task AddShow(Guid id, Guid showId) - { - _database.AddLinks(id, showId); - await _database.SaveChangesAsync(); - } - - /// - public override async Task Delete(Collection obj) - { - _database.Entry(obj).State = EntityState.Deleted; - await _database.SaveChangesAsync(); - await base.Delete(obj); - } + /// + public override async Task Delete(Collection obj) + { + _database.Entry(obj).State = EntityState.Deleted; + await _database.SaveChangesAsync(); + await base.Delete(obj); } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs index 24ad01ac..bea65d27 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/EpisodeRepository.cs @@ -27,130 +27,128 @@ using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle episodes. +/// +public class EpisodeRepository : LocalRepository { /// - /// A local repository to handle episodes. + /// The database handle /// - public class EpisodeRepository : LocalRepository + private readonly DatabaseContext _database; + + private readonly IRepository _shows; + + static EpisodeRepository() { - /// - /// The database handle - /// - private readonly DatabaseContext _database; - - private readonly IRepository _shows; - - static EpisodeRepository() + // Edit episode slugs when the show's slug changes. + IRepository.OnEdited += async (show) => { - // Edit episode slugs when the show's slug changes. - IRepository.OnEdited += async (show) => - { - await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); - DatabaseContext database = - scope.ServiceProvider.GetRequiredService(); - List 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.OnResourceEdited(ep); - } - }; - } - - /// - /// Create a new . - /// - /// The database handle to use. - /// A show repository - /// The thumbnail manager used to store images. - public EpisodeRepository( - DatabaseContext database, - IRepository shows, - IThumbnailsManager thumbs - ) - : base(database, thumbs) - { - _database = database; - _shows = shows; - } - - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Episodes, include) - .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) - .Take(20) + await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); + DatabaseContext database = scope.ServiceProvider.GetRequiredService(); + List 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.OnResourceEdited(ep); + } + }; + } - protected override Task GetDuplicated(Episode item) - { - if (item is { SeasonNumber: not null, EpisodeNumber: not null }) - return _database.Episodes.FirstOrDefaultAsync(x => - x.ShowId == item.ShowId - && x.SeasonNumber == item.SeasonNumber - && x.EpisodeNumber == item.EpisodeNumber - ); + /// + /// Create a new . + /// + /// The database handle to use. + /// A show repository + /// The thumbnail manager used to store images. + public EpisodeRepository( + DatabaseContext database, + IRepository shows, + IThumbnailsManager thumbs + ) + : base(database, thumbs) + { + _database = database; + _shows = shows; + } + + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Episodes, include) + .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + protected override Task GetDuplicated(Episode item) + { + if (item is { SeasonNumber: not null, EpisodeNumber: not null }) 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 + ); + } + + /// + public override async Task 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.OnResourceCreated(obj); + return obj; + } + + /// + 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 ); } + } - /// - public override async Task 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.OnResourceCreated(obj); - return obj; - } - - /// - 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 - ); - } - } - - /// - 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); - } + /// + 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); } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs index 9c070a5b..958f12ca 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LibraryItemRepository.cs @@ -25,103 +25,102 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Utils; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle library items. +/// +public class LibraryItemRepository : DapperRepository { - /// - /// A local repository to handle library items. - /// - public class LibraryItemRepository : DapperRepository + // language=PostgreSQL + protected override FormattableString Sql => + $""" + 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 Config => + new() + { + { "s", typeof(Show) }, + { "m", typeof(Movie) }, + { "c", typeof(Collection) } + }; + + protected override ILibraryItem Mapper(List 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> GetAllOfCollection( + Guid collectionId, + Filter? filter = default, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ) { // language=PostgreSQL - protected override FormattableString Sql => - $""" + FormattableString sql = $""" + select + s.*, + m.* + /* includes */ + from ( select - s.*, -- Show as s - m.*, - c.* - /* includes */ + * -- Show 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 Config => - new() - { - { "s", typeof(Show) }, - { "m", typeof(Movie) }, - { "c", typeof(Collection) } - }; - - protected override ILibraryItem Mapper(List 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> GetAllOfCollection( - Guid collectionId, - Filter? filter = default, - Sort? sort = default, - Include? include = default, - Pagination? limit = default - ) - { - // language=PostgreSQL - FormattableString sql = $""" + shows + inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId} + ) as s + full outer join ( select - s.*, - m.* - /* includes */ - from ( - select - * -- 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 - """; + * -- 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( - sql, - new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, - Mapper, - (id) => Get(id), - Context, - include, - filter, - sort ?? new Sort.Default(), - limit ?? new() - ); - } + return await Database.Query( + sql, + new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, + Mapper, + (id) => Get(id), + Context, + include, + filter, + sort ?? new Sort.Default(), + limit ?? new() + ); } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs index f502d380..c0e56b7f 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs @@ -32,468 +32,456 @@ using Kyoo.Postgresql; using Kyoo.Utils; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A base class to create repositories using Entity Framework. +/// +/// The type of this repository +public abstract class LocalRepository : IRepository + where T : class, IResource, IQuery { /// - /// A base class to create repositories using Entity Framework. + /// The Entity Framework's Database handle. /// - /// The type of this repository - public abstract class LocalRepository : IRepository - where T : class, IResource, IQuery + protected DbContext Database { get; } + + /// + /// The thumbnail manager used to store images. + /// + private readonly IThumbnailsManager _thumbs; + + /// + /// Create a new base with the given database handle. + /// + /// A database connection to load resources of type + /// The thumbnail manager used to store images. + protected LocalRepository(DbContext database, IThumbnailsManager thumbs) { - /// - /// The Entity Framework's Database handle. - /// - protected DbContext Database { get; } + Database = database; + _thumbs = thumbs; + } - /// - /// The thumbnail manager used to store images. - /// - private readonly IThumbnailsManager _thumbs; + /// + public Type RepositoryType => typeof(T); - /// - /// Create a new base with the given database handle. - /// - /// A database connection to load resources of type - /// The thumbnail manager used to store images. - protected LocalRepository(DbContext database, IThumbnailsManager thumbs) + /// + /// Sort the given query. + /// + /// The query to sort. + /// How to sort the query. + /// The newly sorted query. + protected IOrderedQueryable Sort(IQueryable query, Sort? sortBy) + { + sortBy ??= new Sort.Default(); + + IOrderedQueryable _SortBy( + IQueryable qr, + Expression> sort, + bool desc, + bool then + ) { - Database = database; - _thumbs = thumbs; + if (then && qr is IOrderedQueryable qro) + { + return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); + } + return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); } - /// - public Type RepositoryType => typeof(T); - - /// - /// Sort the given query. - /// - /// The query to sort. - /// How to sort the query. - /// The newly sorted query. - protected IOrderedQueryable Sort(IQueryable query, Sort? sortBy) + IOrderedQueryable _Sort(IQueryable query, Sort sortBy, bool then) { - sortBy ??= new Sort.Default(); - - IOrderedQueryable _SortBy( - IQueryable qr, - Expression> sort, - bool desc, - bool then - ) + switch (sortBy) { - if (then && qr is IOrderedQueryable qro) - { - return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); - } - return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); + case Sort.Default(var value): + return _Sort(query, value, then); + case Sort.By(var key, var desc): + return _SortBy(query, x => EF.Property(x, key), desc, then); + case Sort.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.Conglomerate(var sorts): + IOrderedQueryable nQuery = _Sort(query, sorts.First(), false); + foreach (Sort 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 _Sort(IQueryable query, Sort sortBy, bool then) - { - switch (sortBy) - { - case Sort.Default(var value): - return _Sort(query, value, then); - case Sort.By(var key, var desc): - return _SortBy(query, x => EF.Property(x, key), desc, then); - case Sort.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.Conglomerate(var sorts): - IOrderedQueryable nQuery = _Sort(query, sorts.First(), false); - foreach (Sort 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 AddIncludes(IQueryable query, Include? include) - { - if (include == null) - return query; - foreach (string field in include.Fields) - query = query.Include(field); + protected IQueryable AddIncludes(IQueryable query, Include? include) + { + if (include == null) return query; - } + foreach (string field in include.Fields) + query = query.Include(field); + return query; + } - /// - /// Get a resource from it's ID and make the instance track it. - /// - /// The ID of the resource - /// If the item is not found - /// The tracked resource with the given ID - protected virtual async Task GetWithTracking(Guid id) + /// + /// Get a resource from it's ID and make the instance track it. + /// + /// The ID of the resource + /// If the item is not found + /// The tracked resource with the given ID + protected virtual async Task GetWithTracking(Guid id) + { + T? ret = await Database.Set().AsTracking().FirstOrDefaultAsync(x => x.Id == id); + if (ret == null) + throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); + return ret; + } + + /// + public virtual async Task Get(Guid id, Include? 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; + } + + /// + public virtual async Task Get(string slug, Include? 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; + } + + /// + public virtual async Task Get( + Filter filter, + Include? include = default, + Sort? 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 GetDuplicated(T item) + { + return GetOrDefault(item.Slug); + } + + /// + public virtual Task GetOrDefault(Guid id, Include? include = default) + { + return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Id == id); + } + + /// + public virtual Task GetOrDefault(string slug, Include? include = default) + { + if (slug == "random") { - T? ret = await Database.Set().AsTracking().FirstOrDefaultAsync(x => x.Id == id); - if (ret == null) - throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); - return ret; + return AddIncludes(Database.Set(), include) + .OrderBy(x => EF.Functions.Random()) + .FirstOrDefaultAsync(); } + return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Slug == slug); + } - /// - public virtual async Task Get(Guid id, Include? 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; - } + /// + public virtual async Task GetOrDefault( + Filter? filter, + Include? include = default, + Sort? sortBy = default, + bool reverse = false, + Guid? afterId = default + ) + { + IQueryable query = await ApplyFilters( + Database.Set(), + filter, + sortBy, + new Pagination(1, afterId, reverse), + include + ); + return await query.FirstOrDefaultAsync(); + } - /// - public virtual async Task Get(string slug, Include? 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; - } - - /// - public virtual async Task Get( - Filter filter, - Include? include = default, - Sort? sortBy = default, - bool reverse = false, - Guid? afterId = default + /// + public virtual async Task> FromIds( + IList ids, + Include? include = default + ) + { + return ( + await AddIncludes(Database.Set(), include) + .Where(x => ids.Contains(x.Id)) + .ToListAsync() ) - { - 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; - } + .OrderBy(x => ids.IndexOf(x.Id)) + .ToList(); + } - protected virtual Task GetDuplicated(T item) - { - return GetOrDefault(item.Slug); - } + /// + public abstract Task> Search(string query, Include? include = default); - /// - public virtual Task GetOrDefault(Guid id, Include? include = default) - { - return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Id == id); - } + /// + public virtual async Task> GetAll( + Filter? filter = null, + Sort? sort = default, + Include? include = default, + Pagination? limit = default + ) + { + IQueryable query = await ApplyFilters(Database.Set(), filter, sort, limit, include); + return await query.ToListAsync(); + } - /// - public virtual Task GetOrDefault(string slug, Include? include = default) - { - if (slug == "random") - { - return AddIncludes(Database.Set(), include) - .OrderBy(x => EF.Functions.Random()) - .FirstOrDefaultAsync(); - } - return AddIncludes(Database.Set(), include).FirstOrDefaultAsync(x => x.Slug == slug); - } + /// + /// Apply filters to a query to ease sort, pagination and where queries for resources of this repository + /// + /// The base query to filter. + /// An expression to filter based on arbitrary conditions + /// The sort settings (sort order and sort by) + /// Pagination information (where to start and how many to get) + /// Related fields to also load with this query. + /// The filtered query + protected async Task> ApplyFilters( + IQueryable query, + Filter? filter = null, + Sort? sort = default, + Pagination? limit = default, + Include? include = default + ) + { + query = AddIncludes(query, include); + query = Sort(query, sort); + limit ??= new(); - /// - public virtual async Task GetOrDefault( - Filter? filter, - Include? include = default, - Sort? sortBy = default, - bool reverse = false, - Guid? afterId = default - ) + if (limit.AfterID != null) { - IQueryable query = await ApplyFilters( - Database.Set(), - filter, - sortBy, - new Pagination(1, afterId, reverse), - include - ); - return await query.FirstOrDefaultAsync(); - } - - /// - public virtual async Task> FromIds( - IList ids, - Include? include = default - ) - { - return ( - await AddIncludes(Database.Set(), include) - .Where(x => ids.Contains(x.Id)) - .ToListAsync() - ) - .OrderBy(x => ids.IndexOf(x.Id)) - .ToList(); - } - - /// - public abstract Task> Search(string query, Include? include = default); - - /// - public virtual async Task> GetAll( - Filter? filter = null, - Sort? sort = default, - Include? include = default, - Pagination? limit = default - ) - { - IQueryable query = await ApplyFilters( - Database.Set(), - filter, + T reference = await Get(limit.AfterID.Value); + Filter? keysetFilter = RepositoryHelper.KeysetPaginate( sort, - limit, - include + reference, + !limit.Reverse ); - return await query.ToListAsync(); + filter = Filter.And(filter, keysetFilter); } + if (filter != null) + query = query.Where(filter.ToEfLambda()); - /// - /// Apply filters to a query to ease sort, pagination and where queries for resources of this repository - /// - /// The base query to filter. - /// An expression to filter based on arbitrary conditions - /// The sort settings (sort order and sort by) - /// Pagination information (where to start and how many to get) - /// Related fields to also load with this query. - /// The filtered query - protected async Task> ApplyFilters( - IQueryable query, - Filter? filter = null, - Sort? sort = default, - Pagination? limit = default, - Include? include = default - ) - { - query = AddIncludes(query, include); - query = Sort(query, sort); - limit ??= new(); + if (limit.Reverse) + query = query.Reverse(); + if (limit.Limit > 0) + query = query.Take(limit.Limit); + if (limit.Reverse) + query = query.Reverse(); - if (limit.AfterID != null) - { - T reference = await Get(limit.AfterID.Value); - Filter? keysetFilter = RepositoryHelper.KeysetPaginate( - sort, - reference, - !limit.Reverse - ); - filter = Filter.And(filter, keysetFilter); - } - if (filter != null) - query = query.Where(filter.ToEfLambda()); + return query; + } - if (limit.Reverse) - query = query.Reverse(); - if (limit.Limit > 0) - query = query.Take(limit.Limit); - if (limit.Reverse) - query = query.Reverse(); + /// + public virtual Task GetCount(Filter? filter = null) + { + IQueryable query = Database.Set(); + if (filter != null) + query = query.Where(filter.ToEfLambda()); + return query.CountAsync(); + } - return query; - } - - /// - public virtual Task GetCount(Filter? filter = null) - { - IQueryable query = Database.Set(); - if (filter != null) - query = query.Where(filter.ToEfLambda()); - return query.CountAsync(); - } - - /// - public virtual async Task 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; - } - - /// - public virtual async Task CreateIfNotExists(T obj) + /// + public virtual async Task Create(T obj) + { + await Validate(obj); + if (obj is IThumbnails thumbs) { try { - T? old = await GetOrDefault(obj.Slug); - if (old != null) - return old; - - return await Create(obj); + await _thumbs.DownloadImages(thumbs); } - 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; + } - /// - public virtual async Task Edit(T edited) + /// + public virtual async Task CreateIfNotExists(T obj) + { + try { - bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; - Database.ChangeTracker.LazyLoadingEnabled = false; - try - { - T old = await GetWithTracking(edited.Id); - - Merger.Complete( - old, - edited, - x => x.GetCustomAttribute() == null - ); - await EditRelations(old, edited); - await Database.SaveChangesAsync(); - await IRepository.OnResourceEdited(old); + T? old = await GetOrDefault(obj.Slug); + if (old != null) return old; - } - finally - { - Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; - Database.ChangeTracker.Clear(); - } - } - /// - public virtual async Task Patch(Guid id, Func patch) + return await Create(obj); + } + catch (DuplicatedItemException) + { + return await Get(obj.Slug); + } + } + + /// + public virtual async Task 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() == null + ); + await EditRelations(old, edited); + await Database.SaveChangesAsync(); + await IRepository.OnResourceEdited(old); + return old; + } + finally + { + Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; + Database.ChangeTracker.Clear(); + } + } + + /// + public virtual async Task Patch(Guid id, Func patch) + { + bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; + Database.ChangeTracker.LazyLoadingEnabled = false; + try + { + T resource = await GetWithTracking(id); + + resource = patch(resource); + + await Database.SaveChangesAsync(); + await IRepository.OnResourceEdited(resource); + return resource; + } + finally + { + Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; + Database.ChangeTracker.Clear(); + } + } + + /// + /// An overridable method to edit relation of a resource. + /// + /// + /// The non edited resource + /// + /// + /// The new version of . + /// This item will be saved on the database and replace + /// + /// A representing the asynchronous operation. + 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); + } + + /// + /// A method called just before saving a new resource to the database. + /// It is also called on the default implementation of + /// + /// The resource that will be saved + /// + /// You can throw this if the resource is illegal and should not be saved. + /// + /// A representing the asynchronous operation. + protected virtual Task Validate(T resource) + { + if ( + typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute() + != 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 { - T resource = await GetWithTracking(id); - - resource = patch(resource); - - await Database.SaveChangesAsync(); - await IRepository.OnResourceEdited(resource); - return resource; - } - finally - { - Database.ChangeTracker.LazyLoadingEnabled = lazyLoading; - Database.ChangeTracker.Clear(); - } - } - - /// - /// An overridable method to edit relation of a resource. - /// - /// - /// The non edited resource - /// - /// - /// The new version of . - /// This item will be saved on the database and replace - /// - /// A representing the asynchronous operation. - 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); - } - - /// - /// A method called just before saving a new resource to the database. - /// It is also called on the default implementation of - /// - /// The resource that will be saved - /// - /// You can throw this if the resource is illegal and should not be saved. - /// - /// A representing the asynchronous operation. - protected virtual Task Validate(T resource) - { - if ( - typeof(T) - .GetProperty(nameof(resource.Slug))! - .GetCustomAttribute() != 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 - { + 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\"." ); - } } - return Task.CompletedTask; + catch + { + throw new ArgumentException( + "Resources slug can't be number only or the literal \"random\"." + ); + } } + return Task.CompletedTask; + } - /// - public virtual async Task Delete(Guid id) - { - T resource = await Get(id); + /// + public virtual async Task Delete(Guid id) + { + T resource = await Get(id); + await Delete(resource); + } + + /// + public virtual async Task Delete(string slug) + { + T resource = await Get(slug); + await Delete(resource); + } + + /// + public virtual Task Delete(T obj) + { + IRepository.OnResourceDeleted(obj); + if (obj is IThumbnails thumbs) + return _thumbs.DeleteImages(thumbs); + return Task.CompletedTask; + } + + /// + public async Task DeleteAll(Filter filter) + { + foreach (T resource in await GetAll(filter)) await Delete(resource); - } - - /// - public virtual async Task Delete(string slug) - { - T resource = await Get(slug); - await Delete(resource); - } - - /// - public virtual Task Delete(T obj) - { - IRepository.OnResourceDeleted(obj); - if (obj is IThumbnails thumbs) - return _thumbs.DeleteImages(thumbs); - return Task.CompletedTask; - } - - /// - public async Task DeleteAll(Filter filter) - { - foreach (T resource in await GetAll(filter)) - await Delete(resource); - } } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs index 327dd906..602f8e8a 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/MovieRepository.cs @@ -25,85 +25,84 @@ using Kyoo.Abstractions.Models.Utils; using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle shows +/// +public class MovieRepository : LocalRepository { /// - /// A local repository to handle shows + /// The database handle /// - public class MovieRepository : LocalRepository + private readonly DatabaseContext _database; + + /// + /// A studio repository to handle creation/validation of related studios. + /// + private readonly IRepository _studios; + + public MovieRepository( + DatabaseContext database, + IRepository studios, + IThumbnailsManager thumbs + ) + : base(database, thumbs) { - /// - /// The database handle - /// - private readonly DatabaseContext _database; + _database = database; + _studios = studios; + } - /// - /// A studio repository to handle creation/validation of related studios. - /// - private readonly IRepository _studios; + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Movies, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } - public MovieRepository( - DatabaseContext database, - IRepository studios, - IThumbnailsManager thumbs - ) - : base(database, thumbs) + /// + public override async Task Create(Movie obj) + { + await base.Create(obj); + _database.Entry(obj).State = EntityState.Added; + await _database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } + + /// + protected override async Task Validate(Movie resource) + { + await base.Validate(resource); + if (resource.Studio != null) { - _database = database; - _studios = studios; - } - - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Movies, include) - .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) - .Take(20) - .ToListAsync(); - } - - /// - public override async Task Create(Movie obj) - { - await base.Create(obj); - _database.Entry(obj).State = EntityState.Added; - await _database.SaveChangesAsync(() => Get(obj.Slug)); - await IRepository.OnResourceCreated(obj); - return obj; - } - - /// - 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; - } - } - - /// - 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; - } - } - - /// - public override async Task Delete(Movie obj) - { - _database.Remove(obj); - await _database.SaveChangesAsync(); - await base.Delete(obj); + resource.Studio = await _studios.CreateIfNotExists(resource.Studio); + resource.StudioId = resource.Studio.Id; } } + + /// + 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; + } + } + + /// + public override async Task Delete(Movie obj) + { + _database.Remove(obj); + await _database.SaveChangesAsync(); + await base.Delete(obj); + } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs index 6b32491f..f55cb31a 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/NewsRepository.cs @@ -22,43 +22,42 @@ using System.Data.Common; using System.IO; using Kyoo.Abstractions.Models; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle shows +/// +public class NewsRepository : DapperRepository { - /// - /// A local repository to handle shows - /// - public class NewsRepository : DapperRepository - { - // language=PostgreSQL - protected override FormattableString Sql => - $""" + // language=PostgreSQL + protected override FormattableString Sql => + $""" + select + e.*, -- Episode as e + m.* + /* includes */ + from + episodes as e + full outer join ( select - e.*, -- Episode as e - m.* - /* includes */ + * -- Movie from - episodes as e - full outer join ( - select - * -- Movie - from - movies - ) as m on false - """; + movies + ) as m on false + """; - protected override Dictionary Config => - new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; + protected override Dictionary Config => + new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; - protected override INews Mapper(List items) - { - if (items[0] is Episode episode && episode.Id != Guid.Empty) - return episode; - if (items[1] is Movie movie && movie.Id != Guid.Empty) - return movie; - throw new InvalidDataException(); - } - - public NewsRepository(DbConnection database, SqlVariableContext context) - : base(database, context) { } + protected override INews Mapper(List items) + { + if (items[0] is Episode episode && episode.Id != Guid.Empty) + return episode; + if (items[1] is Movie movie && movie.Id != Guid.Empty) + return movie; + throw new InvalidDataException(); } + + public NewsRepository(DbConnection database, SqlVariableContext context) + : base(database, context) { } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs index 2d60d42a..18f53e96 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/SeasonRepository.cs @@ -29,105 +29,103 @@ using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle seasons. +/// +public class SeasonRepository : LocalRepository { /// - /// A local repository to handle seasons. + /// The database handle /// - public class SeasonRepository : LocalRepository + private readonly DatabaseContext _database; + + static SeasonRepository() { - /// - /// The database handle - /// - private readonly DatabaseContext _database; - - static SeasonRepository() + // Edit seasons slugs when the show's slug changes. + IRepository.OnEdited += async (show) => { - // Edit seasons slugs when the show's slug changes. - IRepository.OnEdited += async (show) => - { - await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); - DatabaseContext database = - scope.ServiceProvider.GetRequiredService(); - List 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.OnResourceEdited(season); - } - }; - } - - /// - /// Create a new . - /// - /// The database handle that will be used - /// The thumbnail manager used to store images. - public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs) - : base(database, thumbs) - { - _database = database; - } - - protected override Task GetDuplicated(Season item) - { - return _database.Seasons.FirstOrDefaultAsync(x => - x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber - ); - } - - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Seasons, include) - .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) - .Take(20) + await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); + DatabaseContext database = scope.ServiceProvider.GetRequiredService(); + List seasons = await database + .Seasons.AsTracking() + .Where(x => x.ShowId == show.Id) .ToListAsync(); - } - - /// - public override async Task 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.OnResourceCreated(obj); - return obj; - } - - /// - protected override async Task Validate(Season resource) - { - await base.Validate(resource); - if (resource.ShowId == Guid.Empty) + foreach (Season season in seasons) { - if (resource.Show == null) - { - throw new ValidationException( - $"Can't store a season not related to any show " - + $"(showID: {resource.ShowId})." - ); - } - resource.ShowId = resource.Show.Id; + season.ShowSlug = show.Slug; + await database.SaveChangesAsync(); + await IRepository.OnResourceEdited(season); } - } + }; + } - /// - public override async Task Delete(Season obj) + /// + /// Create a new . + /// + /// The database handle that will be used + /// The thumbnail manager used to store images. + public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs) + : base(database, thumbs) + { + _database = database; + } + + protected override Task GetDuplicated(Season item) + { + return _database.Seasons.FirstOrDefaultAsync(x => + x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber + ); + } + + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Seasons, include) + .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) + .Take(20) + .ToListAsync(); + } + + /// + public override async Task 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.OnResourceCreated(obj); + return obj; + } + + /// + protected override async Task Validate(Season resource) + { + await base.Validate(resource); + if (resource.ShowId == Guid.Empty) { - _database.Remove(obj); - await _database.SaveChangesAsync(); - await base.Delete(obj); + if (resource.Show == null) + { + throw new ValidationException( + $"Can't store a season not related to any show " + + $"(showID: {resource.ShowId})." + ); + } + resource.ShowId = resource.Show.Id; } } + + /// + public override async Task Delete(Season obj) + { + _database.Remove(obj); + await _database.SaveChangesAsync(); + await base.Delete(obj); + } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs index 5bc8e194..2253da0f 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/ShowRepository.cs @@ -26,85 +26,84 @@ using Kyoo.Postgresql; using Kyoo.Utils; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle shows +/// +public class ShowRepository : LocalRepository { /// - /// A local repository to handle shows + /// The database handle /// - public class ShowRepository : LocalRepository + private readonly DatabaseContext _database; + + /// + /// A studio repository to handle creation/validation of related studios. + /// + private readonly IRepository _studios; + + public ShowRepository( + DatabaseContext database, + IRepository studios, + IThumbnailsManager thumbs + ) + : base(database, thumbs) { - /// - /// The database handle - /// - private readonly DatabaseContext _database; + _database = database; + _studios = studios; + } - /// - /// A studio repository to handle creation/validation of related studios. - /// - private readonly IRepository _studios; + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Shows, include) + .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) + .Take(20) + .ToListAsync(); + } - public ShowRepository( - DatabaseContext database, - IRepository studios, - IThumbnailsManager thumbs - ) - : base(database, thumbs) + /// + public override async Task Create(Show obj) + { + await base.Create(obj); + _database.Entry(obj).State = EntityState.Added; + await _database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } + + /// + protected override async Task Validate(Show resource) + { + await base.Validate(resource); + if (resource.Studio != null) { - _database = database; - _studios = studios; - } - - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Shows, include) - .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) - .Take(20) - .ToListAsync(); - } - - /// - public override async Task Create(Show obj) - { - await base.Create(obj); - _database.Entry(obj).State = EntityState.Added; - await _database.SaveChangesAsync(() => Get(obj.Slug)); - await IRepository.OnResourceCreated(obj); - return obj; - } - - /// - 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; - } - } - - /// - 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; - } - } - - /// - public override async Task Delete(Show obj) - { - _database.Remove(obj); - await _database.SaveChangesAsync(); - await base.Delete(obj); + resource.Studio = await _studios.CreateIfNotExists(resource.Studio); + resource.StudioId = resource.Studio.Id; } } + + /// + 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; + } + } + + /// + public override async Task Delete(Show obj) + { + _database.Remove(obj); + await _database.SaveChangesAsync(); + await base.Delete(obj); + } } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs index 743db76d..7cdc1358 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/StudioRepository.cs @@ -26,57 +26,56 @@ using Kyoo.Postgresql; using Kyoo.Utils; using Microsoft.EntityFrameworkCore; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// A local repository to handle studios +/// +public class StudioRepository : LocalRepository { /// - /// A local repository to handle studios + /// The database handle /// - public class StudioRepository : LocalRepository + private readonly DatabaseContext _database; + + /// + /// Create a new . + /// + /// The database handle + /// The thumbnail manager used to store images. + public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs) + : base(database, thumbs) { - /// - /// The database handle - /// - private readonly DatabaseContext _database; + _database = database; + } - /// - /// Create a new . - /// - /// The database handle - /// The thumbnail manager used to store images. - public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs) - : base(database, thumbs) - { - _database = database; - } + /// + public override async Task> Search( + string query, + Include? include = default + ) + { + return await AddIncludes(_database.Studios, include) + .Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) + .Take(20) + .ToListAsync(); + } - /// - public override async Task> Search( - string query, - Include? include = default - ) - { - return await AddIncludes(_database.Studios, include) - .Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) - .Take(20) - .ToListAsync(); - } + /// + public override async Task Create(Studio obj) + { + await base.Create(obj); + _database.Entry(obj).State = EntityState.Added; + await _database.SaveChangesAsync(() => Get(obj.Slug)); + await IRepository.OnResourceCreated(obj); + return obj; + } - /// - public override async Task Create(Studio obj) - { - await base.Create(obj); - _database.Entry(obj).State = EntityState.Added; - await _database.SaveChangesAsync(() => Get(obj.Slug)); - await IRepository.OnResourceCreated(obj); - return obj; - } - - /// - public override async Task Delete(Studio obj) - { - _database.Entry(obj).State = EntityState.Deleted; - await _database.SaveChangesAsync(); - await base.Delete(obj); - } + /// + public override async Task Delete(Studio obj) + { + _database.Entry(obj).State = EntityState.Deleted; + await _database.SaveChangesAsync(); + await base.Delete(obj); } } diff --git a/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs b/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs index c8624dd9..70caee44 100644 --- a/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs +++ b/back/src/Kyoo.Core/Controllers/ThumbnailsManager.cs @@ -31,238 +31,236 @@ using Kyoo.Abstractions.Models.Exceptions; using Microsoft.Extensions.Logging; using SkiaSharp; -namespace Kyoo.Core.Controllers +namespace Kyoo.Core.Controllers; + +/// +/// Download images and retrieve the path of those images for a resource. +/// +public class ThumbnailsManager( + IHttpClientFactory clientFactory, + ILogger logger, + Lazy> users +) : IThumbnailsManager { - /// - /// Download images and retrieve the path of those images for a resource. - /// - public class ThumbnailsManager( - IHttpClientFactory clientFactory, - ILogger logger, - Lazy> users - ) : IThumbnailsManager + private static readonly Dictionary> _downloading = new(); + + private static async Task _WriteTo(SKBitmap bitmap, string path, int quality) { - private static readonly Dictionary> _downloading = - new(); + SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); + 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); - await using Stream reader = data.AsStream(); - await using Stream file = File.Create(path); - await reader.CopyToAsync(file); - } + logger.LogInformation("Downloading image {What}", what); - private async Task _DownloadImage(Image? image, string localPath, string what) - { - if (image == null) - return; - try + 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.LogInformation("Downloading image {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); - } - } - - /// - public async Task DownloadImages(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? 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 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); - } - - /// - public string GetImagePath(T item, string image, ImageQuality quality) - where T : IThumbnails - { - return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp"; - } - - /// - public Task DeleteImages(T item) - where T : IThumbnails - { - IEnumerable 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 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 { } + logger.LogError("Unsupported codec for {What}", what); 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); + + 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); } } + + /// + public async Task DownloadImages(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? 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 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); + } + + /// + public string GetImagePath(T item, string image, ImageQuality quality) + where T : IThumbnails + { + return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp"; + } + + /// + public Task DeleteImages(T item) + where T : IThumbnails + { + IEnumerable 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 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); + } } diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 6a0c36f3..e7b27081 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -33,116 +33,115 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -namespace Kyoo.Core +namespace Kyoo.Core; + +/// +/// The core module containing default implementations +/// +public class CoreModule : IPlugin { /// - /// The core module containing default implementations + /// A service provider to access services in static context (in events for example). /// - public class CoreModule : IPlugin + /// Don't forget to create a scope. + public static IServiceProvider Services { get; set; } + + /// + public string Name => "Core"; + + /// + public void Configure(ContainerBuilder builder) { - /// - /// A service provider to access services in static context (in events for example). - /// - /// Don't forget to create a scope. - public static IServiceProvider Services { get; set; } + builder + .RegisterType() + .As() + .InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); - /// - public string Name => "Core"; - - /// - public void Configure(ContainerBuilder builder) - { - builder - .RegisterType() - .As() - .InstancePerLifetimeScope(); - builder.RegisterType().As().InstancePerLifetimeScope(); - - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository(); - builder.RegisterRepository().As(); - builder.RegisterRepository(); - builder - .RegisterType() - .As() - .AsSelf() - .InstancePerLifetimeScope(); - builder - .RegisterType() - .As() - .AsSelf() - .InstancePerLifetimeScope(); - builder.RegisterType().InstancePerLifetimeScope(); - } - - /// - public void Configure(IServiceCollection services) - { - services.AddHttpContextAccessor(); - - services - .AddMvcCore(options => - { - options.Filters.Add(); - 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(x => - { - x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); - }); - - services.AddResponseCompression(x => - { - x.EnableForHttps = true; - }); - - services.AddProxies(); - services.AddHttpClient(); - } - - /// - public IEnumerable ConfigureSteps => - new IStartupAction[] - { - SA.New(app => app.UseHsts(), SA.Before), - SA.New(app => app.UseResponseCompression(), SA.Routing + 1), - SA.New(app => app.UseRouting(), SA.Routing), - SA.New( - app => app.UseEndpoints(x => x.MapControllers()), - SA.Endpoint - ) - }; + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository(); + builder.RegisterRepository().As(); + builder.RegisterRepository(); + builder + .RegisterType() + .As() + .AsSelf() + .InstancePerLifetimeScope(); + builder + .RegisterType() + .As() + .AsSelf() + .InstancePerLifetimeScope(); + builder.RegisterType().InstancePerLifetimeScope(); } + + /// + public void Configure(IServiceCollection services) + { + services.AddHttpContextAccessor(); + + services + .AddMvcCore(options => + { + options.Filters.Add(); + 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(x => + { + x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint)); + }); + + services.AddResponseCompression(x => + { + x.EnableForHttps = true; + }); + + services.AddProxies(); + services.AddHttpClient(); + } + + /// + public IEnumerable ConfigureSteps => + new IStartupAction[] + { + SA.New(app => app.UseHsts(), SA.Before), + SA.New(app => app.UseResponseCompression(), SA.Routing + 1), + SA.New(app => app.UseRouting(), SA.Routing), + SA.New( + app => app.UseEndpoints(x => x.MapControllers()), + SA.Endpoint + ) + }; } diff --git a/back/src/Kyoo.Core/ExceptionFilter.cs b/back/src/Kyoo.Core/ExceptionFilter.cs index cc625641..77ea1027 100644 --- a/back/src/Kyoo.Core/ExceptionFilter.cs +++ b/back/src/Kyoo.Core/ExceptionFilter.cs @@ -25,59 +25,58 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; -namespace Kyoo.Core -{ - /// - /// A middleware to handle errors globally. - /// - /// - /// Initializes a new instance of the class. - /// - /// The logger used to log errors. - public class ExceptionFilter(ILogger logger) : IExceptionFilter - { - /// - 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; - } - } +namespace Kyoo.Core; - /// - public class ServerErrorObjectResult : ObjectResult +/// +/// A middleware to handle errors globally. +/// +/// +/// Initializes a new instance of the class. +/// +/// The logger used to log errors. +public class ExceptionFilter(ILogger logger) : IExceptionFilter +{ + /// + public void OnException(ExceptionContext context) + { + switch (context.Exception) { - /// - /// Initializes a new instance of the class. - /// - /// The object to return. - public ServerErrorObjectResult(object value) - : base(value) - { - StatusCode = StatusCodes.Status500InternalServerError; - } + 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; + } + } + + /// + public class ServerErrorObjectResult : ObjectResult + { + /// + /// Initializes a new instance of the class. + /// + /// The object to return. + public ServerErrorObjectResult(object value) + : base(value) + { + StatusCode = StatusCodes.Status500InternalServerError; } } } diff --git a/back/src/Kyoo.Core/Views/Health.cs b/back/src/Kyoo.Core/Views/Health.cs index 248d7ebf..7680b3b4 100644 --- a/back/src/Kyoo.Core/Views/Health.cs +++ b/back/src/Kyoo.Core/Views/Health.cs @@ -22,54 +22,53 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Diagnostics.HealthChecks; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// An API endpoint to check the health. +/// +[Route("health")] +[ApiController] +[ApiDefinition("Health")] +public class Health : BaseApi { + private readonly HealthCheckService _healthCheckService; + /// - /// An API endpoint to check the health. + /// Create a new . /// - [Route("health")] - [ApiController] - [ApiDefinition("Health")] - public class Health : BaseApi + /// The service to check health. + public Health(HealthCheckService healthCheckService) { - private readonly HealthCheckService _healthCheckService; - - /// - /// Create a new . - /// - /// The service to check health. - public Health(HealthCheckService healthCheckService) - { - _healthCheckService = healthCheckService; - } - - /// - /// Check if the api is ready to accept requests. - /// - /// A status indicating the health of the api. - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - public async Task 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), - }; - } - - /// - /// The result of a health operation. - /// - public record HealthResult(string Status); + _healthCheckService = healthCheckService; } + + /// + /// Check if the api is ready to accept requests. + /// + /// A status indicating the health of the api. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task 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), + }; + } + + /// + /// The result of a health operation. + /// + public record HealthResult(string Status); } diff --git a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs index 8db3378d..9d1c7fa7 100644 --- a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs @@ -25,76 +25,75 @@ using Kyoo.Abstractions.Models; using Kyoo.Utils; using Microsoft.AspNetCore.Mvc; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// A common API containing custom methods to help inheritors. +/// +public abstract class BaseApi : ControllerBase { /// - /// A common API containing custom methods to help inheritors. + /// Construct and return a page from an api. /// - public abstract class BaseApi : ControllerBase + /// The list of resources that should be included in the current page. + /// + /// 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. + /// + /// The type of items on the page. + /// A Page representing the response. + protected Page Page(ICollection resources, int limit) + where TResult : IResource { - /// - /// Construct and return a page from an api. - /// - /// The list of resources that should be included in the current page. - /// - /// 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. - /// - /// The type of items on the page. - /// A Page representing the response. - protected Page Page(ICollection resources, int limit) - where TResult : IResource + Dictionary query = Request.Query.ToDictionary( + 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...) + if (query.ContainsKey("sortBy")) { - Dictionary query = Request.Query.ToDictionary( - x => x.Key, - x => x.Value.ToString(), - StringComparer.InvariantCultureIgnoreCase - ); + object seed = HttpContext.Items["seed"]!; - // If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) - if (query.ContainsKey("sortBy")) - { - object seed = HttpContext.Items["seed"]!; + query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); + } + return new Page(resources, Request.Path, query, limit); + } - query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); - } - return new Page(resources, Request.Path, query, limit); + protected SearchPage SearchPage(SearchPage.SearchResult result) + where TResult : IResource + { + Dictionary 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 SearchPage(SearchPage.SearchResult result) - where TResult : IResource - { - Dictionary query = Request.Query.ToDictionary( - x => x.Key, - x => x.Value.ToString(), - StringComparer.InvariantCultureIgnoreCase - ); + query.Remove("skip"); + first = Request.Path + query.ToQueryString(); - 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(); - } - - query.Remove("skip"); - first = Request.Path + query.ToQueryString(); - - return new SearchPage(result, self, previous, next, first); - } + return new SearchPage(result, self, previous, next, first); } } diff --git a/back/src/Kyoo.Core/Views/Helper/CrudApi.cs b/back/src/Kyoo.Core/Views/Helper/CrudApi.cs index 4e537b69..ae389f95 100644 --- a/back/src/Kyoo.Core/Views/Helper/CrudApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/CrudApi.cs @@ -27,250 +27,246 @@ using Kyoo.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// A base class to handle CRUD operations on a specific resource type . +/// +/// The type of resource to make CRUD apis for. +[ApiController] +public class CrudApi : BaseApi + where T : class, IResource, IQuery { /// - /// A base class to handle CRUD operations on a specific resource type . + /// The repository of the resource, used to retrieve, save and do operations on the baking store. /// - /// The type of resource to make CRUD apis for. - [ApiController] - public class CrudApi : BaseApi - where T : class, IResource, IQuery + protected IRepository Repository { get; } + + /// + /// Create a new using the given repository and base url. + /// + /// + /// The repository to use as a baking store for the type . + /// + public CrudApi(IRepository repository) { - /// - /// The repository of the resource, used to retrieve, save and do operations on the baking store. - /// - protected IRepository Repository { get; } + Repository = repository; + } - /// - /// Create a new using the given repository and base url. - /// - /// - /// The repository to use as a baking store for the type . - /// - public CrudApi(IRepository repository) - { - Repository = repository; - } + /// + /// Get item + /// + /// + /// Get a specific resource via it's ID or it's slug. + /// + /// The ID or slug of the resource to retrieve. + /// The aditional fields to include in the result. + /// The retrieved resource. + /// A resource with the given ID or slug does not exist. + [HttpGet("{identifier:id}")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(Identifier identifier, [FromQuery] Include? fields) + { + T? ret = await identifier.Match( + id => Repository.GetOrDefault(id, fields), + slug => Repository.GetOrDefault(slug, fields) + ); + if (ret == null) + return NotFound(); + return ret; + } - /// - /// Get item - /// - /// - /// Get a specific resource via it's ID or it's slug. - /// - /// The ID or slug of the resource to retrieve. - /// The aditional fields to include in the result. - /// The retrieved resource. - /// A resource with the given ID or slug does not exist. - [HttpGet("{identifier:id}")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Get( - Identifier identifier, - [FromQuery] Include? fields - ) - { - T? ret = await identifier.Match( - id => Repository.GetOrDefault(id, fields), - slug => Repository.GetOrDefault(slug, fields) - ); - if (ret == null) - return NotFound(); - return ret; - } + /// + /// Get count + /// + /// + /// Get the number of resources that match the filters. + /// + /// A list of filters to respect. + /// How many resources matched that filter. + /// Invalid filters. + [HttpGet("count")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> GetCount([FromQuery] Filter filter) + { + return await Repository.GetCount(filter); + } - /// - /// Get count - /// - /// - /// Get the number of resources that match the filters. - /// - /// A list of filters to respect. - /// How many resources matched that filter. - /// Invalid filters. - [HttpGet("count")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - public async Task> GetCount([FromQuery] Filter filter) - { - return await Repository.GetCount(filter); - } + /// + /// Get all + /// + /// + /// Get all resources that match the given filter. + /// + /// Sort information about the query (sort by, sort order). + /// Filter the returned items. + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of resources that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll( + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await Repository.GetAll(filter, sortBy, fields, pagination); - /// - /// Get all - /// - /// - /// Get all resources that match the given filter. - /// - /// Sort information about the query (sort by, sort order). - /// Filter the returned items. - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of resources that match every filters. - /// Invalid filters or sort information. - [HttpGet] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - public async Task>> GetAll( - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include? fields - ) - { - ICollection resources = await Repository.GetAll(filter, sortBy, fields, pagination); + return Page(resources, pagination.Limit); + } - return Page(resources, pagination.Limit); - } + /// + /// Create new + /// + /// + /// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. + /// + /// The resource to create. + /// The created resource. + /// The resource in the request body is invalid. + /// This item already exists (maybe a duplicated slug). + [HttpPost] + [PartialPermission(Kind.Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] + public virtual async Task> Create([FromBody] T resource) + { + return await Repository.Create(resource); + } - /// - /// Create new - /// - /// - /// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. - /// - /// The resource to create. - /// The created resource. - /// The resource in the request body is invalid. - /// This item already exists (maybe a duplicated slug). - [HttpPost] - [PartialPermission(Kind.Create)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] - public virtual async Task> Create([FromBody] T resource) - { - return await Repository.Create(resource); - } - - /// - /// Edit - /// - /// - /// 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. - /// - /// The resource to edit. - /// The edited resource. - /// The resource in the request body is invalid. - /// No item found with the specified ID (or slug). - [HttpPut] - [PartialPermission(Kind.Write)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> 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; + /// + /// Edit + /// + /// + /// 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. + /// + /// The resource to edit. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPut] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Edit([FromBody] T resource) + { + if (resource.Id != Guid.Empty) return await Repository.Edit(resource); - } - /// - /// Patch - /// - /// - /// 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. - /// - /// The resource to patch. - /// The edited resource. - /// The resource in the request body is invalid. - /// No item found with the specified ID (or slug). - [HttpPatch] - [PartialPermission(Kind.Write)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Patch([FromBody] Patch 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(resource.Slug); + resource.Id = old.Id; + return await Repository.Edit(resource); + } - T old = await Repository.Get(patch.Slug); - return await Repository.Patch(old.Id, patch.Apply); - } - - /// - /// Patch - /// - /// - /// 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. - /// - /// The id or slug of the resource. - /// The resource to patch. - /// The edited resource. - /// The resource in the request body is invalid. - /// No item found with the specified ID (or slug). - [HttpPatch("{identifier:id}")] - [PartialPermission(Kind.Write)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Patch(Identifier identifier, [FromBody] Patch patch) - { - Guid id = await identifier.Match( - id => Task.FromResult(id), - async slug => (await Repository.Get(slug)).Id + /// + /// Patch + /// + /// + /// 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. + /// + /// The resource to patch. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPatch] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Patch([FromBody] Patch 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." ); - 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); - } - /// - /// Delete an item - /// - /// - /// Delete one item via it's ID or it's slug. - /// - /// The ID or slug of the resource to delete. - /// The item has successfully been deleted. - /// No item could be found with the given id or slug. - [HttpDelete("{identifier:id}")] - [PartialPermission(Kind.Delete)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Delete(Identifier identifier) - { - await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug)); - return NoContent(); - } + T old = await Repository.Get(patch.Slug); + return await Repository.Patch(old.Id, patch.Apply); + } - /// - /// Delete all where - /// - /// - /// Delete all items matching the given filters. If no filter is specified, delete all items. - /// - /// The list of filters. - /// The item(s) has successfully been deleted. - /// One or multiple filters are invalid. - [HttpDelete] - [PartialPermission(Kind.Delete)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - public async Task Delete([FromQuery] Filter filter) - { - if (filter == null) - return BadRequest( - new RequestError("Incule a filter to delete items, all items won't be deleted.") - ); + /// + /// Patch + /// + /// + /// 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. + /// + /// The id or slug of the resource. + /// The resource to patch. + /// The edited resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). + [HttpPatch("{identifier:id}")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Patch(Identifier identifier, [FromBody] Patch 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); - return NoContent(); - } + /// + /// Delete an item + /// + /// + /// Delete one item via it's ID or it's slug. + /// + /// The ID or slug of the resource to delete. + /// The item has successfully been deleted. + /// No item could be found with the given id or slug. + [HttpDelete("{identifier:id}")] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Identifier identifier) + { + await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug)); + return NoContent(); + } + + /// + /// Delete all where + /// + /// + /// Delete all items matching the given filters. If no filter is specified, delete all items. + /// + /// The list of filters. + /// The item(s) has successfully been deleted. + /// One or multiple filters are invalid. + [HttpDelete] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task Delete([FromQuery] Filter 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(); } } diff --git a/back/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs b/back/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs index 190a05ab..c0978878 100644 --- a/back/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/CrudThumbsApi.cs @@ -26,126 +26,119 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// A base class to handle CRUD operations and services thumbnails for +/// a specific resource type . +/// +/// The type of resource to make CRUD and thumbnails apis for. +[ApiController] +public class CrudThumbsApi : CrudApi + where T : class, IResource, IThumbnails, IQuery { /// - /// A base class to handle CRUD operations and services thumbnails for - /// a specific resource type . + /// The thumbnail manager used to retrieve images paths. /// - /// The type of resource to make CRUD and thumbnails apis for. - [ApiController] - public class CrudThumbsApi : CrudApi - where T : class, IResource, IThumbnails, IQuery + private readonly IThumbnailsManager _thumbs; + + /// + /// Create a new that handles crud requests and thumbnails. + /// + /// + /// The repository to use as a baking store for the type . + /// + /// The thumbnail manager used to retrieve images paths. + public CrudThumbsApi(IRepository repository, IThumbnailsManager thumbs) + : base(repository) { - /// - /// The thumbnail manager used to retrieve images paths. - /// - private readonly IThumbnailsManager _thumbs; + _thumbs = thumbs; + } - /// - /// Create a new that handles crud requests and thumbnails. - /// - /// - /// The repository to use as a baking store for the type . - /// - /// The thumbnail manager used to retrieve images paths. - public CrudThumbsApi(IRepository repository, IThumbnailsManager thumbs) - : base(repository) + private async Task _GetImage( + Identifier identifier, + string image, + ImageQuality? quality + ) + { + T? resource = await identifier.Match( + id => Repository.GetOrDefault(id), + 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 _GetImage( - Identifier identifier, - string image, - ImageQuality? quality - ) - { - T? resource = await identifier.Match( - id => Repository.GetOrDefault(id), - 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(); + /// + /// Get Poster + /// + /// + /// Get the poster for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/poster")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "poster", quality); + } - if (!identifier.Match(id => false, slug => slug == "random")) - { - // 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); - } + /// + /// Get Logo + /// + /// + /// Get the logo for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/logo")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public Task GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "logo", quality); + } - /// - /// Get Poster - /// - /// - /// Get the poster for the specified item. - /// - /// The ID or slug of the resource to get the image for. - /// The quality of the image to retrieve. - /// The image asked. - /// - /// No item exist with the specific identifier or the image does not exists on kyoo. - /// - [HttpGet("{identifier:id}/poster")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public Task GetPoster( - Identifier identifier, - [FromQuery] ImageQuality? quality - ) - { - return _GetImage(identifier, "poster", quality); - } - - /// - /// Get Logo - /// - /// - /// Get the logo for the specified item. - /// - /// The ID or slug of the resource to get the image for. - /// The quality of the image to retrieve. - /// The image asked. - /// - /// No item exist with the specific identifier or the image does not exists on kyoo. - /// - [HttpGet("{identifier:id}/logo")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public Task GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality) - { - return _GetImage(identifier, "logo", quality); - } - - /// - /// Get Thumbnail - /// - /// - /// Get the thumbnail for the specified item. - /// - /// The ID or slug of the resource to get the image for. - /// The quality of the image to retrieve. - /// The image asked. - /// - /// No item exist with the specific identifier or the image does not exists on kyoo. - /// - [HttpGet("{identifier:id}/thumbnail")] - [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] - public Task GetBackdrop( - Identifier identifier, - [FromQuery] ImageQuality? quality - ) - { - return _GetImage(identifier, "thumbnail", quality); - } + /// + /// Get Thumbnail + /// + /// + /// Get the thumbnail for the specified item. + /// + /// The ID or slug of the resource to get the image for. + /// The quality of the image to retrieve. + /// The image asked. + /// + /// No item exist with the specific identifier or the image does not exists on kyoo. + /// + [HttpGet("{identifier:id}/thumbnail")] + [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] + public Task GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality) + { + return _GetImage(identifier, "thumbnail", quality); } } diff --git a/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs b/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs index ebb0f48e..a3148359 100644 --- a/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs +++ b/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs @@ -28,76 +28,75 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("studios")] +[Route("studio", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Show))] +[ApiDefinition("Studios", Group = MetadataGroup)] +public class StudioApi : CrudApi { /// - /// Information about one or multiple . + /// The library manager used to modify or retrieve information in the data store. /// - [Route("studios")] - [Route("studio", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Show))] - [ApiDefinition("Studios", Group = MetadataGroup)] - public class StudioApi : CrudApi + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new . + /// + /// + /// The library manager used to modify or retrieve information in the data store. + /// + public StudioApi(ILibraryManager libraryManager) + : base(libraryManager.Studios) { - /// - /// The library manager used to modify or retrieve information in the data store. - /// - private readonly ILibraryManager _libraryManager; + _libraryManager = libraryManager; + } - /// - /// Create a new . - /// - /// - /// The library manager used to modify or retrieve information in the data store. - /// - public StudioApi(ILibraryManager libraryManager) - : base(libraryManager.Studios) - { - _libraryManager = libraryManager; - } + /// + /// Get shows + /// + /// + /// List shows that were made by this specific studio. + /// + /// The ID or slug of the . + /// A key to sort shows by. + /// An optional list of filters. + /// The number of shows to return. + /// The aditional fields to include in the result. + /// A page of shows. + /// The filters or the sort parameters are invalid. + /// No studio with the given ID or slug could be found. + [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>> GetShows( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Shows.GetAll( + Filter.And(filter, identifier.Matcher(x => x.StudioId, x => x.Studio!.Slug)), + sortBy, + fields, + pagination + ); - /// - /// Get shows - /// - /// - /// List shows that were made by this specific studio. - /// - /// The ID or slug of the . - /// A key to sort shows by. - /// An optional list of filters. - /// The number of shows to return. - /// The aditional fields to include in the result. - /// A page of shows. - /// The filters or the sort parameters are invalid. - /// No studio with the given ID or slug could be found. - [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>> GetShows( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields + if ( + !resources.Any() + && await _libraryManager.Studios.GetOrDefault(identifier.IsSame()) == null ) - { - ICollection resources = await _libraryManager.Shows.GetAll( - Filter.And(filter, identifier.Matcher(x => x.StudioId, x => x.Studio!.Slug)), - sortBy, - fields, - pagination - ); - - if ( - !resources.Any() - && await _libraryManager.Studios.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + return NotFound(); + return Page(resources, pagination.Limit); } } diff --git a/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs b/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs index c9ef63cd..c40b1bc6 100644 --- a/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs @@ -30,234 +30,233 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("collections")] +[Route("collection", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Collection))] +[ApiDefinition("Collections", Group = ResourcesGroup)] +public class CollectionApi : CrudThumbsApi { - /// - /// Information about one or multiple . - /// - [Route("collections")] - [Route("collection", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Collection))] - [ApiDefinition("Collections", Group = ResourcesGroup)] - public class CollectionApi : CrudThumbsApi + private readonly ILibraryManager _libraryManager; + private readonly CollectionRepository _collections; + private readonly LibraryItemRepository _items; + + public CollectionApi( + ILibraryManager libraryManager, + CollectionRepository collections, + LibraryItemRepository items, + IThumbnailsManager thumbs + ) + : base(libraryManager.Collections, thumbs) { - private readonly ILibraryManager _libraryManager; - private readonly CollectionRepository _collections; - private readonly LibraryItemRepository _items; + _libraryManager = libraryManager; + _collections = collections; + _items = items; + } - public CollectionApi( - ILibraryManager libraryManager, - CollectionRepository collections, - LibraryItemRepository items, - IThumbnailsManager thumbs + /// + /// Add a movie + /// + /// + /// Add a movie in the collection. + /// + /// The ID or slug of the . + /// The ID or slug of the to add. + /// Nothing if successful. + /// No collection or movie with the given ID could be found. + /// The specified movie is already in this collection. + [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 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(); + } + + /// + /// Add a show + /// + /// + /// Add a show in the collection. + /// + /// The ID or slug of the . + /// The ID or slug of the to add. + /// Nothing if successful. + /// No collection or show with the given ID could be found. + /// The specified show is already in this collection. + [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 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(); + } + + /// + /// Get items in collection + /// + /// + /// Lists the items that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort items by. + /// An optional list of filters. + /// The number of items to return. + /// The aditional fields to include in the result. + /// A page of items. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [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>> GetItems( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + Guid collectionId = await identifier.Match( + id => Task.FromResult(id), + async slug => (await _libraryManager.Collections.Get(slug)).Id + ); + ICollection resources = await _items.GetAllOfCollection( + collectionId, + filter, + sortBy == new Sort.Default() + ? new Sort.By(nameof(Movie.AirDate)) + : sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) + == null ) - : base(libraryManager.Collections, thumbs) - { - _libraryManager = libraryManager; - _collections = collections; - _items = items; - } + return NotFound(); + return Page(resources, pagination.Limit); + } - /// - /// Add a movie - /// - /// - /// Add a movie in the collection. - /// - /// The ID or slug of the . - /// The ID or slug of the to add. - /// Nothing if successful. - /// No collection or movie with the given ID could be found. - /// The specified movie is already in this collection. - [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 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(); - } + /// + /// Get shows in collection + /// + /// + /// Lists the shows that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort shows by. + /// An optional list of filters. + /// The number of shows to return. + /// The additional fields to include in the result. + /// A page of shows. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [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>> GetShows( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await _libraryManager.Shows.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), + sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, + fields, + pagination + ); - /// - /// Add a show - /// - /// - /// Add a show in the collection. - /// - /// The ID or slug of the . - /// The ID or slug of the to add. - /// Nothing if successful. - /// No collection or show with the given ID could be found. - /// The specified show is already in this collection. - [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 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(); - } - - /// - /// Get items in collection - /// - /// - /// Lists the items that are contained in the collection with the given id or slug. - /// - /// The ID or slug of the . - /// A key to sort items by. - /// An optional list of filters. - /// The number of items to return. - /// The aditional fields to include in the result. - /// A page of items. - /// The filters or the sort parameters are invalid. - /// No collection with the given ID could be found. - [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>> GetItems( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include? fields + if ( + !resources.Any() + && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) + == null ) - { - Guid collectionId = await identifier.Match( - id => Task.FromResult(id), - async slug => (await _libraryManager.Collections.Get(slug)).Id - ); - ICollection resources = await _items.GetAllOfCollection( - collectionId, - filter, - sortBy == new Sort.Default() - ? new Sort.By(nameof(Movie.AirDate)) - : sortBy, - fields, - pagination - ); + return NotFound(); + return Page(resources, pagination.Limit); + } - if ( - !resources.Any() - && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) - == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + /// + /// Get movies in collection + /// + /// + /// Lists the movies that are contained in the collection with the given id or slug. + /// + /// The ID or slug of the . + /// A key to sort movies by. + /// An optional list of filters. + /// The number of movies to return. + /// The aditional fields to include in the result. + /// A page of movies. + /// The filters or the sort parameters are invalid. + /// No collection with the given ID could be found. + [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>> GetMovies( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) + { + ICollection resources = await _libraryManager.Movies.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), + sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, + fields, + pagination + ); - /// - /// Get shows in collection - /// - /// - /// Lists the shows that are contained in the collection with the given id or slug. - /// - /// The ID or slug of the . - /// A key to sort shows by. - /// An optional list of filters. - /// The number of shows to return. - /// The additional fields to include in the result. - /// A page of shows. - /// The filters or the sort parameters are invalid. - /// No collection with the given ID could be found. - [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>> GetShows( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include? fields + if ( + !resources.Any() + && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) + == null ) - { - ICollection resources = await _libraryManager.Shows.GetAll( - Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), - sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, - fields, - pagination - ); - - if ( - !resources.Any() - && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) - == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } - - /// - /// Get movies in collection - /// - /// - /// Lists the movies that are contained in the collection with the given id or slug. - /// - /// The ID or slug of the . - /// A key to sort movies by. - /// An optional list of filters. - /// The number of movies to return. - /// The aditional fields to include in the result. - /// A page of movies. - /// The filters or the sort parameters are invalid. - /// No collection with the given ID could be found. - [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>> GetMovies( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include? fields - ) - { - ICollection resources = await _libraryManager.Movies.GetAll( - Filter.And(filter, identifier.IsContainedIn(x => x.Collections)), - sortBy == new Sort.Default() ? new Sort.By(x => x.AirDate) : sortBy, - fields, - pagination - ); - - if ( - !resources.Any() - && await _libraryManager.Collections.GetOrDefault(identifier.IsSame()) - == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + return NotFound(); + return Page(resources, pagination.Limit); } } diff --git a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs index af801914..f44cdf6e 100644 --- a/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/EpisodeApi.cs @@ -28,172 +28,171 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("episodes")] +[Route("episode", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Episode))] +[ApiDefinition("Episodes", Group = ResourcesGroup)] +public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) + : TranscoderApi(libraryManager.Episodes, thumbnails) { /// - /// Information about one or multiple . + /// Get episode's show /// - [Route("episodes")] - [Route("episode", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Episode))] - [ApiDefinition("Episodes", Group = ResourcesGroup)] - public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) - : TranscoderApi(libraryManager.Episodes, thumbnails) + /// + /// Get the show that this episode is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The show that contains this episode. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/show")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetShow( + Identifier identifier, + [FromQuery] Include fields + ) { - /// - /// Get episode's show - /// - /// - /// Get the show that this episode is part of. - /// - /// The ID or slug of the . - /// The aditional fields to include in the result. - /// The show that contains this episode. - /// No episode with the given ID or slug could be found. - [HttpGet("{identifier:id}/show")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetShow( - Identifier identifier, - [FromQuery] Include fields - ) - { - return await libraryManager.Shows.Get( - identifier.IsContainedIn(x => x.Episodes!), - fields - ); - } + return await libraryManager.Shows.Get( + identifier.IsContainedIn(x => x.Episodes!), + fields + ); + } - /// - /// Get episode's season - /// - /// - /// Get the season that this episode is part of. - /// - /// The ID or slug of the . - /// The aditional fields to include in the result. - /// The season that contains this episode. - /// The episode is not part of a season. - /// No episode with the given ID or slug could be found. - [HttpGet("{identifier:id}/season")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetSeason( - Identifier identifier, - [FromQuery] Include fields - ) - { - Season? ret = await libraryManager.Seasons.GetOrDefault( - identifier.IsContainedIn(x => x.Episodes!), - fields - ); - if (ret != null) - return ret; - Episode? episode = await identifier.Match( - id => libraryManager.Episodes.GetOrDefault(id), - slug => libraryManager.Episodes.GetOrDefault(slug) - ); - return episode == null ? NotFound() : NoContent(); - } + /// + /// Get episode's season + /// + /// + /// Get the season that this episode is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The season that contains this episode. + /// The episode is not part of a season. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/season")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetSeason( + Identifier identifier, + [FromQuery] Include fields + ) + { + Season? ret = await libraryManager.Seasons.GetOrDefault( + identifier.IsContainedIn(x => x.Episodes!), + fields + ); + if (ret != null) + return ret; + Episode? episode = await identifier.Match( + id => libraryManager.Episodes.GetOrDefault(id), + slug => libraryManager.Episodes.GetOrDefault(slug) + ); + return episode == null ? NotFound() : NoContent(); + } - /// - /// Get watch status - /// - /// - /// Get when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The status. - /// This episode does not have a specific status. - /// No episode with the given ID or slug could be found. - [HttpGet("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetWatchStatus(Identifier identifier) - { - Guid id = await identifier.Match( - id => Task.FromResult(id), - async slug => (await libraryManager.Episodes.Get(slug)).Id - ); - return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); - } + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This episode does not have a specific status. + /// No episode with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); + } - /// - /// Set watch status - /// - /// - /// Set when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The new watch status. - /// Where the user stopped watching (in seconds). - /// Where the user stopped watching (in percent). - /// The newly set status. - /// The status has been set - /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). - /// No episode with the given ID or slug could be found. - [HttpPost("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task SetWatchStatus( - Identifier identifier, - WatchStatus status, - int? watchedTime, - int? percent - ) - { - Guid id = await identifier.Match( - id => Task.FromResult(id), - async slug => (await libraryManager.Episodes.Get(slug)).Id - ); - return await libraryManager.WatchStatus.SetEpisodeStatus( - id, - User.GetIdOrThrow(), - status, - watchedTime, - percent - ); - } + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// Where the user stopped watching (in seconds). + /// Where the user stopped watching (in percent). + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// No episode with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetWatchStatus( + Identifier identifier, + WatchStatus status, + int? watchedTime, + int? percent + ) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Episodes.Get(slug)).Id + ); + return await libraryManager.WatchStatus.SetEpisodeStatus( + id, + User.GetIdOrThrow(), + status, + watchedTime, + percent + ); + } - /// - /// Delete watch status - /// - /// - /// Delete watch status (to rewatch for example). - /// - /// The ID or slug of the . - /// The newly set status. - /// The status has been deleted. - /// No episode with the given ID or slug could be found. - [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.Episodes.Get(slug)).Id - ); - await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); - } + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No episode with the given ID or slug could be found. + [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.Episodes.Get(slug)).Id + ); + await libraryManager.WatchStatus.DeleteEpisodeStatus(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, $"/episodes/{identifier}"); - } + 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, $"/episodes/{identifier}"); } } diff --git a/back/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs b/back/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs index f4bbe1c4..9e203375 100644 --- a/back/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/LibraryItemApi.cs @@ -23,35 +23,34 @@ using Kyoo.Abstractions.Models.Permissions; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Endpoint for items that are not part of a specific library. +/// An item can ether represent a collection or a show. +/// +[Route("items")] +[Route("item", Order = AlternativeRoute)] +[ApiController] +[PartialPermission("LibraryItem")] +[ApiDefinition("Items", Group = ResourcesGroup)] +public class LibraryItemApi : CrudThumbsApi { /// - /// Endpoint for items that are not part of a specific library. - /// An item can ether represent a collection or a show. + /// The library item repository used to modify or retrieve information in the data store. /// - [Route("items")] - [Route("item", Order = AlternativeRoute)] - [ApiController] - [PartialPermission("LibraryItem")] - [ApiDefinition("Items", Group = ResourcesGroup)] - public class LibraryItemApi : CrudThumbsApi - { - /// - /// The library item repository used to modify or retrieve information in the data store. - /// - private readonly IRepository _libraryItems; + private readonly IRepository _libraryItems; - /// - /// Create a new . - /// - /// - /// The library item repository used to modify or retrieve information in the data store. - /// - /// Thumbnail manager to retrieve images. - public LibraryItemApi(IRepository libraryItems, IThumbnailsManager thumbs) - : base(libraryItems, thumbs) - { - _libraryItems = libraryItems; - } + /// + /// Create a new . + /// + /// + /// The library item repository used to modify or retrieve information in the data store. + /// + /// Thumbnail manager to retrieve images. + public LibraryItemApi(IRepository libraryItems, IThumbnailsManager thumbs) + : base(libraryItems, thumbs) + { + _libraryItems = libraryItems; } } diff --git a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs index f83d92cc..fddc00b0 100644 --- a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs @@ -30,182 +30,181 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("movies")] +[Route("movie", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Show))] +[ApiDefinition("Shows", Group = ResourcesGroup)] +public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) + : TranscoderApi(libraryManager.Movies, thumbs) { /// - /// Information about one or multiple . + /// Get studio that made the show /// - [Route("movies")] - [Route("movie", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Show))] - [ApiDefinition("Shows", Group = ResourcesGroup)] - public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) - : TranscoderApi(libraryManager.Movies, thumbs) + /// + /// Get the studio that made the show. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The studio that made the show. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/studio")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStudio( + Identifier identifier, + [FromQuery] Include fields + ) { - /// - /// Get studio that made the show - /// - /// - /// Get the studio that made the show. - /// - /// The ID or slug of the . - /// The aditional fields to include in the result. - /// The studio that made the show. - /// No show with the given ID or slug could be found. - [HttpGet("{identifier:id}/studio")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetStudio( - Identifier identifier, - [FromQuery] Include fields + return await libraryManager.Studios.Get( + identifier.IsContainedIn(x => x.Movies!), + fields + ); + } + + /// + /// Get collections containing this show + /// + /// + /// List the collections that contain this show. + /// + /// The ID or slug of the . + /// A key to sort collections by. + /// An optional list of filters. + /// The number of collections to return. + /// The aditional fields to include in the result. + /// A page of collections. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [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>> GetCollections( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await libraryManager.Collections.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Movies)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await libraryManager.Movies.GetOrDefault(identifier.IsSame()) == null ) - { - return await libraryManager.Studios.Get( - identifier.IsContainedIn(x => x.Movies!), - fields - ); - } + return NotFound(); + return Page(resources, pagination.Limit); + } - /// - /// Get collections containing this show - /// - /// - /// List the collections that contain this show. - /// - /// The ID or slug of the . - /// A key to sort collections by. - /// An optional list of filters. - /// The number of collections to return. - /// The aditional fields to include in the result. - /// A page of collections. - /// The filters or the sort parameters are invalid. - /// No show with the given ID or slug could be found. - [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>> GetCollections( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields - ) - { - ICollection resources = await libraryManager.Collections.GetAll( - Filter.And(filter, identifier.IsContainedIn(x => x.Movies)), - sortBy, - fields, - pagination - ); + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This movie does not have a specific status. + /// No movie with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await libraryManager.Movies.Get(slug)).Id + ); + return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); + } - if ( - !resources.Any() - && await libraryManager.Movies.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// Where the user stopped watching. + /// Where the user stopped watching (in percent). + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// WatchedTime can't be specified if status is not watching. + /// No movie with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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 + ); + } - /// - /// Get watch status - /// - /// - /// Get when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The status. - /// This movie does not have a specific status. - /// No movie with the given ID or slug could be found. - [HttpGet("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetWatchStatus(Identifier identifier) - { - Guid id = await identifier.Match( - id => Task.FromResult(id), - async slug => (await libraryManager.Movies.Get(slug)).Id - ); - return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); - } + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No movie with the given ID or slug could be found. + [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()); + } - /// - /// Set watch status - /// - /// - /// Set when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The new watch status. - /// Where the user stopped watching. - /// Where the user stopped watching (in percent). - /// The newly set status. - /// The status has been set - /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). - /// WatchedTime can't be specified if status is not watching. - /// No movie with the given ID or slug could be found. - [HttpPost("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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 - ); - } - - /// - /// Delete watch status - /// - /// - /// Delete watch status (to rewatch for example). - /// - /// The ID or slug of the . - /// The newly set status. - /// The status has been deleted. - /// No movie with the given ID or slug could be found. - [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}"); - } + 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}"); } } diff --git a/back/src/Kyoo.Core/Views/Resources/NewsApi.cs b/back/src/Kyoo.Core/Views/Resources/NewsApi.cs index ae936e4f..56153d6b 100644 --- a/back/src/Kyoo.Core/Views/Resources/NewsApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/NewsApi.cs @@ -23,19 +23,18 @@ using Kyoo.Abstractions.Models.Permissions; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// List new items added to kyoo. +/// +[Route("news")] +[Route("new", Order = AlternativeRoute)] +[ApiController] +[PartialPermission("LibraryItem")] +[ApiDefinition("News", Group = ResourcesGroup)] +public class NewsApi : CrudThumbsApi { - /// - /// List new items added to kyoo. - /// - [Route("news")] - [Route("new", Order = AlternativeRoute)] - [ApiController] - [PartialPermission("LibraryItem")] - [ApiDefinition("News", Group = ResourcesGroup)] - public class NewsApi : CrudThumbsApi - { - public NewsApi(IRepository news, IThumbnailsManager thumbs) - : base(news, thumbs) { } - } + public NewsApi(IRepository news, IThumbnailsManager thumbs) + : base(news, thumbs) { } } diff --git a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs index 46bc24f1..c0043c15 100644 --- a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs @@ -26,182 +26,179 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource +/// is available on the said endpoint. +/// +[Route("search")] +[ApiController] +[ApiDefinition("Search", Group = ResourcesGroup)] +public class SearchApi : BaseApi { - /// - /// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource - /// is available on the said endpoint. - /// - [Route("search")] - [ApiController] - [ApiDefinition("Search", Group = ResourcesGroup)] - public class SearchApi : BaseApi + private readonly ISearchManager _searchManager; + + public SearchApi(ISearchManager searchManager) { - private readonly ISearchManager _searchManager; + _searchManager = searchManager; + } - public SearchApi(ISearchManager searchManager) - { - _searchManager = searchManager; - } + // TODO: add filters and facets - // TODO: add filters and facets + /// + /// Search collections + /// + /// + /// Search for collections + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of collections found for the specified query. + [HttpGet("collections")] + [HttpGet("collection", Order = AlternativeRoute)] + [Permission(nameof(Collection), Kind.Read)] + [ApiDefinition("Collections")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchCollections( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields)); + } - /// - /// Search collections - /// - /// - /// Search for collections - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of collections found for the specified query. - [HttpGet("collections")] - [HttpGet("collection", Order = AlternativeRoute)] - [Permission(nameof(Collection), Kind.Read)] - [ApiDefinition("Collections")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchCollections( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage( - await _searchManager.SearchCollections(q, sortBy, pagination, fields) - ); - } + /// + /// Search shows + /// + /// + /// Search for shows + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of shows found for the specified query. + [HttpGet("shows")] + [HttpGet("show", Order = AlternativeRoute)] + [Permission(nameof(Show), Kind.Read)] + [ApiDefinition("Show")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchShows( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); + } - /// - /// Search shows - /// - /// - /// Search for shows - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of shows found for the specified query. - [HttpGet("shows")] - [HttpGet("show", Order = AlternativeRoute)] - [Permission(nameof(Show), Kind.Read)] - [ApiDefinition("Show")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchShows( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); - } + /// + /// Search movie + /// + /// + /// Search for movie + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of movies found for the specified query. + [HttpGet("movies")] + [HttpGet("movie", Order = AlternativeRoute)] + [Permission(nameof(Movie), Kind.Read)] + [ApiDefinition("Movie")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchMovies( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); + } - /// - /// Search movie - /// - /// - /// Search for movie - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of movies found for the specified query. - [HttpGet("movies")] - [HttpGet("movie", Order = AlternativeRoute)] - [Permission(nameof(Movie), Kind.Read)] - [ApiDefinition("Movie")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchMovies( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); - } + /// + /// Search items + /// + /// + /// Search for items + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of items found for the specified query. + [HttpGet("items")] + [HttpGet("item", Order = AlternativeRoute)] + [Permission(nameof(ILibraryItem), Kind.Read)] + [ApiDefinition("Item")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchItems( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); + } - /// - /// Search items - /// - /// - /// Search for items - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of items found for the specified query. - [HttpGet("items")] - [HttpGet("item", Order = AlternativeRoute)] - [Permission(nameof(ILibraryItem), Kind.Read)] - [ApiDefinition("Item")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchItems( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); - } + /// + /// Search episodes + /// + /// + /// Search for episodes + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of episodes found for the specified query. + [HttpGet("episodes")] + [HttpGet("episode", Order = AlternativeRoute)] + [Permission(nameof(Episode), Kind.Read)] + [ApiDefinition("Episodes")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchEpisodes( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); + } - /// - /// Search episodes - /// - /// - /// Search for episodes - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of episodes found for the specified query. - [HttpGet("episodes")] - [HttpGet("episode", Order = AlternativeRoute)] - [Permission(nameof(Episode), Kind.Read)] - [ApiDefinition("Episodes")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchEpisodes( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); - } - - /// - /// Search studios - /// - /// - /// Search for studios - /// - /// The query to search for. - /// Sort information about the query (sort by, sort order). - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of studios found for the specified query. - [HttpGet("studios")] - [HttpGet("studio", Order = AlternativeRoute)] - [Permission(nameof(Studio), Kind.Read)] - [ApiDefinition("Studios")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchStudios( - [FromQuery] string? q, - [FromQuery] Sort sortBy, - [FromQuery] SearchPagination pagination, - [FromQuery] Include fields - ) - { - return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); - } + /// + /// Search studios + /// + /// + /// Search for studios + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of studios found for the specified query. + [HttpGet("studios")] + [HttpGet("studio", Order = AlternativeRoute)] + [Permission(nameof(Studio), Kind.Read)] + [ApiDefinition("Studios")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchStudios( + [FromQuery] string? q, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields + ) + { + return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); } } diff --git a/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs b/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs index bcfb517b..d12c2273 100644 --- a/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs @@ -28,108 +28,104 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("seasons")] +[Route("season", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Season))] +[ApiDefinition("Seasons", Group = ResourcesGroup)] +public class SeasonApi : CrudThumbsApi { /// - /// Information about one or multiple . + /// The library manager used to modify or retrieve information in the data store. /// - [Route("seasons")] - [Route("season", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Season))] - [ApiDefinition("Seasons", Group = ResourcesGroup)] - public class SeasonApi : CrudThumbsApi + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new . + /// + /// + /// The library manager used to modify or retrieve information in the data store. + /// + /// The thumbnail manager used to retrieve images paths. + public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) + : base(libraryManager.Seasons, thumbs) { - /// - /// The library manager used to modify or retrieve information in the data store. - /// - private readonly ILibraryManager _libraryManager; + _libraryManager = libraryManager; + } - /// - /// Create a new . - /// - /// - /// The library manager used to modify or retrieve information in the data store. - /// - /// The thumbnail manager used to retrieve images paths. - public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) - : base(libraryManager.Seasons, thumbs) - { - _libraryManager = libraryManager; - } + /// + /// Get episodes in the season + /// + /// + /// List the episodes that are part of the specified season. + /// + /// The ID or slug of the . + /// A key to sort episodes by. + /// An optional list of filters. + /// The number of episodes to return. + /// The aditional fields to include in the result. + /// A page of episodes. + /// The filters or the sort parameters are invalid. + /// No season with the given ID or slug could be found. + [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>> GetEpisode( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Episodes.GetAll( + Filter.And(filter, identifier.Matcher(x => x.SeasonId, x => x.Season!.Slug)), + sortBy, + fields, + pagination + ); - /// - /// Get episodes in the season - /// - /// - /// List the episodes that are part of the specified season. - /// - /// The ID or slug of the . - /// A key to sort episodes by. - /// An optional list of filters. - /// The number of episodes to return. - /// The aditional fields to include in the result. - /// A page of episodes. - /// The filters or the sort parameters are invalid. - /// No season with the given ID or slug could be found. - [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>> GetEpisode( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields + if ( + !resources.Any() + && await _libraryManager.Seasons.GetOrDefault(identifier.IsSame()) == null ) - { - ICollection resources = await _libraryManager.Episodes.GetAll( - Filter.And( - filter, - identifier.Matcher(x => x.SeasonId, x => x.Season!.Slug) - ), - sortBy, - fields, - pagination - ); + return NotFound(); + return Page(resources, pagination.Limit); + } - if ( - !resources.Any() - && await _libraryManager.Seasons.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } - - /// - /// Get season's show - /// - /// - /// Get the show that this season is part of. - /// - /// The ID or slug of the . - /// The aditional fields to include in the result. - /// The show that contains this season. - /// No season with the given ID or slug could be found. - [HttpGet("{identifier:id}/show")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetShow( - Identifier identifier, - [FromQuery] Include fields - ) - { - Show? ret = await _libraryManager.Shows.GetOrDefault( - identifier.IsContainedIn(x => x.Seasons!), - fields - ); - if (ret == null) - return NotFound(); - return ret; - } + /// + /// Get season's show + /// + /// + /// Get the show that this season is part of. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The show that contains this season. + /// No season with the given ID or slug could be found. + [HttpGet("{identifier:id}/show")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetShow( + Identifier identifier, + [FromQuery] Include fields + ) + { + Show? ret = await _libraryManager.Shows.GetOrDefault( + identifier.IsContainedIn(x => x.Seasons!), + fields + ); + if (ret == null) + return NotFound(); + return ret; } } diff --git a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs index 96da58c2..0946e2c8 100644 --- a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs @@ -30,265 +30,261 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// Information about one or multiple . +/// +[Route("shows")] +[Route("show", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Show))] +[ApiDefinition("Shows", Group = ResourcesGroup)] +public class ShowApi : CrudThumbsApi { /// - /// Information about one or multiple . + /// The library manager used to modify or retrieve information in the data store. /// - [Route("shows")] - [Route("show", Order = AlternativeRoute)] - [ApiController] - [PartialPermission(nameof(Show))] - [ApiDefinition("Shows", Group = ResourcesGroup)] - public class ShowApi : CrudThumbsApi + private readonly ILibraryManager _libraryManager; + + /// + /// Create a new . + /// + /// + /// The library manager used to modify or retrieve information about the data store. + /// + /// The thumbnail manager used to retrieve images paths. + public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) + : base(libraryManager.Shows, thumbs) { - /// - /// The library manager used to modify or retrieve information in the data store. - /// - private readonly ILibraryManager _libraryManager; + _libraryManager = libraryManager; + } - /// - /// Create a new . - /// - /// - /// The library manager used to modify or retrieve information about the data store. - /// - /// The thumbnail manager used to retrieve images paths. - public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) - : base(libraryManager.Shows, thumbs) - { - _libraryManager = libraryManager; - } + /// + /// Get seasons of this show + /// + /// + /// List the seasons that are part of the specified show. + /// + /// The ID or slug of the . + /// A key to sort seasons by. + /// An optional list of filters. + /// The number of seasons to return. + /// The aditional fields to include in the result. + /// A page of seasons. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [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>> GetSeasons( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Seasons.GetAll( + Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), + sortBy, + fields, + pagination + ); - /// - /// Get seasons of this show - /// - /// - /// List the seasons that are part of the specified show. - /// - /// The ID or slug of the . - /// A key to sort seasons by. - /// An optional list of filters. - /// The number of seasons to return. - /// The aditional fields to include in the result. - /// A page of seasons. - /// The filters or the sort parameters are invalid. - /// No show with the given ID or slug could be found. - [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>> GetSeasons( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields + if ( + !resources.Any() + && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null ) - { - ICollection resources = await _libraryManager.Seasons.GetAll( - Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), - sortBy, - fields, - pagination - ); + return NotFound(); + return Page(resources, pagination.Limit); + } - if ( - !resources.Any() - && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + /// + /// Get episodes of this show + /// + /// + /// List the episodes that are part of the specified show. + /// + /// The ID or slug of the . + /// A key to sort episodes by. + /// An optional list of filters. + /// The number of episodes to return. + /// The aditional fields to include in the result. + /// A page of episodes. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [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>> GetEpisodes( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Episodes.GetAll( + Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), + sortBy, + fields, + pagination + ); - /// - /// Get episodes of this show - /// - /// - /// List the episodes that are part of the specified show. - /// - /// The ID or slug of the . - /// A key to sort episodes by. - /// An optional list of filters. - /// The number of episodes to return. - /// The aditional fields to include in the result. - /// A page of episodes. - /// The filters or the sort parameters are invalid. - /// No show with the given ID or slug could be found. - [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>> GetEpisodes( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields + if ( + !resources.Any() + && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null ) - { - ICollection resources = await _libraryManager.Episodes.GetAll( - Filter.And(filter, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), - sortBy, - fields, - pagination - ); + return NotFound(); + return Page(resources, pagination.Limit); + } - if ( - !resources.Any() - && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + /// + /// Get studio that made the show + /// + /// + /// Get the studio that made the show. + /// + /// The ID or slug of the . + /// The aditional fields to include in the result. + /// The studio that made the show. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/studio")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetStudio( + Identifier identifier, + [FromQuery] Include fields + ) + { + return await _libraryManager.Studios.Get( + identifier.IsContainedIn(x => x.Shows!), + fields + ); + } - /// - /// Get studio that made the show - /// - /// - /// Get the studio that made the show. - /// - /// The ID or slug of the . - /// The aditional fields to include in the result. - /// The studio that made the show. - /// No show with the given ID or slug could be found. - [HttpGet("{identifier:id}/studio")] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetStudio( - Identifier identifier, - [FromQuery] Include fields + /// + /// Get collections containing this show + /// + /// + /// List the collections that contain this show. + /// + /// The ID or slug of the . + /// A key to sort collections by. + /// An optional list of filters. + /// The number of collections to return. + /// The aditional fields to include in the result. + /// A page of collections. + /// The filters or the sort parameters are invalid. + /// No show with the given ID or slug could be found. + [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>> GetCollections( + Identifier identifier, + [FromQuery] Sort sortBy, + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include fields + ) + { + ICollection resources = await _libraryManager.Collections.GetAll( + Filter.And(filter, identifier.IsContainedIn(x => x.Shows!)), + sortBy, + fields, + pagination + ); + + if ( + !resources.Any() + && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null ) - { - return await _libraryManager.Studios.Get( - identifier.IsContainedIn(x => x.Shows!), - fields - ); - } + return NotFound(); + return Page(resources, pagination.Limit); + } - /// - /// Get collections containing this show - /// - /// - /// List the collections that contain this show. - /// - /// The ID or slug of the . - /// A key to sort collections by. - /// An optional list of filters. - /// The number of collections to return. - /// The aditional fields to include in the result. - /// A page of collections. - /// The filters or the sort parameters are invalid. - /// No show with the given ID or slug could be found. - [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>> GetCollections( - Identifier identifier, - [FromQuery] Sort sortBy, - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include fields - ) - { - ICollection resources = await _libraryManager.Collections.GetAll( - Filter.And(filter, identifier.IsContainedIn(x => x.Shows!)), - sortBy, - fields, - pagination - ); + /// + /// Get watch status + /// + /// + /// Get when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The status. + /// This show does not have a specific status. + /// No show with the given ID or slug could be found. + [HttpGet("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetWatchStatus(Identifier identifier) + { + Guid id = await identifier.Match( + id => Task.FromResult(id), + async slug => (await _libraryManager.Shows.Get(slug)).Id + ); + return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow()); + } - if ( - !resources.Any() - && await _libraryManager.Shows.GetOrDefault(identifier.IsSame()) == null - ) - return NotFound(); - return Page(resources, pagination.Limit); - } + /// + /// Set watch status + /// + /// + /// Set when an item has been wathed and if it was watched. + /// + /// The ID or slug of the . + /// The new watch status. + /// The newly set status. + /// The status has been set + /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). + /// No movie with the given ID or slug could be found. + [HttpPost("{identifier:id}/watchStatus")] + [UserOnly] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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); + } - /// - /// Get watch status - /// - /// - /// Get when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The status. - /// This show does not have a specific status. - /// No show with the given ID or slug could be found. - [HttpGet("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task GetWatchStatus(Identifier identifier) - { - Guid id = await identifier.Match( - id => Task.FromResult(id), - async slug => (await _libraryManager.Shows.Get(slug)).Id - ); - return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow()); - } - - /// - /// Set watch status - /// - /// - /// Set when an item has been wathed and if it was watched. - /// - /// The ID or slug of the . - /// The new watch status. - /// The newly set status. - /// The status has been set - /// The status was not considered impactfull enough to be saved (less then 5% of watched for example). - /// No movie with the given ID or slug could be found. - [HttpPost("{identifier:id}/watchStatus")] - [UserOnly] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task 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); - } - - /// - /// Delete watch status - /// - /// - /// Delete watch status (to rewatch for example). - /// - /// The ID or slug of the . - /// The newly set status. - /// The status has been deleted. - /// No show with the given ID or slug could be found. - [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()); - } + /// + /// Delete watch status + /// + /// + /// Delete watch status (to rewatch for example). + /// + /// The ID or slug of the . + /// The newly set status. + /// The status has been deleted. + /// No show with the given ID or slug could be found. + [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()); } } diff --git a/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs b/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs index 74cc03c7..e0c4b158 100644 --- a/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/WatchlistApi.cs @@ -29,44 +29,43 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Core.Api +namespace Kyoo.Core.Api; + +/// +/// List new items added to kyoo. +/// +[Route("watchlist")] +[ApiController] +[PartialPermission("LibraryItem")] +[ApiDefinition("News", Group = ResourcesGroup)] +[UserOnly] +public class WatchlistApi(IWatchStatusRepository repository) : BaseApi { /// - /// List new items added to kyoo. + /// Get all /// - [Route("watchlist")] - [ApiController] - [PartialPermission("LibraryItem")] - [ApiDefinition("News", Group = ResourcesGroup)] - [UserOnly] - public class WatchlistApi(IWatchStatusRepository repository) : BaseApi + /// + /// Get all resources that match the given filter. + /// + /// Filter the returned items. + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of resources that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll( + [FromQuery] Filter? filter, + [FromQuery] Pagination pagination, + [FromQuery] Include? fields + ) { - /// - /// Get all - /// - /// - /// Get all resources that match the given filter. - /// - /// Filter the returned items. - /// How many items per page should be returned, where should the page start... - /// The aditional fields to include in the result. - /// A list of resources that match every filters. - /// Invalid filters or sort information. - [HttpGet] - [PartialPermission(Kind.Read)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] - public async Task>> GetAll( - [FromQuery] Filter? filter, - [FromQuery] Pagination pagination, - [FromQuery] Include? fields - ) - { - if (User.GetId() == null) - throw new UnauthorizedException(); - ICollection resources = await repository.GetAll(filter, fields, pagination); + if (User.GetId() == null) + throw new UnauthorizedException(); + ICollection resources = await repository.GetAll(filter, fields, pagination); - return Page(resources, pagination.Limit); - } + return Page(resources, pagination.Limit); } } diff --git a/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs index 6de3160f..cd3a1d03 100644 --- a/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs +++ b/back/src/Kyoo.Core/Views/Watch/ProxyApi.cs @@ -28,69 +28,68 @@ using Kyoo.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Kyoo.Core.Api -{ - /// - /// Proxy to other services - /// - [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); - } +namespace Kyoo.Core.Api; - /// - /// Transcoder proxy - /// - /// - /// Simply proxy requests to the transcoder - /// - /// The path of the transcoder. - /// The return value of the transcoder. - [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 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}")); - } +/// +/// Proxy to other services +/// +[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); + } + + /// + /// Transcoder proxy + /// + /// + /// Simply proxy requests to the transcoder + /// + /// The path of the transcoder. + /// The return value of the transcoder. + [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 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}")); } } diff --git a/back/src/Kyoo.Host/Application.cs b/back/src/Kyoo.Host/Application.cs index 5633d844..2647e758 100644 --- a/back/src/Kyoo.Host/Application.cs +++ b/back/src/Kyoo.Host/Application.cs @@ -37,166 +37,160 @@ using Serilog.Templates; using Serilog.Templates.Themes; using ILogger = Serilog.ILogger; -namespace Kyoo.Host +namespace Kyoo.Host; + +/// +/// Hosts of kyoo (main functions) generally only create a new +/// and return . +/// +public class Application { /// - /// Hosts of kyoo (main functions) generally only create a new - /// and return . + /// The environment in witch Kyoo will run (ether "Production" or "Development"). /// - public class Application + private readonly string _environment; + + /// + /// The logger used for startup and error messages. + /// + private ILogger _logger; + + /// + /// Create a new that will use the specified environment. + /// + /// The environment to run in. + public Application(string environment) { - /// - /// The environment in witch Kyoo will run (ether "Production" or "Development"). - /// - private readonly string _environment; + _environment = environment; + } - /// - /// The logger used for startup and error messages. - /// - private ILogger _logger; + /// + /// Start the application with the given console args. + /// This is generally called from the Main entrypoint of Kyoo. + /// + /// The console arguments to use for kyoo. + /// A task representing the whole process + public Task Start(string[] args) + { + return Start(args, _ => { }); + } - /// - /// Create a new that will use the specified environment. - /// - /// The environment to run in. - public Application(string environment) + /// + /// Start the application with the given console args. + /// This is generally called from the Main entrypoint of Kyoo. + /// + /// The console arguments to use for kyoo. + /// A custom action to configure the container before the start + /// A task representing the whole process + public async Task Start(string[] args, Action 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(); + + 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); } - /// - /// Start the application with the given console args. - /// This is generally called from the Main entrypoint of Kyoo. - /// - /// The console arguments to use for kyoo. - /// A task representing the whole process - public Task Start(string[] args) + await _StartWithHost(host); + } + + /// + /// Start the given host and log failing exceptions. + /// + /// The host to start. + /// A token to allow one to stop the host. + 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); } - - /// - /// Start the application with the given console args. - /// This is generally called from the Main entrypoint of Kyoo. - /// - /// The console arguments to use for kyoo. - /// A custom action to configure the container before the start - /// A task representing the whole process - public async Task Start(string[] args, Action configure) + catch (Exception ex) { - 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(); - - 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); - } - - /// - /// Start the given host and log failing exceptions. - /// - /// The host to start. - /// A token to allow one to stop the host. - 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"); - } - } - - /// - /// Create a a web host - /// - /// Command line parameters that can be handled by kestrel - /// A new web host instance - 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()) - ) - ); - } - - /// - /// Register settings.json, environment variables and command lines arguments as configuration. - /// - /// The configuration builder to use - /// The command line arguments - /// The modified configuration builder - private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args) - { - return builder - .AddEnvironmentVariables() - .AddEnvironmentVariables("KYOO_") - .AddCommandLine(args); - } - - /// - /// Configure the logging. - /// - /// The logger builder to configure. - 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(); + _logger.Fatal(ex, "Unhandled exception"); } } + + /// + /// Create a a web host + /// + /// Command line parameters that can be handled by kestrel + /// A new web host instance + 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()) + ) + ); + } + + /// + /// Register settings.json, environment variables and command lines arguments as configuration. + /// + /// The configuration builder to use + /// The command line arguments + /// The modified configuration builder + private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args) + { + return builder + .AddEnvironmentVariables() + .AddEnvironmentVariables("KYOO_") + .AddCommandLine(args); + } + + /// + /// Configure the logging. + /// + /// The logger builder to configure. + 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(); + } } diff --git a/back/src/Kyoo.Host/Contollers/PluginManager.cs b/back/src/Kyoo.Host/Contollers/PluginManager.cs index 181dc6a1..fcaa2d9d 100644 --- a/back/src/Kyoo.Host/Contollers/PluginManager.cs +++ b/back/src/Kyoo.Host/Contollers/PluginManager.cs @@ -23,73 +23,70 @@ using Kyoo.Abstractions.Controllers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -namespace Kyoo.Host.Controllers +namespace Kyoo.Host.Controllers; + +/// +/// An implementation of . +/// This is used to load plugins and retrieve information from them. +/// +public class PluginManager : IPluginManager { /// - /// An implementation of . - /// This is used to load plugins and retrieve information from them. + /// The service provider. It allow plugin's activation. /// - public class PluginManager : IPluginManager + private readonly IServiceProvider _provider; + + /// + /// The logger used by this class. + /// + private readonly ILogger _logger; + + /// + /// The list of plugins that are currently loaded. + /// + private readonly List _plugins = new(); + + /// + /// Create a new instance. + /// + /// A service container to allow initialization of plugins + /// The logger used by this class. + public PluginManager(IServiceProvider provider, ILogger logger) { - /// - /// The service provider. It allow plugin's activation. - /// - private readonly IServiceProvider _provider; + _provider = provider; + _logger = logger; + } - /// - /// The logger used by this class. - /// - private readonly ILogger _logger; + /// + public T GetPlugin(string name) + { + return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T); + } - /// - /// The list of plugins that are currently loaded. - /// - private readonly List _plugins = new(); + /// + public ICollection GetPlugins() + { + return _plugins?.OfType().ToArray(); + } - /// - /// Create a new instance. - /// - /// A service container to allow initialization of plugins - /// The logger used by this class. - public PluginManager(IServiceProvider provider, ILogger logger) - { - _provider = provider; - _logger = logger; - } + /// + public ICollection GetAllPlugins() + { + return _plugins; + } - /// - public T GetPlugin(string name) - { - return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T); - } + /// + public void LoadPlugins(ICollection plugins) + { + _plugins.AddRange(plugins); + _logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name)); + } - /// - public ICollection GetPlugins() - { - return _plugins?.OfType().ToArray(); - } - - /// - public ICollection GetAllPlugins() - { - return _plugins; - } - - /// - public void LoadPlugins(ICollection plugins) - { - _plugins.AddRange(plugins); - _logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name)); - } - - /// - public void LoadPlugins(params Type[] plugins) - { - LoadPlugins( - plugins - .Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)) - .ToArray() - ); - } + /// + public void LoadPlugins(params Type[] plugins) + { + LoadPlugins( + plugins.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)).ToArray() + ); } } diff --git a/back/src/Kyoo.Host/HostModule.cs b/back/src/Kyoo.Host/HostModule.cs index 46997857..6c43b9a8 100644 --- a/back/src/Kyoo.Host/HostModule.cs +++ b/back/src/Kyoo.Host/HostModule.cs @@ -23,39 +23,38 @@ using Kyoo.Abstractions.Controllers; using Microsoft.AspNetCore.Builder; using Serilog; -namespace Kyoo.Host +namespace Kyoo.Host; + +/// +/// A module that registers host controllers and other needed things. +/// +public class HostModule : IPlugin { + /// + public string Name => "Host"; + /// - /// A module that registers host controllers and other needed things. + /// The plugin manager that loaded all plugins. /// - public class HostModule : IPlugin + private readonly IPluginManager _plugins; + + /// + /// Create a new . + /// + /// The plugin manager that loaded all plugins. + public HostModule(IPluginManager plugins) { - /// - public string Name => "Host"; - - /// - /// The plugin manager that loaded all plugins. - /// - private readonly IPluginManager _plugins; - - /// - /// Create a new . - /// - /// The plugin manager that loaded all plugins. - public HostModule(IPluginManager plugins) - { - _plugins = plugins; - } - - /// - public void Configure(ContainerBuilder builder) - { - builder.RegisterModule(); - builder.RegisterInstance(_plugins).As().ExternallyOwned(); - } - - /// - public IEnumerable ConfigureSteps => - new[] { SA.New(app => app.UseSerilogRequestLogging(), SA.Before) }; + _plugins = plugins; } + + /// + public void Configure(ContainerBuilder builder) + { + builder.RegisterModule(); + builder.RegisterInstance(_plugins).As().ExternallyOwned(); + } + + /// + public IEnumerable ConfigureSteps => + new[] { SA.New(app => app.UseSerilogRequestLogging(), SA.Before) }; } diff --git a/back/src/Kyoo.Host/PluginsStartup.cs b/back/src/Kyoo.Host/PluginsStartup.cs index a4abf396..07fa1621 100644 --- a/back/src/Kyoo.Host/PluginsStartup.cs +++ b/back/src/Kyoo.Host/PluginsStartup.cs @@ -36,167 +36,163 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Kyoo.Host +namespace Kyoo.Host; + +/// +/// The Startup class is used to configure the AspNet's webhost. +/// +public class PluginsStartup { /// - /// 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. /// - public class PluginsStartup + private readonly IPluginManager _plugins; + + /// + /// The plugin that adds controllers and tasks specific to this host. + /// + private readonly IPlugin _hostModule; + + /// + /// Created from the DI container, those services are needed to load information and instantiate plugins.s + /// + /// The plugin manager to use to load new plugins and configure the host. + public PluginsStartup(IPluginManager plugins) + { + _plugins = plugins; + _hostModule = new HostModule(_plugins); + _plugins.LoadPlugins( + typeof(CoreModule), + typeof(AuthenticationModule), + typeof(PostgresModule), + typeof(MeilisearchModule), + typeof(SwaggerModule) + ); + } + + /// + /// Create a new from a webhost. + /// This is meant to be used from . + /// + /// The context of the web host. + /// + /// The logger factory used to log while the application is setting itself up. + /// + /// A new . + public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger) + { + HostServiceProvider hostProvider = new(host.HostingEnvironment, host.Configuration, logger); + PluginManager plugins = new(hostProvider, logger.CreateLogger()); + return new PluginsStartup(plugins); + } + + /// + /// Configure the services context via the . + /// + /// The service collection to fill. + 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); + } + + /// + /// Configure the autofac container via the . + /// + /// The builder to configure. + public void ConfigureContainer(ContainerBuilder builder) + { + _hostModule.Configure(builder); + foreach (IPlugin plugin in _plugins.GetAllPlugins()) + plugin.Configure(builder); + } + + /// + /// Configure the asp net host. + /// + /// The asp net host to configure + /// An autofac container used to create a new scope to configure asp-net. + public void Configure(IApplicationBuilder app, ILifetimeScope container) + { + IEnumerable 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(); + foreach (IStartupAction step in steps) + step.Run(provider); + } + + /// + /// A simple host service provider used to activate plugins instance. + /// The same services as a generic host are available and an has been added. + /// + private class HostServiceProvider : IServiceProvider { /// - /// 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. /// - private readonly IPluginManager _plugins; + private readonly IWebHostEnvironment _hostEnvironment; /// - /// The plugin that adds controllers and tasks specific to this host. + /// The configuration context. /// - private readonly IPlugin _hostModule; + private readonly IConfiguration _configuration; /// - /// 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. /// - /// The plugin manager to use to load new plugins and configure the host. - public PluginsStartup(IPluginManager plugins) - { - _plugins = plugins; - _hostModule = new HostModule(_plugins); - _plugins.LoadPlugins( - typeof(CoreModule), - typeof(AuthenticationModule), - typeof(PostgresModule), - typeof(MeilisearchModule), - typeof(SwaggerModule) - ); - } + private readonly ILoggerFactory _loggerFactory; /// - /// Create a new from a webhost. - /// This is meant to be used from . + /// Create a new that will return given services when asked. /// - /// The context of the web host. - /// - /// The logger factory used to log while the application is setting itself up. + /// + /// The host environment that could be used by plugins to configure themself. /// - /// A new . - public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger) + /// The configuration context + /// A logger factory used to create a logger for the plugin manager. + public HostServiceProvider( + IWebHostEnvironment hostEnvironment, + IConfiguration configuration, + ILoggerFactory loggerFactory + ) { - HostServiceProvider hostProvider = - new(host.HostingEnvironment, host.Configuration, logger); - PluginManager plugins = new(hostProvider, logger.CreateLogger()); - return new PluginsStartup(plugins); + _hostEnvironment = hostEnvironment; + _configuration = configuration; + _loggerFactory = loggerFactory; } - /// - /// Configure the services context via the . - /// - /// The service collection to fill. - public void ConfigureServices(IServiceCollection services) + /// + public object GetService(Type serviceType) { - 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); - } - - /// - /// Configure the autofac container via the . - /// - /// The builder to configure. - public void ConfigureContainer(ContainerBuilder builder) - { - _hostModule.Configure(builder); - foreach (IPlugin plugin in _plugins.GetAllPlugins()) - plugin.Configure(builder); - } - - /// - /// Configure the asp net host. - /// - /// The asp net host to configure - /// An autofac container used to create a new scope to configure asp-net. - public void Configure(IApplicationBuilder app, ILifetimeScope container) - { - IEnumerable 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(); - foreach (IStartupAction step in steps) - step.Run(provider); - } - - /// - /// A simple host service provider used to activate plugins instance. - /// The same services as a generic host are available and an has been added. - /// - private class HostServiceProvider : IServiceProvider - { - /// - /// The host environment that could be used by plugins to configure themself. - /// - private readonly IWebHostEnvironment _hostEnvironment; - - /// - /// The configuration context. - /// - private readonly IConfiguration _configuration; - - /// - /// A logger factory used to create a logger for the plugin manager. - /// - private readonly ILoggerFactory _loggerFactory; - - /// - /// Create a new that will return given services when asked. - /// - /// - /// The host environment that could be used by plugins to configure themself. - /// - /// The configuration context - /// A logger factory used to create a logger for the plugin manager. - public HostServiceProvider( - IWebHostEnvironment hostEnvironment, - IConfiguration configuration, - ILoggerFactory loggerFactory + if ( + serviceType == typeof(IWebHostEnvironment) + || serviceType == typeof(IHostEnvironment) ) + return _hostEnvironment; + if (serviceType == typeof(IConfiguration)) + return _configuration; + if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>)) { - _hostEnvironment = hostEnvironment; - _configuration = configuration; - _loggerFactory = loggerFactory; + return Utility.RunGenericMethod( + typeof(LoggerFactoryExtensions), + nameof(LoggerFactoryExtensions.CreateLogger), + serviceType.GetGenericArguments().First(), + _loggerFactory + ); } - /// - 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( - typeof(LoggerFactoryExtensions), - nameof(LoggerFactoryExtensions.CreateLogger), - serviceType.GetGenericArguments().First(), - _loggerFactory - ); - } - - return null; - } + return null; } } } diff --git a/back/src/Kyoo.Host/Program.cs b/back/src/Kyoo.Host/Program.cs index 2217b0f7..708aa6b4 100644 --- a/back/src/Kyoo.Host/Program.cs +++ b/back/src/Kyoo.Host/Program.cs @@ -19,31 +19,30 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; -namespace Kyoo.Host +namespace Kyoo.Host; + +/// +/// Program entrypoint. +/// +public static class Program { /// - /// Program entrypoint. + /// The string representation of the environment used in . /// - public static class Program - { - /// - /// The string representation of the environment used in . - /// #if DEBUG - private const string Environment = "Development"; + private const string Environment = "Development"; #else - private const string Environment = "Production"; + private const string Environment = "Production"; #endif - /// - /// Main function of the program - /// - /// Command line arguments - /// A representing the lifetime of the program. - public static Task Main(string[] args) - { - Application application = new(Environment); - return application.Start(args); - } + /// + /// Main function of the program + /// + /// Command line arguments + /// A representing the lifetime of the program. + public static Task Main(string[] args) + { + Application application = new(Environment); + return application.Start(args); } } diff --git a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs index 04930d41..03438ca8 100644 --- a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs +++ b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs @@ -24,171 +24,166 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using static System.Text.Json.JsonNamingPolicy; -namespace Kyoo.Meiliseach +namespace Kyoo.Meiliseach; + +public class MeilisearchModule : IPlugin { - public class MeilisearchModule : IPlugin + /// + public string Name => "Meilisearch"; + + private readonly IConfiguration _configuration; + + public static Dictionary 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(), + SortableAttributes = Array.Empty(), + DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), }, + // TODO: Add stopwords + } + }, + }; + + public MeilisearchModule(IConfiguration configuration) { - /// - public string Name => "Meilisearch"; + _configuration = configuration; + } - private readonly IConfiguration _configuration; + /// + /// Init meilisearch indexes. + /// + /// The service list to retrieve the meilisearch client + /// A representing the asynchronous operation. + public static async Task Initialize(IServiceProvider provider) + { + MeilisearchClient client = provider.GetRequiredService(); - public static Dictionary 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(), - SortableAttributes = Array.Empty(), - DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), }, - // TODO: Add stopwords - } - }, - }; + await _CreateIndex(client, "items", true); + await _CreateIndex(client, nameof(Episode), false); + await _CreateIndex(client, nameof(Studio), false); - 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(); + MeiliSync search = provider.GetRequiredService(); - /// - /// Init meilisearch indexes. - /// - /// The service list to retrieve the meilisearch client - /// A representing the asynchronous operation. - public static async Task Initialize(IServiceProvider provider) - { - MeilisearchClient client = provider.GetRequiredService(); - - await _CreateIndex(client, "items", true); - await _CreateIndex(client, nameof(Episode), false); - await _CreateIndex(client, nameof(Studio), false); - - 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(); - MeiliSync search = provider.GetRequiredService(); - - // 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]); - } - - /// - public void Configure(ContainerBuilder builder) - { - builder - .RegisterInstance( - new MeilisearchClient( - _configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), - _configuration.GetValue("MEILI_MASTER_KEY") - ) - ) - .SingleInstance(); - builder.RegisterType().AsSelf().SingleInstance().AutoActivate(); - builder.RegisterType().As().InstancePerLifetimeScope(); + // 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]); + } + + /// + public void Configure(ContainerBuilder builder) + { + builder + .RegisterInstance( + new MeilisearchClient( + _configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), + _configuration.GetValue("MEILI_MASTER_KEY") + ) + ) + .SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance().AutoActivate(); + builder.RegisterType().As().InstancePerLifetimeScope(); + } } diff --git a/back/src/Kyoo.Postgresql/DatabaseContext.cs b/back/src/Kyoo.Postgresql/DatabaseContext.cs index 6d40001a..db5de9c2 100644 --- a/back/src/Kyoo.Postgresql/DatabaseContext.cs +++ b/back/src/Kyoo.Postgresql/DatabaseContext.cs @@ -31,617 +31,607 @@ using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace Kyoo.Postgresql +namespace Kyoo.Postgresql; + +/// +/// The database handle used for all local repositories. +/// This is an abstract class. It is meant to be implemented by plugins. This allow the core to be database agnostic. +/// +/// +/// It should not be used directly, to access the database use a or repositories. +/// +public abstract class DatabaseContext : DbContext { + private readonly IHttpContextAccessor _accessor; + /// - /// The database handle used for all local repositories. - /// This is an abstract class. It is meant to be implemented by plugins. This allow the core to be database agnostic. + /// Calculate the MD5 of a string, can only be used in database context. /// - /// - /// It should not be used directly, to access the database use a or repositories. - /// - public abstract class DatabaseContext : DbContext + /// The string to hash + /// The hash + public static string MD5(string str) => throw new NotSupportedException(); + + public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId(); + + /// + /// All collections of Kyoo. See . + /// + public DbSet Collections { get; set; } + + /// + /// All movies of Kyoo. See . + /// + public DbSet Movies { get; set; } + + /// + /// All shows of Kyoo. See . + /// + public DbSet Shows { get; set; } + + /// + /// All seasons of Kyoo. See . + /// + public DbSet Seasons { get; set; } + + /// + /// All episodes of Kyoo. See . + /// + public DbSet Episodes { get; set; } + + /// + /// All studios of Kyoo. See . + /// + public DbSet Studios { get; set; } + + /// + /// The list of registered users. + /// + public DbSet Users { get; set; } + + public DbSet MovieWatchStatus { get; set; } + + public DbSet ShowWatchStatus { get; set; } + + public DbSet EpisodeWatchStatus { get; set; } + + public DbSet Issues { get; set; } + + /// + /// Add a many to many link between two resources. + /// + /// Types are order dependant. You can't inverse the order. Please always put the owner first. + /// The ID of the first resource. + /// The ID of the second resource. + /// The first resource type of the relation. It is the owner of the second + /// The second resource type of the relation. It is the contained resource. + public void AddLinks(Guid first, Guid second) + where T1 : class, IResource + where T2 : class, IResource { - private readonly IHttpContextAccessor _accessor; - - /// - /// Calculate the MD5 of a string, can only be used in database context. - /// - /// The string to hash - /// The hash - public static string MD5(string str) => throw new NotSupportedException(); - - public Guid? CurrentUserId => _accessor.HttpContext?.User.GetId(); - - /// - /// All collections of Kyoo. See . - /// - public DbSet Collections { get; set; } - - /// - /// All movies of Kyoo. See . - /// - public DbSet Movies { get; set; } - - /// - /// All shows of Kyoo. See . - /// - public DbSet Shows { get; set; } - - /// - /// All seasons of Kyoo. See . - /// - public DbSet Seasons { get; set; } - - /// - /// All episodes of Kyoo. See . - /// - public DbSet Episodes { get; set; } - - /// - /// All studios of Kyoo. See . - /// - public DbSet Studios { get; set; } - - /// - /// The list of registered users. - /// - public DbSet Users { get; set; } - - public DbSet MovieWatchStatus { get; set; } - - public DbSet ShowWatchStatus { get; set; } - - public DbSet EpisodeWatchStatus { get; set; } - - public DbSet Issues { get; set; } - - /// - /// Add a many to many link between two resources. - /// - /// Types are order dependant. You can't inverse the order. Please always put the owner first. - /// The ID of the first resource. - /// The ID of the second resource. - /// The first resource type of the relation. It is the owner of the second - /// The second resource type of the relation. It is the contained resource. - public void AddLinks(Guid first, Guid second) - where T1 : class, IResource - where T2 : class, IResource - { - Set>(LinkName()) - .Add( - new Dictionary - { - [LinkNameFk()] = first, - [LinkNameFk()] = second - } - ); - } - - protected DatabaseContext(IHttpContextAccessor accessor) - { - _accessor = accessor; - } - - protected DatabaseContext(DbContextOptions options, IHttpContextAccessor accessor) - : base(options) - { - _accessor = accessor; - } - - /// - /// Get the name of the link table of the two given types. - /// - /// The owner type of the relation - /// The child type of the relation - /// The name of the table containing the links. - protected abstract string LinkName() - where T : IResource - where T2 : IResource; - - /// - /// Get the name of a link's foreign key. - /// - /// The type that will be accessible via the navigation - /// The name of the foreign key for the given resource. - protected abstract string LinkNameFk() - where T : IResource; - - /// - /// Set basic configurations (like preventing query tracking) - /// - /// An option builder to fill. - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - base.OnConfiguring(optionsBuilder); - optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - } - - private static ValueComparer> _GetComparer() - { - return new( - (c1, c2) => c1!.SequenceEqual(c2!), - c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) - ); - } - - /// - /// Build the metadata model for the given type. - /// - /// The database model builder - /// The type to add metadata to. - private static void _HasMetadata(ModelBuilder modelBuilder) - where T : class, IMetadata - { - // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 - // modelBuilder.Entity() - // .OwnsOne(x => x.ExternalId, x => - // { - // x.ToJson(); - // }); - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .Metadata.SetValueComparer(_GetComparer()); - } - - private static void _HasImages(ModelBuilder modelBuilder) - where T : class, IThumbnails - { - modelBuilder.Entity().OwnsOne(x => x.Poster); - modelBuilder.Entity().OwnsOne(x => x.Thumbnail); - modelBuilder.Entity().OwnsOne(x => x.Logo); - } - - private static void _HasAddedDate(ModelBuilder modelBuilder) - where T : class, IAddedDate - { - modelBuilder - .Entity() - .Property(x => x.AddedDate) - .HasDefaultValueSql("now() at time zone 'utc'") - .ValueGeneratedOnAdd(); - } - - /// - /// Create a many to many relationship between the two entities. - /// The resulting relationship will have an available method. - /// - /// The database model builder - /// The first navigation expression from T to T2 - /// The second navigation expression from T2 to T - /// The owning type of the relationship - /// The owned type of the relationship - private void _HasManyToMany( - ModelBuilder modelBuilder, - Expression?>> firstNavigation, - Expression?>> secondNavigation - ) - where T : class, IResource - where T2 : class, IResource - { - modelBuilder - .Entity() - .HasMany(secondNavigation) - .WithMany(firstNavigation) - .UsingEntity>( - LinkName(), - x => - x.HasOne() - .WithMany() - .HasForeignKey(LinkNameFk()) - .OnDelete(DeleteBehavior.Cascade), - x => - x.HasOne() - .WithMany() - .HasForeignKey(LinkNameFk()) - .OnDelete(DeleteBehavior.Cascade) - ); - } - - /// - /// Set database parameters to support every types of Kyoo. - /// - /// The database's model builder. - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity().Ignore(x => x.FirstEpisode).Ignore(x => x.AirDate); - modelBuilder - .Entity() - .Ignore(x => x.PreviousEpisode) - .Ignore(x => x.NextEpisode); - - modelBuilder - .Entity() - .HasMany(x => x.Seasons) - .WithOne(x => x.Show) - .OnDelete(DeleteBehavior.Cascade); - modelBuilder - .Entity() - .HasMany(x => x.Episodes) - .WithOne(x => x.Show) - .OnDelete(DeleteBehavior.Cascade); - modelBuilder - .Entity() - .HasMany(x => x.Episodes) - .WithOne(x => x.Season) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder - .Entity() - .HasOne(x => x.Studio) - .WithMany(x => x.Movies) - .OnDelete(DeleteBehavior.SetNull); - modelBuilder - .Entity() - .HasOne(x => x.Studio) - .WithMany(x => x.Shows) - .OnDelete(DeleteBehavior.SetNull); - - _HasManyToMany(modelBuilder, x => x.Movies, x => x.Collections); - _HasManyToMany(modelBuilder, x => x.Shows, x => x.Collections); - - _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); - _HasMetadata(modelBuilder); - - _HasImages(modelBuilder); - _HasImages(modelBuilder); - _HasImages(modelBuilder); - _HasImages(modelBuilder); - _HasImages(modelBuilder); - - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - - modelBuilder - .Entity() - .HasKey(x => new { User = x.UserId, Movie = x.MovieId }); - modelBuilder - .Entity() - .HasKey(x => new { User = x.UserId, Show = x.ShowId }); - modelBuilder - .Entity() - .HasKey(x => new { User = x.UserId, Episode = x.EpisodeId }); - - modelBuilder - .Entity() - .HasOne(x => x.Movie) - .WithMany(x => x.Watched) - .HasForeignKey(x => x.MovieId) - .OnDelete(DeleteBehavior.Cascade); - modelBuilder - .Entity() - .HasOne(x => x.Show) - .WithMany(x => x.Watched) - .HasForeignKey(x => x.ShowId) - .OnDelete(DeleteBehavior.Cascade); - modelBuilder - .Entity() - .HasOne(x => x.NextEpisode) - .WithMany() - .HasForeignKey(x => x.NextEpisodeId) - .OnDelete(DeleteBehavior.SetNull); - modelBuilder - .Entity() - .HasOne(x => x.Episode) - .WithMany(x => x.Watched) - .HasForeignKey(x => x.EpisodeId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); - modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); - modelBuilder - .Entity() - .HasQueryFilter(x => x.UserId == CurrentUserId); - - modelBuilder.Entity().Navigation(x => x.NextEpisode).AutoInclude(); - - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - _HasAddedDate(modelBuilder); - - modelBuilder.Entity().Ignore(x => x.WatchStatus); - modelBuilder.Entity().Ignore(x => x.WatchStatus); - modelBuilder.Entity().Ignore(x => x.WatchStatus); - - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder - .Entity() - .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber }) - .IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder - .Entity() - .HasIndex(x => new + Set>(LinkName()) + .Add( + new Dictionary { - ShowID = x.ShowId, - x.SeasonNumber, - x.EpisodeNumber, - x.AbsoluteNumber - }) - .IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); - modelBuilder.Entity().HasIndex(x => x.Username).IsUnique(); + [LinkNameFk()] = first, + [LinkNameFk()] = second + } + ); + } - modelBuilder.Entity().Ignore(x => x.Links); + protected DatabaseContext(IHttpContextAccessor accessor) + { + _accessor = accessor; + } - modelBuilder.Entity().HasKey(x => new { x.Domain, x.Cause }); + protected DatabaseContext(DbContextOptions options, IHttpContextAccessor accessor) + : base(options) + { + _accessor = accessor; + } - // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 - // modelBuilder.Entity() - // .OwnsOne(x => x.ExternalId, x => - // { - // x.ToJson(); - // }); - modelBuilder - .Entity() - .Property(x => x.Settings) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.Settings) - .Metadata.SetValueComparer(_GetComparer()); + /// + /// Get the name of the link table of the two given types. + /// + /// The owner type of the relation + /// The child type of the relation + /// The name of the table containing the links. + protected abstract string LinkName() + where T : IResource + where T2 : IResource; - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.ExternalId) - .Metadata.SetValueComparer(_GetComparer()); + /// + /// Get the name of a link's foreign key. + /// + /// The type that will be accessible via the navigation + /// The name of the foreign key for the given resource. + protected abstract string LinkNameFk() + where T : IResource; - modelBuilder - .Entity() - .Property(x => x.Extra) - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), - v => - JsonSerializer.Deserialize>( - v, - (JsonSerializerOptions?)null - )! - ) - .HasColumnType("json"); - modelBuilder - .Entity() - .Property(x => x.Extra) - .Metadata.SetValueComparer(_GetComparer()); - } + /// + /// Set basic configurations (like preventing query tracking) + /// + /// An option builder to fill. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + } - /// - /// Return a new or an in cache temporary object wih the same ID as the one given - /// - /// If a resource with the same ID is found in the database, it will be used. - /// will be used otherwise - /// The type of the resource - /// A resource that is now tracked by this context. - public T GetTemporaryObject(T model) - where T : class, IResource - { - T? tmp = Set().Local.FirstOrDefault(x => x.Id == model.Id); - if (tmp != null) - return tmp; - Entry(model).State = EntityState.Unchanged; - return model; - } + private static ValueComparer> _GetComparer() + { + return new( + (c1, c2) => c1!.SequenceEqual(c2!), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) + ); + } - /// - /// Save changes that are applied to this context. - /// - /// A duplicated item has been found. - /// The number of state entries written to the database. - public override int SaveChanges() - { - try - { - return base.SaveChanges(); - } - catch (DbUpdateException ex) - { - DiscardChanges(); - if (IsDuplicateException(ex)) - throw new DuplicatedItemException(); - throw; - } - } - - /// - /// Save changes that are applied to this context. - /// - /// Indicates whether AcceptAllChanges() is called after the changes - /// have been sent successfully to the database. - /// A duplicated item has been found. - /// The number of state entries written to the database. - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - try - { - return base.SaveChanges(acceptAllChangesOnSuccess); - } - catch (DbUpdateException ex) - { - DiscardChanges(); - if (IsDuplicateException(ex)) - throw new DuplicatedItemException(); - throw; - } - } - - /// - /// Save changes that are applied to this context. - /// - /// Indicates whether AcceptAllChanges() is called after the changes - /// have been sent successfully to the database. - /// A to observe while waiting for the task to complete - /// A duplicated item has been found. - /// The number of state entries written to the database. - public override async Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default - ) - { - try - { - return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } - catch (DbUpdateException ex) - { - DiscardChanges(); - if (IsDuplicateException(ex)) - throw new DuplicatedItemException(); - throw; - } - } - - /// - /// Save changes that are applied to this context. - /// - /// A to observe while waiting for the task to complete - /// A duplicated item has been found. - /// The number of state entries written to the database. - public override async Task SaveChangesAsync( - CancellationToken cancellationToken = default - ) - { - try - { - return await base.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException ex) - { - DiscardChanges(); - if (IsDuplicateException(ex)) - throw new DuplicatedItemException(); - throw; - } - } - - /// - /// Save changes that are applied to this context. - /// - /// How to retrieve the conflicting item. - /// A to observe while waiting for the task to complete - /// A duplicated item has been found. - /// The type of the potential duplicate (this is unused). - /// The number of state entries written to the database. - public async Task SaveChangesAsync( - Func> getExisting, - CancellationToken cancellationToken = default - ) - { - try - { - return await SaveChangesAsync(cancellationToken); - } - catch (DbUpdateException ex) - { - DiscardChanges(); - if (IsDuplicateException(ex)) - throw new DuplicatedItemException(await getExisting()); - throw; - } - catch (DuplicatedItemException) - { - throw new DuplicatedItemException(await getExisting()); - } - } - - /// - /// Save changes if no duplicates are found. If one is found, no change are saved but the current changes are no discarded. - /// The current context will still hold those invalid changes. - /// - /// A to observe while waiting for the task to complete - /// The number of state entries written to the database or -1 if a duplicate exist. - public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) - { - try - { - return await SaveChangesAsync(cancellationToken); - } - catch (DuplicatedItemException) - { - return -1; - } - } - - /// - /// Return the first resource with the given slug that is currently tracked by this context. - /// This allow one to limit redundant calls to during the - /// same transaction and prevent fails from EF when two same entities are being tracked. - /// - /// The slug of the resource to check - /// The type of entity to check - /// The local entity representing the resource with the given slug if it exists or null. - public T? LocalEntity(string slug) - where T : class, IResource - { - return ChangeTracker.Entries().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; - } - - /// - /// Check if the exception is a duplicated exception. - /// - /// The exception to check - /// True if the exception is a duplicate exception. False otherwise - protected abstract bool IsDuplicateException(Exception ex); - - /// - /// Delete every changes that are on this context. - /// - public void DiscardChanges() - { - foreach ( - EntityEntry entry in ChangeTracker - .Entries() - .Where(x => x.State != EntityState.Detached) + /// + /// Build the metadata model for the given type. + /// + /// The database model builder + /// The type to add metadata to. + private static void _HasMetadata(ModelBuilder modelBuilder) + where T : class, IMetadata + { + // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 + // modelBuilder.Entity() + // .OwnsOne(x => x.ExternalId, x => + // { + // x.ToJson(); + // }); + modelBuilder + .Entity() + .Property(x => x.ExternalId) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => + JsonSerializer.Deserialize>( + v, + (JsonSerializerOptions?)null + )! ) + .HasColumnType("json"); + modelBuilder + .Entity() + .Property(x => x.ExternalId) + .Metadata.SetValueComparer(_GetComparer()); + } + + private static void _HasImages(ModelBuilder modelBuilder) + where T : class, IThumbnails + { + modelBuilder.Entity().OwnsOne(x => x.Poster); + modelBuilder.Entity().OwnsOne(x => x.Thumbnail); + modelBuilder.Entity().OwnsOne(x => x.Logo); + } + + private static void _HasAddedDate(ModelBuilder modelBuilder) + where T : class, IAddedDate + { + modelBuilder + .Entity() + .Property(x => x.AddedDate) + .HasDefaultValueSql("now() at time zone 'utc'") + .ValueGeneratedOnAdd(); + } + + /// + /// Create a many to many relationship between the two entities. + /// The resulting relationship will have an available method. + /// + /// The database model builder + /// The first navigation expression from T to T2 + /// The second navigation expression from T2 to T + /// The owning type of the relationship + /// The owned type of the relationship + private void _HasManyToMany( + ModelBuilder modelBuilder, + Expression?>> firstNavigation, + Expression?>> secondNavigation + ) + where T : class, IResource + where T2 : class, IResource + { + modelBuilder + .Entity() + .HasMany(secondNavigation) + .WithMany(firstNavigation) + .UsingEntity>( + LinkName(), + x => + x.HasOne() + .WithMany() + .HasForeignKey(LinkNameFk()) + .OnDelete(DeleteBehavior.Cascade), + x => + x.HasOne() + .WithMany() + .HasForeignKey(LinkNameFk()) + .OnDelete(DeleteBehavior.Cascade) + ); + } + + /// + /// Set database parameters to support every types of Kyoo. + /// + /// The database's model builder. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().Ignore(x => x.FirstEpisode).Ignore(x => x.AirDate); + modelBuilder.Entity().Ignore(x => x.PreviousEpisode).Ignore(x => x.NextEpisode); + + modelBuilder + .Entity() + .HasMany(x => x.Seasons) + .WithOne(x => x.Show) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasMany(x => x.Episodes) + .WithOne(x => x.Show) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasMany(x => x.Episodes) + .WithOne(x => x.Season) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder + .Entity() + .HasOne(x => x.Studio) + .WithMany(x => x.Movies) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder + .Entity() + .HasOne(x => x.Studio) + .WithMany(x => x.Shows) + .OnDelete(DeleteBehavior.SetNull); + + _HasManyToMany(modelBuilder, x => x.Movies, x => x.Collections); + _HasManyToMany(modelBuilder, x => x.Shows, x => x.Collections); + + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + _HasMetadata(modelBuilder); + + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + _HasImages(modelBuilder); + + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Movie = x.MovieId }); + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Show = x.ShowId }); + modelBuilder + .Entity() + .HasKey(x => new { User = x.UserId, Episode = x.EpisodeId }); + + modelBuilder + .Entity() + .HasOne(x => x.Movie) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.MovieId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasOne(x => x.Show) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.ShowId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasOne(x => x.NextEpisode) + .WithMany() + .HasForeignKey(x => x.NextEpisodeId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder + .Entity() + .HasOne(x => x.Episode) + .WithMany(x => x.Watched) + .HasForeignKey(x => x.EpisodeId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(x => x.UserId == CurrentUserId); + + modelBuilder.Entity().Navigation(x => x.NextEpisode).AutoInclude(); + + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + _HasAddedDate(modelBuilder); + + modelBuilder.Entity().Ignore(x => x.WatchStatus); + modelBuilder.Entity().Ignore(x => x.WatchStatus); + modelBuilder.Entity().Ignore(x => x.WatchStatus); + + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder + .Entity() + .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber }) + .IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder + .Entity() + .HasIndex(x => new { - entry.State = EntityState.Detached; - } + ShowID = x.ShowId, + x.SeasonNumber, + x.EpisodeNumber, + x.AbsoluteNumber + }) + .IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Slug).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Username).IsUnique(); + + modelBuilder.Entity().Ignore(x => x.Links); + + modelBuilder.Entity().HasKey(x => new { x.Domain, x.Cause }); + + // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 + // modelBuilder.Entity() + // .OwnsOne(x => x.ExternalId, x => + // { + // x.ToJson(); + // }); + modelBuilder + .Entity() + .Property(x => x.Settings) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => + JsonSerializer.Deserialize>( + v, + (JsonSerializerOptions?)null + )! + ) + .HasColumnType("json"); + modelBuilder + .Entity() + .Property(x => x.Settings) + .Metadata.SetValueComparer(_GetComparer()); + + modelBuilder + .Entity() + .Property(x => x.ExternalId) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => + JsonSerializer.Deserialize>( + v, + (JsonSerializerOptions?)null + )! + ) + .HasColumnType("json"); + modelBuilder + .Entity() + .Property(x => x.ExternalId) + .Metadata.SetValueComparer(_GetComparer()); + + modelBuilder + .Entity() + .Property(x => x.Extra) + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), + v => + JsonSerializer.Deserialize>( + v, + (JsonSerializerOptions?)null + )! + ) + .HasColumnType("json"); + modelBuilder + .Entity() + .Property(x => x.Extra) + .Metadata.SetValueComparer(_GetComparer()); + } + + /// + /// Return a new or an in cache temporary object wih the same ID as the one given + /// + /// If a resource with the same ID is found in the database, it will be used. + /// will be used otherwise + /// The type of the resource + /// A resource that is now tracked by this context. + public T GetTemporaryObject(T model) + where T : class, IResource + { + T? tmp = Set().Local.FirstOrDefault(x => x.Id == model.Id); + if (tmp != null) + return tmp; + Entry(model).State = EntityState.Unchanged; + return model; + } + + /// + /// Save changes that are applied to this context. + /// + /// A duplicated item has been found. + /// The number of state entries written to the database. + public override int SaveChanges() + { + try + { + return base.SaveChanges(); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + /// + /// Save changes that are applied to this context. + /// + /// Indicates whether AcceptAllChanges() is called after the changes + /// have been sent successfully to the database. + /// A duplicated item has been found. + /// The number of state entries written to the database. + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + try + { + return base.SaveChanges(acceptAllChangesOnSuccess); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + /// + /// Save changes that are applied to this context. + /// + /// Indicates whether AcceptAllChanges() is called after the changes + /// have been sent successfully to the database. + /// A to observe while waiting for the task to complete + /// A duplicated item has been found. + /// The number of state entries written to the database. + public override async Task SaveChangesAsync( + bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default + ) + { + try + { + return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + /// + /// Save changes that are applied to this context. + /// + /// A to observe while waiting for the task to complete + /// A duplicated item has been found. + /// The number of state entries written to the database. + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + return await base.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(); + throw; + } + } + + /// + /// Save changes that are applied to this context. + /// + /// How to retrieve the conflicting item. + /// A to observe while waiting for the task to complete + /// A duplicated item has been found. + /// The type of the potential duplicate (this is unused). + /// The number of state entries written to the database. + public async Task SaveChangesAsync( + Func> getExisting, + CancellationToken cancellationToken = default + ) + { + try + { + return await SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + DiscardChanges(); + if (IsDuplicateException(ex)) + throw new DuplicatedItemException(await getExisting()); + throw; + } + catch (DuplicatedItemException) + { + throw new DuplicatedItemException(await getExisting()); + } + } + + /// + /// Save changes if no duplicates are found. If one is found, no change are saved but the current changes are no discarded. + /// The current context will still hold those invalid changes. + /// + /// A to observe while waiting for the task to complete + /// The number of state entries written to the database or -1 if a duplicate exist. + public async Task SaveIfNoDuplicates(CancellationToken cancellationToken = default) + { + try + { + return await SaveChangesAsync(cancellationToken); + } + catch (DuplicatedItemException) + { + return -1; + } + } + + /// + /// Return the first resource with the given slug that is currently tracked by this context. + /// This allow one to limit redundant calls to during the + /// same transaction and prevent fails from EF when two same entities are being tracked. + /// + /// The slug of the resource to check + /// The type of entity to check + /// The local entity representing the resource with the given slug if it exists or null. + public T? LocalEntity(string slug) + where T : class, IResource + { + return ChangeTracker.Entries().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity; + } + + /// + /// Check if the exception is a duplicated exception. + /// + /// The exception to check + /// True if the exception is a duplicate exception. False otherwise + protected abstract bool IsDuplicateException(Exception ex); + + /// + /// Delete every changes that are on this context. + /// + public void DiscardChanges() + { + foreach ( + EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached) + ) + { + entry.State = EntityState.Detached; } } } diff --git a/back/src/Kyoo.Postgresql/PostgresContext.cs b/back/src/Kyoo.Postgresql/PostgresContext.cs index cea9b215..fbc5ccf1 100644 --- a/back/src/Kyoo.Postgresql/PostgresContext.cs +++ b/back/src/Kyoo.Postgresql/PostgresContext.cs @@ -25,114 +25,113 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Npgsql; -namespace Kyoo.Postgresql +namespace Kyoo.Postgresql; + +/// +/// A postgresql implementation of . +/// +public class PostgresContext : DatabaseContext { /// - /// A postgresql implementation of . + /// Is this instance in debug mode? /// - public class PostgresContext : DatabaseContext + private readonly bool _debugMode; + + /// + /// Should the configure step be skipped? This is used when the database is created via DbContextOptions. + /// + 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() { - /// - /// Is this instance in debug mode? - /// - private readonly bool _debugMode; + NpgsqlConnection.GlobalTypeMapper.MapEnum(); + NpgsqlConnection.GlobalTypeMapper.MapEnum(); + NpgsqlConnection.GlobalTypeMapper.MapEnum(); + } - /// - /// Should the configure step be skipped? This is used when the database is created via DbContextOptions. - /// - private readonly bool _skipConfigure; + /// + /// Design time constructor (dotnet ef migrations add). Do not use + /// + public PostgresContext() + : base(null!) { } - // TODO: This needs ot be updated but ef-core still does not offer a way to use this. - [Obsolete] - static PostgresContext() + public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) + : base(options, accessor) + { + _skipConfigure = true; + } + + public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor) + : base(accessor) + { + _debugMode = debugMode; + } + + /// + /// Set connection information for this database context + /// + /// An option builder to fill. + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!_skipConfigure) { - NpgsqlConnection.GlobalTypeMapper.MapEnum(); - NpgsqlConnection.GlobalTypeMapper.MapEnum(); - NpgsqlConnection.GlobalTypeMapper.MapEnum(); + optionsBuilder.UseNpgsql(); + if (_debugMode) + optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging(); } - /// - /// Design time constructor (dotnet ef migrations add). Do not use - /// - public PostgresContext() - : base(null!) { } + optionsBuilder.UseSnakeCaseNamingConvention(); + base.OnConfiguring(optionsBuilder); + } - public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) - : base(options, accessor) - { - _skipConfigure = true; - } + /// + /// Set database parameters to support every types of Kyoo. + /// + /// The database's model builder. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); + modelBuilder.HasPostgresEnum(); - public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor) - : base(accessor) - { - _debugMode = debugMode; - } + 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 + )); - /// - /// Set connection information for this database context - /// - /// An option builder to fill. - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - if (!_skipConfigure) + base.OnModelCreating(modelBuilder); + } + + /// + protected override string LinkName() + { + SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); + return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name); + } + + /// + protected override string LinkNameFk() + { + SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); + return rewriter.RewriteName(typeof(T).Name + "ID"); + } + + /// + protected override bool IsDuplicateException(Exception ex) + { + return ex.InnerException + is PostgresException { - optionsBuilder.UseNpgsql(); - if (_debugMode) - optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging(); - } - - optionsBuilder.UseSnakeCaseNamingConvention(); - base.OnConfiguring(optionsBuilder); - } - - /// - /// Set database parameters to support every types of Kyoo. - /// - /// The database's model builder. - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.HasPostgresEnum(); - modelBuilder.HasPostgresEnum(); - modelBuilder.HasPostgresEnum(); - - 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); - } - - /// - protected override string LinkName() - { - SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); - return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name); - } - - /// - protected override string LinkNameFk() - { - SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture); - return rewriter.RewriteName(typeof(T).Name + "ID"); - } - - /// - protected override bool IsDuplicateException(Exception ex) - { - return ex.InnerException - is PostgresException - { - SqlState: PostgresErrorCodes.UniqueViolation - or PostgresErrorCodes.ForeignKeyViolation - }; - } + SqlState: PostgresErrorCodes.UniqueViolation + or PostgresErrorCodes.ForeignKeyViolation + }; } } diff --git a/back/src/Kyoo.Postgresql/PostgresModule.cs b/back/src/Kyoo.Postgresql/PostgresModule.cs index 61f06c04..e3286c9b 100644 --- a/back/src/Kyoo.Postgresql/PostgresModule.cs +++ b/back/src/Kyoo.Postgresql/PostgresModule.cs @@ -33,115 +33,112 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Npgsql; -namespace Kyoo.Postgresql +namespace Kyoo.Postgresql; + +/// +/// A module to add postgresql capacity to the app. +/// +public class PostgresModule : IPlugin { + /// + public string Name => "Postgresql"; + /// - /// A module to add postgresql capacity to the app. + /// The configuration to use. The database connection string is pulled from it. /// - public class PostgresModule : IPlugin + private readonly IConfiguration _configuration; + + /// + /// The host environment to check if the app is in debug mode. + /// + private readonly IWebHostEnvironment _environment; + + /// + /// Create a new postgres module instance and use the given configuration and environment. + /// + /// The configuration to use + /// The environment that will be used (if the env is in development mode, more information will be displayed on errors. + public PostgresModule(IConfiguration configuration, IWebHostEnvironment env) { - /// - public string Name => "Postgresql"; + _configuration = configuration; + _environment = env; + } - /// - /// The configuration to use. The database connection string is pulled from it. - /// - private readonly IConfiguration _configuration; + /// + /// Migrate the database. + /// + /// The service list to retrieve the database context + public static void Initialize(IServiceProvider provider) + { + DatabaseContext context = provider.GetRequiredService(); + context.Database.Migrate(); - /// - /// The host environment to check if the app is in debug mode. - /// - private readonly IWebHostEnvironment _environment; + using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection(); + conn.Open(); + conn.ReloadTypes(); - /// - /// Create a new postgres module instance and use the given configuration and environment. - /// - /// The configuration to use - /// The environment that will be used (if the env is in development mode, more information will be displayed on errors. - public PostgresModule(IConfiguration configuration, IWebHostEnvironment env) + SqlMapper.TypeMapProvider = (type) => { - _configuration = configuration; - _environment = env; - } + return new CustomPropertyTypeMap( + 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), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler( + typeof(Dictionary), + new JsonTypeHandler>() + ); + SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); + SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); + SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); + InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; + InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; + } - /// - /// Migrate the database. - /// - /// The service list to retrieve the database context - public static void Initialize(IServiceProvider provider) - { - DatabaseContext context = provider.GetRequiredService(); - context.Database.Migrate(); - - using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection(); - conn.Open(); - conn.ReloadTypes(); - - SqlMapper.TypeMapProvider = (type) => + /// + public void Configure(IServiceCollection services) + { + DbConnectionStringBuilder builder = + new() { - return new CustomPropertyTypeMap( - 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)!; - } - ); + ["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), + ["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), + ["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"), + ["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"), + ["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"), + ["POOLING"] = "true", + ["MAXPOOLSIZE"] = "95", + ["TIMEOUT"] = "30" }; - SqlMapper.AddTypeHandler( - typeof(Dictionary), - new JsonTypeHandler>() - ); - SqlMapper.AddTypeHandler( - typeof(Dictionary), - new JsonTypeHandler>() - ); - SqlMapper.AddTypeHandler( - typeof(Dictionary), - new JsonTypeHandler>() - ); - SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); - SqlMapper.AddTypeHandler(typeof(List), new ListTypeHandler()); - SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); - InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true; - InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false; - } - /// - public void Configure(IServiceCollection services) - { - DbConnectionStringBuilder builder = - new() - { - ["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), - ["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), - ["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"), - ["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"), - ["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"), - ["POOLING"] = "true", - ["MAXPOOLSIZE"] = "95", - ["TIMEOUT"] = "30" - }; + services.AddDbContext( + x => + { + x.UseNpgsql(builder.ConnectionString).UseProjectables(); + if (_environment.IsDevelopment()) + x.EnableDetailedErrors().EnableSensitiveDataLogging(); + }, + ServiceLifetime.Transient + ); + services.AddTransient((_) => new NpgsqlConnection(builder.ConnectionString)); - services.AddDbContext( - x => - { - x.UseNpgsql(builder.ConnectionString).UseProjectables(); - if (_environment.IsDevelopment()) - x.EnableDetailedErrors().EnableSensitiveDataLogging(); - }, - ServiceLifetime.Transient - ); - services.AddTransient( - (_) => new NpgsqlConnection(builder.ConnectionString) - ); - - services.AddHealthChecks().AddDbContextCheck(); - } + services.AddHealthChecks().AddDbContextCheck(); } } diff --git a/back/src/Kyoo.Swagger/ApiSorter.cs b/back/src/Kyoo.Swagger/ApiSorter.cs index 0a02e43a..0334805c 100644 --- a/back/src/Kyoo.Swagger/ApiSorter.cs +++ b/back/src/Kyoo.Swagger/ApiSorter.cs @@ -22,45 +22,44 @@ using Kyoo.Swagger.Models; using NSwag; using NSwag.Generation.AspNetCore; -namespace Kyoo.Swagger +namespace Kyoo.Swagger; + +/// +/// A class to sort apis. +/// +public static class ApiSorter { /// - /// A class to sort apis. + /// Sort apis by alphabetical orders. /// - public static class ApiSorter + /// The swagger settings to update. + public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options) { - /// - /// Sort apis by alphabetical orders. - /// - /// The swagger settings to update. - public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options) + options.PostProcess += postProcess => { - options.PostProcess += postProcess => - { - // We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. - List> sorted = postProcess - .Paths.OrderBy(x => x.Key) - .ToList(); - postProcess.Paths.Clear(); - foreach ((string key, OpenApiPathItem value) in sorted) - postProcess.Paths.Add(key, value); - }; + // We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. + List> sorted = postProcess + .Paths.OrderBy(x => x.Key) + .ToList(); + postProcess.Paths.Clear(); + foreach ((string key, OpenApiPathItem value) in sorted) + postProcess.Paths.Add(key, value); + }; - options.PostProcess += postProcess => - { - if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) - return; - List tagGroups = (List)list; - postProcess.ExtensionData["x-tagGroups"] = tagGroups - .OrderBy(x => x.Name) - .Select(x => - { - x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; - x.Tags = x.Tags.OrderBy(y => y).ToList(); - return x; - }) - .ToList(); - }; - } + options.PostProcess += postProcess => + { + if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) + return; + List tagGroups = (List)list; + postProcess.ExtensionData["x-tagGroups"] = tagGroups + .OrderBy(x => x.Name) + .Select(x => + { + x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; + x.Tags = x.Tags.OrderBy(y => y).ToList(); + return x; + }) + .ToList(); + }; } } diff --git a/back/src/Kyoo.Swagger/ApiTagsFilter.cs b/back/src/Kyoo.Swagger/ApiTagsFilter.cs index e272557e..7c001c1b 100644 --- a/back/src/Kyoo.Swagger/ApiTagsFilter.cs +++ b/back/src/Kyoo.Swagger/ApiTagsFilter.cs @@ -26,99 +26,98 @@ using NSwag; using NSwag.Generation.AspNetCore; using NSwag.Generation.Processors.Contexts; -namespace Kyoo.Swagger +namespace Kyoo.Swagger; + +/// +/// A class to handle Api Groups (OpenApi tags and x-tagGroups). +/// Tags should be specified via and this filter will map this to the +/// . +/// +public static class ApiTagsFilter { /// - /// A class to handle Api Groups (OpenApi tags and x-tagGroups). - /// Tags should be specified via and this filter will map this to the - /// . + /// The main operation filter that will map every . /// - public static class ApiTagsFilter + /// The processor context, this is given by the AddOperationFilter method. + /// This always return true since it should not remove operations. + public static bool OperationFilter(OperationProcessorContext context) { - /// - /// The main operation filter that will map every . - /// - /// The processor context, this is given by the AddOperationFilter method. - /// This always return true since it should not remove operations. - public static bool OperationFilter(OperationProcessorContext context) + ApiDefinitionAttribute def = + context.ControllerType.GetCustomAttribute(); + string name = def?.Name ?? context.ControllerType.Name; + + ApiDefinitionAttribute methodOverride = + context.MethodInfo.GetCustomAttribute(); + if (methodOverride != null) + name = methodOverride.Name; + + context.OperationDescription.Operation.Tags.Add(name); + if (context.Document.Tags.All(x => x.Name != name)) { - ApiDefinitionAttribute def = - context.ControllerType.GetCustomAttribute(); - string name = def?.Name ?? context.ControllerType.Name; - - ApiDefinitionAttribute methodOverride = - context.MethodInfo.GetCustomAttribute(); - 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(); - context.Document.ExtensionData.TryAdd("x-tagGroups", new List()); - List obj = (List)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 { def.Name } - } - ); - } + context.Document.Tags.Add( + new OpenApiTag + { + Name = name, + Description = context.ControllerType.GetXmlDocsSummary() + } + ); + } + if (def?.Group == null) return true; + + context.Document.ExtensionData ??= new Dictionary(); + context.Document.ExtensionData.TryAdd("x-tagGroups", new List()); + List obj = (List)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 { def.Name } + } + ); } - /// - /// 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. - /// - /// - /// The document to do this for. This should be done in the PostProcess part of the document or after - /// the main operation filter (see ) has finished. - /// - public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) - { - List tagGroups = (List)postProcess.ExtensionData["x-tagGroups"]; - List 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 }); - } - } + return true; + } - /// - /// Use to create tags and groups of tags on the resulting swagger - /// document. - /// - /// The settings of the swagger document. - public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) + /// + /// 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. + /// + /// + /// The document to do this for. This should be done in the PostProcess part of the document or after + /// the main operation filter (see ) has finished. + /// + public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) + { + List tagGroups = (List)postProcess.ExtensionData["x-tagGroups"]; + List 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); - options.PostProcess += x => x.AddLeftoversToOthersGroup(); + tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup }); } } + + /// + /// Use to create tags and groups of tags on the resulting swagger + /// document. + /// + /// The settings of the swagger document. + public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) + { + options.AddOperationFilter(OperationFilter); + options.PostProcess += x => x.AddLeftoversToOthersGroup(); + } } diff --git a/back/src/Kyoo.Swagger/GenericResponseProvider.cs b/back/src/Kyoo.Swagger/GenericResponseProvider.cs index b9435a4c..a3aeaf78 100644 --- a/back/src/Kyoo.Swagger/GenericResponseProvider.cs +++ b/back/src/Kyoo.Swagger/GenericResponseProvider.cs @@ -24,45 +24,44 @@ using Kyoo.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; -namespace Kyoo.Swagger +namespace Kyoo.Swagger; + +/// +/// A filter that change 's +/// that where set to to the +/// return type of the method. +/// +/// +/// 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. +/// +public class GenericResponseProvider : IApplicationModelProvider { - /// - /// A filter that change 's - /// that where set to to the - /// return type of the method. - /// - /// - /// 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. - /// - public class GenericResponseProvider : IApplicationModelProvider + /// + public int Order => -1; + + /// + public void OnProvidersExecuted(ApplicationModelProviderContext context) { } + + /// + public void OnProvidersExecuting(ApplicationModelProviderContext context) { - /// - public int Order => -1; - - /// - public void OnProvidersExecuted(ApplicationModelProviderContext context) { } - - /// - public void OnProvidersExecuting(ApplicationModelProviderContext context) + foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) { - foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) + IEnumerable responses = action + .Filters.OfType() + .Where(x => x.Type == typeof(ActionResult<>)); + foreach (ProducesResponseTypeAttribute response in responses) { - IEnumerable responses = action - .Filters.OfType() - .Where(x => x.Type == typeof(ActionResult<>)); - foreach (ProducesResponseTypeAttribute response in responses) - { - Type type = action.ActionMethod.ReturnType; - type = - Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] - ?? type; - type = - Utility - .GetGenericDefinition(type, typeof(ActionResult<>)) - ?.GetGenericArguments()[0] ?? type; - response.Type = type; - } + Type type = action.ActionMethod.ReturnType; + type = + Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] + ?? type; + type = + Utility + .GetGenericDefinition(type, typeof(ActionResult<>)) + ?.GetGenericArguments()[0] ?? type; + response.Type = type; } } } diff --git a/back/src/Kyoo.Swagger/Models/TagGroups.cs b/back/src/Kyoo.Swagger/Models/TagGroups.cs index d04df266..e16d4580 100644 --- a/back/src/Kyoo.Swagger/Models/TagGroups.cs +++ b/back/src/Kyoo.Swagger/Models/TagGroups.cs @@ -20,23 +20,22 @@ using System.Collections.Generic; using Newtonsoft.Json; using NSwag; -namespace Kyoo.Swagger.Models +namespace Kyoo.Swagger.Models; + +/// +/// A class representing a group of tags in the +/// +public class TagGroups { /// - /// A class representing a group of tags in the + /// The name of the tag group. /// - public class TagGroups - { - /// - /// The name of the tag group. - /// - [JsonProperty(PropertyName = "name")] - public string Name { get; set; } + [JsonProperty(PropertyName = "name")] + public string Name { get; set; } - /// - /// The list of tags in this group. - /// - [JsonProperty(PropertyName = "tags")] - public List Tags { get; set; } - } + /// + /// The list of tags in this group. + /// + [JsonProperty(PropertyName = "tags")] + public List Tags { get; set; } } diff --git a/back/src/Kyoo.Swagger/OperationPermissionProcessor.cs b/back/src/Kyoo.Swagger/OperationPermissionProcessor.cs index cb1a8cfc..d56727a3 100644 --- a/back/src/Kyoo.Swagger/OperationPermissionProcessor.cs +++ b/back/src/Kyoo.Swagger/OperationPermissionProcessor.cs @@ -25,80 +25,78 @@ using NSwag; using NSwag.Generation.Processors; using NSwag.Generation.Processors.Contexts; -namespace Kyoo.Swagger -{ - /// - /// An operation processor that adds permissions information from the and the - /// . - /// - public class OperationPermissionProcessor : IOperationProcessor - { - /// - public bool Process(OperationProcessorContext context) - { - context.OperationDescription.Operation.Security ??= - new List(); - OpenApiSecurityRequirement perms = context - .MethodInfo.GetCustomAttributes() - .Aggregate( - new OpenApiSecurityRequirement(), - (agg, _) => - { - agg[nameof(Kyoo)] = Array.Empty(); - return agg; - } - ); +namespace Kyoo.Swagger; +/// +/// An operation processor that adds permissions information from the and the +/// . +/// +public class OperationPermissionProcessor : IOperationProcessor +{ + /// + public bool Process(OperationProcessorContext context) + { + context.OperationDescription.Operation.Security ??= new List(); + OpenApiSecurityRequirement perms = context + .MethodInfo.GetCustomAttributes() + .Aggregate( + new OpenApiSecurityRequirement(), + (agg, _) => + { + agg[nameof(Kyoo)] = Array.Empty(); + return agg; + } + ); + + perms = context + .MethodInfo.GetCustomAttributes() + .Aggregate( + perms, + (agg, cur) => + { + ICollection permissions = _GetPermissionsList(agg, cur.Group); + permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); + agg[nameof(Kyoo)] = permissions; + return agg; + } + ); + + PartialPermissionAttribute controller = + context.ControllerType.GetCustomAttribute(); + if (controller != null) + { perms = context - .MethodInfo.GetCustomAttributes() + .MethodInfo.GetCustomAttributes() .Aggregate( perms, (agg, cur) => { - ICollection permissions = _GetPermissionsList(agg, cur.Group); - permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); + 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 permissions = _GetPermissionsList( + agg, + group ?? Group.Overall + ); + permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}"); agg[nameof(Kyoo)] = permissions; return agg; } ); - - PartialPermissionAttribute controller = - context.ControllerType.GetCustomAttribute(); - if (controller != null) - { - perms = context - .MethodInfo.GetCustomAttributes() - .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 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 _GetPermissionsList( - OpenApiSecurityRequirement security, - Group group - ) - { - return security.TryGetValue(group.ToString(), out IEnumerable perms) - ? perms.ToList() - : new List(); - } + context.OperationDescription.Operation.Security.Add(perms); + return true; + } + + private static ICollection _GetPermissionsList( + OpenApiSecurityRequirement security, + Group group + ) + { + return security.TryGetValue(group.ToString(), out IEnumerable perms) + ? perms.ToList() + : new List(); } } diff --git a/back/src/Kyoo.Swagger/SwaggerModule.cs b/back/src/Kyoo.Swagger/SwaggerModule.cs index cc4033fc..932ed103 100644 --- a/back/src/Kyoo.Swagger/SwaggerModule.cs +++ b/back/src/Kyoo.Swagger/SwaggerModule.cs @@ -29,103 +29,102 @@ using NSwag; using NSwag.Generation.AspNetCore; using static Kyoo.Abstractions.Models.Utils.Constants; -namespace Kyoo.Swagger +namespace Kyoo.Swagger; + +/// +/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo. +/// +public class SwaggerModule : IPlugin { - /// - /// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo. - /// - public class SwaggerModule : IPlugin + /// + public string Name => "Swagger"; + + /// + public void Configure(IServiceCollection services) { - /// - public string Name => "Swagger"; - - /// - public void Configure(IServiceCollection services) + services.AddTransient(); + services.AddOpenApiDocument(document => { - services.AddTransient(); - services.AddOpenApiDocument(document => + document.Title = "Kyoo API"; + // 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"; - // 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 { - options.Info.Contact = new OpenApiContact - { - 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(); - options.Info.ExtensionData["x-logo"] = new - { - url = "/banner.png", - backgroundColor = "#FFFFFF", - altText = "Kyoo's logo" - }; + Name = "Kyoo's github", + Url = "https://github.com/zoriya/Kyoo" }; - document.UseApiTags(); - document.SortApis(); - document.AddOperationFilter(x => + options.Info.License = new OpenApiLicense { - 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; - } - ) - ); + Name = "GPL-3.0-or-later", + Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE" + }; - document.AddSecurity( - nameof(Kyoo), - new OpenApiSecurityScheme - { - Type = OpenApiSecuritySchemeType.Http, - Scheme = "Bearer", - BearerFormat = "JWT", - Description = "The user's bearer" - } - ); - document.OperationProcessors.Add(new OperationPermissionProcessor()); - }); - } - - /// - public IEnumerable ConfigureSteps => - new IStartupAction[] - { - SA.New(app => app.UseOpenApi(), SA.Before + 1), - SA.New( - app => - app.UseReDoc(x => - { - x.Path = "/doc"; - x.TransformToExternalPath = (internalUiRoute, _) => - "/api" + internalUiRoute; - x.AdditionalSettings["theme"] = new - { - colors = new { primary = new { main = "#e13e13" } } - }; - }), - SA.Before - ) + options.Info.ExtensionData ??= new Dictionary(); + options.Info.ExtensionData["x-logo"] = new + { + url = "/banner.png", + backgroundColor = "#FFFFFF", + altText = "Kyoo's logo" + }; }; + 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()); + }); } + + /// + public IEnumerable ConfigureSteps => + new IStartupAction[] + { + SA.New(app => app.UseOpenApi(), SA.Before + 1), + SA.New( + app => + app.UseReDoc(x => + { + x.Path = "/doc"; + x.TransformToExternalPath = (internalUiRoute, _) => + "/api" + internalUiRoute; + x.AdditionalSettings["theme"] = new + { + colors = new { primary = new { main = "#e13e13" } } + }; + }), + SA.Before + ) + }; }